| author | |
| committer | |
| log | c2b9680a5efc0855d24fb5a4bdcf9320469354d4 |
| tree | a07436d65c5e019f4da1b97dbe62a18eb3a65ea7 |
| parent | a973e43421c9829a7450a7fe06d5f0e81c1b98c1 |
69 files changed, 1386 insertions(+), 1101 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts-6| ... | @@ -6,7 +6,6 @@ import { execFileSync } from "node:child_process"; | ... | @@ -6,7 +6,6 @@ import { execFileSync } from "node:child_process"; |
| 6 | import { NodeServices } from "@effect/platform-node"; | 6 | import { NodeServices } from "@effect/platform-node"; |
| 7 | import { | 7 | import { |
| 8 | ApprovalRequestId, | 8 | ApprovalRequestId, |
| 9 | CheckpointRef, | ||
| 10 | type OrchestrationEvent, | 9 | type OrchestrationEvent, |
| 11 | type OrchestrationThread, | 10 | type OrchestrationThread, |
| 12 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| ... | @@ -63,11 +62,6 @@ function initializeGitWorkspace(cwd: string) { | ... | @@ -63,11 +62,6 @@ function initializeGitWorkspace(cwd: string) { |
| 63 | runGit(cwd, ["commit", "-m", "Initial"]); | 62 | runGit(cwd, ["commit", "-m", "Initial"]); |
| 64 | } | 63 | } |
| 65 | 64 | ||
| 66 | export function checkpointRefForTurn(threadId: string, turnCount: number): CheckpointRef { | ||
| 67 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | ||
| 68 | return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`); | ||
| 69 | } | ||
| 70 | |||
| 71 | export function gitRefExists(cwd: string, ref: string): boolean { | 65 | export function gitRefExists(cwd: string, ref: string): boolean { |
| 72 | try { | 66 | try { |
| 73 | runGit(cwd, ["show-ref", "--verify", "--quiet", ref]); | 67 | runGit(cwd, ["show-ref", "--verify", "--quiet", ref]); |
apps/server/integration/orchestrationEngine.integration.test.ts+32-20| ... | @@ -17,17 +17,15 @@ import { Effect, Option } from "effect"; | ... | @@ -17,17 +17,15 @@ import { Effect, Option } from "effect"; |
| 17 | 17 | ||
| 18 | import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; | 18 | import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; |
| 19 | import { | 19 | import { |
| 20 | checkpointRefForTurn, | ||
| 21 | gitRefExists, | 20 | gitRefExists, |
| 22 | gitShowFileAtRef, | 21 | gitShowFileAtRef, |
| 23 | makeOrchestrationIntegrationHarness, | 22 | makeOrchestrationIntegrationHarness, |
| 24 | type OrchestrationIntegrationHarness, | 23 | type OrchestrationIntegrationHarness, |
| 25 | } from "./OrchestrationEngineHarness.integration.ts"; | 24 | } from "./OrchestrationEngineHarness.integration.ts"; |
| 25 | import { checkpointRefForThreadTurn } from "../src/checkpointing/Refs.ts"; | ||
| 26 | 26 | ||
| 27 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | ||
| 28 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | 27 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 29 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 28 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 30 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 31 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 29 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 32 | const asProviderSessionId = (value: string): ProviderSessionId => | 30 | const asProviderSessionId = (value: string): ProviderSessionId => |
| 33 | ProviderSessionId.makeUnsafe(value); | 31 | ProviderSessionId.makeUnsafe(value); |
| ... | @@ -37,7 +35,7 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => | ... | @@ -37,7 +35,7 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => |
| 37 | ApprovalRequestId.makeUnsafe(value); | 35 | ApprovalRequestId.makeUnsafe(value); |
| 38 | 36 | ||
| 39 | const PROJECT_ID = asProjectId("project-1"); | 37 | const PROJECT_ID = asProjectId("project-1"); |
| 40 | const THREAD_ID = asThreadId("thread-1"); | 38 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); |
| 41 | const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); | 39 | const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); |
| 42 | const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); | 40 | const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); |
| 43 | const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); | 41 | const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); |
| ... | @@ -95,7 +93,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | ... | @@ -95,7 +93,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 95 | 93 | ||
| 96 | yield* harness.engine.dispatch({ | 94 | yield* harness.engine.dispatch({ |
| 97 | type: "project.create", | 95 | type: "project.create", |
| 98 | commandId: asCommandId("cmd-project-create"), | 96 | commandId: CommandId.makeUnsafe("cmd-project-create"), |
| 99 | projectId: PROJECT_ID, | 97 | projectId: PROJECT_ID, |
| 100 | title: "Integration Project", | 98 | title: "Integration Project", |
| 101 | workspaceRoot: harness.workspaceDir, | 99 | workspaceRoot: harness.workspaceDir, |
| ... | @@ -105,7 +103,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | ... | @@ -105,7 +103,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 105 | 103 | ||
| 106 | yield* harness.engine.dispatch({ | 104 | yield* harness.engine.dispatch({ |
| 107 | type: "thread.create", | 105 | type: "thread.create", |
| 108 | commandId: asCommandId("cmd-thread-create"), | 106 | commandId: CommandId.makeUnsafe("cmd-thread-create"), |
| 109 | threadId: THREAD_ID, | 107 | threadId: THREAD_ID, |
| 110 | projectId: PROJECT_ID, | 108 | projectId: PROJECT_ID, |
| 111 | title: "Integration Thread", | 109 | title: "Integration Thread", |
| ... | @@ -124,7 +122,7 @@ const startTurn = (input: { | ... | @@ -124,7 +122,7 @@ const startTurn = (input: { |
| 124 | }) => | 122 | }) => |
| 125 | input.harness.engine.dispatch({ | 123 | input.harness.engine.dispatch({ |
| 126 | type: "thread.turn.start", | 124 | type: "thread.turn.start", |
| 127 | commandId: asCommandId(input.commandId), | 125 | commandId: CommandId.makeUnsafe(input.commandId), |
| 128 | threadId: THREAD_ID, | 126 | threadId: THREAD_ID, |
| 129 | message: { | 127 | message: { |
| 130 | messageId: asMessageId(input.messageId), | 128 | messageId: asMessageId(input.messageId), |
| ... | @@ -195,8 +193,8 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + | ... | @@ -195,8 +193,8 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + |
| 195 | 193 | ||
| 196 | yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed"); | 194 | yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed"); |
| 197 | 195 | ||
| 198 | const ref0 = checkpointRefForTurn(THREAD_ID, 0); | 196 | const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); |
| 199 | const ref1 = checkpointRefForTurn(THREAD_ID, 1); | 197 | const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); |
| 200 | assert.equal(gitRefExists(harness.workspaceDir, ref0), true); | 198 | assert.equal(gitRefExists(harness.workspaceDir, ref0), true); |
| 201 | assert.equal(gitRefExists(harness.workspaceDir, ref1), true); | 199 | assert.equal(gitRefExists(harness.workspaceDir, ref1), true); |
| 202 | assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); | 200 | assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); |
| ... | @@ -335,26 +333,34 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => | ... | @@ -335,26 +333,34 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 335 | 333 | ||
| 336 | const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({ | 334 | const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({ |
| 337 | cwd: harness.workspaceDir, | 335 | cwd: harness.workspaceDir, |
| 338 | fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 1), | 336 | fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1), |
| 339 | toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2), | 337 | toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), |
| 340 | fallbackFromToHead: false, | 338 | fallbackFromToHead: false, |
| 341 | }); | 339 | }); |
| 342 | assert.equal(incrementalDiff.includes("README.md"), true); | 340 | assert.equal(incrementalDiff.includes("README.md"), true); |
| 343 | 341 | ||
| 344 | const fullDiff = yield* harness.checkpointStore.diffCheckpoints({ | 342 | const fullDiff = yield* harness.checkpointStore.diffCheckpoints({ |
| 345 | cwd: harness.workspaceDir, | 343 | cwd: harness.workspaceDir, |
| 346 | fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 0), | 344 | fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0), |
| 347 | toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2), | 345 | toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2), |
| 348 | fallbackFromToHead: false, | 346 | fallbackFromToHead: false, |
| 349 | }); | 347 | }); |
| 350 | assert.equal(fullDiff.includes("README.md"), true); | 348 | assert.equal(fullDiff.includes("README.md"), true); |
| 351 | 349 | ||
| 352 | assert.equal( | 350 | assert.equal( |
| 353 | gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1), "README.md"), | 351 | gitShowFileAtRef( |
| 352 | harness.workspaceDir, | ||
| 353 | checkpointRefForThreadTurn(THREAD_ID, 1), | ||
| 354 | "README.md", | ||
| 355 | ), | ||
| 354 | "v2\n", | 356 | "v2\n", |
| 355 | ); | 357 | ); |
| 356 | assert.equal( | 358 | assert.equal( |
| 357 | gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2), "README.md"), | 359 | gitShowFileAtRef( |
| 360 | harness.workspaceDir, | ||
| 361 | checkpointRefForThreadTurn(THREAD_ID, 2), | ||
| 362 | "README.md", | ||
| 363 | ), | ||
| 358 | "v3\n", | 364 | "v3\n", |
| 359 | ); | 365 | ); |
| 360 | }), | 366 | }), |
| ... | @@ -416,7 +422,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons | ... | @@ -416,7 +422,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons |
| 416 | 422 | ||
| 417 | yield* harness.engine.dispatch({ | 423 | yield* harness.engine.dispatch({ |
| 418 | type: "thread.approval.respond", | 424 | type: "thread.approval.respond", |
| 419 | commandId: asCommandId("cmd-approval-respond"), | 425 | commandId: CommandId.makeUnsafe("cmd-approval-respond"), |
| 420 | threadId: THREAD_ID, | 426 | threadId: THREAD_ID, |
| 421 | requestId: APPROVAL_REQUEST_ID, | 427 | requestId: APPROVAL_REQUEST_ID, |
| 422 | decision: "accept", | 428 | decision: "accept", |
| ... | @@ -507,7 +513,10 @@ it.live("records failed turn runtime state and checkpoint status as error", () = | ... | @@ -507,7 +513,10 @@ it.live("records failed turn runtime state and checkpoint status as error", () = |
| 507 | if (Option.isSome(checkpointRow)) { | 513 | if (Option.isSome(checkpointRow)) { |
| 508 | assert.equal(checkpointRow.value.status, "error"); | 514 | assert.equal(checkpointRow.value.status, "error"); |
| 509 | } | 515 | } |
| 510 | assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1)), true); | 516 | assert.equal( |
| 517 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)), | ||
| 518 | true, | ||
| 519 | ); | ||
| 511 | }), | 520 | }), |
| 512 | ), | 521 | ), |
| 513 | ); | 522 | ); |
| ... | @@ -644,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git | ... | @@ -644,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 644 | 653 | ||
| 645 | yield* harness.engine.dispatch({ | 654 | yield* harness.engine.dispatch({ |
| 646 | type: "thread.checkpoint.revert", | 655 | type: "thread.checkpoint.revert", |
| 647 | commandId: asCommandId("cmd-checkpoint-revert"), | 656 | commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), |
| 648 | threadId: THREAD_ID, | 657 | threadId: THREAD_ID, |
| 649 | turnCount: 1, | 658 | turnCount: 1, |
| 650 | createdAt: nowIso(), | 659 | createdAt: nowIso(), |
| ... | @@ -681,7 +690,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git | ... | @@ -681,7 +690,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 681 | true, | 690 | true, |
| 682 | ); | 691 | ); |
| 683 | assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); | 692 | assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); |
| 684 | assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false); | 693 | assert.equal( |
| 694 | gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)), | ||
| 695 | false, | ||
| 696 | ); | ||
| 685 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); | 697 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); |
| 686 | 698 | ||
| 687 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ | 699 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ |
| ... | @@ -701,7 +713,7 @@ it.live( | ... | @@ -701,7 +713,7 @@ it.live( |
| 701 | 713 | ||
| 702 | yield* harness.engine.dispatch({ | 714 | yield* harness.engine.dispatch({ |
| 703 | type: "thread.checkpoint.revert", | 715 | type: "thread.checkpoint.revert", |
| 704 | commandId: asCommandId("cmd-checkpoint-revert-no-session"), | 716 | commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"), |
| 705 | threadId: THREAD_ID, | 717 | threadId: THREAD_ID, |
| 706 | turnCount: 0, | 718 | turnCount: 0, |
| 707 | createdAt: nowIso(), | 719 | createdAt: nowIso(), |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts created+190| ... | @@ -0,0 +1,190 @@ | ||
| 1 | import { | ||
| 2 | CheckpointRef, | ||
| 3 | ProjectId, | ||
| 4 | ThreadId, | ||
| 5 | TurnId, | ||
| 6 | type OrchestrationReadModel, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { Effect, Layer } from "effect"; | ||
| 9 | import { describe, expect, it } from "vitest"; | ||
| 10 | |||
| 11 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 12 | import { checkpointRefForThreadTurn } from "../Refs.ts"; | ||
| 13 | import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts"; | ||
| 14 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | ||
| 15 | import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts"; | ||
| 16 | |||
| 17 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | ||
| 18 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | ||
| 19 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | ||
| 20 | |||
| 21 | function makeSnapshot(input: { | ||
| 22 | readonly projectId: ProjectId; | ||
| 23 | readonly threadId: ThreadId; | ||
| 24 | readonly workspaceRoot: string; | ||
| 25 | readonly worktreePath: string | null; | ||
| 26 | readonly checkpointTurnCount: number; | ||
| 27 | readonly checkpointRef: CheckpointRef; | ||
| 28 | }): OrchestrationReadModel { | ||
| 29 | return { | ||
| 30 | snapshotSequence: 0, | ||
| 31 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 32 | projects: [ | ||
| 33 | { | ||
| 34 | id: input.projectId, | ||
| 35 | title: "Project", | ||
| 36 | workspaceRoot: input.workspaceRoot, | ||
| 37 | defaultModel: null, | ||
| 38 | scripts: [], | ||
| 39 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 40 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 41 | deletedAt: null, | ||
| 42 | }, | ||
| 43 | ], | ||
| 44 | threads: [ | ||
| 45 | { | ||
| 46 | id: input.threadId, | ||
| 47 | projectId: input.projectId, | ||
| 48 | title: "Thread", | ||
| 49 | model: "gpt-5-codex", | ||
| 50 | branch: null, | ||
| 51 | worktreePath: input.worktreePath, | ||
| 52 | latestTurnId: asTurnId("turn-1"), | ||
| 53 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 54 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 55 | deletedAt: null, | ||
| 56 | messages: [], | ||
| 57 | activities: [], | ||
| 58 | checkpoints: [ | ||
| 59 | { | ||
| 60 | turnId: asTurnId("turn-1"), | ||
| 61 | checkpointTurnCount: input.checkpointTurnCount, | ||
| 62 | checkpointRef: input.checkpointRef, | ||
| 63 | status: "ready", | ||
| 64 | files: [], | ||
| 65 | assistantMessageId: null, | ||
| 66 | completedAt: "2026-01-01T00:00:00.000Z", | ||
| 67 | }, | ||
| 68 | ], | ||
| 69 | session: null, | ||
| 70 | }, | ||
| 71 | ], | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | describe("CheckpointDiffQueryLive", () => { | ||
| 76 | it("computes diffs using canonical turn-0 checkpoint refs", async () => { | ||
| 77 | const projectId = asProjectId("project-1"); | ||
| 78 | const threadId = ThreadId.makeUnsafe("thread-1"); | ||
| 79 | const toCheckpointRef = asCheckpointRef("refs/t3/checkpoints/thread-1/turn/1"); | ||
| 80 | const hasCheckpointRefCalls: Array<CheckpointRef> = []; | ||
| 81 | const diffCheckpointsCalls: Array<{ | ||
| 82 | readonly fromCheckpointRef: CheckpointRef; | ||
| 83 | readonly toCheckpointRef: CheckpointRef; | ||
| 84 | readonly cwd: string; | ||
| 85 | }> = []; | ||
| 86 | |||
| 87 | const snapshot = makeSnapshot({ | ||
| 88 | projectId, | ||
| 89 | threadId, | ||
| 90 | workspaceRoot: "/tmp/workspace", | ||
| 91 | worktreePath: null, | ||
| 92 | checkpointTurnCount: 1, | ||
| 93 | checkpointRef: toCheckpointRef, | ||
| 94 | }); | ||
| 95 | |||
| 96 | const checkpointStore: CheckpointStoreShape = { | ||
| 97 | isGitRepository: () => Effect.succeed(true), | ||
| 98 | captureCheckpoint: () => Effect.void, | ||
| 99 | hasCheckpointRef: ({ checkpointRef }) => | ||
| 100 | Effect.sync(() => { | ||
| 101 | hasCheckpointRefCalls.push(checkpointRef); | ||
| 102 | return true; | ||
| 103 | }), | ||
| 104 | restoreCheckpoint: () => Effect.succeed(true), | ||
| 105 | diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) => | ||
| 106 | Effect.sync(() => { | ||
| 107 | diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd }); | ||
| 108 | return "diff patch"; | ||
| 109 | }), | ||
| 110 | deleteCheckpointRefs: () => Effect.void, | ||
| 111 | }; | ||
| 112 | |||
| 113 | const layer = CheckpointDiffQueryLive.pipe( | ||
| 114 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | ||
| 115 | Layer.provideMerge( | ||
| 116 | Layer.succeed(ProjectionSnapshotQuery, { | ||
| 117 | getSnapshot: () => Effect.succeed(snapshot), | ||
| 118 | }), | ||
| 119 | ), | ||
| 120 | ); | ||
| 121 | |||
| 122 | const result = await Effect.runPromise( | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const query = yield* CheckpointDiffQuery; | ||
| 125 | return yield* query.getTurnDiff({ | ||
| 126 | threadId, | ||
| 127 | fromTurnCount: 0, | ||
| 128 | toTurnCount: 1, | ||
| 129 | }); | ||
| 130 | }).pipe(Effect.provide(layer)), | ||
| 131 | ); | ||
| 132 | |||
| 133 | const expectedFromRef = checkpointRefForThreadTurn(threadId, 0); | ||
| 134 | expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]); | ||
| 135 | expect(diffCheckpointsCalls).toEqual([ | ||
| 136 | { | ||
| 137 | cwd: "/tmp/workspace", | ||
| 138 | fromCheckpointRef: expectedFromRef, | ||
| 139 | toCheckpointRef, | ||
| 140 | }, | ||
| 141 | ]); | ||
| 142 | expect(result).toEqual({ | ||
| 143 | threadId, | ||
| 144 | fromTurnCount: 0, | ||
| 145 | toTurnCount: 1, | ||
| 146 | diff: "diff patch", | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("fails when the thread is missing from the snapshot", async () => { | ||
| 151 | const threadId = ThreadId.makeUnsafe("thread-missing"); | ||
| 152 | |||
| 153 | const checkpointStore: CheckpointStoreShape = { | ||
| 154 | isGitRepository: () => Effect.succeed(true), | ||
| 155 | captureCheckpoint: () => Effect.void, | ||
| 156 | hasCheckpointRef: () => Effect.succeed(true), | ||
| 157 | restoreCheckpoint: () => Effect.succeed(true), | ||
| 158 | diffCheckpoints: () => Effect.succeed(""), | ||
| 159 | deleteCheckpointRefs: () => Effect.void, | ||
| 160 | }; | ||
| 161 | |||
| 162 | const layer = CheckpointDiffQueryLive.pipe( | ||
| 163 | Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)), | ||
| 164 | Layer.provideMerge( | ||
| 165 | Layer.succeed(ProjectionSnapshotQuery, { | ||
| 166 | getSnapshot: () => | ||
| 167 | Effect.succeed({ | ||
| 168 | snapshotSequence: 0, | ||
| 169 | projects: [], | ||
| 170 | threads: [], | ||
| 171 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 172 | } satisfies OrchestrationReadModel), | ||
| 173 | }), | ||
| 174 | ), | ||
| 175 | ); | ||
| 176 | |||
| 177 | await expect( | ||
| 178 | Effect.runPromise( | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const query = yield* CheckpointDiffQuery; | ||
| 181 | return yield* query.getTurnDiff({ | ||
| 182 | threadId, | ||
| 183 | fromTurnCount: 0, | ||
| 184 | toTurnCount: 1, | ||
| 185 | }); | ||
| 186 | }).pipe(Effect.provide(layer)), | ||
| 187 | ), | ||
| 188 | ).rejects.toThrow("Thread 'thread-missing' not found."); | ||
| 189 | }); | ||
| 190 | }); | ||
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts created+193| ... | @@ -0,0 +1,193 @@ | ||
| 1 | import { | ||
| 2 | OrchestrationGetTurnDiffResult, | ||
| 3 | type ProjectId, | ||
| 4 | type OrchestrationGetFullThreadDiffInput, | ||
| 5 | type OrchestrationGetFullThreadDiffResult, | ||
| 6 | type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { Effect, Layer, Schema } from "effect"; | ||
| 9 | |||
| 10 | import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 11 | import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts"; | ||
| 12 | import { checkpointRefForThreadTurn } from "../Refs.ts"; | ||
| 13 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | ||
| 14 | import { CheckpointDiffQuery, type CheckpointDiffQueryShape } from "../Services/CheckpointDiffQuery.ts"; | ||
| 15 | |||
| 16 | function trimToNonEmpty(value: string | null | undefined): string | undefined { | ||
| 17 | const normalized = value?.trim(); | ||
| 18 | return normalized && normalized.length > 0 ? normalized : undefined; | ||
| 19 | } | ||
| 20 | |||
| 21 | function resolveThreadWorkspaceCwd(input: { | ||
| 22 | readonly thread: { | ||
| 23 | readonly projectId: ProjectId; | ||
| 24 | readonly worktreePath: string | null; | ||
| 25 | }; | ||
| 26 | readonly projects: ReadonlyArray<{ | ||
| 27 | readonly id: ProjectId; | ||
| 28 | readonly workspaceRoot: string; | ||
| 29 | }>; | ||
| 30 | }): string | undefined { | ||
| 31 | const worktreeCwd = trimToNonEmpty(input.thread.worktreePath); | ||
| 32 | if (worktreeCwd) { | ||
| 33 | return worktreeCwd; | ||
| 34 | } | ||
| 35 | |||
| 36 | const workspaceRoot = input.projects.find( | ||
| 37 | (project) => project.id === input.thread.projectId, | ||
| 38 | )?.workspaceRoot; | ||
| 39 | return trimToNonEmpty(workspaceRoot); | ||
| 40 | } | ||
| 41 | |||
| 42 | const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult); | ||
| 43 | |||
| 44 | const make = Effect.gen(function* () { | ||
| 45 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 46 | const checkpointStore = yield* CheckpointStore; | ||
| 47 | |||
| 48 | const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) => | ||
| 49 | Effect.gen(function* () { | ||
| 50 | const operation = "CheckpointDiffQuery.getTurnDiff"; | ||
| 51 | |||
| 52 | if (input.fromTurnCount === input.toTurnCount) { | ||
| 53 | const emptyDiff: OrchestrationGetTurnDiffResultType = { | ||
| 54 | threadId: input.threadId, | ||
| 55 | fromTurnCount: input.fromTurnCount, | ||
| 56 | toTurnCount: input.toTurnCount, | ||
| 57 | diff: "", | ||
| 58 | }; | ||
| 59 | if (!isTurnDiffResult(emptyDiff)) { | ||
| 60 | return yield* new CheckpointInvariantError({ | ||
| 61 | operation, | ||
| 62 | detail: "Computed turn diff result does not satisfy contract schema.", | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | return emptyDiff; | ||
| 66 | } | ||
| 67 | |||
| 68 | const snapshot = yield* projectionSnapshotQuery.getSnapshot(); | ||
| 69 | const thread = snapshot.threads.find((entry) => entry.id === input.threadId); | ||
| 70 | if (!thread) { | ||
| 71 | return yield* new CheckpointInvariantError({ | ||
| 72 | operation, | ||
| 73 | detail: `Thread '${input.threadId}' not found.`, | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | |||
| 77 | const maxTurnCount = thread.checkpoints.reduce( | ||
| 78 | (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), | ||
| 79 | 0, | ||
| 80 | ); | ||
| 81 | if (input.toTurnCount > maxTurnCount) { | ||
| 82 | return yield* new CheckpointUnavailableError({ | ||
| 83 | threadId: input.threadId, | ||
| 84 | turnCount: input.toTurnCount, | ||
| 85 | detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`, | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | |||
| 89 | const workspaceCwd = resolveThreadWorkspaceCwd({ | ||
| 90 | thread, | ||
| 91 | projects: snapshot.projects, | ||
| 92 | }); | ||
| 93 | if (!workspaceCwd) { | ||
| 94 | return yield* new CheckpointInvariantError({ | ||
| 95 | operation, | ||
| 96 | detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`, | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | |||
| 100 | const fromCheckpointRef = | ||
| 101 | input.fromTurnCount === 0 | ||
| 102 | ? checkpointRefForThreadTurn(input.threadId, 0) | ||
| 103 | : thread.checkpoints.find( | ||
| 104 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, | ||
| 105 | )?.checkpointRef; | ||
| 106 | if (!fromCheckpointRef) { | ||
| 107 | return yield* new CheckpointUnavailableError({ | ||
| 108 | threadId: input.threadId, | ||
| 109 | turnCount: input.fromTurnCount, | ||
| 110 | detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`, | ||
| 111 | }); | ||
| 112 | } | ||
| 113 | |||
| 114 | const toCheckpointRef = thread.checkpoints.find( | ||
| 115 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, | ||
| 116 | )?.checkpointRef; | ||
| 117 | if (!toCheckpointRef) { | ||
| 118 | return yield* new CheckpointUnavailableError({ | ||
| 119 | threadId: input.threadId, | ||
| 120 | turnCount: input.toTurnCount, | ||
| 121 | detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`, | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | const [fromExists, toExists] = yield* Effect.all( | ||
| 126 | [ | ||
| 127 | checkpointStore.hasCheckpointRef({ | ||
| 128 | cwd: workspaceCwd, | ||
| 129 | checkpointRef: fromCheckpointRef, | ||
| 130 | }), | ||
| 131 | checkpointStore.hasCheckpointRef({ | ||
| 132 | cwd: workspaceCwd, | ||
| 133 | checkpointRef: toCheckpointRef, | ||
| 134 | }), | ||
| 135 | ], | ||
| 136 | { concurrency: "unbounded" }, | ||
| 137 | ); | ||
| 138 | |||
| 139 | if (!fromExists) { | ||
| 140 | return yield* new CheckpointUnavailableError({ | ||
| 141 | threadId: input.threadId, | ||
| 142 | turnCount: input.fromTurnCount, | ||
| 143 | detail: `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount}.`, | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | |||
| 147 | if (!toExists) { | ||
| 148 | return yield* new CheckpointUnavailableError({ | ||
| 149 | threadId: input.threadId, | ||
| 150 | turnCount: input.toTurnCount, | ||
| 151 | detail: `Filesystem checkpoint is unavailable for turn ${input.toTurnCount}.`, | ||
| 152 | }); | ||
| 153 | } | ||
| 154 | |||
| 155 | const diff = yield* checkpointStore.diffCheckpoints({ | ||
| 156 | cwd: workspaceCwd, | ||
| 157 | fromCheckpointRef, | ||
| 158 | toCheckpointRef, | ||
| 159 | fallbackFromToHead: false, | ||
| 160 | }); | ||
| 161 | |||
| 162 | const turnDiff: OrchestrationGetTurnDiffResultType = { | ||
| 163 | threadId: input.threadId, | ||
| 164 | fromTurnCount: input.fromTurnCount, | ||
| 165 | toTurnCount: input.toTurnCount, | ||
| 166 | diff, | ||
| 167 | }; | ||
| 168 | if (!isTurnDiffResult(turnDiff)) { | ||
| 169 | return yield* new CheckpointInvariantError({ | ||
| 170 | operation, | ||
| 171 | detail: "Computed turn diff result does not satisfy contract schema.", | ||
| 172 | }); | ||
| 173 | } | ||
| 174 | |||
| 175 | return turnDiff; | ||
| 176 | }); | ||
| 177 | |||
| 178 | const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = ( | ||
| 179 | input: OrchestrationGetFullThreadDiffInput, | ||
| 180 | ) => | ||
| 181 | getTurnDiff({ | ||
| 182 | threadId: input.threadId, | ||
| 183 | fromTurnCount: 0, | ||
| 184 | toTurnCount: input.toTurnCount, | ||
| 185 | }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result)); | ||
| 186 | |||
| 187 | return { | ||
| 188 | getTurnDiff, | ||
| 189 | getFullThreadDiff, | ||
| 190 | } satisfies CheckpointDiffQueryShape; | ||
| 191 | }); | ||
| 192 | |||
| 193 | export const CheckpointDiffQueryLive = Layer.effect(CheckpointDiffQuery, make); | ||
apps/server/src/checkpointing/Refs.ts created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | import { Encoding } from "effect"; | ||
| 2 | import { CheckpointRef, type ThreadId } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | ||
| 5 | |||
| 6 | export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { | ||
| 7 | return CheckpointRef.makeUnsafe( | ||
| 8 | `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`, | ||
| 9 | ); | ||
| 10 | } | ||
apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import type { | ||
| 2 | OrchestrationGetFullThreadDiffInput, | ||
| 3 | OrchestrationGetFullThreadDiffResult, | ||
| 4 | OrchestrationGetTurnDiffInput, | ||
| 5 | OrchestrationGetTurnDiffResult, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { ServiceMap } from "effect"; | ||
| 8 | import type { Effect } from "effect"; | ||
| 9 | |||
| 10 | import type { CheckpointServiceError } from "../Errors.ts"; | ||
| 11 | |||
| 12 | export interface CheckpointDiffQueryShape { | ||
| 13 | readonly getTurnDiff: ( | ||
| 14 | input: OrchestrationGetTurnDiffInput, | ||
| 15 | ) => Effect.Effect<OrchestrationGetTurnDiffResult, CheckpointServiceError>; | ||
| 16 | readonly getFullThreadDiff: ( | ||
| 17 | input: OrchestrationGetFullThreadDiffInput, | ||
| 18 | ) => Effect.Effect<OrchestrationGetFullThreadDiffResult, CheckpointServiceError>; | ||
| 19 | } | ||
| 20 | |||
| 21 | export class CheckpointDiffQuery extends ServiceMap.Service< | ||
| 22 | CheckpointDiffQuery, | ||
| 23 | CheckpointDiffQueryShape | ||
| 24 | >()("checkpointing/CheckpointDiffQuery") {} | ||
apps/server/src/git.ts+4-4| ... | @@ -216,7 +216,7 @@ export class GitCoreService { | ... | @@ -216,7 +216,7 @@ export class GitCoreService { |
| 216 | 216 | ||
| 217 | async status(input: GitStatusInput): Promise<GitStatusResult> { | 217 | async status(input: GitStatusInput): Promise<GitStatusResult> { |
| 218 | const details = await this.statusDetails(input.cwd); | 218 | const details = await this.statusDetails(input.cwd); |
| 219 | return GitStatusResult.makeUnsafe({ | 219 | return { |
| 220 | branch: details.branch, | 220 | branch: details.branch, |
| 221 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | 221 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 222 | workingTree: details.workingTree, | 222 | workingTree: details.workingTree, |
| ... | @@ -224,7 +224,7 @@ export class GitCoreService { | ... | @@ -224,7 +224,7 @@ export class GitCoreService { |
| 224 | aheadCount: details.aheadCount, | 224 | aheadCount: details.aheadCount, |
| 225 | behindCount: details.behindCount, | 225 | behindCount: details.behindCount, |
| 226 | openPr: null, | 226 | openPr: null, |
| 227 | }); | 227 | }; |
| 228 | } | 228 | } |
| 229 | 229 | ||
| 230 | async statusDetails(cwd: string): Promise<GitStatusDetails> { | 230 | async statusDetails(cwd: string): Promise<GitStatusDetails> { |
| ... | @@ -385,11 +385,11 @@ export class GitCoreService { | ... | @@ -385,11 +385,11 @@ export class GitCoreService { |
| 385 | }); | 385 | }); |
| 386 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); | 386 | const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true)); |
| 387 | const refreshed = await this.statusDetails(cwd); | 387 | const refreshed = await this.statusDetails(cwd); |
| 388 | return GitPullResult.makeUnsafe({ | 388 | return { |
| 389 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | 389 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", |
| 390 | branch, | 390 | branch, |
| 391 | upstreamBranch: refreshed.upstreamRef, | 391 | upstreamBranch: refreshed.upstreamRef, |
| 392 | }); | 392 | }; |
| 393 | } | 393 | } |
| 394 | 394 | ||
| 395 | async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { | 395 | async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> { |
apps/server/src/keybindings.test.ts+1-1| ... | @@ -40,7 +40,7 @@ const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRu | ... | @@ -40,7 +40,7 @@ const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRu |
| 40 | Effect.gen(function* () { | 40 | Effect.gen(function* () { |
| 41 | const fileSystem = yield* FileSystem.FileSystem; | 41 | const fileSystem = yield* FileSystem.FileSystem; |
| 42 | const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)( | 42 | const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)( |
| 43 | rules.map((rule) => KeybindingRule.makeUnsafe(rule)), | 43 | rules, |
| 44 | ); | 44 | ); |
| 45 | yield* fileSystem.writeFileString(configPath, encoded); | 45 | yield* fileSystem.writeFileString(configPath, encoded); |
| 46 | }); | 46 | }); |
apps/server/src/keybindings.ts+2-2| ... | @@ -313,7 +313,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( | ... | @@ -313,7 +313,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( |
| 313 | title: "Invalid keybinding rule", | 313 | title: "Invalid keybinding rule", |
| 314 | }), | 314 | }), |
| 315 | ), | 315 | ), |
| 316 | Effect.map((resolved) => ResolvedKeybindingRule.makeUnsafe(resolved)), | 316 | Effect.map((resolved) => resolved), |
| 317 | ), | 317 | ), |
| 318 | 318 | ||
| 319 | encode: (resolved) => | 319 | encode: (resolved) => |
| ... | @@ -456,7 +456,7 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -456,7 +456,7 @@ const makeKeybindings = Effect.gen(function* () { |
| 456 | const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; | 456 | const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; |
| 457 | 457 | ||
| 458 | return Schema.encodeEffect(KeybindingsConfigJson)( | 458 | return Schema.encodeEffect(KeybindingsConfigJson)( |
| 459 | rules.map((rule) => KeybindingRule.makeUnsafe(rule)), | 459 | rules, |
| 460 | ).pipe( | 460 | ).pipe( |
| 461 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), | 461 | Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), |
| 462 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), | 462 | Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), |
apps/server/src/orchestration/Layer.test.ts+33-35| ... | @@ -22,9 +22,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; | ... | @@ -22,9 +22,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; |
| 22 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; | 22 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; |
| 23 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; | 23 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 24 | 24 | ||
| 25 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | ||
| 26 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 25 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 27 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 28 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | 26 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 29 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | 27 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 30 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | 28 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); |
| ... | @@ -58,7 +56,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -58,7 +56,7 @@ describe("OrchestrationEngine", () => { |
| 58 | await system.run( | 56 | await system.run( |
| 59 | engine.dispatch({ | 57 | engine.dispatch({ |
| 60 | type: "project.create", | 58 | type: "project.create", |
| 61 | commandId: asCommandId("cmd-project-1-create"), | 59 | commandId: CommandId.makeUnsafe("cmd-project-1-create"), |
| 62 | projectId: asProjectId("project-1"), | 60 | projectId: asProjectId("project-1"), |
| 63 | title: "Project 1", | 61 | title: "Project 1", |
| 64 | workspaceRoot: "/tmp/project-1", | 62 | workspaceRoot: "/tmp/project-1", |
| ... | @@ -69,8 +67,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -69,8 +67,8 @@ describe("OrchestrationEngine", () => { |
| 69 | await system.run( | 67 | await system.run( |
| 70 | engine.dispatch({ | 68 | engine.dispatch({ |
| 71 | type: "thread.create", | 69 | type: "thread.create", |
| 72 | commandId: asCommandId("cmd-thread-1-create"), | 70 | commandId: CommandId.makeUnsafe("cmd-thread-1-create"), |
| 73 | threadId: asThreadId("thread-1"), | 71 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 74 | projectId: asProjectId("project-1"), | 72 | projectId: asProjectId("project-1"), |
| 75 | title: "Thread", | 73 | title: "Thread", |
| 76 | model: "gpt-5-codex", | 74 | model: "gpt-5-codex", |
| ... | @@ -82,8 +80,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -82,8 +80,8 @@ describe("OrchestrationEngine", () => { |
| 82 | await system.run( | 80 | await system.run( |
| 83 | engine.dispatch({ | 81 | engine.dispatch({ |
| 84 | type: "thread.turn.start", | 82 | type: "thread.turn.start", |
| 85 | commandId: asCommandId("cmd-turn-start-1"), | 83 | commandId: CommandId.makeUnsafe("cmd-turn-start-1"), |
| 86 | threadId: asThreadId("thread-1"), | 84 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 87 | message: { | 85 | message: { |
| 88 | messageId: asMessageId("msg-1"), | 86 | messageId: asMessageId("msg-1"), |
| 89 | role: "user", | 87 | role: "user", |
| ... | @@ -108,7 +106,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -108,7 +106,7 @@ describe("OrchestrationEngine", () => { |
| 108 | await system.run( | 106 | await system.run( |
| 109 | engine.dispatch({ | 107 | engine.dispatch({ |
| 110 | type: "project.create", | 108 | type: "project.create", |
| 111 | commandId: asCommandId("cmd-project-replay-create"), | 109 | commandId: CommandId.makeUnsafe("cmd-project-replay-create"), |
| 112 | projectId: asProjectId("project-replay"), | 110 | projectId: asProjectId("project-replay"), |
| 113 | title: "Replay Project", | 111 | title: "Replay Project", |
| 114 | workspaceRoot: "/tmp/project-replay", | 112 | workspaceRoot: "/tmp/project-replay", |
| ... | @@ -119,8 +117,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -119,8 +117,8 @@ describe("OrchestrationEngine", () => { |
| 119 | await system.run( | 117 | await system.run( |
| 120 | engine.dispatch({ | 118 | engine.dispatch({ |
| 121 | type: "thread.create", | 119 | type: "thread.create", |
| 122 | commandId: asCommandId("cmd-thread-replay-create"), | 120 | commandId: CommandId.makeUnsafe("cmd-thread-replay-create"), |
| 123 | threadId: asThreadId("thread-replay"), | 121 | threadId: ThreadId.makeUnsafe("thread-replay"), |
| 124 | projectId: asProjectId("project-replay"), | 122 | projectId: asProjectId("project-replay"), |
| 125 | title: "replay", | 123 | title: "replay", |
| 126 | model: "gpt-5-codex", | 124 | model: "gpt-5-codex", |
| ... | @@ -132,8 +130,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -132,8 +130,8 @@ describe("OrchestrationEngine", () => { |
| 132 | await system.run( | 130 | await system.run( |
| 133 | engine.dispatch({ | 131 | engine.dispatch({ |
| 134 | type: "thread.delete", | 132 | type: "thread.delete", |
| 135 | commandId: asCommandId("cmd-thread-replay-delete"), | 133 | commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"), |
| 136 | threadId: asThreadId("thread-replay"), | 134 | threadId: ThreadId.makeUnsafe("thread-replay"), |
| 137 | }), | 135 | }), |
| 138 | ); | 136 | ); |
| 139 | 137 | ||
| ... | @@ -158,7 +156,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -158,7 +156,7 @@ describe("OrchestrationEngine", () => { |
| 158 | await system.run( | 156 | await system.run( |
| 159 | engine.dispatch({ | 157 | engine.dispatch({ |
| 160 | type: "project.create", | 158 | type: "project.create", |
| 161 | commandId: asCommandId("cmd-project-stream-create"), | 159 | commandId: CommandId.makeUnsafe("cmd-project-stream-create"), |
| 162 | projectId: asProjectId("project-stream"), | 160 | projectId: asProjectId("project-stream"), |
| 163 | title: "Stream Project", | 161 | title: "Stream Project", |
| 164 | workspaceRoot: "/tmp/project-stream", | 162 | workspaceRoot: "/tmp/project-stream", |
| ... | @@ -179,8 +177,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -179,8 +177,8 @@ describe("OrchestrationEngine", () => { |
| 179 | yield* Effect.sleep("10 millis"); | 177 | yield* Effect.sleep("10 millis"); |
| 180 | yield* engine.dispatch({ | 178 | yield* engine.dispatch({ |
| 181 | type: "thread.create", | 179 | type: "thread.create", |
| 182 | commandId: asCommandId("cmd-stream-thread-create"), | 180 | commandId: CommandId.makeUnsafe("cmd-stream-thread-create"), |
| 183 | threadId: asThreadId("thread-stream"), | 181 | threadId: ThreadId.makeUnsafe("thread-stream"), |
| 184 | projectId: asProjectId("project-stream"), | 182 | projectId: asProjectId("project-stream"), |
| 185 | title: "domain-stream", | 183 | title: "domain-stream", |
| 186 | model: "gpt-5-codex", | 184 | model: "gpt-5-codex", |
| ... | @@ -190,8 +188,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -190,8 +188,8 @@ describe("OrchestrationEngine", () => { |
| 190 | }); | 188 | }); |
| 191 | yield* engine.dispatch({ | 189 | yield* engine.dispatch({ |
| 192 | type: "thread.meta.update", | 190 | type: "thread.meta.update", |
| 193 | commandId: asCommandId("cmd-stream-thread-update"), | 191 | commandId: CommandId.makeUnsafe("cmd-stream-thread-update"), |
| 194 | threadId: asThreadId("thread-stream"), | 192 | threadId: ThreadId.makeUnsafe("thread-stream"), |
| 195 | title: "domain-stream-updated", | 193 | title: "domain-stream-updated", |
| 196 | }); | 194 | }); |
| 197 | eventTypes.push((yield* Queue.take(eventQueue)).type); | 195 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| ... | @@ -211,7 +209,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -211,7 +209,7 @@ describe("OrchestrationEngine", () => { |
| 211 | await system.run( | 209 | await system.run( |
| 212 | engine.dispatch({ | 210 | engine.dispatch({ |
| 213 | type: "project.create", | 211 | type: "project.create", |
| 214 | commandId: asCommandId("cmd-project-turn-diff-create"), | 212 | commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"), |
| 215 | projectId: asProjectId("project-turn-diff"), | 213 | projectId: asProjectId("project-turn-diff"), |
| 216 | title: "Turn Diff Project", | 214 | title: "Turn Diff Project", |
| 217 | workspaceRoot: "/tmp/project-turn-diff", | 215 | workspaceRoot: "/tmp/project-turn-diff", |
| ... | @@ -222,8 +220,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -222,8 +220,8 @@ describe("OrchestrationEngine", () => { |
| 222 | await system.run( | 220 | await system.run( |
| 223 | engine.dispatch({ | 221 | engine.dispatch({ |
| 224 | type: "thread.create", | 222 | type: "thread.create", |
| 225 | commandId: asCommandId("cmd-thread-turn-diff-create"), | 223 | commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"), |
| 226 | threadId: asThreadId("thread-turn-diff"), | 224 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), |
| 227 | projectId: asProjectId("project-turn-diff"), | 225 | projectId: asProjectId("project-turn-diff"), |
| 228 | title: "Turn diff thread", | 226 | title: "Turn diff thread", |
| 229 | model: "gpt-5-codex", | 227 | model: "gpt-5-codex", |
| ... | @@ -235,8 +233,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -235,8 +233,8 @@ describe("OrchestrationEngine", () => { |
| 235 | await system.run( | 233 | await system.run( |
| 236 | engine.dispatch({ | 234 | engine.dispatch({ |
| 237 | type: "thread.turn.diff.complete", | 235 | type: "thread.turn.diff.complete", |
| 238 | commandId: asCommandId("cmd-turn-diff-complete"), | 236 | commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"), |
| 239 | threadId: asThreadId("thread-turn-diff"), | 237 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), |
| 240 | turnId: asTurnId("turn-1"), | 238 | turnId: asTurnId("turn-1"), |
| 241 | completedAt: createdAt, | 239 | completedAt: createdAt, |
| 242 | checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), | 240 | checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), |
| ... | @@ -275,7 +273,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -275,7 +273,7 @@ describe("OrchestrationEngine", () => { |
| 275 | 273 | ||
| 276 | const flakyStore: OrchestrationEventStoreShape = { | 274 | const flakyStore: OrchestrationEventStoreShape = { |
| 277 | append(event) { | 275 | append(event) { |
| 278 | if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) { | 276 | if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) { |
| 279 | shouldFailFirstAppend = false; | 277 | shouldFailFirstAppend = false; |
| 280 | return Effect.fail( | 278 | return Effect.fail( |
| 281 | new PersistenceSqlError({ | 279 | new PersistenceSqlError({ |
| ... | @@ -314,7 +312,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -314,7 +312,7 @@ describe("OrchestrationEngine", () => { |
| 314 | await runtime.runPromise( | 312 | await runtime.runPromise( |
| 315 | engine.dispatch({ | 313 | engine.dispatch({ |
| 316 | type: "project.create", | 314 | type: "project.create", |
| 317 | commandId: asCommandId("cmd-project-flaky-create"), | 315 | commandId: CommandId.makeUnsafe("cmd-project-flaky-create"), |
| 318 | projectId: asProjectId("project-flaky"), | 316 | projectId: asProjectId("project-flaky"), |
| 319 | title: "Flaky Project", | 317 | title: "Flaky Project", |
| 320 | workspaceRoot: "/tmp/project-flaky", | 318 | workspaceRoot: "/tmp/project-flaky", |
| ... | @@ -327,8 +325,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -327,8 +325,8 @@ describe("OrchestrationEngine", () => { |
| 327 | runtime.runPromise( | 325 | runtime.runPromise( |
| 328 | engine.dispatch({ | 326 | engine.dispatch({ |
| 329 | type: "thread.create", | 327 | type: "thread.create", |
| 330 | commandId: asCommandId("cmd-flaky-1"), | 328 | commandId: CommandId.makeUnsafe("cmd-flaky-1"), |
| 331 | threadId: asThreadId("thread-flaky-fail"), | 329 | threadId: ThreadId.makeUnsafe("thread-flaky-fail"), |
| 332 | projectId: asProjectId("project-flaky"), | 330 | projectId: asProjectId("project-flaky"), |
| 333 | title: "flaky-fail", | 331 | title: "flaky-fail", |
| 334 | model: "gpt-5-codex", | 332 | model: "gpt-5-codex", |
| ... | @@ -342,8 +340,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -342,8 +340,8 @@ describe("OrchestrationEngine", () => { |
| 342 | const result = await runtime.runPromise( | 340 | const result = await runtime.runPromise( |
| 343 | engine.dispatch({ | 341 | engine.dispatch({ |
| 344 | type: "thread.create", | 342 | type: "thread.create", |
| 345 | commandId: asCommandId("cmd-flaky-2"), | 343 | commandId: CommandId.makeUnsafe("cmd-flaky-2"), |
| 346 | threadId: asThreadId("thread-flaky-ok"), | 344 | threadId: ThreadId.makeUnsafe("thread-flaky-ok"), |
| 347 | projectId: asProjectId("project-flaky"), | 345 | projectId: asProjectId("project-flaky"), |
| 348 | title: "flaky-ok", | 346 | title: "flaky-ok", |
| 349 | model: "gpt-5-codex", | 347 | model: "gpt-5-codex", |
| ... | @@ -366,8 +364,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -366,8 +364,8 @@ describe("OrchestrationEngine", () => { |
| 366 | system.run( | 364 | system.run( |
| 367 | engine.dispatch({ | 365 | engine.dispatch({ |
| 368 | type: "thread.turn.start", | 366 | type: "thread.turn.start", |
| 369 | commandId: asCommandId("cmd-invariant-missing-thread"), | 367 | commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"), |
| 370 | threadId: asThreadId("thread-missing"), | 368 | threadId: ThreadId.makeUnsafe("thread-missing"), |
| 371 | message: { | 369 | message: { |
| 372 | messageId: asMessageId("msg-missing"), | 370 | messageId: asMessageId("msg-missing"), |
| 373 | role: "user", | 371 | role: "user", |
| ... | @@ -390,7 +388,7 @@ describe("OrchestrationEngine", () => { | ... | @@ -390,7 +388,7 @@ describe("OrchestrationEngine", () => { |
| 390 | await system.run( | 388 | await system.run( |
| 391 | engine.dispatch({ | 389 | engine.dispatch({ |
| 392 | type: "project.create", | 390 | type: "project.create", |
| 393 | commandId: asCommandId("cmd-project-duplicate-create"), | 391 | commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"), |
| 394 | projectId: asProjectId("project-duplicate"), | 392 | projectId: asProjectId("project-duplicate"), |
| 395 | title: "Duplicate Project", | 393 | title: "Duplicate Project", |
| 396 | workspaceRoot: "/tmp/project-duplicate", | 394 | workspaceRoot: "/tmp/project-duplicate", |
| ... | @@ -402,8 +400,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -402,8 +400,8 @@ describe("OrchestrationEngine", () => { |
| 402 | await system.run( | 400 | await system.run( |
| 403 | engine.dispatch({ | 401 | engine.dispatch({ |
| 404 | type: "thread.create", | 402 | type: "thread.create", |
| 405 | commandId: asCommandId("cmd-thread-duplicate-1"), | 403 | commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"), |
| 406 | threadId: asThreadId("thread-duplicate"), | 404 | threadId: ThreadId.makeUnsafe("thread-duplicate"), |
| 407 | projectId: asProjectId("project-duplicate"), | 405 | projectId: asProjectId("project-duplicate"), |
| 408 | title: "duplicate", | 406 | title: "duplicate", |
| 409 | model: "gpt-5-codex", | 407 | model: "gpt-5-codex", |
| ... | @@ -417,8 +415,8 @@ describe("OrchestrationEngine", () => { | ... | @@ -417,8 +415,8 @@ describe("OrchestrationEngine", () => { |
| 417 | system.run( | 415 | system.run( |
| 418 | engine.dispatch({ | 416 | engine.dispatch({ |
| 419 | type: "thread.create", | 417 | type: "thread.create", |
| 420 | commandId: asCommandId("cmd-thread-duplicate-2"), | 418 | commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"), |
| 421 | threadId: asThreadId("thread-duplicate"), | 419 | threadId: ThreadId.makeUnsafe("thread-duplicate"), |
| 422 | projectId: asProjectId("project-duplicate"), | 420 | projectId: asProjectId("project-duplicate"), |
| 423 | title: "duplicate", | 421 | title: "duplicate", |
| 424 | model: "gpt-5-codex", | 422 | model: "gpt-5-codex", |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+66-49| ... | @@ -5,7 +5,6 @@ import { execFileSync } from "node:child_process"; | ... | @@ -5,7 +5,6 @@ import { execFileSync } from "node:child_process"; |
| 5 | 5 | ||
| 6 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | 6 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 8 | CheckpointRef, | ||
| 9 | CommandId, | 8 | CommandId, |
| 10 | EventId, | 9 | EventId, |
| 11 | MessageId, | 10 | MessageId, |
| ... | @@ -36,19 +35,13 @@ import { | ... | @@ -36,19 +35,13 @@ import { |
| 36 | ProviderService, | 35 | ProviderService, |
| 37 | type ProviderServiceShape, | 36 | type ProviderServiceShape, |
| 38 | } from "../../provider/Services/ProviderService.ts"; | 37 | } from "../../provider/Services/ProviderService.ts"; |
| 38 | import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts"; | ||
| 39 | 39 | ||
| 40 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | ||
| 41 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 40 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 42 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 43 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | 41 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 44 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | 42 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); |
| 45 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | 43 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 46 | 44 | ||
| 47 | function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef { | ||
| 48 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | ||
| 49 | return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`); | ||
| 50 | } | ||
| 51 | |||
| 52 | function createProviderServiceHarness(cwd: string, hasSession = true) { | 45 | function createProviderServiceHarness(cwd: string, hasSession = true) { |
| 53 | const now = new Date().toISOString(); | 46 | const now = new Date().toISOString(); |
| 54 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 47 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| ... | @@ -109,7 +102,7 @@ async function waitForThread( | ... | @@ -109,7 +102,7 @@ async function waitForThread( |
| 109 | activities: ReadonlyArray<{ kind: string }>; | 102 | activities: ReadonlyArray<{ kind: string }>; |
| 110 | }> => { | 103 | }> => { |
| 111 | const readModel = await Effect.runPromise(engine.getReadModel()); | 104 | const readModel = await Effect.runPromise(engine.getReadModel()); |
| 112 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | 105 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 113 | if (thread && predicate(thread)) { | 106 | if (thread && predicate(thread)) { |
| 114 | return thread; | 107 | return thread; |
| 115 | } | 108 | } |
| ... | @@ -250,7 +243,7 @@ describe("CheckpointReactor", () => { | ... | @@ -250,7 +243,7 @@ describe("CheckpointReactor", () => { |
| 250 | await Effect.runPromise( | 243 | await Effect.runPromise( |
| 251 | engine.dispatch({ | 244 | engine.dispatch({ |
| 252 | type: "project.create", | 245 | type: "project.create", |
| 253 | commandId: asCommandId("cmd-project-create"), | 246 | commandId: CommandId.makeUnsafe("cmd-project-create"), |
| 254 | projectId: asProjectId("project-1"), | 247 | projectId: asProjectId("project-1"), |
| 255 | title: "Test Project", | 248 | title: "Test Project", |
| 256 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, | 249 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, |
| ... | @@ -261,8 +254,8 @@ describe("CheckpointReactor", () => { | ... | @@ -261,8 +254,8 @@ describe("CheckpointReactor", () => { |
| 261 | await Effect.runPromise( | 254 | await Effect.runPromise( |
| 262 | engine.dispatch({ | 255 | engine.dispatch({ |
| 263 | type: "thread.create", | 256 | type: "thread.create", |
| 264 | commandId: asCommandId("cmd-thread-create"), | 257 | commandId: CommandId.makeUnsafe("cmd-thread-create"), |
| 265 | threadId: asThreadId("thread-1"), | 258 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 266 | projectId: asProjectId("project-1"), | 259 | projectId: asProjectId("project-1"), |
| 267 | title: "Thread", | 260 | title: "Thread", |
| 268 | model: "gpt-5-codex", | 261 | model: "gpt-5-codex", |
| ... | @@ -276,21 +269,21 @@ describe("CheckpointReactor", () => { | ... | @@ -276,21 +269,21 @@ describe("CheckpointReactor", () => { |
| 276 | await runtime.runPromise( | 269 | await runtime.runPromise( |
| 277 | checkpointStore.captureCheckpoint({ | 270 | checkpointStore.captureCheckpoint({ |
| 278 | cwd, | 271 | cwd, |
| 279 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 0), | 272 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), |
| 280 | }), | 273 | }), |
| 281 | ); | 274 | ); |
| 282 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); | 275 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); |
| 283 | await runtime.runPromise( | 276 | await runtime.runPromise( |
| 284 | checkpointStore.captureCheckpoint({ | 277 | checkpointStore.captureCheckpoint({ |
| 285 | cwd, | 278 | cwd, |
| 286 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1), | 279 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), |
| 287 | }), | 280 | }), |
| 288 | ); | 281 | ); |
| 289 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); | 282 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); |
| 290 | await runtime.runPromise( | 283 | await runtime.runPromise( |
| 291 | checkpointStore.captureCheckpoint({ | 284 | checkpointStore.captureCheckpoint({ |
| 292 | cwd, | 285 | cwd, |
| 293 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2), | 286 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), |
| 294 | }), | 287 | }), |
| 295 | ); | 288 | ); |
| 296 | } | 289 | } |
| ... | @@ -309,10 +302,10 @@ describe("CheckpointReactor", () => { | ... | @@ -309,10 +302,10 @@ describe("CheckpointReactor", () => { |
| 309 | await Effect.runPromise( | 302 | await Effect.runPromise( |
| 310 | harness.engine.dispatch({ | 303 | harness.engine.dispatch({ |
| 311 | type: "thread.session.set", | 304 | type: "thread.session.set", |
| 312 | commandId: asCommandId("cmd-session-set-capture"), | 305 | commandId: CommandId.makeUnsafe("cmd-session-set-capture"), |
| 313 | threadId: asThreadId("thread-1"), | 306 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 314 | session: { | 307 | session: { |
| 315 | threadId: asThreadId("thread-1"), | 308 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 316 | status: "ready", | 309 | status: "ready", |
| 317 | providerName: "codex", | 310 | providerName: "codex", |
| 318 | providerSessionId: asSessionId("sess-1"), | 311 | providerSessionId: asSessionId("sess-1"), |
| ... | @@ -334,7 +327,7 @@ describe("CheckpointReactor", () => { | ... | @@ -334,7 +327,7 @@ describe("CheckpointReactor", () => { |
| 334 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | 327 | threadId: ProviderThreadId.makeUnsafe("provider-thread-1"), |
| 335 | turnId: asTurnId("turn-1"), | 328 | turnId: asTurnId("turn-1"), |
| 336 | }); | 329 | }); |
| 337 | await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0)); | 330 | await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)); |
| 338 | 331 | ||
| 339 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); | 332 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 340 | harness.provider.emit({ | 333 | harness.provider.emit({ |
| ... | @@ -354,13 +347,25 @@ describe("CheckpointReactor", () => { | ... | @@ -354,13 +347,25 @@ describe("CheckpointReactor", () => { |
| 354 | (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1, | 347 | (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1, |
| 355 | ); | 348 | ); |
| 356 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | 349 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 357 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0))).toBe(true); | 350 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0))).toBe( |
| 358 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true); | 351 | true, |
| 352 | ); | ||
| 353 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1))).toBe( | ||
| 354 | true, | ||
| 355 | ); | ||
| 359 | expect( | 356 | expect( |
| 360 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"), | 357 | gitShowFileAtRef( |
| 358 | harness.cwd, | ||
| 359 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | ||
| 360 | "README.md", | ||
| 361 | ), | ||
| 361 | ).toBe("v1\n"); | 362 | ).toBe("v1\n"); |
| 362 | expect( | 363 | expect( |
| 363 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"), | 364 | gitShowFileAtRef( |
| 365 | harness.cwd, | ||
| 366 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | ||
| 367 | "README.md", | ||
| 368 | ), | ||
| 364 | ).toBe("v2\n"); | 369 | ).toBe("v2\n"); |
| 365 | }); | 370 | }); |
| 366 | 371 | ||
| ... | @@ -374,8 +379,8 @@ describe("CheckpointReactor", () => { | ... | @@ -374,8 +379,8 @@ describe("CheckpointReactor", () => { |
| 374 | await Effect.runPromise( | 379 | await Effect.runPromise( |
| 375 | harness.engine.dispatch({ | 380 | harness.engine.dispatch({ |
| 376 | type: "thread.turn.start", | 381 | type: "thread.turn.start", |
| 377 | commandId: asCommandId("cmd-turn-start-for-baseline"), | 382 | commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"), |
| 378 | threadId: asThreadId("thread-1"), | 383 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 379 | message: { | 384 | message: { |
| 380 | messageId: MessageId.makeUnsafe("message-user-1"), | 385 | messageId: MessageId.makeUnsafe("message-user-1"), |
| 381 | role: "user", | 386 | role: "user", |
| ... | @@ -386,9 +391,13 @@ describe("CheckpointReactor", () => { | ... | @@ -386,9 +391,13 @@ describe("CheckpointReactor", () => { |
| 386 | }), | 391 | }), |
| 387 | ); | 392 | ); |
| 388 | 393 | ||
| 389 | await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0)); | 394 | await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)); |
| 390 | expect( | 395 | expect( |
| 391 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"), | 396 | gitShowFileAtRef( |
| 397 | harness.cwd, | ||
| 398 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | ||
| 399 | "README.md", | ||
| 400 | ), | ||
| 392 | ).toBe("v1\n"); | 401 | ).toBe("v1\n"); |
| 393 | }); | 402 | }); |
| 394 | 403 | ||
| ... | @@ -403,10 +412,10 @@ describe("CheckpointReactor", () => { | ... | @@ -403,10 +412,10 @@ describe("CheckpointReactor", () => { |
| 403 | await Effect.runPromise( | 412 | await Effect.runPromise( |
| 404 | harness.engine.dispatch({ | 413 | harness.engine.dispatch({ |
| 405 | type: "thread.session.set", | 414 | type: "thread.session.set", |
| 406 | commandId: asCommandId("cmd-session-set-missing-provider-cwd"), | 415 | commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"), |
| 407 | threadId: asThreadId("thread-1"), | 416 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 408 | session: { | 417 | session: { |
| 409 | threadId: asThreadId("thread-1"), | 418 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 410 | status: "running", | 419 | status: "running", |
| 411 | providerName: "codex", | 420 | providerName: "codex", |
| 412 | providerSessionId: asSessionId("sess-missing"), | 421 | providerSessionId: asSessionId("sess-missing"), |
| ... | @@ -432,9 +441,15 @@ describe("CheckpointReactor", () => { | ... | @@ -432,9 +441,15 @@ describe("CheckpointReactor", () => { |
| 432 | }); | 441 | }); |
| 433 | 442 | ||
| 434 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); | 443 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); |
| 435 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true); | 444 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1))).toBe( |
| 445 | true, | ||
| 446 | ); | ||
| 436 | expect( | 447 | expect( |
| 437 | gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"), | 448 | gitShowFileAtRef( |
| 449 | harness.cwd, | ||
| 450 | checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | ||
| 451 | "README.md", | ||
| 452 | ), | ||
| 438 | ).toBe("v2\n"); | 453 | ).toBe("v2\n"); |
| 439 | }); | 454 | }); |
| 440 | 455 | ||
| ... | @@ -445,10 +460,10 @@ describe("CheckpointReactor", () => { | ... | @@ -445,10 +460,10 @@ describe("CheckpointReactor", () => { |
| 445 | await Effect.runPromise( | 460 | await Effect.runPromise( |
| 446 | harness.engine.dispatch({ | 461 | harness.engine.dispatch({ |
| 447 | type: "thread.session.set", | 462 | type: "thread.session.set", |
| 448 | commandId: asCommandId("cmd-session-set-checkpoint-captured"), | 463 | commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"), |
| 449 | threadId: asThreadId("thread-1"), | 464 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 450 | session: { | 465 | session: { |
| 451 | threadId: asThreadId("thread-1"), | 466 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 452 | status: "ready", | 467 | status: "ready", |
| 453 | providerName: "codex", | 468 | providerName: "codex", |
| 454 | providerSessionId: asSessionId("sess-1"), | 469 | providerSessionId: asSessionId("sess-1"), |
| ... | @@ -488,10 +503,10 @@ describe("CheckpointReactor", () => { | ... | @@ -488,10 +503,10 @@ describe("CheckpointReactor", () => { |
| 488 | await Effect.runPromise( | 503 | await Effect.runPromise( |
| 489 | harness.engine.dispatch({ | 504 | harness.engine.dispatch({ |
| 490 | type: "thread.session.set", | 505 | type: "thread.session.set", |
| 491 | commandId: asCommandId("cmd-session-set"), | 506 | commandId: CommandId.makeUnsafe("cmd-session-set"), |
| 492 | threadId: asThreadId("thread-1"), | 507 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 493 | session: { | 508 | session: { |
| 494 | threadId: asThreadId("thread-1"), | 509 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 495 | status: "ready", | 510 | status: "ready", |
| 496 | providerName: "codex", | 511 | providerName: "codex", |
| 497 | providerSessionId: asSessionId("sess-1"), | 512 | providerSessionId: asSessionId("sess-1"), |
| ... | @@ -507,11 +522,11 @@ describe("CheckpointReactor", () => { | ... | @@ -507,11 +522,11 @@ describe("CheckpointReactor", () => { |
| 507 | await Effect.runPromise( | 522 | await Effect.runPromise( |
| 508 | harness.engine.dispatch({ | 523 | harness.engine.dispatch({ |
| 509 | type: "thread.turn.diff.complete", | 524 | type: "thread.turn.diff.complete", |
| 510 | commandId: asCommandId("cmd-diff-1"), | 525 | commandId: CommandId.makeUnsafe("cmd-diff-1"), |
| 511 | threadId: asThreadId("thread-1"), | 526 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 512 | turnId: asTurnId("turn-1"), | 527 | turnId: asTurnId("turn-1"), |
| 513 | completedAt: createdAt, | 528 | completedAt: createdAt, |
| 514 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1), | 529 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), |
| 515 | status: "ready", | 530 | status: "ready", |
| 516 | files: [], | 531 | files: [], |
| 517 | checkpointTurnCount: 1, | 532 | checkpointTurnCount: 1, |
| ... | @@ -521,11 +536,11 @@ describe("CheckpointReactor", () => { | ... | @@ -521,11 +536,11 @@ describe("CheckpointReactor", () => { |
| 521 | await Effect.runPromise( | 536 | await Effect.runPromise( |
| 522 | harness.engine.dispatch({ | 537 | harness.engine.dispatch({ |
| 523 | type: "thread.turn.diff.complete", | 538 | type: "thread.turn.diff.complete", |
| 524 | commandId: asCommandId("cmd-diff-2"), | 539 | commandId: CommandId.makeUnsafe("cmd-diff-2"), |
| 525 | threadId: asThreadId("thread-1"), | 540 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 526 | turnId: asTurnId("turn-2"), | 541 | turnId: asTurnId("turn-2"), |
| 527 | completedAt: createdAt, | 542 | completedAt: createdAt, |
| 528 | checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2), | 543 | checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), |
| 529 | status: "ready", | 544 | status: "ready", |
| 530 | files: [], | 545 | files: [], |
| 531 | checkpointTurnCount: 2, | 546 | checkpointTurnCount: 2, |
| ... | @@ -536,8 +551,8 @@ describe("CheckpointReactor", () => { | ... | @@ -536,8 +551,8 @@ describe("CheckpointReactor", () => { |
| 536 | await Effect.runPromise( | 551 | await Effect.runPromise( |
| 537 | harness.engine.dispatch({ | 552 | harness.engine.dispatch({ |
| 538 | type: "thread.checkpoint.revert", | 553 | type: "thread.checkpoint.revert", |
| 539 | commandId: asCommandId("cmd-revert-request"), | 554 | commandId: CommandId.makeUnsafe("cmd-revert-request"), |
| 540 | threadId: asThreadId("thread-1"), | 555 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 541 | turnCount: 1, | 556 | turnCount: 1, |
| 542 | createdAt, | 557 | createdAt, |
| 543 | }), | 558 | }), |
| ... | @@ -555,7 +570,9 @@ describe("CheckpointReactor", () => { | ... | @@ -555,7 +570,9 @@ describe("CheckpointReactor", () => { |
| 555 | numTurns: 1, | 570 | numTurns: 1, |
| 556 | }); | 571 | }); |
| 557 | expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); | 572 | expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); |
| 558 | expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 2))).toBe(false); | 573 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2))).toBe( |
| 574 | false, | ||
| 575 | ); | ||
| 559 | }); | 576 | }); |
| 560 | 577 | ||
| 561 | it("appends an error activity when revert is requested without an active session", async () => { | 578 | it("appends an error activity when revert is requested without an active session", async () => { |
| ... | @@ -565,8 +582,8 @@ describe("CheckpointReactor", () => { | ... | @@ -565,8 +582,8 @@ describe("CheckpointReactor", () => { |
| 565 | await Effect.runPromise( | 582 | await Effect.runPromise( |
| 566 | harness.engine.dispatch({ | 583 | harness.engine.dispatch({ |
| 567 | type: "thread.checkpoint.revert", | 584 | type: "thread.checkpoint.revert", |
| 568 | commandId: asCommandId("cmd-revert-no-session"), | 585 | commandId: CommandId.makeUnsafe("cmd-revert-no-session"), |
| 569 | threadId: asThreadId("thread-1"), | 586 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 570 | turnCount: 1, | 587 | turnCount: 1, |
| 571 | createdAt, | 588 | createdAt, |
| 572 | }), | 589 | }), |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+4-10| ... | @@ -1,17 +1,18 @@ | ... | @@ -1,17 +1,18 @@ |
| 1 | import { | 1 | import { |
| 2 | CheckpointRef, | ||
| 3 | CommandId, | 2 | CommandId, |
| 4 | EventId, | 3 | EventId, |
| 5 | MessageId, | 4 | MessageId, |
| 6 | ProviderSessionId, | 5 | ProviderSessionId, |
| 7 | ThreadId, | 6 | ThreadId, |
| 8 | TurnId, | 7 | TurnId, |
| 8 | type ProjectId, | ||
| 9 | type OrchestrationEvent, | 9 | type OrchestrationEvent, |
| 10 | type ProviderRuntimeEvent, | 10 | type ProviderRuntimeEvent, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { Effect, Layer, Option, Queue, Stream } from "effect"; | 12 | import { Effect, Layer, Option, Queue, Stream } from "effect"; |
| 13 | 13 | ||
| 14 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; | 14 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; |
| 15 | import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts"; | ||
| 15 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | 16 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 16 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 17 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 17 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; | 18 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; |
| ... | @@ -19,8 +20,6 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | ... | @@ -19,8 +20,6 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 19 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | 20 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 20 | import { OrchestrationDispatchError } from "../Errors.ts"; | 21 | import { OrchestrationDispatchError } from "../Errors.ts"; |
| 21 | 22 | ||
| 22 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | ||
| 23 | |||
| 24 | type ReactorInput = | 23 | type ReactorInput = |
| 25 | | { | 24 | | { |
| 26 | readonly source: "runtime"; | 25 | readonly source: "runtime"; |
| ... | @@ -31,11 +30,6 @@ type ReactorInput = | ... | @@ -31,11 +30,6 @@ type ReactorInput = |
| 31 | readonly event: OrchestrationEvent; | 30 | readonly event: OrchestrationEvent; |
| 32 | }; | 31 | }; |
| 33 | 32 | ||
| 34 | function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { | ||
| 35 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | ||
| 36 | return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`); | ||
| 37 | } | ||
| 38 | |||
| 39 | function toTurnId(value: string | undefined): TurnId | null { | 33 | function toTurnId(value: string | undefined): TurnId | null { |
| 40 | const normalized = value?.trim(); | 34 | const normalized = value?.trim(); |
| 41 | return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null; | 35 | return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null; |
| ... | @@ -48,11 +42,11 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined { | ... | @@ -48,11 +42,11 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined { |
| 48 | 42 | ||
| 49 | function resolveThreadWorkspaceCwd(input: { | 43 | function resolveThreadWorkspaceCwd(input: { |
| 50 | readonly thread: { | 44 | readonly thread: { |
| 51 | readonly projectId: string; | 45 | readonly projectId: ProjectId; |
| 52 | readonly worktreePath: string | null; | 46 | readonly worktreePath: string | null; |
| 53 | }; | 47 | }; |
| 54 | readonly projects: ReadonlyArray<{ | 48 | readonly projects: ReadonlyArray<{ |
| 55 | readonly id: string; | 49 | readonly id: ProjectId; |
| 56 | readonly workspaceRoot: string; | 50 | readonly workspaceRoot: string; |
| 57 | }>; | 51 | }>; |
| 58 | }): string | undefined { | 52 | }): string | undefined { |
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+1-23| ... | @@ -5,13 +5,11 @@ import type { | ... | @@ -5,13 +5,11 @@ import type { |
| 5 | ThreadId, | 5 | ThreadId, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { OrchestrationCommand } from "@t3tools/contracts"; | 7 | import { OrchestrationCommand } from "@t3tools/contracts"; |
| 8 | import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"; | 8 | import { Deferred, Effect, Layer, Option, PubSub, Queue, Stream } from "effect"; |
| 9 | 9 | ||
| 10 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; | 10 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 11 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; | 11 | import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; |
| 12 | import { | 12 | import { |
| 13 | toOrchestrationCommandDecodeError, | ||
| 14 | toOrchestrationJsonParseError, | ||
| 15 | OrchestrationCommandInvariantError, | 13 | OrchestrationCommandInvariantError, |
| 16 | OrchestrationCommandPreviouslyRejectedError, | 14 | OrchestrationCommandPreviouslyRejectedError, |
| 17 | type OrchestrationDispatchError, | 15 | type OrchestrationDispatchError, |
| ... | @@ -29,8 +27,6 @@ interface CommandEnvelope { | ... | @@ -29,8 +27,6 @@ interface CommandEnvelope { |
| 29 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; | 27 | result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; |
| 30 | } | 28 | } |
| 31 | 29 | ||
| 32 | const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommand); | ||
| 33 | |||
| 34 | function commandToAggregateRef(command: OrchestrationCommand): { | 30 | function commandToAggregateRef(command: OrchestrationCommand): { |
| 35 | readonly aggregateKind: "project" | "thread"; | 31 | readonly aggregateKind: "project" | "thread"; |
| 36 | readonly aggregateId: ProjectId | ThreadId; | 32 | readonly aggregateId: ProjectId | ThreadId; |
| ... | @@ -175,30 +171,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { | ... | @@ -175,30 +171,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 175 | return yield* Deferred.await(result); | 171 | return yield* Deferred.await(result); |
| 176 | }); | 172 | }); |
| 177 | 173 | ||
| 178 | const dispatchUnknownCommand: OrchestrationEngineShape["dispatchUnknownCommand"] = (command) => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | const payload = | ||
| 181 | typeof command === "string" | ||
| 182 | ? yield* Effect.try({ | ||
| 183 | try: () => JSON.parse(command) as unknown, | ||
| 184 | catch: toOrchestrationJsonParseError, | ||
| 185 | }) | ||
| 186 | : command; | ||
| 187 | |||
| 188 | const decoded = yield* decodeUnknownCommand(payload).pipe( | ||
| 189 | Effect.mapError(toOrchestrationCommandDecodeError), | ||
| 190 | ); | ||
| 191 | |||
| 192 | return yield* dispatch(decoded); | ||
| 193 | }); | ||
| 194 | |||
| 195 | const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = | 174 | const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = |
| 196 | Stream.fromPubSub(eventPubSub); | 175 | Stream.fromPubSub(eventPubSub); |
| 197 | 176 | ||
| 198 | return { | 177 | return { |
| 199 | getReadModel, | 178 | getReadModel, |
| 200 | readEvents, | 179 | readEvents, |
| 201 | dispatchUnknownCommand, | ||
| 202 | dispatch, | 180 | dispatch, |
| 203 | streamDomainEvents, | 181 | streamDomainEvents, |
| 204 | } satisfies OrchestrationEngineShape; | 182 | } satisfies OrchestrationEngineShape; |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2-3| ... | @@ -18,7 +18,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu | ... | @@ -18,7 +18,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu |
| 18 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; | 18 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 19 | 19 | ||
| 20 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 20 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 21 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 22 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | 21 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 23 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | 22 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 24 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 23 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| ... | @@ -225,7 +224,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -225,7 +224,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 225 | ]); | 224 | ]); |
| 226 | assert.deepEqual(snapshot.threads, [ | 225 | assert.deepEqual(snapshot.threads, [ |
| 227 | { | 226 | { |
| 228 | id: asThreadId("thread-1"), | 227 | id: ThreadId.makeUnsafe("thread-1"), |
| 229 | projectId: asProjectId("project-1"), | 228 | projectId: asProjectId("project-1"), |
| 230 | title: "Thread 1", | 229 | title: "Thread 1", |
| 231 | model: "gpt-5-codex", | 230 | model: "gpt-5-codex", |
| ... | @@ -269,7 +268,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -269,7 +268,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 269 | }, | 268 | }, |
| 270 | ], | 269 | ], |
| 271 | session: { | 270 | session: { |
| 272 | threadId: asThreadId("thread-1"), | 271 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 273 | status: "running", | 272 | status: "running", |
| 274 | providerName: "codex", | 273 | providerName: "codex", |
| 275 | providerSessionId: asProviderSessionId("provider-session-1"), | 274 | providerSessionId: asProviderSessionId("provider-session-1"), |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+22-24| ... | @@ -26,9 +26,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; | ... | @@ -26,9 +26,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; |
| 26 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 26 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 27 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 27 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 28 | 28 | ||
| 29 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | ||
| 30 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 29 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 31 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 32 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | 30 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 33 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | 31 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 34 | const asApprovalRequestId = (value: string): ApprovalRequestId => | 32 | const asApprovalRequestId = (value: string): ApprovalRequestId => |
| ... | @@ -126,7 +124,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -126,7 +124,7 @@ describe("ProviderCommandReactor", () => { |
| 126 | await Effect.runPromise( | 124 | await Effect.runPromise( |
| 127 | engine.dispatch({ | 125 | engine.dispatch({ |
| 128 | type: "project.create", | 126 | type: "project.create", |
| 129 | commandId: asCommandId("cmd-project-create"), | 127 | commandId: CommandId.makeUnsafe("cmd-project-create"), |
| 130 | projectId: asProjectId("project-1"), | 128 | projectId: asProjectId("project-1"), |
| 131 | title: "Provider Project", | 129 | title: "Provider Project", |
| 132 | workspaceRoot: "/tmp/provider-project", | 130 | workspaceRoot: "/tmp/provider-project", |
| ... | @@ -137,8 +135,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -137,8 +135,8 @@ describe("ProviderCommandReactor", () => { |
| 137 | await Effect.runPromise( | 135 | await Effect.runPromise( |
| 138 | engine.dispatch({ | 136 | engine.dispatch({ |
| 139 | type: "thread.create", | 137 | type: "thread.create", |
| 140 | commandId: asCommandId("cmd-thread-create"), | 138 | commandId: CommandId.makeUnsafe("cmd-thread-create"), |
| 141 | threadId: asThreadId("thread-1"), | 139 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 142 | projectId: asProjectId("project-1"), | 140 | projectId: asProjectId("project-1"), |
| 143 | title: "Thread", | 141 | title: "Thread", |
| 144 | model: "gpt-5-codex", | 142 | model: "gpt-5-codex", |
| ... | @@ -165,8 +163,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -165,8 +163,8 @@ describe("ProviderCommandReactor", () => { |
| 165 | await Effect.runPromise( | 163 | await Effect.runPromise( |
| 166 | harness.engine.dispatch({ | 164 | harness.engine.dispatch({ |
| 167 | type: "thread.turn.start", | 165 | type: "thread.turn.start", |
| 168 | commandId: asCommandId("cmd-turn-start-1"), | 166 | commandId: CommandId.makeUnsafe("cmd-turn-start-1"), |
| 169 | threadId: asThreadId("thread-1"), | 167 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 170 | message: { | 168 | message: { |
| 171 | messageId: asMessageId("user-message-1"), | 169 | messageId: asMessageId("user-message-1"), |
| 172 | role: "user", | 170 | role: "user", |
| ... | @@ -185,7 +183,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -185,7 +183,7 @@ describe("ProviderCommandReactor", () => { |
| 185 | }); | 183 | }); |
| 186 | 184 | ||
| 187 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | 185 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 188 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | 186 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 189 | expect(thread?.session?.providerSessionId).toBe("sess-1"); | 187 | expect(thread?.session?.providerSessionId).toBe("sess-1"); |
| 190 | }); | 188 | }); |
| 191 | 189 | ||
| ... | @@ -196,10 +194,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -196,10 +194,10 @@ describe("ProviderCommandReactor", () => { |
| 196 | await Effect.runPromise( | 194 | await Effect.runPromise( |
| 197 | harness.engine.dispatch({ | 195 | harness.engine.dispatch({ |
| 198 | type: "thread.session.set", | 196 | type: "thread.session.set", |
| 199 | commandId: asCommandId("cmd-session-set"), | 197 | commandId: CommandId.makeUnsafe("cmd-session-set"), |
| 200 | threadId: asThreadId("thread-1"), | 198 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 201 | session: { | 199 | session: { |
| 202 | threadId: asThreadId("thread-1"), | 200 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 203 | status: "running", | 201 | status: "running", |
| 204 | providerName: "codex", | 202 | providerName: "codex", |
| 205 | providerSessionId: asSessionId("sess-1"), | 203 | providerSessionId: asSessionId("sess-1"), |
| ... | @@ -215,8 +213,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -215,8 +213,8 @@ describe("ProviderCommandReactor", () => { |
| 215 | await Effect.runPromise( | 213 | await Effect.runPromise( |
| 216 | harness.engine.dispatch({ | 214 | harness.engine.dispatch({ |
| 217 | type: "thread.turn.interrupt", | 215 | type: "thread.turn.interrupt", |
| 218 | commandId: asCommandId("cmd-turn-interrupt"), | 216 | commandId: CommandId.makeUnsafe("cmd-turn-interrupt"), |
| 219 | threadId: asThreadId("thread-1"), | 217 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 220 | turnId: asTurnId("turn-1"), | 218 | turnId: asTurnId("turn-1"), |
| 221 | createdAt: now, | 219 | createdAt: now, |
| 222 | }), | 220 | }), |
| ... | @@ -235,10 +233,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -235,10 +233,10 @@ describe("ProviderCommandReactor", () => { |
| 235 | await Effect.runPromise( | 233 | await Effect.runPromise( |
| 236 | harness.engine.dispatch({ | 234 | harness.engine.dispatch({ |
| 237 | type: "thread.session.set", | 235 | type: "thread.session.set", |
| 238 | commandId: asCommandId("cmd-session-set-for-approval"), | 236 | commandId: CommandId.makeUnsafe("cmd-session-set-for-approval"), |
| 239 | threadId: asThreadId("thread-1"), | 237 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 240 | session: { | 238 | session: { |
| 241 | threadId: asThreadId("thread-1"), | 239 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 242 | status: "running", | 240 | status: "running", |
| 243 | providerName: "codex", | 241 | providerName: "codex", |
| 244 | providerSessionId: asSessionId("sess-1"), | 242 | providerSessionId: asSessionId("sess-1"), |
| ... | @@ -254,8 +252,8 @@ describe("ProviderCommandReactor", () => { | ... | @@ -254,8 +252,8 @@ describe("ProviderCommandReactor", () => { |
| 254 | await Effect.runPromise( | 252 | await Effect.runPromise( |
| 255 | harness.engine.dispatch({ | 253 | harness.engine.dispatch({ |
| 256 | type: "thread.approval.respond", | 254 | type: "thread.approval.respond", |
| 257 | commandId: asCommandId("cmd-approval-respond"), | 255 | commandId: CommandId.makeUnsafe("cmd-approval-respond"), |
| 258 | threadId: asThreadId("thread-1"), | 256 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 259 | requestId: asApprovalRequestId("approval-request-1"), | 257 | requestId: asApprovalRequestId("approval-request-1"), |
| 260 | decision: "accept", | 258 | decision: "accept", |
| 261 | createdAt: now, | 259 | createdAt: now, |
| ... | @@ -277,10 +275,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -277,10 +275,10 @@ describe("ProviderCommandReactor", () => { |
| 277 | await Effect.runPromise( | 275 | await Effect.runPromise( |
| 278 | harness.engine.dispatch({ | 276 | harness.engine.dispatch({ |
| 279 | type: "thread.session.set", | 277 | type: "thread.session.set", |
| 280 | commandId: asCommandId("cmd-session-set-for-stop"), | 278 | commandId: CommandId.makeUnsafe("cmd-session-set-for-stop"), |
| 281 | threadId: asThreadId("thread-1"), | 279 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 282 | session: { | 280 | session: { |
| 283 | threadId: asThreadId("thread-1"), | 281 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 284 | status: "ready", | 282 | status: "ready", |
| 285 | providerName: "codex", | 283 | providerName: "codex", |
| 286 | providerSessionId: asSessionId("sess-1"), | 284 | providerSessionId: asSessionId("sess-1"), |
| ... | @@ -296,15 +294,15 @@ describe("ProviderCommandReactor", () => { | ... | @@ -296,15 +294,15 @@ describe("ProviderCommandReactor", () => { |
| 296 | await Effect.runPromise( | 294 | await Effect.runPromise( |
| 297 | harness.engine.dispatch({ | 295 | harness.engine.dispatch({ |
| 298 | type: "thread.session.stop", | 296 | type: "thread.session.stop", |
| 299 | commandId: asCommandId("cmd-session-stop"), | 297 | commandId: CommandId.makeUnsafe("cmd-session-stop"), |
| 300 | threadId: asThreadId("thread-1"), | 298 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 301 | createdAt: now, | 299 | createdAt: now, |
| 302 | }), | 300 | }), |
| 303 | ); | 301 | ); |
| 304 | 302 | ||
| 305 | await waitFor(() => harness.stopSession.mock.calls.length === 1); | 303 | await waitFor(() => harness.stopSession.mock.calls.length === 1); |
| 306 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | 304 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 307 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | 305 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 308 | expect(thread?.session).not.toBeNull(); | 306 | expect(thread?.session).not.toBeNull(); |
| 309 | expect(thread?.session?.status).toBe("stopped"); | 307 | expect(thread?.session?.status).toBe("stopped"); |
| 310 | expect(thread?.session?.providerSessionId).toBeNull(); | 308 | expect(thread?.session?.providerSessionId).toBeNull(); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+7-9| ... | @@ -28,9 +28,7 @@ import { | ... | @@ -28,9 +28,7 @@ import { |
| 28 | } from "../Services/OrchestrationEngine.ts"; | 28 | } from "../Services/OrchestrationEngine.ts"; |
| 29 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 29 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 30 | 30 | ||
| 31 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | ||
| 32 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 31 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 33 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 34 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | 32 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 35 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | 33 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 36 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | 34 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| ... | @@ -78,7 +76,7 @@ async function waitForThread( | ... | @@ -78,7 +76,7 @@ async function waitForThread( |
| 78 | activities: ReadonlyArray<{ kind: string }>; | 76 | activities: ReadonlyArray<{ kind: string }>; |
| 79 | }> => { | 77 | }> => { |
| 80 | const readModel = await Effect.runPromise(engine.getReadModel()); | 78 | const readModel = await Effect.runPromise(engine.getReadModel()); |
| 81 | const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); | 79 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 82 | if (thread && predicate(thread)) { | 80 | if (thread && predicate(thread)) { |
| 83 | return thread; | 81 | return thread; |
| 84 | } | 82 | } |
| ... | @@ -132,7 +130,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -132,7 +130,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 132 | await Effect.runPromise( | 130 | await Effect.runPromise( |
| 133 | engine.dispatch({ | 131 | engine.dispatch({ |
| 134 | type: "project.create", | 132 | type: "project.create", |
| 135 | commandId: asCommandId("cmd-provider-project-create"), | 133 | commandId: CommandId.makeUnsafe("cmd-provider-project-create"), |
| 136 | projectId: asProjectId("project-1"), | 134 | projectId: asProjectId("project-1"), |
| 137 | title: "Provider Project", | 135 | title: "Provider Project", |
| 138 | workspaceRoot: "/tmp/provider-project", | 136 | workspaceRoot: "/tmp/provider-project", |
| ... | @@ -143,8 +141,8 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -143,8 +141,8 @@ describe("ProviderRuntimeIngestion", () => { |
| 143 | await Effect.runPromise( | 141 | await Effect.runPromise( |
| 144 | engine.dispatch({ | 142 | engine.dispatch({ |
| 145 | type: "thread.create", | 143 | type: "thread.create", |
| 146 | commandId: asCommandId("cmd-thread-create"), | 144 | commandId: CommandId.makeUnsafe("cmd-thread-create"), |
| 147 | threadId: asThreadId("thread-1"), | 145 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 148 | projectId: asProjectId("project-1"), | 146 | projectId: asProjectId("project-1"), |
| 149 | title: "Thread", | 147 | title: "Thread", |
| 150 | model: "gpt-5-codex", | 148 | model: "gpt-5-codex", |
| ... | @@ -156,10 +154,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -156,10 +154,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 156 | await Effect.runPromise( | 154 | await Effect.runPromise( |
| 157 | engine.dispatch({ | 155 | engine.dispatch({ |
| 158 | type: "thread.session.set", | 156 | type: "thread.session.set", |
| 159 | commandId: asCommandId("cmd-session-seed"), | 157 | commandId: CommandId.makeUnsafe("cmd-session-seed"), |
| 160 | threadId: asThreadId("thread-1"), | 158 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 161 | session: { | 159 | session: { |
| 162 | threadId: asThreadId("thread-1"), | 160 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 163 | status: "ready", | 161 | status: "ready", |
| 164 | providerName: "codex", | 162 | providerName: "codex", |
| 165 | providerSessionId: asSessionId("sess-1"), | 163 | providerSessionId: asSessionId("sess-1"), |
apps/server/src/orchestration/Services/OrchestrationEngine.ts+1-13| ... | @@ -18,7 +18,7 @@ import type { | ... | @@ -18,7 +18,7 @@ import type { |
| 18 | import { ServiceMap } from "effect"; | 18 | import { ServiceMap } from "effect"; |
| 19 | import type { Effect, Stream } from "effect"; | 19 | import type { Effect, Stream } from "effect"; |
| 20 | 20 | ||
| 21 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "../Errors.ts"; | 21 | import type { OrchestrationDispatchError } from "../Errors.ts"; |
| 22 | import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; | 22 | import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; |
| 23 | 23 | ||
| 24 | export interface OrchestrationEngineShape { | 24 | export interface OrchestrationEngineShape { |
| ... | @@ -39,18 +39,6 @@ export interface OrchestrationEngineShape { | ... | @@ -39,18 +39,6 @@ export interface OrchestrationEngineShape { |
| 39 | fromSequenceExclusive: number, | 39 | fromSequenceExclusive: number, |
| 40 | ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>; | 40 | ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>; |
| 41 | 41 | ||
| 42 | /** | ||
| 43 | * Validate and dispatch an unknown command payload. | ||
| 44 | * | ||
| 45 | * Accepts either parsed values or raw JSON strings. | ||
| 46 | * | ||
| 47 | * @param command - Unknown command payload. | ||
| 48 | * @returns Effect containing the sequence of the persisted event. | ||
| 49 | */ | ||
| 50 | readonly dispatchUnknownCommand: ( | ||
| 51 | command: unknown, | ||
| 52 | ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError, never>; | ||
| 53 | |||
| 54 | /** | 42 | /** |
| 55 | * Dispatch a validated orchestration command. | 43 | * Dispatch a validated orchestration command. |
| 56 | * | 44 | * |
apps/server/src/orchestration/decider.projectScripts.test.ts+12-14| ... | @@ -5,10 +5,8 @@ import { Effect } from "effect"; | ... | @@ -5,10 +5,8 @@ import { Effect } from "effect"; |
| 5 | import { decideOrchestrationCommand } from "./decider.ts"; | 5 | import { decideOrchestrationCommand } from "./decider.ts"; |
| 6 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | 6 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; |
| 7 | 7 | ||
| 8 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | ||
| 9 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 8 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 10 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 9 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 11 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 12 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | 10 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 13 | 11 | ||
| 14 | describe("decider project scripts", () => { | 12 | describe("decider project scripts", () => { |
| ... | @@ -20,7 +18,7 @@ describe("decider project scripts", () => { | ... | @@ -20,7 +18,7 @@ describe("decider project scripts", () => { |
| 20 | decideOrchestrationCommand({ | 18 | decideOrchestrationCommand({ |
| 21 | command: { | 19 | command: { |
| 22 | type: "project.create", | 20 | type: "project.create", |
| 23 | commandId: asCommandId("cmd-project-create-scripts"), | 21 | commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), |
| 24 | projectId: asProjectId("project-scripts"), | 22 | projectId: asProjectId("project-scripts"), |
| 25 | title: "Scripts", | 23 | title: "Scripts", |
| 26 | workspaceRoot: "/tmp/scripts", | 24 | workspaceRoot: "/tmp/scripts", |
| ... | @@ -46,9 +44,9 @@ describe("decider project scripts", () => { | ... | @@ -46,9 +44,9 @@ describe("decider project scripts", () => { |
| 46 | aggregateId: asProjectId("project-scripts"), | 44 | aggregateId: asProjectId("project-scripts"), |
| 47 | type: "project.created", | 45 | type: "project.created", |
| 48 | occurredAt: now, | 46 | occurredAt: now, |
| 49 | commandId: asCommandId("cmd-project-create-scripts"), | 47 | commandId: CommandId.makeUnsafe("cmd-project-create-scripts"), |
| 50 | causationEventId: null, | 48 | causationEventId: null, |
| 51 | correlationId: asCommandId("cmd-project-create-scripts"), | 49 | correlationId: CommandId.makeUnsafe("cmd-project-create-scripts"), |
| 52 | metadata: {}, | 50 | metadata: {}, |
| 53 | payload: { | 51 | payload: { |
| 54 | projectId: asProjectId("project-scripts"), | 52 | projectId: asProjectId("project-scripts"), |
| ... | @@ -76,7 +74,7 @@ describe("decider project scripts", () => { | ... | @@ -76,7 +74,7 @@ describe("decider project scripts", () => { |
| 76 | decideOrchestrationCommand({ | 74 | decideOrchestrationCommand({ |
| 77 | command: { | 75 | command: { |
| 78 | type: "project.meta.update", | 76 | type: "project.meta.update", |
| 79 | commandId: asCommandId("cmd-project-update-scripts"), | 77 | commandId: CommandId.makeUnsafe("cmd-project-update-scripts"), |
| 80 | projectId: asProjectId("project-scripts"), | 78 | projectId: asProjectId("project-scripts"), |
| 81 | scripts: Array.from(scripts), | 79 | scripts: Array.from(scripts), |
| 82 | }, | 80 | }, |
| ... | @@ -100,9 +98,9 @@ describe("decider project scripts", () => { | ... | @@ -100,9 +98,9 @@ describe("decider project scripts", () => { |
| 100 | aggregateId: asProjectId("project-1"), | 98 | aggregateId: asProjectId("project-1"), |
| 101 | type: "project.created", | 99 | type: "project.created", |
| 102 | occurredAt: now, | 100 | occurredAt: now, |
| 103 | commandId: asCommandId("cmd-project-create"), | 101 | commandId: CommandId.makeUnsafe("cmd-project-create"), |
| 104 | causationEventId: null, | 102 | causationEventId: null, |
| 105 | correlationId: asCommandId("cmd-project-create"), | 103 | correlationId: CommandId.makeUnsafe("cmd-project-create"), |
| 106 | metadata: {}, | 104 | metadata: {}, |
| 107 | payload: { | 105 | payload: { |
| 108 | projectId: asProjectId("project-1"), | 106 | projectId: asProjectId("project-1"), |
| ... | @@ -120,15 +118,15 @@ describe("decider project scripts", () => { | ... | @@ -120,15 +118,15 @@ describe("decider project scripts", () => { |
| 120 | sequence: 2, | 118 | sequence: 2, |
| 121 | eventId: asEventId("evt-thread-create"), | 119 | eventId: asEventId("evt-thread-create"), |
| 122 | aggregateKind: "thread", | 120 | aggregateKind: "thread", |
| 123 | aggregateId: asThreadId("thread-1"), | 121 | aggregateId: ThreadId.makeUnsafe("thread-1"), |
| 124 | type: "thread.created", | 122 | type: "thread.created", |
| 125 | occurredAt: now, | 123 | occurredAt: now, |
| 126 | commandId: asCommandId("cmd-thread-create"), | 124 | commandId: CommandId.makeUnsafe("cmd-thread-create"), |
| 127 | causationEventId: null, | 125 | causationEventId: null, |
| 128 | correlationId: asCommandId("cmd-thread-create"), | 126 | correlationId: CommandId.makeUnsafe("cmd-thread-create"), |
| 129 | metadata: {}, | 127 | metadata: {}, |
| 130 | payload: { | 128 | payload: { |
| 131 | threadId: asThreadId("thread-1"), | 129 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 132 | projectId: asProjectId("project-1"), | 130 | projectId: asProjectId("project-1"), |
| 133 | title: "Thread", | 131 | title: "Thread", |
| 134 | model: "gpt-5-codex", | 132 | model: "gpt-5-codex", |
| ... | @@ -144,8 +142,8 @@ describe("decider project scripts", () => { | ... | @@ -144,8 +142,8 @@ describe("decider project scripts", () => { |
| 144 | decideOrchestrationCommand({ | 142 | decideOrchestrationCommand({ |
| 145 | command: { | 143 | command: { |
| 146 | type: "thread.turn.start", | 144 | type: "thread.turn.start", |
| 147 | commandId: asCommandId("cmd-turn-start"), | 145 | commandId: CommandId.makeUnsafe("cmd-turn-start"), |
| 148 | threadId: asThreadId("thread-1"), | 146 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 149 | message: { | 147 | message: { |
| 150 | messageId: asMessageId("message-user-1"), | 148 | messageId: asMessageId("message-user-1"), |
| 151 | role: "user", | 149 | role: "user", |
apps/server/src/provider/Layers/CodexAdapter.test.ts+5-6| ... | @@ -23,7 +23,6 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; | ... | @@ -23,7 +23,6 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 23 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; | 23 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| 24 | 24 | ||
| 25 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | 25 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 26 | const asThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | ||
| 27 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | 26 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 28 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 27 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 29 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | 28 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| ... | @@ -36,7 +35,7 @@ class FakeCodexManager extends CodexAppServerManager { | ... | @@ -36,7 +35,7 @@ class FakeCodexManager extends CodexAppServerManager { |
| 36 | sessionId: asSessionId("sess-1"), | 35 | sessionId: asSessionId("sess-1"), |
| 37 | provider: "codex", | 36 | provider: "codex", |
| 38 | status: "ready", | 37 | status: "ready", |
| 39 | threadId: asThreadId("thread-1"), | 38 | threadId: ProviderThreadId.makeUnsafe("thread-1"), |
| 40 | cwd: input.cwd, | 39 | cwd: input.cwd, |
| 41 | createdAt: now, | 40 | createdAt: now, |
| 42 | updatedAt: now, | 41 | updatedAt: now, |
| ... | @@ -46,7 +45,7 @@ class FakeCodexManager extends CodexAppServerManager { | ... | @@ -46,7 +45,7 @@ class FakeCodexManager extends CodexAppServerManager { |
| 46 | 45 | ||
| 47 | public sendTurnImpl = vi.fn( | 46 | public sendTurnImpl = vi.fn( |
| 48 | async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({ | 47 | async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({ |
| 49 | threadId: asThreadId("thread-1"), | 48 | threadId: ProviderThreadId.makeUnsafe("thread-1"), |
| 50 | turnId: asTurnId("turn-1"), | 49 | turnId: asTurnId("turn-1"), |
| 51 | }), | 50 | }), |
| 52 | ); | 51 | ); |
| ... | @@ -56,12 +55,12 @@ class FakeCodexManager extends CodexAppServerManager { | ... | @@ -56,12 +55,12 @@ class FakeCodexManager extends CodexAppServerManager { |
| 56 | ); | 55 | ); |
| 57 | 56 | ||
| 58 | public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ | 57 | public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ |
| 59 | threadId: asThreadId("thread-1"), | 58 | threadId: ProviderThreadId.makeUnsafe("thread-1"), |
| 60 | turns: [], | 59 | turns: [], |
| 61 | })); | 60 | })); |
| 62 | 61 | ||
| 63 | public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({ | 62 | public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({ |
| 64 | threadId: asThreadId("thread-1"), | 63 | threadId: ProviderThreadId.makeUnsafe("thread-1"), |
| 65 | turns: [], | 64 | turns: [], |
| 66 | })); | 65 | })); |
| 67 | 66 | ||
| ... | @@ -194,7 +193,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | ... | @@ -194,7 +193,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 194 | sessionId: asSessionId("sess-1"), | 193 | sessionId: asSessionId("sess-1"), |
| 195 | createdAt: new Date().toISOString(), | 194 | createdAt: new Date().toISOString(), |
| 196 | method: "item/completed", | 195 | method: "item/completed", |
| 197 | threadId: asThreadId("thread-1"), | 196 | threadId: ProviderThreadId.makeUnsafe("thread-1"), |
| 198 | turnId: asTurnId("turn-1"), | 197 | turnId: asTurnId("turn-1"), |
| 199 | itemId: asItemId("msg_1"), | 198 | itemId: asItemId("msg_1"), |
| 200 | payload: { | 199 | payload: { |
apps/server/src/provider/Layers/ProviderService.test.ts+5-6| ... | @@ -21,7 +21,7 @@ import { | ... | @@ -21,7 +21,7 @@ import { |
| 21 | import { it, assert, vi } from "@effect/vitest"; | 21 | import { it, assert, vi } from "@effect/vitest"; |
| 22 | import { assertFailure } from "@effect/vitest/utils"; | 22 | import { assertFailure } from "@effect/vitest/utils"; |
| 23 | 23 | ||
| 24 | import { Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Stream } from "effect"; | 24 | import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect"; |
| 25 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 25 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 26 | 26 | ||
| 27 | import { | 27 | import { |
| ... | @@ -46,7 +46,6 @@ import { | ... | @@ -46,7 +46,6 @@ import { |
| 46 | } from "../../persistence/Layers/Sqlite.ts"; | 46 | } from "../../persistence/Layers/Sqlite.ts"; |
| 47 | 47 | ||
| 48 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | 48 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 49 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 50 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | 49 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 51 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); | 50 | const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); |
| 52 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 51 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| ... | @@ -568,7 +567,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -568,7 +567,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 568 | provider: "codex", | 567 | provider: "codex", |
| 569 | sessionId: session.sessionId, | 568 | sessionId: session.sessionId, |
| 570 | createdAt: new Date().toISOString(), | 569 | createdAt: new Date().toISOString(), |
| 571 | threadId: asThreadId("thread-1"), | 570 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 572 | turnId: asTurnId("turn-1"), | 571 | turnId: asTurnId("turn-1"), |
| 573 | status: "completed", | 572 | status: "completed", |
| 574 | }; | 573 | }; |
| ... | @@ -616,7 +615,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -616,7 +615,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 616 | provider: "codex", | 615 | provider: "codex", |
| 617 | sessionId: session.sessionId, | 616 | sessionId: session.sessionId, |
| 618 | createdAt: new Date().toISOString(), | 617 | createdAt: new Date().toISOString(), |
| 619 | threadId: asThreadId("thread-1"), | 618 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 620 | turnId: asTurnId("turn-1"), | 619 | turnId: asTurnId("turn-1"), |
| 621 | toolKind: "command", | 620 | toolKind: "command", |
| 622 | title: "Command run", | 621 | title: "Command run", |
| ... | @@ -628,7 +627,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -628,7 +627,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 628 | provider: "codex", | 627 | provider: "codex", |
| 629 | sessionId: session.sessionId, | 628 | sessionId: session.sessionId, |
| 630 | createdAt: new Date().toISOString(), | 629 | createdAt: new Date().toISOString(), |
| 631 | threadId: asThreadId("thread-1"), | 630 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 632 | turnId: asTurnId("turn-1"), | 631 | turnId: asTurnId("turn-1"), |
| 633 | delta: "hello", | 632 | delta: "hello", |
| 634 | }, | 633 | }, |
| ... | @@ -638,7 +637,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { | ... | @@ -638,7 +637,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { |
| 638 | provider: "codex", | 637 | provider: "codex", |
| 639 | sessionId: session.sessionId, | 638 | sessionId: session.sessionId, |
| 640 | createdAt: new Date().toISOString(), | 639 | createdAt: new Date().toISOString(), |
| 641 | threadId: asThreadId("thread-1"), | 640 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 642 | turnId: asTurnId("turn-1"), | 641 | turnId: asTurnId("turn-1"), |
| 643 | status: "completed", | 642 | status: "completed", |
| 644 | }, | 643 | }, |
apps/server/src/serverLayers.ts+8| ... | @@ -4,8 +4,10 @@ import { NodeServices } from "@effect/platform-node"; | ... | @@ -4,8 +4,10 @@ import { NodeServices } from "@effect/platform-node"; |
| 4 | import { Effect, Layer } from "effect"; | 4 | import { Effect, Layer } from "effect"; |
| 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 6 | 6 | ||
| 7 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; | ||
| 7 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | 8 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 8 | import { ServerConfig } from "./config"; | 9 | import { ServerConfig } from "./config"; |
| 10 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; | ||
| 9 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; | 11 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; |
| 10 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; | 12 | import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; |
| 11 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; | 13 | import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; |
| ... | @@ -55,6 +57,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< | ... | @@ -55,6 +57,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 55 | | OrchestrationEngineService | 57 | | OrchestrationEngineService |
| 56 | | ProjectionSnapshotQuery | 58 | | ProjectionSnapshotQuery |
| 57 | | CheckpointStore | 59 | | CheckpointStore |
| 60 | | CheckpointDiffQuery | ||
| 58 | | OrchestrationReactor | 61 | | OrchestrationReactor |
| 59 | | TerminalManager | 62 | | TerminalManager |
| 60 | | Keybindings, | 63 | | Keybindings, |
| ... | @@ -67,11 +70,16 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< | ... | @@ -67,11 +70,16 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< |
| 67 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 70 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 68 | ); | 71 | ); |
| 69 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | 72 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); |
| 73 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( | ||
| 74 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | ||
| 75 | Layer.provideMerge(checkpointStoreLayer), | ||
| 76 | ); | ||
| 70 | 77 | ||
| 71 | const runtimeServicesLayer = Layer.mergeAll( | 78 | const runtimeServicesLayer = Layer.mergeAll( |
| 72 | orchestrationLayer, | 79 | orchestrationLayer, |
| 73 | OrchestrationProjectionSnapshotQueryLive, | 80 | OrchestrationProjectionSnapshotQueryLive, |
| 74 | checkpointStoreLayer, | 81 | checkpointStoreLayer, |
| 82 | checkpointDiffQueryLayer, | ||
| 75 | ); | 83 | ); |
| 76 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 84 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 77 | Layer.provideMerge(runtimeServicesLayer), | 85 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/terminalManager.test.ts+4-3| ... | @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from "vitest"; | ... | @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from "vitest"; |
| 11 | 11 | ||
| 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; | 12 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; |
| 13 | import { TerminalManagerRuntime } from "./terminalManager"; | 13 | import { TerminalManagerRuntime } from "./terminalManager"; |
| 14 | import { Encoding } from "effect"; | ||
| 14 | 15 | ||
| 15 | class FakePtyProcess implements PtyProcess { | 16 | class FakePtyProcess implements PtyProcess { |
| 16 | readonly writes: string[] = []; | 17 | readonly writes: string[] = []; |
| ... | @@ -107,15 +108,15 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu | ... | @@ -107,15 +108,15 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu |
| 107 | } | 108 | } |
| 108 | 109 | ||
| 109 | function historyLogName(threadId: string): string { | 110 | function historyLogName(threadId: string): string { |
| 110 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`; | 111 | return `terminal_${Encoding.encodeBase64Url(threadId)}.log`; |
| 111 | } | 112 | } |
| 112 | 113 | ||
| 113 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { | 114 | function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { |
| 114 | const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; | 115 | const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`; |
| 115 | if (terminalId === DEFAULT_TERMINAL_ID) { | 116 | if (terminalId === DEFAULT_TERMINAL_ID) { |
| 116 | return `${threadPart}.log`; | 117 | return `${threadPart}.log`; |
| 117 | } | 118 | } |
| 118 | return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`; | 119 | return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`; |
| 119 | } | 120 | } |
| 120 | 121 | ||
| 121 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { | 122 | function historyLogPath(logsDir: string, threadId = "thread-1"): string { |
apps/server/src/terminalManager.ts+3-3| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | type TerminalSessionSnapshot, | 13 | type TerminalSessionSnapshot, |
| 14 | type TerminalSessionStatus, | 14 | type TerminalSessionStatus, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 16 | import { Effect, Layer, Schema, ServiceMap } from "effect"; | 16 | import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect"; |
| 17 | 17 | ||
| 18 | import { createLogger } from "./logger"; | 18 | import { createLogger } from "./logger"; |
| 19 | import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; | 19 | import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; |
| ... | @@ -269,11 +269,11 @@ function legacySafeThreadId(threadId: string): string { | ... | @@ -269,11 +269,11 @@ function legacySafeThreadId(threadId: string): string { |
| 269 | } | 269 | } |
| 270 | 270 | ||
| 271 | function toSafeThreadId(threadId: string): string { | 271 | function toSafeThreadId(threadId: string): string { |
| 272 | return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; | 272 | return `terminal_${Encoding.encodeBase64Url(threadId)}`; |
| 273 | } | 273 | } |
| 274 | 274 | ||
| 275 | function toSafeTerminalId(terminalId: string): string { | 275 | function toSafeTerminalId(terminalId: string): string { |
| 276 | return Buffer.from(terminalId, "utf8").toString("base64url"); | 276 | return Encoding.encodeBase64Url(terminalId); |
| 277 | } | 277 | } |
| 278 | 278 | ||
| 279 | function toSessionKey(threadId: string, terminalId: string): string { | 279 | function toSessionKey(threadId: string, terminalId: string): string { |
apps/server/src/wsServer.test.ts+21-1| ... | @@ -175,7 +175,7 @@ class MockTerminalManager implements TerminalManagerShape { | ... | @@ -175,7 +175,7 @@ class MockTerminalManager implements TerminalManagerShape { |
| 175 | this.sessions.delete(this.key(input.threadId, input.terminalId)); | 175 | this.sessions.delete(this.key(input.threadId, input.terminalId)); |
| 176 | return; | 176 | return; |
| 177 | } | 177 | } |
| 178 | for (const key of [...this.sessions.keys()]) { | 178 | for (const key of this.sessions.keys()) { |
| 179 | if (key.startsWith(`${input.threadId}\u0000`)) { | 179 | if (key.startsWith(`${input.threadId}\u0000`)) { |
| 180 | this.sessions.delete(key); | 180 | this.sessions.delete(key); |
| 181 | } | 181 | } |
| ... | @@ -837,6 +837,26 @@ describe("WebSocket Server", () => { | ... | @@ -837,6 +837,26 @@ describe("WebSocket Server", () => { |
| 837 | expect(response.error?.message).toContain("Thread 'thread-missing' not found."); | 837 | expect(response.error?.message).toContain("Thread 'thread-missing' not found."); |
| 838 | }); | 838 | }); |
| 839 | 839 | ||
| 840 | it("returns error when requesting turn diff with an inverted range", async () => { | ||
| 841 | server = await createTestServer({ cwd: "/test" }); | ||
| 842 | const addr = server.address(); | ||
| 843 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 844 | |||
| 845 | const ws = await connectWs(port); | ||
| 846 | connections.push(ws); | ||
| 847 | await waitForMessage(ws); | ||
| 848 | |||
| 849 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | ||
| 850 | threadId: "thread-any", | ||
| 851 | fromTurnCount: 2, | ||
| 852 | toTurnCount: 1, | ||
| 853 | }); | ||
| 854 | expect(response.result).toBeUndefined(); | ||
| 855 | expect(response.error?.message).toContain( | ||
| 856 | "fromTurnCount must be less than or equal to toTurnCount", | ||
| 857 | ); | ||
| 858 | }); | ||
| 859 | |||
| 840 | it("returns error when requesting full thread diff for unknown thread", async () => { | 860 | it("returns error when requesting full thread diff for unknown thread", async () => { |
| 841 | server = await createTestServer({ cwd: "/test" }); | 861 | server = await createTestServer({ cwd: "/test" }); |
| 842 | const addr = server.address(); | 862 | const addr = server.address(); |
apps/server/src/wsServer.ts+29-153| ... | @@ -4,19 +4,19 @@ import path from "node:path"; | ... | @@ -4,19 +4,19 @@ import path from "node:path"; |
| 4 | import type { Duplex } from "node:stream"; | 4 | import type { Duplex } from "node:stream"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 7 | CheckpointRef, | 7 | CommandId, |
| 8 | ORCHESTRATION_WS_CHANNELS, | 8 | ORCHESTRATION_WS_CHANNELS, |
| 9 | ORCHESTRATION_WS_METHODS, | 9 | ORCHESTRATION_WS_METHODS, |
| 10 | ProjectId, | ||
| 10 | TerminalEvent, | 11 | TerminalEvent, |
| 11 | WS_CHANNELS, | 12 | WS_CHANNELS, |
| 12 | WS_METHODS, | 13 | WS_METHODS, |
| 13 | WebSocketRequest, | 14 | WebSocketRequest, |
| 14 | WebSocketResponse, | ||
| 15 | WsPush, | 15 | WsPush, |
| 16 | WsResponse, | 16 | WsResponse, |
| 17 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 18 | import { NodeHttpServer } from "@effect/platform-node"; | 18 | import { NodeHttpServer } from "@effect/platform-node"; |
| 19 | import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; | 19 | import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream, Struct } from "effect"; |
| 20 | import { WebSocketServer, type WebSocket } from "ws"; | 20 | import { WebSocketServer, type WebSocket } from "ws"; |
| 21 | 21 | ||
| 22 | import { createLogger } from "./logger"; | 22 | import { createLogger } from "./logger"; |
| ... | @@ -37,7 +37,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati | ... | @@ -37,7 +37,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati |
| 37 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; | 37 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; |
| 38 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; | 38 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 39 | import { ProviderService } from "./provider/Services/ProviderService"; | 39 | import { ProviderService } from "./provider/Services/ProviderService"; |
| 40 | import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; | 40 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| 41 | import { clamp } from "effect/Number"; | 41 | import { clamp } from "effect/Number"; |
| 42 | import { Open } from "./open"; | 42 | import { Open } from "./open"; |
| 43 | import { ServerConfig } from "./config"; | 43 | import { ServerConfig } from "./config"; |
| ... | @@ -133,22 +133,14 @@ function websocketRawToString(raw: unknown): string | null { | ... | @@ -133,22 +133,14 @@ function websocketRawToString(raw: unknown): string | null { |
| 133 | return null; | 133 | return null; |
| 134 | } | 134 | } |
| 135 | 135 | ||
| 136 | function stripRequestTag<T extends { _tag: string }>(body: T): Omit<T, "_tag"> { | 136 | function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 137 | const { _tag: _ignoredTag, ...rest } = body; | 137 | return Struct.omit(body, ["_tag"]); |
| 138 | return rest; | ||
| 139 | } | ||
| 140 | |||
| 141 | const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | ||
| 142 | |||
| 143 | function checkpointRefForThreadTurn(threadId: string, turnCount: number): CheckpointRef { | ||
| 144 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | ||
| 145 | return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`); | ||
| 146 | } | 138 | } |
| 147 | 139 | ||
| 148 | export type ServerCoreRuntimeServices = | 140 | export type ServerCoreRuntimeServices = |
| 149 | | OrchestrationEngineService | 141 | | OrchestrationEngineService |
| 150 | | ProjectionSnapshotQuery | 142 | | ProjectionSnapshotQuery |
| 151 | | CheckpointStore | 143 | | CheckpointDiffQuery |
| 152 | | OrchestrationReactor | 144 | | OrchestrationReactor |
| 153 | | ProviderService; | 145 | | ProviderService; |
| 154 | 146 | ||
| ... | @@ -301,7 +293,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -301,7 +293,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 301 | 293 | ||
| 302 | const orchestrationEngine = yield* OrchestrationEngineService; | 294 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 303 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; | 295 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; |
| 304 | const liveCheckpointStore = yield* CheckpointStore; | 296 | const checkpointDiffQuery = yield* CheckpointDiffQuery; |
| 305 | const liveProviderService = yield* ProviderService; | 297 | const liveProviderService = yield* ProviderService; |
| 306 | const orchestrationReactor = yield* OrchestrationReactor; | 298 | const orchestrationReactor = yield* OrchestrationReactor; |
| 307 | const { openInEditor } = yield* Open; | 299 | const { openInEditor } = yield* Open; |
| ... | @@ -338,10 +330,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -338,10 +330,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 338 | if (!existing) { | 330 | if (!existing) { |
| 339 | const createdAt = new Date().toISOString(); | 331 | const createdAt = new Date().toISOString(); |
| 340 | const projectName = path.basename(cwd) || "project"; | 332 | const projectName = path.basename(cwd) || "project"; |
| 341 | yield* orchestrationEngine.dispatchUnknownCommand({ | 333 | yield* orchestrationEngine.dispatch({ |
| 342 | type: "project.create", | 334 | type: "project.create", |
| 343 | commandId: crypto.randomUUID(), | 335 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), |
| 344 | projectId: crypto.randomUUID(), | 336 | projectId: ProjectId.makeUnsafe(crypto.randomUUID()), |
| 345 | title: projectName, | 337 | title: projectName, |
| 346 | workspaceRoot: cwd, | 338 | workspaceRoot: cwd, |
| 347 | defaultModel: "gpt-5-codex", | 339 | defaultModel: "gpt-5-codex", |
| ... | @@ -351,110 +343,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -351,110 +343,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 351 | } | 343 | } |
| 352 | 344 | ||
| 353 | const routeRequest = Effect.fn(function* (request: WebSocketRequest) { | 345 | const routeRequest = Effect.fn(function* (request: WebSocketRequest) { |
| 354 | const computeTurnDiff = Effect.fn(function* (input: { | ||
| 355 | readonly threadId: string; | ||
| 356 | readonly fromTurnCount: number; | ||
| 357 | readonly toTurnCount: number; | ||
| 358 | }) { | ||
| 359 | if (input.fromTurnCount > input.toTurnCount) { | ||
| 360 | throw new Error( | ||
| 361 | `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`, | ||
| 362 | ); | ||
| 363 | } | ||
| 364 | |||
| 365 | if (input.fromTurnCount === input.toTurnCount) { | ||
| 366 | return { | ||
| 367 | threadId: input.threadId, | ||
| 368 | fromTurnCount: input.fromTurnCount, | ||
| 369 | toTurnCount: input.toTurnCount, | ||
| 370 | diff: "", | ||
| 371 | }; | ||
| 372 | } | ||
| 373 | |||
| 374 | const snapshot = yield* projectionReadModelQuery.getSnapshot(); | ||
| 375 | const thread = snapshot.threads.find((entry) => entry.id === input.threadId); | ||
| 376 | if (!thread) { | ||
| 377 | throw new Error(`Thread '${input.threadId}' not found.`); | ||
| 378 | } | ||
| 379 | |||
| 380 | const maxTurnCount = thread.checkpoints.reduce( | ||
| 381 | (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), | ||
| 382 | 0, | ||
| 383 | ); | ||
| 384 | if (input.toTurnCount > maxTurnCount) { | ||
| 385 | throw new Error( | ||
| 386 | `Turn diff range exceeds current turn count for thread '${input.threadId}': requested ${input.toTurnCount}, current ${maxTurnCount}.`, | ||
| 387 | ); | ||
| 388 | } | ||
| 389 | |||
| 390 | const workspaceCwd = | ||
| 391 | thread.worktreePath?.trim() || | ||
| 392 | snapshot.projects.find((project) => project.id === thread.projectId)?.workspaceRoot?.trim(); | ||
| 393 | if (!workspaceCwd) { | ||
| 394 | throw new Error( | ||
| 395 | `Invariant violation: workspace path missing for thread '${input.threadId}' when computing turn diff.`, | ||
| 396 | ); | ||
| 397 | } | ||
| 398 | |||
| 399 | const fromCheckpointRef = | ||
| 400 | input.fromTurnCount === 0 | ||
| 401 | ? checkpointRefForThreadTurn(input.threadId, 0) | ||
| 402 | : thread.checkpoints.find( | ||
| 403 | (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount, | ||
| 404 | )?.checkpointRef; | ||
| 405 | if (!fromCheckpointRef) { | ||
| 406 | throw new Error( | ||
| 407 | `Checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`, | ||
| 408 | ); | ||
| 409 | } | ||
| 410 | |||
| 411 | const toCheckpointRef = thread.checkpoints.find( | ||
| 412 | (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount, | ||
| 413 | )?.checkpointRef; | ||
| 414 | if (!toCheckpointRef) { | ||
| 415 | throw new Error( | ||
| 416 | `Checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`, | ||
| 417 | ); | ||
| 418 | } | ||
| 419 | |||
| 420 | const [fromExists, toExists] = yield* Effect.all( | ||
| 421 | [ | ||
| 422 | liveCheckpointStore.hasCheckpointRef({ | ||
| 423 | cwd: workspaceCwd, | ||
| 424 | checkpointRef: fromCheckpointRef, | ||
| 425 | }), | ||
| 426 | liveCheckpointStore.hasCheckpointRef({ | ||
| 427 | cwd: workspaceCwd, | ||
| 428 | checkpointRef: toCheckpointRef, | ||
| 429 | }), | ||
| 430 | ], | ||
| 431 | { concurrency: "unbounded" }, | ||
| 432 | ); | ||
| 433 | if (!fromExists) { | ||
| 434 | throw new Error( | ||
| 435 | `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`, | ||
| 436 | ); | ||
| 437 | } | ||
| 438 | if (!toExists) { | ||
| 439 | throw new Error( | ||
| 440 | `Filesystem checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`, | ||
| 441 | ); | ||
| 442 | } | ||
| 443 | |||
| 444 | const diff = yield* liveCheckpointStore.diffCheckpoints({ | ||
| 445 | cwd: workspaceCwd, | ||
| 446 | fromCheckpointRef, | ||
| 447 | toCheckpointRef, | ||
| 448 | fallbackFromToHead: false, | ||
| 449 | }); | ||
| 450 | return { | ||
| 451 | threadId: input.threadId, | ||
| 452 | fromTurnCount: input.fromTurnCount, | ||
| 453 | toTurnCount: input.toTurnCount, | ||
| 454 | diff, | ||
| 455 | }; | ||
| 456 | }); | ||
| 457 | |||
| 458 | switch (request.body._tag) { | 346 | switch (request.body._tag) { |
| 459 | case ORCHESTRATION_WS_METHODS.getSnapshot: | 347 | case ORCHESTRATION_WS_METHODS.getSnapshot: |
| 460 | return yield* projectionReadModelQuery.getSnapshot(); | 348 | return yield* projectionReadModelQuery.getSnapshot(); |
| ... | @@ -466,16 +354,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -466,16 +354,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 466 | 354 | ||
| 467 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { | 355 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { |
| 468 | const body = stripRequestTag(request.body); | 356 | const body = stripRequestTag(request.body); |
| 469 | return yield* computeTurnDiff(body); | 357 | return yield* checkpointDiffQuery.getTurnDiff(body); |
| 470 | } | 358 | } |
| 471 | 359 | ||
| 472 | case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { | 360 | case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { |
| 473 | const body = stripRequestTag(request.body); | 361 | const body = stripRequestTag(request.body); |
| 474 | return yield* computeTurnDiff({ | 362 | return yield* checkpointDiffQuery.getFullThreadDiff(body); |
| 475 | threadId: body.threadId, | ||
| 476 | fromTurnCount: 0, | ||
| 477 | toTurnCount: body.toTurnCount, | ||
| 478 | }); | ||
| 479 | } | 363 | } |
| 480 | 364 | ||
| 481 | case ORCHESTRATION_WS_METHODS.replayEvents: { | 365 | case ORCHESTRATION_WS_METHODS.replayEvents: { |
| ... | @@ -599,46 +483,38 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E | ... | @@ -599,46 +483,38 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E |
| 599 | 483 | ||
| 600 | const messageText = websocketRawToString(raw); | 484 | const messageText = websocketRawToString(raw); |
| 601 | if (messageText === null) { | 485 | if (messageText === null) { |
| 602 | const errorResponse = yield* encodeResponse( | 486 | const errorResponse = yield* encodeResponse({ |
| 603 | new WebSocketResponse({ | 487 | id: "unknown", |
| 604 | id: "unknown", | 488 | error: { message: "Invalid request format: Failed to read message" }, |
| 605 | error: { message: "Invalid request format: Failed to read message" }, | 489 | }); |
| 606 | }), | ||
| 607 | ); | ||
| 608 | ws.send(errorResponse); | 490 | ws.send(errorResponse); |
| 609 | return; | 491 | return; |
| 610 | } | 492 | } |
| 611 | 493 | ||
| 612 | const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText); | 494 | const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText); |
| 613 | if (request._tag === "Failure") { | 495 | if (request._tag === "Failure") { |
| 614 | const errorResponse = yield* encodeResponse( | 496 | const errorResponse = yield* encodeResponse({ |
| 615 | new WebSocketResponse({ | 497 | id: "unknown", |
| 616 | id: "unknown", | 498 | error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, |
| 617 | error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, | 499 | }); |
| 618 | }), | ||
| 619 | ); | ||
| 620 | ws.send(errorResponse); | 500 | ws.send(errorResponse); |
| 621 | return; | 501 | return; |
| 622 | } | 502 | } |
| 623 | 503 | ||
| 624 | const result = yield* Effect.exit(routeRequest(request.value)); | 504 | const result = yield* Effect.exit(routeRequest(request.value)); |
| 625 | if (result._tag === "Failure") { | 505 | if (result._tag === "Failure") { |
| 626 | const errorResponse = yield* encodeResponse( | 506 | const errorResponse = yield* encodeResponse({ |
| 627 | new WebSocketResponse({ | 507 | id: request.value.id, |
| 628 | id: request.value.id, | 508 | error: { message: Cause.pretty(result.cause) }, |
| 629 | error: { message: Cause.pretty(result.cause) }, | 509 | }); |
| 630 | }), | ||
| 631 | ); | ||
| 632 | ws.send(errorResponse); | 510 | ws.send(errorResponse); |
| 633 | return; | 511 | return; |
| 634 | } | 512 | } |
| 635 | 513 | ||
| 636 | const response = yield* encodeResponse( | 514 | const response = yield* encodeResponse({ |
| 637 | new WebSocketResponse({ | 515 | id: request.value.id, |
| 638 | id: request.value.id, | 516 | result: result.value, |
| 639 | result: result.value, | 517 | }); |
| 640 | }), | ||
| 641 | ); | ||
| 642 | ws.send(response); | 518 | ws.send(response); |
| 643 | }); | 519 | }); |
| 644 | 520 |
apps/web/src/components/BranchToolbar.logic.test.ts+7-4| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import { ThreadId, type GitBranch } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; | 3 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; |
| 4 | 4 | ||
| ... | @@ -19,8 +19,9 @@ const branches: GitBranch[] = [ | ... | @@ -19,8 +19,9 @@ const branches: GitBranch[] = [ |
| 19 | 19 | ||
| 20 | describe("deriveSyncedLocalBranch", () => { | 20 | describe("deriveSyncedLocalBranch", () => { |
| 21 | it("syncs to git current branch in local mode", () => { | 21 | it("syncs to git current branch in local mode", () => { |
| 22 | const threadId = ThreadId.makeUnsafe("thread-1"); | ||
| 22 | const result = deriveSyncedLocalBranch({ | 23 | const result = deriveSyncedLocalBranch({ |
| 23 | activeThreadId: "thread-1", | 24 | activeThreadId: threadId, |
| 24 | activeWorktreePath: null, | 25 | activeWorktreePath: null, |
| 25 | envMode: "local", | 26 | envMode: "local", |
| 26 | activeThreadBranch: "feature/demo", | 27 | activeThreadBranch: "feature/demo", |
| ... | @@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => { | ... | @@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => { |
| 31 | }); | 32 | }); |
| 32 | 33 | ||
| 33 | it("does not sync when creating a new worktree", () => { | 34 | it("does not sync when creating a new worktree", () => { |
| 35 | const threadId = ThreadId.makeUnsafe("thread-1"); | ||
| 34 | const result = deriveSyncedLocalBranch({ | 36 | const result = deriveSyncedLocalBranch({ |
| 35 | activeThreadId: "thread-1", | 37 | activeThreadId: threadId, |
| 36 | activeWorktreePath: null, | 38 | activeWorktreePath: null, |
| 37 | envMode: "worktree", | 39 | envMode: "worktree", |
| 38 | activeThreadBranch: "feature/demo", | 40 | activeThreadBranch: "feature/demo", |
| ... | @@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => { | ... | @@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => { |
| 43 | }); | 45 | }); |
| 44 | 46 | ||
| 45 | it("does not sync when thread already targets a worktree path", () => { | 47 | it("does not sync when thread already targets a worktree path", () => { |
| 48 | const threadId = ThreadId.makeUnsafe("thread-1"); | ||
| 46 | const result = deriveSyncedLocalBranch({ | 49 | const result = deriveSyncedLocalBranch({ |
| 47 | activeThreadId: "thread-1", | 50 | activeThreadId: threadId, |
| 48 | activeWorktreePath: "/tmp/repo/worktrees/feature-demo", | 51 | activeWorktreePath: "/tmp/repo/worktrees/feature-demo", |
| 49 | envMode: "local", | 52 | envMode: "local", |
| 50 | activeThreadBranch: "feature/demo", | 53 | activeThreadBranch: "feature/demo", |
apps/web/src/components/BranchToolbar.logic.ts+2-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | interface DeriveSyncedLocalBranchInput { | 3 | interface DeriveSyncedLocalBranchInput { |
| 4 | activeThreadId: string | undefined; | 4 | activeThreadId: ThreadId | undefined; |
| 5 | activeWorktreePath: string | null; | 5 | activeWorktreePath: string | null; |
| 6 | envMode: "local" | "worktree"; | 6 | envMode: "local" | "worktree"; |
| 7 | activeThreadBranch: string | null; | 7 | activeThreadBranch: string | null; |
apps/web/src/components/BranchToolbar.tsx+14-12| ... | @@ -1,14 +1,14 @@ | ... | @@ -1,14 +1,14 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useEffect, useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { useNativeApi } from "../hooks/useNativeApi"; | 5 | import { newCommandId } from "../lib/orchestrationIds"; |
| 6 | import { asThreadId, newCommandId } from "../lib/orchestrationIds"; | ||
| 7 | import { | 6 | import { |
| 8 | gitBranchesQueryOptions, | 7 | gitBranchesQueryOptions, |
| 9 | gitCheckoutMutationOptions, | 8 | gitCheckoutMutationOptions, |
| 10 | gitCreateBranchAndCheckoutMutationOptions, | 9 | gitCreateBranchAndCheckoutMutationOptions, |
| 11 | } from "../lib/gitReactQuery"; | 10 | } from "../lib/gitReactQuery"; |
| 11 | import { readNativeApi } from "../nativeApi"; | ||
| 12 | import { useStore } from "../store"; | 12 | import { useStore } from "../store"; |
| 13 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; | 13 | import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; |
| 14 | import { Button } from "./ui/button"; | 14 | import { Button } from "./ui/button"; |
| ... | @@ -24,7 +24,7 @@ import { | ... | @@ -24,7 +24,7 @@ import { |
| 24 | import { ChevronDownIcon } from "lucide-react"; | 24 | import { ChevronDownIcon } from "lucide-react"; |
| 25 | 25 | ||
| 26 | interface BranchToolbarProps { | 26 | interface BranchToolbarProps { |
| 27 | threadId: string; | 27 | threadId: ThreadId; |
| 28 | envMode: "local" | "worktree"; | 28 | envMode: "local" | "worktree"; |
| 29 | onEnvModeChange: (mode: "local" | "worktree") => void; | 29 | onEnvModeChange: (mode: "local" | "worktree") => void; |
| 30 | envLocked: boolean; | 30 | envLocked: boolean; |
| ... | @@ -39,7 +39,6 @@ export default function BranchToolbar({ | ... | @@ -39,7 +39,6 @@ export default function BranchToolbar({ |
| 39 | onComposerFocusRequest, | 39 | onComposerFocusRequest, |
| 40 | }: BranchToolbarProps) { | 40 | }: BranchToolbarProps) { |
| 41 | const { state, dispatch } = useStore(); | 41 | const { state, dispatch } = useStore(); |
| 42 | const api = useNativeApi(); | ||
| 43 | const queryClient = useQueryClient(); | 42 | const queryClient = useQueryClient(); |
| 44 | 43 | ||
| 45 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 44 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| ... | @@ -54,7 +53,7 @@ export default function BranchToolbar({ | ... | @@ -54,7 +53,7 @@ export default function BranchToolbar({ |
| 54 | 53 | ||
| 55 | // ── Queries ─────────────────────────────────────────────────────────── | 54 | // ── Queries ─────────────────────────────────────────────────────────── |
| 56 | 55 | ||
| 57 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd)); | 56 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); |
| 58 | 57 | ||
| 59 | const branches = branchesQuery.data?.branches ?? []; | 58 | const branches = branchesQuery.data?.branches ?? []; |
| 60 | const branchNames = branches.map((branch) => branch.name); | 59 | const branchNames = branches.map((branch) => branch.name); |
| ... | @@ -72,11 +71,11 @@ export default function BranchToolbar({ | ... | @@ -72,11 +71,11 @@ export default function BranchToolbar({ |
| 72 | // ── Mutations ───────────────────────────────────────────────────────── | 71 | // ── Mutations ───────────────────────────────────────────────────────── |
| 73 | 72 | ||
| 74 | const checkoutMutation = useMutation( | 73 | const checkoutMutation = useMutation( |
| 75 | gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | 74 | gitCheckoutMutationOptions({ cwd: branchCwd, queryClient }), |
| 76 | ); | 75 | ); |
| 77 | 76 | ||
| 78 | const createBranchMutation = useMutation( | 77 | const createBranchMutation = useMutation( |
| 79 | gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), | 78 | gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }), |
| 80 | ); | 79 | ); |
| 81 | 80 | ||
| 82 | // ── Effects ─────────────────────────────────────────────────────────── | 81 | // ── Effects ─────────────────────────────────────────────────────────── |
| ... | @@ -92,12 +91,13 @@ export default function BranchToolbar({ | ... | @@ -92,12 +91,13 @@ export default function BranchToolbar({ |
| 92 | queryBranches, | 91 | queryBranches, |
| 93 | }); | 92 | }); |
| 94 | if (!activeThreadId || !syncedBranch) return; | 93 | if (!activeThreadId || !syncedBranch) return; |
| 94 | const api = readNativeApi(); | ||
| 95 | 95 | ||
| 96 | if (api) { | 96 | if (api) { |
| 97 | void api.orchestration.dispatchCommand({ | 97 | void api.orchestration.dispatchCommand({ |
| 98 | type: "thread.meta.update", | 98 | type: "thread.meta.update", |
| 99 | commandId: newCommandId(), | 99 | commandId: newCommandId(), |
| 100 | threadId: asThreadId(activeThreadId), | 100 | threadId: activeThreadId, |
| 101 | branch: syncedBranch, | 101 | branch: syncedBranch, |
| 102 | worktreePath: null, | 102 | worktreePath: null, |
| 103 | }); | 103 | }); |
| ... | @@ -115,7 +115,6 @@ export default function BranchToolbar({ | ... | @@ -115,7 +115,6 @@ export default function BranchToolbar({ |
| 115 | queryBranches, | 115 | queryBranches, |
| 116 | envMode, | 116 | envMode, |
| 117 | dispatch, | 117 | dispatch, |
| 118 | api, | ||
| 119 | ]); | 118 | ]); |
| 120 | 119 | ||
| 121 | useEffect(() => { | 120 | useEffect(() => { |
| ... | @@ -132,6 +131,7 @@ export default function BranchToolbar({ | ... | @@ -132,6 +131,7 @@ export default function BranchToolbar({ |
| 132 | 131 | ||
| 133 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { | 132 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| 134 | if (!activeThreadId) return; | 133 | if (!activeThreadId) return; |
| 134 | const api = readNativeApi(); | ||
| 135 | // If the effective cwd is about to change, stop the running session so the | 135 | // If the effective cwd is about to change, stop the running session so the |
| 136 | // next message creates a new one with the correct cwd. | 136 | // next message creates a new one with the correct cwd. |
| 137 | const sessionId = activeThread?.session?.sessionId; | 137 | const sessionId = activeThread?.session?.sessionId; |
| ... | @@ -140,7 +140,7 @@ export default function BranchToolbar({ | ... | @@ -140,7 +140,7 @@ export default function BranchToolbar({ |
| 140 | .dispatchCommand({ | 140 | .dispatchCommand({ |
| 141 | type: "thread.session.stop", | 141 | type: "thread.session.stop", |
| 142 | commandId: newCommandId(), | 142 | commandId: newCommandId(), |
| 143 | threadId: asThreadId(activeThreadId), | 143 | threadId: activeThreadId, |
| 144 | createdAt: new Date().toISOString(), | 144 | createdAt: new Date().toISOString(), |
| 145 | }) | 145 | }) |
| 146 | .catch(() => undefined); | 146 | .catch(() => undefined); |
| ... | @@ -149,7 +149,7 @@ export default function BranchToolbar({ | ... | @@ -149,7 +149,7 @@ export default function BranchToolbar({ |
| 149 | void api.orchestration.dispatchCommand({ | 149 | void api.orchestration.dispatchCommand({ |
| 150 | type: "thread.meta.update", | 150 | type: "thread.meta.update", |
| 151 | commandId: newCommandId(), | 151 | commandId: newCommandId(), |
| 152 | threadId: asThreadId(activeThreadId), | 152 | threadId: activeThreadId, |
| 153 | branch, | 153 | branch, |
| 154 | worktreePath, | 154 | worktreePath, |
| 155 | }); | 155 | }); |
| ... | @@ -158,6 +158,7 @@ export default function BranchToolbar({ | ... | @@ -158,6 +158,7 @@ export default function BranchToolbar({ |
| 158 | }; | 158 | }; |
| 159 | 159 | ||
| 160 | const selectBranch = (branch: GitBranch) => { | 160 | const selectBranch = (branch: GitBranch) => { |
| 161 | const api = readNativeApi(); | ||
| 161 | if (!api || !activeThreadId || !branchCwd) return; | 162 | if (!api || !activeThreadId || !branchCwd) return; |
| 162 | 163 | ||
| 163 | // For new worktree mode, selecting a branch picks the base branch. | 164 | // For new worktree mode, selecting a branch picks the base branch. |
| ... | @@ -198,6 +199,7 @@ export default function BranchToolbar({ | ... | @@ -198,6 +199,7 @@ export default function BranchToolbar({ |
| 198 | 199 | ||
| 199 | const createBranch = (rawName: string) => { | 200 | const createBranch = (rawName: string) => { |
| 200 | const name = rawName.trim(); | 201 | const name = rawName.trim(); |
| 202 | const api = readNativeApi(); | ||
| 201 | if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return; | 203 | if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return; |
| 202 | createBranchMutation.mutate(name, { | 204 | createBranchMutation.mutate(name, { |
| 203 | onSuccess: () => { | 205 | onSuccess: () => { |
apps/web/src/components/ChatMarkdown.tsx+9-5| ... | @@ -3,7 +3,7 @@ import type { Components } from "react-markdown"; | ... | @@ -3,7 +3,7 @@ import type { Components } from "react-markdown"; |
| 3 | import ReactMarkdown from "react-markdown"; | 3 | import ReactMarkdown from "react-markdown"; |
| 4 | import rehypeHighlight from "rehype-highlight"; | 4 | import rehypeHighlight from "rehype-highlight"; |
| 5 | import remarkGfm from "remark-gfm"; | 5 | import remarkGfm from "remark-gfm"; |
| 6 | import { useNativeApi } from "../hooks/useNativeApi"; | 6 | import { readNativeApi } from "../nativeApi"; |
| 7 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; | 7 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 8 | import { preferredTerminalEditor } from "../terminal-links"; | 8 | import { preferredTerminalEditor } from "../terminal-links"; |
| 9 | 9 | ||
| ... | @@ -13,12 +13,11 @@ interface ChatMarkdownProps { | ... | @@ -13,12 +13,11 @@ interface ChatMarkdownProps { |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { | 15 | function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { |
| 16 | const api = useNativeApi(); | ||
| 17 | const markdownComponents = useMemo<Components>( | 16 | const markdownComponents = useMemo<Components>( |
| 18 | () => ({ | 17 | () => ({ |
| 19 | a({ node: _node, href, ...props }) { | 18 | a({ node: _node, href, ...props }) { |
| 20 | const targetPath = resolveMarkdownFileLinkTarget(href, cwd); | 19 | const targetPath = resolveMarkdownFileLinkTarget(href, cwd); |
| 21 | if (!targetPath || !api) { | 20 | if (!targetPath) { |
| 22 | return <a {...props} href={href} target="_blank" rel="noreferrer" />; | 21 | return <a {...props} href={href} target="_blank" rel="noreferrer" />; |
| 23 | } | 22 | } |
| 24 | 23 | ||
| ... | @@ -29,13 +28,18 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { | ... | @@ -29,13 +28,18 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { |
| 29 | onClick={(event) => { | 28 | onClick={(event) => { |
| 30 | event.preventDefault(); | 29 | event.preventDefault(); |
| 31 | event.stopPropagation(); | 30 | event.stopPropagation(); |
| 32 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()); | 31 | const api = readNativeApi(); |
| 32 | if (api) { | ||
| 33 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()); | ||
| 34 | } else { | ||
| 35 | console.warn("Native API not found. Unable to open file in editor."); | ||
| 36 | } | ||
| 33 | }} | 37 | }} |
| 34 | /> | 38 | /> |
| 35 | ); | 39 | ); |
| 36 | }, | 40 | }, |
| 37 | }), | 41 | }), |
| 38 | [api, cwd], | 42 | [cwd], |
| 39 | ); | 43 | ); |
| 40 | 44 | ||
| 41 | return ( | 45 | return ( |
apps/web/src/components/ChatView.tsx+101-98| ... | @@ -1,15 +1,25 @@ | ... | @@ -1,15 +1,25 @@ |
| 1 | import { | 1 | import { |
| 2 | type ApprovalRequestId, | ||
| 3 | DEFAULT_MODEL, | ||
| 4 | DEFAULT_REASONING, | ||
| 2 | EDITORS, | 5 | EDITORS, |
| 3 | type EditorId, | 6 | type EditorId, |
| 4 | type KeybindingCommand, | 7 | type KeybindingCommand, |
| 5 | type NativeApi, | 8 | type MessageId, |
| 9 | MODEL_OPTIONS, | ||
| 10 | type ProjectId, | ||
| 6 | type ProjectEntry, | 11 | type ProjectEntry, |
| 7 | type ProjectScript, | 12 | type ProjectScript, |
| 8 | ModelSlug, | 13 | ModelSlug, |
| 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 15 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 16 | REASONING_OPTIONS, | ||
| 17 | type ReasoningEffort, | ||
| 11 | type ResolvedKeybindingsConfig, | 18 | type ResolvedKeybindingsConfig, |
| 12 | type ProviderApprovalDecision, | 19 | type ProviderApprovalDecision, |
| 20 | type ThreadId, | ||
| 21 | type TurnId, | ||
| 22 | resolveModelSlug, | ||
| 13 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 14 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 24 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 15 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 25 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| ... | @@ -27,14 +37,6 @@ import { | ... | @@ -27,14 +37,6 @@ import { |
| 27 | detectComposerTrigger, | 37 | detectComposerTrigger, |
| 28 | replaceTextRange, | 38 | replaceTextRange, |
| 29 | } from "../composer-logic"; | 39 | } from "../composer-logic"; |
| 30 | import { | ||
| 31 | DEFAULT_MODEL, | ||
| 32 | DEFAULT_REASONING, | ||
| 33 | MODEL_OPTIONS, | ||
| 34 | REASONING_OPTIONS, | ||
| 35 | ReasoningEffort, | ||
| 36 | resolveModelSlug, | ||
| 37 | } from "../model-logic"; | ||
| 38 | import { | 40 | import { |
| 39 | derivePendingApprovals, | 41 | derivePendingApprovals, |
| 40 | derivePhase, | 42 | derivePhase, |
| ... | @@ -66,7 +68,6 @@ import { | ... | @@ -66,7 +68,6 @@ import { |
| 66 | } from "../keybindings"; | 68 | } from "../keybindings"; |
| 67 | import ChatMarkdown from "./ChatMarkdown"; | 69 | import ChatMarkdown from "./ChatMarkdown"; |
| 68 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 70 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 69 | import { useNativeApi } from "../hooks/useNativeApi"; | ||
| 70 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | 71 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 71 | import { | 72 | import { |
| 72 | BotIcon, | 73 | BotIcon, |
| ... | @@ -101,13 +102,8 @@ import { | ... | @@ -101,13 +102,8 @@ import { |
| 101 | } from "~/projectScripts"; | 102 | } from "~/projectScripts"; |
| 102 | import { Toggle } from "./ui/toggle"; | 103 | import { Toggle } from "./ui/toggle"; |
| 103 | import { SidebarTrigger } from "./ui/sidebar"; | 104 | import { SidebarTrigger } from "./ui/sidebar"; |
| 104 | import { | 105 | import { newCommandId, newMessageId } from "~/lib/orchestrationIds"; |
| 105 | asApprovalRequestId, | 106 | import { readNativeApi } from "~/nativeApi"; |
| 106 | asProjectId, | ||
| 107 | asThreadId, | ||
| 108 | newCommandId, | ||
| 109 | newMessageId, | ||
| 110 | } from "~/lib/orchestrationIds"; | ||
| 111 | 107 | ||
| 112 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 108 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 113 | if (!duration) return formatTimestamp(createdAt); | 109 | if (!duration) return formatTimestamp(createdAt); |
| ... | @@ -326,7 +322,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { | ... | @@ -326,7 +322,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { |
| 326 | }); | 322 | }); |
| 327 | 323 | ||
| 328 | interface ChatViewProps { | 324 | interface ChatViewProps { |
| 329 | threadId: string; | 325 | threadId: ThreadId; |
| 330 | } | 326 | } |
| 331 | 327 | ||
| 332 | export default function ChatView({ threadId }: ChatViewProps) { | 328 | export default function ChatView({ threadId }: ChatViewProps) { |
| ... | @@ -336,12 +332,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -336,12 +332,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 336 | strict: false, | 332 | strict: false, |
| 337 | select: (params) => parseDiffRouteSearch(params), | 333 | select: (params) => parseDiffRouteSearch(params), |
| 338 | }); | 334 | }); |
| 339 | const api = useNativeApi(); | ||
| 340 | const { resolvedTheme } = useTheme(); | 335 | const { resolvedTheme } = useTheme(); |
| 341 | const queryClient = useQueryClient(); | 336 | const queryClient = useQueryClient(); |
| 342 | const createWorktreeMutation = useMutation( | 337 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); |
| 343 | gitCreateWorktreeMutationOptions({ api, queryClient }), | ||
| 344 | ); | ||
| 345 | const [prompt, setPrompt] = useState(""); | 338 | const [prompt, setPrompt] = useState(""); |
| 346 | const promptRef = useRef(prompt); | 339 | const promptRef = useRef(prompt); |
| 347 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); | 340 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| ... | @@ -353,7 +346,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -353,7 +346,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 353 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); | 346 | const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); |
| 354 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); | 347 | const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); |
| 355 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | 348 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| 356 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]); | 349 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| 357 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 350 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 358 | const [nowTick, setNowTick] = useState(() => Date.now()); | 351 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 359 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 352 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| ... | @@ -429,7 +422,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -429,7 +422,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 429 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 422 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 430 | useTurnDiffSummaries(activeThread); | 423 | useTurnDiffSummaries(activeThread); |
| 431 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { | 424 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 432 | const byMessageId = new Map<string, TurnDiffSummary>(); | 425 | const byMessageId = new Map<MessageId, TurnDiffSummary>(); |
| 433 | for (const summary of turnDiffSummaries) { | 426 | for (const summary of turnDiffSummaries) { |
| 434 | if (!summary.assistantMessageId) continue; | 427 | if (!summary.assistantMessageId) continue; |
| 435 | byMessageId.set(summary.assistantMessageId, summary); | 428 | byMessageId.set(summary.assistantMessageId, summary); |
| ... | @@ -437,7 +430,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -437,7 +430,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 437 | return byMessageId; | 430 | return byMessageId; |
| 438 | }, [turnDiffSummaries]); | 431 | }, [turnDiffSummaries]); |
| 439 | const revertTurnCountByUserMessageId = useMemo(() => { | 432 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 440 | const byUserMessageId = new Map<string, number>(); | 433 | const byUserMessageId = new Map<MessageId, number>(); |
| 441 | for (let index = 0; index < timelineEntries.length; index += 1) { | 434 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| 442 | const entry = timelineEntries[index]; | 435 | const entry = timelineEntries[index]; |
| 443 | if (!entry || entry.kind !== "message" || entry.message.role !== "user") { | 436 | if (!entry || entry.kind !== "message" || entry.message.role !== "user") { |
| ... | @@ -536,14 +529,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -536,14 +529,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 536 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 529 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 537 | ); | 530 | ); |
| 538 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | 531 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 539 | const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); | 532 | const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); |
| 540 | const keybindingsQuery = useQuery({ | 533 | const keybindingsQuery = useQuery({ |
| 541 | ...serverConfigQueryOptions(api), | 534 | ...serverConfigQueryOptions(), |
| 542 | select: (config) => config.keybindings, | 535 | select: (config) => config.keybindings, |
| 543 | }); | 536 | }); |
| 544 | const workspaceEntriesQuery = useQuery( | 537 | const workspaceEntriesQuery = useQuery( |
| 545 | projectSearchEntriesQueryOptions({ | 538 | projectSearchEntriesQueryOptions({ |
| 546 | api, | ||
| 547 | cwd: gitCwd, | 539 | cwd: gitCwd, |
| 548 | query: effectivePathQuery, | 540 | query: effectivePathQuery, |
| 549 | enabled: isPathTrigger, | 541 | enabled: isPathTrigger, |
| ... | @@ -689,6 +681,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -689,6 +681,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 689 | ); | 681 | ); |
| 690 | const closeTerminal = useCallback( | 682 | const closeTerminal = useCallback( |
| 691 | (terminalId: string) => { | 683 | (terminalId: string) => { |
| 684 | const api = readNativeApi(); | ||
| 692 | if (!activeThreadId || !api) return; | 685 | if (!activeThreadId || !api) return; |
| 693 | const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1; | 686 | const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1; |
| 694 | const fallbackExitWrite = () => | 687 | const fallbackExitWrite = () => |
| ... | @@ -714,7 +707,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -714,7 +707,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 714 | }); | 707 | }); |
| 715 | setTerminalFocusRequestId((value) => value + 1); | 708 | setTerminalFocusRequestId((value) => value + 1); |
| 716 | }, | 709 | }, |
| 717 | [activeThread?.terminalIds.length, activeThreadId, api, dispatch], | 710 | [activeThread?.terminalIds.length, activeThreadId, dispatch], |
| 718 | ); | 711 | ); |
| 719 | const runProjectScript = useCallback( | 712 | const runProjectScript = useCallback( |
| 720 | async ( | 713 | async ( |
| ... | @@ -727,6 +720,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -727,6 +720,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 727 | rememberAsLastInvoked?: boolean; | 720 | rememberAsLastInvoked?: boolean; |
| 728 | }, | 721 | }, |
| 729 | ) => { | 722 | ) => { |
| 723 | const api = readNativeApi(); | ||
| 730 | if (!api || !activeThreadId || !activeProject || !activeThread) return; | 724 | if (!api || !activeThreadId || !activeProject || !activeThread) return; |
| 731 | if (options?.rememberAsLastInvoked !== false) { | 725 | if (options?.rememberAsLastInvoked !== false) { |
| 732 | setLastInvokedScriptByProjectId((current) => { | 726 | setLastInvokedScriptByProjectId((current) => { |
| ... | @@ -799,23 +793,24 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -799,23 +793,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 799 | }); | 793 | }); |
| 800 | } | 794 | } |
| 801 | }, | 795 | }, |
| 802 | [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd], | 796 | [activeProject, activeThread, activeThreadId, dispatch, gitCwd], |
| 803 | ); | 797 | ); |
| 804 | const persistProjectScripts = useCallback( | 798 | const persistProjectScripts = useCallback( |
| 805 | async (input: { | 799 | async (input: { |
| 806 | projectId: string; | 800 | projectId: ProjectId; |
| 807 | projectCwd: string; | 801 | projectCwd: string; |
| 808 | previousScripts: ProjectScript[]; | 802 | previousScripts: ProjectScript[]; |
| 809 | nextScripts: ProjectScript[]; | 803 | nextScripts: ProjectScript[]; |
| 810 | keybinding?: string | null; | 804 | keybinding?: string | null; |
| 811 | keybindingCommand: KeybindingCommand; | 805 | keybindingCommand: KeybindingCommand; |
| 812 | }) => { | 806 | }) => { |
| 807 | const api = readNativeApi(); | ||
| 813 | if (!api) return; | 808 | if (!api) return; |
| 814 | 809 | ||
| 815 | await api.orchestration.dispatchCommand({ | 810 | await api.orchestration.dispatchCommand({ |
| 816 | type: "project.meta.update", | 811 | type: "project.meta.update", |
| 817 | commandId: newCommandId(), | 812 | commandId: newCommandId(), |
| 818 | projectId: asProjectId(input.projectId), | 813 | projectId: input.projectId, |
| 819 | scripts: input.nextScripts, | 814 | scripts: input.nextScripts, |
| 820 | }); | 815 | }); |
| 821 | 816 | ||
| ... | @@ -836,7 +831,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -836,7 +831,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 836 | ); | 831 | ); |
| 837 | } | 832 | } |
| 838 | }, | 833 | }, |
| 839 | [api, queryClient], | 834 | [queryClient], |
| 840 | ); | 835 | ); |
| 841 | const saveProjectScript = useCallback( | 836 | const saveProjectScript = useCallback( |
| 842 | async (input: NewProjectScriptInput) => { | 837 | async (input: NewProjectScriptInput) => { |
| ... | @@ -911,6 +906,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -911,6 +906,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 911 | if (mode === state.runtimeMode) return; | 906 | if (mode === state.runtimeMode) return; |
| 912 | dispatch({ type: "SET_RUNTIME_MODE", mode }); | 907 | dispatch({ type: "SET_RUNTIME_MODE", mode }); |
| 913 | scheduleComposerFocus(); | 908 | scheduleComposerFocus(); |
| 909 | const api = readNativeApi(); | ||
| 914 | if (!api) return; | 910 | if (!api) return; |
| 915 | 911 | ||
| 916 | const runningThreadIds = state.threads | 912 | const runningThreadIds = state.threads |
| ... | @@ -927,7 +923,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -927,7 +923,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 927 | .dispatchCommand({ | 923 | .dispatchCommand({ |
| 928 | type: "thread.session.stop", | 924 | type: "thread.session.stop", |
| 929 | commandId: newCommandId(), | 925 | commandId: newCommandId(), |
| 930 | threadId: asThreadId(threadId), | 926 | threadId, |
| 931 | createdAt: new Date().toISOString(), | 927 | createdAt: new Date().toISOString(), |
| 932 | }) | 928 | }) |
| 933 | .catch(() => undefined), | 929 | .catch(() => undefined), |
| ... | @@ -1182,7 +1178,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1182,7 +1178,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1182 | ]); | 1178 | ]); |
| 1183 | 1179 | ||
| 1184 | const setThreadError = useCallback( | 1180 | const setThreadError = useCallback( |
| 1185 | (threadId: string | null, error: string | null) => { | 1181 | (threadId: ThreadId | null, error: string | null) => { |
| 1186 | if (!threadId) return; | 1182 | if (!threadId) return; |
| 1187 | dispatch({ | 1183 | dispatch({ |
| 1188 | type: "SET_ERROR", | 1184 | type: "SET_ERROR", |
| ... | @@ -1298,9 +1294,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1298,9 +1294,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1298 | 1294 | ||
| 1299 | const onRevertToTurnCount = useCallback( | 1295 | const onRevertToTurnCount = useCallback( |
| 1300 | async (turnCount: number) => { | 1296 | async (turnCount: number) => { |
| 1301 | if (!api || !activeThread || isRevertingCheckpoint) { | 1297 | const api = readNativeApi(); |
| 1302 | return; | 1298 | if (!api || !activeThread || isRevertingCheckpoint) return; |
| 1303 | } | 1299 | |
| 1304 | if (phase === "running" || isSending || isConnecting) { | 1300 | if (phase === "running" || isSending || isConnecting) { |
| 1305 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); | 1301 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 1306 | return; | 1302 | return; |
| ... | @@ -1322,7 +1318,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1322,7 +1318,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1322 | await api.orchestration.dispatchCommand({ | 1318 | await api.orchestration.dispatchCommand({ |
| 1323 | type: "thread.checkpoint.revert", | 1319 | type: "thread.checkpoint.revert", |
| 1324 | commandId: newCommandId(), | 1320 | commandId: newCommandId(), |
| 1325 | threadId: asThreadId(activeThread.id), | 1321 | threadId: activeThread.id, |
| 1326 | turnCount, | 1322 | turnCount, |
| 1327 | createdAt: new Date().toISOString(), | 1323 | createdAt: new Date().toISOString(), |
| 1328 | }); | 1324 | }); |
| ... | @@ -1335,11 +1331,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1335,11 +1331,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1335 | setIsRevertingCheckpoint(false); | 1331 | setIsRevertingCheckpoint(false); |
| 1336 | } | 1332 | } |
| 1337 | }, | 1333 | }, |
| 1338 | [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], | 1334 | [activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], |
| 1339 | ); | 1335 | ); |
| 1340 | 1336 | ||
| 1341 | const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { | 1337 | const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { |
| 1342 | e.preventDefault(); | 1338 | e.preventDefault(); |
| 1339 | const api = readNativeApi(); | ||
| 1343 | if (!api || !activeThread || isSending || isConnecting) return; | 1340 | if (!api || !activeThread || isSending || isConnecting) return; |
| 1344 | const trimmed = prompt.trim(); | 1341 | const trimmed = prompt.trim(); |
| 1345 | if (!trimmed && composerImages.length === 0) return; | 1342 | if (!trimmed && composerImages.length === 0) return; |
| ... | @@ -1363,7 +1360,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1363,7 +1360,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1363 | await api.orchestration.dispatchCommand({ | 1360 | await api.orchestration.dispatchCommand({ |
| 1364 | type: "thread.meta.update", | 1361 | type: "thread.meta.update", |
| 1365 | commandId: newCommandId(), | 1362 | commandId: newCommandId(), |
| 1366 | threadId: asThreadId(activeThread.id), | 1363 | threadId: activeThread.id, |
| 1367 | branch: result.worktree.branch, | 1364 | branch: result.worktree.branch, |
| 1368 | worktreePath: result.worktree.path, | 1365 | worktreePath: result.worktree.path, |
| 1369 | }); | 1366 | }); |
| ... | @@ -1397,7 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1397,7 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1397 | await api.orchestration.dispatchCommand({ | 1394 | await api.orchestration.dispatchCommand({ |
| 1398 | type: "thread.meta.update", | 1395 | type: "thread.meta.update", |
| 1399 | commandId: newCommandId(), | 1396 | commandId: newCommandId(), |
| 1400 | threadId: asThreadId(activeThread.id), | 1397 | threadId: activeThread.id, |
| 1401 | title, | 1398 | title, |
| 1402 | }); | 1399 | }); |
| 1403 | } | 1400 | } |
| ... | @@ -1434,7 +1431,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1434,7 +1431,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1434 | await api.orchestration.dispatchCommand({ | 1431 | await api.orchestration.dispatchCommand({ |
| 1435 | type: "thread.turn.start", | 1432 | type: "thread.turn.start", |
| 1436 | commandId: newCommandId(), | 1433 | commandId: newCommandId(), |
| 1437 | threadId: asThreadId(activeThread.id), | 1434 | threadId: activeThread.id, |
| 1438 | message: { | 1435 | message: { |
| 1439 | messageId: newMessageId(), | 1436 | messageId: newMessageId(), |
| 1440 | role: "user", | 1437 | role: "user", |
| ... | @@ -1456,17 +1453,19 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1456,17 +1453,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1456 | }; | 1453 | }; |
| 1457 | 1454 | ||
| 1458 | const onInterrupt = async () => { | 1455 | const onInterrupt = async () => { |
| 1456 | const api = readNativeApi(); | ||
| 1459 | if (!api || !activeThread) return; | 1457 | if (!api || !activeThread) return; |
| 1460 | await api.orchestration.dispatchCommand({ | 1458 | await api.orchestration.dispatchCommand({ |
| 1461 | type: "thread.turn.interrupt", | 1459 | type: "thread.turn.interrupt", |
| 1462 | commandId: newCommandId(), | 1460 | commandId: newCommandId(), |
| 1463 | threadId: asThreadId(activeThread.id), | 1461 | threadId: activeThread.id, |
| 1464 | createdAt: new Date().toISOString(), | 1462 | createdAt: new Date().toISOString(), |
| 1465 | }); | 1463 | }); |
| 1466 | }; | 1464 | }; |
| 1467 | 1465 | ||
| 1468 | const onRespondToApproval = useCallback( | 1466 | const onRespondToApproval = useCallback( |
| 1469 | async (requestId: string, decision: ProviderApprovalDecision) => { | 1467 | async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { |
| 1468 | const api = readNativeApi(); | ||
| 1470 | if (!api || !activeThreadId) return; | 1469 | if (!api || !activeThreadId) return; |
| 1471 | 1470 | ||
| 1472 | setRespondingRequestIds((existing) => | 1471 | setRespondingRequestIds((existing) => |
| ... | @@ -1476,8 +1475,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1476,8 +1475,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1476 | await api.orchestration.dispatchCommand({ | 1475 | await api.orchestration.dispatchCommand({ |
| 1477 | type: "thread.approval.respond", | 1476 | type: "thread.approval.respond", |
| 1478 | commandId: newCommandId(), | 1477 | commandId: newCommandId(), |
| 1479 | threadId: asThreadId(activeThreadId), | 1478 | threadId: activeThreadId, |
| 1480 | requestId: asApprovalRequestId(requestId), | 1479 | requestId, |
| 1481 | decision, | 1480 | decision, |
| 1482 | createdAt: new Date().toISOString(), | 1481 | createdAt: new Date().toISOString(), |
| 1483 | }); | 1482 | }); |
| ... | @@ -1491,23 +1490,24 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1491,23 +1490,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1491 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 1490 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 1492 | } | 1491 | } |
| 1493 | }, | 1492 | }, |
| 1494 | [activeThreadId, api, dispatch], | 1493 | [activeThreadId, dispatch], |
| 1495 | ); | 1494 | ); |
| 1496 | 1495 | ||
| 1497 | const onModelSelect = useCallback( | 1496 | const onModelSelect = useCallback( |
| 1498 | (model: ModelSlug) => { | 1497 | (model: ModelSlug) => { |
| 1498 | const api = readNativeApi(); | ||
| 1499 | if (!activeThread) return; | 1499 | if (!activeThread) return; |
| 1500 | if (api) { | 1500 | if (api) { |
| 1501 | void api.orchestration.dispatchCommand({ | 1501 | void api.orchestration.dispatchCommand({ |
| 1502 | type: "thread.meta.update", | 1502 | type: "thread.meta.update", |
| 1503 | commandId: newCommandId(), | 1503 | commandId: newCommandId(), |
| 1504 | threadId: asThreadId(activeThread.id), | 1504 | threadId: activeThread.id, |
| 1505 | model: resolveModelSlug(model), | 1505 | model: resolveModelSlug(model), |
| 1506 | }); | 1506 | }); |
| 1507 | } | 1507 | } |
| 1508 | scheduleComposerFocus(); | 1508 | scheduleComposerFocus(); |
| 1509 | }, | 1509 | }, |
| 1510 | [activeThread, api, scheduleComposerFocus], | 1510 | [activeThread, scheduleComposerFocus], |
| 1511 | ); | 1511 | ); |
| 1512 | const onEffortSelect = useCallback( | 1512 | const onEffortSelect = useCallback( |
| 1513 | (effort: ReasoningEffort) => { | 1513 | (effort: ReasoningEffort) => { |
| ... | @@ -1629,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1629,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1629 | }); | 1629 | }); |
| 1630 | }, [diffOpen, navigate, threadId]); | 1630 | }, [diffOpen, navigate, threadId]); |
| 1631 | const onOpenTurnDiff = useCallback( | 1631 | const onOpenTurnDiff = useCallback( |
| 1632 | (turnId: string, filePath?: string) => { | 1632 | (turnId: TurnId, filePath?: string) => { |
| 1633 | void navigate({ | 1633 | void navigate({ |
| 1634 | to: "/$threadId", | 1634 | to: "/$threadId", |
| 1635 | params: { threadId }, | 1635 | params: { threadId }, |
| ... | @@ -1644,7 +1644,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1644,7 +1644,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1644 | [navigate, threadId], | 1644 | [navigate, threadId], |
| 1645 | ); | 1645 | ); |
| 1646 | const onRevertUserMessage = useCallback( | 1646 | const onRevertUserMessage = useCallback( |
| 1647 | (messageId: string) => { | 1647 | (messageId: MessageId) => { |
| 1648 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); | 1648 | const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); |
| 1649 | if (typeof targetTurnCount !== "number") { | 1649 | if (typeof targetTurnCount !== "number") { |
| 1650 | return; | 1650 | return; |
| ... | @@ -1698,7 +1698,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1698,7 +1698,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1698 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null | 1698 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
| 1699 | } | 1699 | } |
| 1700 | keybindings={keybindings} | 1700 | keybindings={keybindings} |
| 1701 | api={api} | ||
| 1702 | gitCwd={gitCwd} | 1701 | gitCwd={gitCwd} |
| 1703 | diffOpen={diffOpen} | 1702 | diffOpen={diffOpen} |
| 1704 | onRunProjectScript={(script) => { | 1703 | onRunProjectScript={(script) => { |
| ... | @@ -1971,35 +1970,39 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1971,35 +1970,39 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1971 | /> | 1970 | /> |
| 1972 | )} | 1971 | )} |
| 1973 | 1972 | ||
| 1974 | {activeThread.terminalOpen && api && activeProject && ( | 1973 | {(() => { |
| 1975 | <ThreadTerminalDrawer | 1974 | if (!activeThread.terminalOpen || !activeProject) { |
| 1976 | key={activeThread.id} | 1975 | return null; |
| 1977 | api={api} | 1976 | } |
| 1978 | threadId={activeThread.id} | 1977 | return ( |
| 1979 | cwd={gitCwd ?? activeProject.cwd} | 1978 | <ThreadTerminalDrawer |
| 1980 | runtimeEnv={threadTerminalRuntimeEnv} | 1979 | key={activeThread.id} |
| 1981 | height={activeThread.terminalHeight} | 1980 | threadId={activeThread.id} |
| 1982 | terminalIds={activeThread.terminalIds} | 1981 | cwd={gitCwd ?? activeProject.cwd} |
| 1983 | activeTerminalId={activeThread.activeTerminalId} | 1982 | runtimeEnv={threadTerminalRuntimeEnv} |
| 1984 | terminalGroups={activeThread.terminalGroups} | 1983 | height={activeThread.terminalHeight} |
| 1985 | activeTerminalGroupId={activeThread.activeTerminalGroupId} | 1984 | terminalIds={activeThread.terminalIds} |
| 1986 | focusRequestId={terminalFocusRequestId} | 1985 | activeTerminalId={activeThread.activeTerminalId} |
| 1987 | onSplitTerminal={splitTerminal} | 1986 | terminalGroups={activeThread.terminalGroups} |
| 1988 | onNewTerminal={createNewTerminal} | 1987 | activeTerminalGroupId={activeThread.activeTerminalGroupId} |
| 1989 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} | 1988 | focusRequestId={terminalFocusRequestId} |
| 1990 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} | 1989 | onSplitTerminal={splitTerminal} |
| 1991 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | 1990 | onNewTerminal={createNewTerminal} |
| 1992 | onActiveTerminalChange={activateTerminal} | 1991 | splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} |
| 1993 | onCloseTerminal={closeTerminal} | 1992 | newShortcutLabel={newTerminalShortcutLabel ?? undefined} |
| 1994 | onHeightChange={(height) => | 1993 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 1995 | dispatch({ | 1994 | onActiveTerminalChange={activateTerminal} |
| 1996 | type: "SET_THREAD_TERMINAL_HEIGHT", | 1995 | onCloseTerminal={closeTerminal} |
| 1997 | threadId: activeThread.id, | 1996 | onHeightChange={(height) => |
| 1998 | height, | 1997 | dispatch({ |
| 1999 | }) | 1998 | type: "SET_THREAD_TERMINAL_HEIGHT", |
| 2000 | } | 1999 | threadId: activeThread.id, |
| 2001 | /> | 2000 | height, |
| 2002 | )} | 2001 | }) |
| 2002 | } | ||
| 2003 | /> | ||
| 2004 | ); | ||
| 2005 | })()} | ||
| 2003 | 2006 | ||
| 2004 | {expandedImage && ( | 2007 | {expandedImage && ( |
| 2005 | <div | 2008 | <div |
| ... | @@ -2040,13 +2043,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2040,13 +2043,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2040 | } | 2043 | } |
| 2041 | 2044 | ||
| 2042 | interface ChatHeaderProps { | 2045 | interface ChatHeaderProps { |
| 2043 | activeThreadId: string; | 2046 | activeThreadId: ThreadId; |
| 2044 | activeThreadTitle: string; | 2047 | activeThreadTitle: string; |
| 2045 | activeProjectName: string | undefined; | 2048 | activeProjectName: string | undefined; |
| 2046 | activeProjectScripts: ProjectScript[] | undefined; | 2049 | activeProjectScripts: ProjectScript[] | undefined; |
| 2047 | preferredScriptId: string | null; | 2050 | preferredScriptId: string | null; |
| 2048 | keybindings: ResolvedKeybindingsConfig; | 2051 | keybindings: ResolvedKeybindingsConfig; |
| 2049 | api: NativeApi | undefined; | ||
| 2050 | gitCwd: string | null; | 2052 | gitCwd: string | null; |
| 2051 | diffOpen: boolean; | 2053 | diffOpen: boolean; |
| 2052 | onRunProjectScript: (script: ProjectScript) => void; | 2054 | onRunProjectScript: (script: ProjectScript) => void; |
| ... | @@ -2062,7 +2064,6 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -2062,7 +2064,6 @@ const ChatHeader = memo(function ChatHeader({ |
| 2062 | activeProjectScripts, | 2064 | activeProjectScripts, |
| 2063 | preferredScriptId, | 2065 | preferredScriptId, |
| 2064 | keybindings, | 2066 | keybindings, |
| 2065 | api, | ||
| 2066 | gitCwd, | 2067 | gitCwd, |
| 2067 | diffOpen, | 2068 | diffOpen, |
| 2068 | onRunProjectScript, | 2069 | onRunProjectScript, |
| ... | @@ -2100,9 +2101,7 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -2100,9 +2101,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 2100 | {activeProjectName && ( | 2101 | {activeProjectName && ( |
| 2101 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> | 2102 | <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} /> |
| 2102 | )} | 2103 | )} |
| 2103 | {activeProjectName && ( | 2104 | {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />} |
| 2104 | <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} /> | ||
| 2105 | )} | ||
| 2106 | <Toggle | 2105 | <Toggle |
| 2107 | className="shrink-0" | 2106 | className="shrink-0" |
| 2108 | pressed={diffOpen} | 2107 | pressed={diffOpen} |
| ... | @@ -2134,8 +2133,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st | ... | @@ -2134,8 +2133,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st |
| 2134 | 2133 | ||
| 2135 | interface PendingApprovalsPanelProps { | 2134 | interface PendingApprovalsPanelProps { |
| 2136 | pendingApprovals: PendingApproval[]; | 2135 | pendingApprovals: PendingApproval[]; |
| 2137 | respondingRequestIds: string[]; | 2136 | respondingRequestIds: ApprovalRequestId[]; |
| 2138 | onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>; | 2137 | onRespondToApproval: ( |
| 2138 | requestId: ApprovalRequestId, | ||
| 2139 | decision: ProviderApprovalDecision, | ||
| 2140 | ) => Promise<void>; | ||
| 2139 | } | 2141 | } |
| 2140 | 2142 | ||
| 2141 | const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ | 2143 | const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ |
| ... | @@ -2211,13 +2213,13 @@ interface MessagesTimelineProps { | ... | @@ -2211,13 +2213,13 @@ interface MessagesTimelineProps { |
| 2211 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | 2213 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2212 | completionDividerBeforeEntryId: string | null; | 2214 | completionDividerBeforeEntryId: string | null; |
| 2213 | completionSummary: string | null; | 2215 | completionSummary: string | null; |
| 2214 | turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>; | 2216 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; |
| 2215 | nowIso: string; | 2217 | nowIso: string; |
| 2216 | expandedWorkGroups: Record<string, boolean>; | 2218 | expandedWorkGroups: Record<string, boolean>; |
| 2217 | onToggleWorkGroup: (groupId: string) => void; | 2219 | onToggleWorkGroup: (groupId: string) => void; |
| 2218 | onOpenTurnDiff: (turnId: string, filePath?: string) => void; | 2220 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 2219 | revertTurnCountByUserMessageId: Map<string, number>; | 2221 | revertTurnCountByUserMessageId: Map<MessageId, number>; |
| 2220 | onRevertUserMessage: (messageId: string) => void; | 2222 | onRevertUserMessage: (messageId: MessageId) => void; |
| 2221 | isRevertingCheckpoint: boolean; | 2223 | isRevertingCheckpoint: boolean; |
| 2222 | onImageExpand: (image: ExpandedImagePreview) => void; | 2224 | onImageExpand: (image: ExpandedImagePreview) => void; |
| 2223 | markdownCwd: string | undefined; | 2225 | markdownCwd: string | undefined; |
| ... | @@ -2667,7 +2669,7 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -2667,7 +2669,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2667 | activeThreadId, | 2669 | activeThreadId, |
| 2668 | }: { | 2670 | }: { |
| 2669 | keybindings: ResolvedKeybindingsConfig; | 2671 | keybindings: ResolvedKeybindingsConfig; |
| 2670 | activeThreadId: string | null; | 2672 | activeThreadId: ThreadId | null; |
| 2671 | }) { | 2673 | }) { |
| 2672 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | 2674 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 2673 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | 2675 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| ... | @@ -2692,13 +2694,13 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -2692,13 +2694,13 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2692 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; | 2694 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; |
| 2693 | const primaryOption = options.find(({ value }) => value === lastEditor); | 2695 | const primaryOption = options.find(({ value }) => value === lastEditor); |
| 2694 | 2696 | ||
| 2695 | const api = useNativeApi(); | ||
| 2696 | const { state } = useStore(); | 2697 | const { state } = useStore(); |
| 2697 | const activeThread = state.threads.find((t) => t.id === activeThreadId); | 2698 | const activeThread = state.threads.find((t) => t.id === activeThreadId); |
| 2698 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 2699 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 2699 | 2700 | ||
| 2700 | const openInEditor = useCallback( | 2701 | const openInEditor = useCallback( |
| 2701 | (editorId: EditorId | null) => { | 2702 | (editorId: EditorId | null) => { |
| 2703 | const api = readNativeApi(); | ||
| 2702 | if (!api || !activeProject) return; | 2704 | if (!api || !activeProject) return; |
| 2703 | const editor = editorId ?? lastEditor; | 2705 | const editor = editorId ?? lastEditor; |
| 2704 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; | 2706 | const cwd = activeThread?.worktreePath ?? activeProject.cwd; |
| ... | @@ -2706,7 +2708,7 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -2706,7 +2708,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2706 | localStorage.setItem(LAST_EDITOR_KEY, editor); | 2708 | localStorage.setItem(LAST_EDITOR_KEY, editor); |
| 2707 | setLastEditor(editor); | 2709 | setLastEditor(editor); |
| 2708 | }, | 2710 | }, |
| 2709 | [api, activeProject, activeThread, lastEditor, setLastEditor], | 2711 | [activeProject, activeThread, lastEditor, setLastEditor], |
| 2710 | ); | 2712 | ); |
| 2711 | 2713 | ||
| 2712 | const openFavoriteEditorShortcutLabel = useMemo( | 2714 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | @@ -2716,6 +2718,7 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -2716,6 +2718,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2716 | 2718 | ||
| 2717 | useEffect(() => { | 2719 | useEffect(() => { |
| 2718 | const handler = (e: globalThis.KeyboardEvent) => { | 2720 | const handler = (e: globalThis.KeyboardEvent) => { |
| 2721 | const api = readNativeApi(); | ||
| 2719 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; | 2722 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 2720 | if (!api || !activeProject) return; | 2723 | if (!api || !activeProject) return; |
| 2721 | 2724 | ||
| ... | @@ -2725,7 +2728,7 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -2725,7 +2728,7 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 2725 | }; | 2728 | }; |
| 2726 | window.addEventListener("keydown", handler); | 2729 | window.addEventListener("keydown", handler); |
| 2727 | return () => window.removeEventListener("keydown", handler); | 2730 | return () => window.removeEventListener("keydown", handler); |
| 2728 | }, [api, activeProject, activeThread, keybindings, lastEditor]); | 2731 | }, [activeProject, activeThread, keybindings, lastEditor]); |
| 2729 | 2732 | ||
| 2730 | return ( | 2733 | return ( |
| 2731 | <Group aria-label="Subscription actions"> | 2734 | <Group aria-label="Subscription actions"> |
apps/web/src/components/DiffPanel.tsx+8-11| ... | @@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs"; | ... | @@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; | 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | import { useQuery } from "@tanstack/react-query"; | 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; | ||
| 5 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; | 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 7 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 8 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 8 | import { cn } from "~/lib/utils"; | 9 | import { cn } from "~/lib/utils"; |
| 9 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 10 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 10 | import { isElectron } from "../env"; | 11 | import { isElectron } from "../env"; |
| 11 | import { useNativeApi } from "../hooks/useNativeApi"; | ||
| 12 | import { useTheme } from "../hooks/useTheme"; | 12 | import { useTheme } from "../hooks/useTheme"; |
| 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; | 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | @@ -81,7 +81,6 @@ interface DiffPanelProps { | ... | @@ -81,7 +81,6 @@ interface DiffPanelProps { |
| 81 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | 81 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| 82 | 82 | ||
| 83 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | 83 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 84 | const api = useNativeApi(); | ||
| 85 | const navigate = useNavigate(); | 84 | const navigate = useNavigate(); |
| 86 | const { resolvedTheme } = useTheme(); | 85 | const { resolvedTheme } = useTheme(); |
| 87 | const { state } = useStore(); | 86 | const { state } = useStore(); |
| ... | @@ -90,13 +89,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -90,13 +89,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 90 | const turnStripRef = useRef<HTMLDivElement>(null); | 89 | const turnStripRef = useRef<HTMLDivElement>(null); |
| 91 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); | 90 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); |
| 92 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); | 91 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); |
| 93 | const params = useParams({ strict: false }); | 92 | const routeThreadId = useParams({ |
| 94 | const rawSearch = useSearch({ strict: false }); | 93 | strict: false, |
| 95 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | 94 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| 96 | const diffSearch = useMemo( | 95 | }); |
| 97 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | 96 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 98 | [rawSearch], | ||
| 99 | ); | ||
| 100 | const activeThreadId = routeThreadId; | 97 | const activeThreadId = routeThreadId; |
| 101 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); | 98 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); |
| 102 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 99 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| ... | @@ -169,7 +166,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -169,7 +166,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 169 | return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; | 166 | return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; |
| 170 | }, [orderedTurnDiffSummaries, selectedTurn]); | 167 | }, [orderedTurnDiffSummaries, selectedTurn]); |
| 171 | const activeCheckpointDiffQuery = useQuery( | 168 | const activeCheckpointDiffQuery = useQuery( |
| 172 | checkpointDiffQueryOptions(api, { | 169 | checkpointDiffQueryOptions({ |
| 173 | threadId: activeThreadId, | 170 | threadId: activeThreadId, |
| 174 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, | 171 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 175 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, | 172 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| ... | @@ -216,7 +213,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -216,7 +213,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 216 | target?.scrollIntoView({ block: "nearest" }); | 213 | target?.scrollIntoView({ block: "nearest" }); |
| 217 | }, [selectedFilePath, renderableFiles]); | 214 | }, [selectedFilePath, renderableFiles]); |
| 218 | 215 | ||
| 219 | const selectTurn = (turnId: string) => { | 216 | const selectTurn = (turnId: TurnId) => { |
| 220 | if (!activeThread) return; | 217 | if (!activeThread) return; |
| 221 | void navigate({ | 218 | void navigate({ |
| 222 | to: "/$threadId", | 219 | to: "/$threadId", |
apps/web/src/components/GitActionsControl.tsx+19-17| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts"; | 1 | import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useCallback, useEffect, useMemo, useState } from "react"; | 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| ... | @@ -39,11 +39,11 @@ import { | ... | @@ -39,11 +39,11 @@ import { |
| 39 | invalidateGitQueries, | 39 | invalidateGitQueries, |
| 40 | } from "~/lib/gitReactQuery"; | 40 | } from "~/lib/gitReactQuery"; |
| 41 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; | 41 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; |
| 42 | import { readNativeApi } from "~/nativeApi"; | ||
| 42 | 43 | ||
| 43 | interface GitActionsControlProps { | 44 | interface GitActionsControlProps { |
| 44 | api: NativeApi | undefined; | ||
| 45 | gitCwd: string | null; | 45 | gitCwd: string | null; |
| 46 | activeThreadId: string | null; | 46 | activeThreadId: ThreadId | null; |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | function getMenuActionDisabledReason( | 49 | function getMenuActionDisabledReason( |
| ... | @@ -136,7 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { | ... | @@ -136,7 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { |
| 136 | return <InfoIcon className={iconClassName} />; | 136 | return <InfoIcon className={iconClassName} />; |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) { | 139 | export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { |
| 140 | const threadToastData = useMemo( | 140 | const threadToastData = useMemo( |
| 141 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), | 141 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 142 | [activeThreadId], | 142 | [activeThreadId], |
| ... | @@ -145,11 +145,9 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -145,11 +145,9 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 145 | const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null); | 145 | const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null); |
| 146 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | 146 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 147 | 147 | ||
| 148 | const { data: gitStatus = null, error: gitStatusError } = useQuery( | 148 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); |
| 149 | gitStatusQueryOptions(api, gitCwd), | ||
| 150 | ); | ||
| 151 | 149 | ||
| 152 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); | 150 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); |
| 153 | // Default to true while loading so we don't flash init controls. | 151 | // Default to true while loading so we don't flash init controls. |
| 154 | const isRepo = branchList?.isRepo ?? true; | 152 | const isRepo = branchList?.isRepo ?? true; |
| 155 | const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; | 153 | const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; |
| ... | @@ -163,12 +161,12 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -163,12 +161,12 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 163 | 161 | ||
| 164 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; | 162 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; |
| 165 | 163 | ||
| 166 | const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); | 164 | const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); |
| 167 | 165 | ||
| 168 | const runImmediateGitActionMutation = useMutation( | 166 | const runImmediateGitActionMutation = useMutation( |
| 169 | gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }), | 167 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), |
| 170 | ); | 168 | ); |
| 171 | const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient })); | 169 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); |
| 172 | 170 | ||
| 173 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; | 171 | const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; |
| 174 | const isDefaultBranch = useMemo(() => { | 172 | const isDefaultBranch = useMemo(() => { |
| ... | @@ -192,6 +190,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -192,6 +190,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 192 | 190 | ||
| 193 | const maybeConfirmPushToDefaultBranch = useCallback( | 191 | const maybeConfirmPushToDefaultBranch = useCallback( |
| 194 | async (action: GitStackedAction): Promise<boolean> => { | 192 | async (action: GitStackedAction): Promise<boolean> => { |
| 193 | const api = readNativeApi(); | ||
| 195 | if (!api) return false; | 194 | if (!api) return false; |
| 196 | if ( | 195 | if ( |
| 197 | !requiresDefaultBranchConfirmation(action, isDefaultBranch) || | 196 | !requiresDefaultBranchConfirmation(action, isDefaultBranch) || |
| ... | @@ -203,10 +202,11 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -203,10 +202,11 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 203 | `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, | 202 | `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`, |
| 204 | ); | 203 | ); |
| 205 | }, | 204 | }, |
| 206 | [api, gitStatusForActions?.branch, isDefaultBranch], | 205 | [gitStatusForActions?.branch, isDefaultBranch], |
| 207 | ); | 206 | ); |
| 208 | 207 | ||
| 209 | const openExistingPr = useCallback(async () => { | 208 | const openExistingPr = useCallback(async () => { |
| 209 | const api = readNativeApi(); | ||
| 210 | if (!api) { | 210 | if (!api) { |
| 211 | toastManager.add({ | 211 | toastManager.add({ |
| 212 | type: "error", | 212 | type: "error", |
| ... | @@ -232,8 +232,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -232,8 +232,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 232 | data: threadToastData, | 232 | data: threadToastData, |
| 233 | }); | 233 | }); |
| 234 | }); | 234 | }); |
| 235 | }, [api, gitStatusForActions?.openPr?.url, threadToastData]); | 235 | }, [gitStatusForActions?.openPr?.url, threadToastData]); |
| 236 | |||
| 237 | const runGitActionWithToast = useCallback( | 236 | const runGitActionWithToast = useCallback( |
| 238 | async ({ | 237 | async ({ |
| 239 | action, | 238 | action, |
| ... | @@ -329,8 +328,10 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -329,8 +328,10 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 329 | actionProps: { | 328 | actionProps: { |
| 330 | children: "Open PR", | 329 | children: "Open PR", |
| 331 | onClick: () => { | 330 | onClick: () => { |
| 331 | const api = readNativeApi(); | ||
| 332 | if (!api) return; | ||
| 332 | closeResultToast(); | 333 | closeResultToast(); |
| 333 | void api?.shell.openExternal(prUrl); | 334 | void api.shell.openExternal(prUrl); |
| 334 | }, | 335 | }, |
| 335 | }, | 336 | }, |
| 336 | } | 337 | } |
| ... | @@ -356,8 +357,8 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -356,8 +357,8 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 356 | }); | 357 | }); |
| 357 | } | 358 | } |
| 358 | }, | 359 | }, |
| 360 | |||
| 359 | [ | 361 | [ |
| 360 | api, | ||
| 361 | gitStatusForActions?.branch, | 362 | gitStatusForActions?.branch, |
| 362 | gitStatusForActions?.hasWorkingTreeChanges, | 363 | gitStatusForActions?.hasWorkingTreeChanges, |
| 363 | gitStatusForActions?.openPr?.url, | 364 | gitStatusForActions?.openPr?.url, |
| ... | @@ -447,6 +448,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -447,6 +448,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 447 | 448 | ||
| 448 | const openChangedFileInEditor = useCallback( | 449 | const openChangedFileInEditor = useCallback( |
| 449 | (filePath: string) => { | 450 | (filePath: string) => { |
| 451 | const api = readNativeApi(); | ||
| 450 | if (!api || !gitCwd) { | 452 | if (!api || !gitCwd) { |
| 451 | toastManager.add({ | 453 | toastManager.add({ |
| 452 | type: "error", | 454 | type: "error", |
| ... | @@ -465,7 +467,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc | ... | @@ -465,7 +467,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc |
| 465 | }); | 467 | }); |
| 466 | }); | 468 | }); |
| 467 | }, | 469 | }, |
| 468 | [api, gitCwd, threadToastData], | 470 | [gitCwd, threadToastData], |
| 469 | ); | 471 | ); |
| 470 | 472 | ||
| 471 | if (!gitCwd) return null; | 473 | if (!gitCwd) return null; |
apps/web/src/components/Sidebar.tsx+34-40| ... | @@ -1,25 +1,23 @@ | ... | @@ -1,25 +1,23 @@ |
| 1 | import { ChevronRightIcon, TerminalIcon } from "lucide-react"; | 1 | import { ChevronRightIcon, TerminalIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 3 | import { |
| 4 | DEFAULT_MODEL, | ||
| 5 | ProjectId, | ||
| 6 | ThreadId, | ||
| 7 | type ResolvedKeybindingsConfig, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 9 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 5 | import { useNavigate, useParams } from "@tanstack/react-router"; | 10 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 6 | import { useAppSettings } from "../appSettings"; | 11 | import { useAppSettings } from "../appSettings"; |
| 7 | import { isElectron } from "../env"; | 12 | import { isElectron } from "../env"; |
| 8 | import { APP_STAGE_LABEL } from "../branding"; | 13 | import { APP_STAGE_LABEL } from "../branding"; |
| 9 | import { DEFAULT_MODEL } from "../model-logic"; | 14 | import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds"; |
| 10 | import { | ||
| 11 | asProjectId, | ||
| 12 | asThreadId, | ||
| 13 | newCommandId, | ||
| 14 | newProjectId, | ||
| 15 | newThreadId, | ||
| 16 | } from "../lib/orchestrationIds"; | ||
| 17 | import { useStore } from "../store"; | 15 | import { useStore } from "../store"; |
| 18 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; | 16 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 19 | import { type Thread } from "../types"; | 17 | import { type Thread } from "../types"; |
| 20 | import { useNativeApi } from "../hooks/useNativeApi"; | ||
| 21 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 18 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; |
| 22 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 19 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 20 | import { readNativeApi } from "../nativeApi"; | ||
| 23 | import { toastManager } from "./ui/toast"; | 21 | import { toastManager } from "./ui/toast"; |
| 24 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 22 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 25 | import { | 23 | import { |
| ... | @@ -37,6 +35,7 @@ import { | ... | @@ -37,6 +35,7 @@ import { |
| 37 | SidebarTrigger, | 35 | SidebarTrigger, |
| 38 | } from "./ui/sidebar"; | 36 | } from "./ui/sidebar"; |
| 39 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 37 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 38 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | ||
| 40 | 39 | ||
| 41 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 40 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 42 | 41 | ||
| ... | @@ -143,33 +142,33 @@ function T3Wordmark() { | ... | @@ -143,33 +142,33 @@ function T3Wordmark() { |
| 143 | 142 | ||
| 144 | export default function Sidebar() { | 143 | export default function Sidebar() { |
| 145 | const { state, dispatch } = useStore(); | 144 | const { state, dispatch } = useStore(); |
| 146 | const api = useNativeApi(); | ||
| 147 | const navigate = useNavigate(); | 145 | const navigate = useNavigate(); |
| 148 | const { settings: appSettings } = useAppSettings(); | 146 | const { settings: appSettings } = useAppSettings(); |
| 149 | const params = useParams({ strict: false }); | 147 | const routeThreadId = useParams({ |
| 150 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | 148 | strict: false, |
| 149 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | ||
| 150 | }); | ||
| 151 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ | 151 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ |
| 152 | ...serverConfigQueryOptions(api), | 152 | ...serverConfigQueryOptions(), |
| 153 | select: (config) => config.keybindings, | 153 | select: (config) => config.keybindings, |
| 154 | }); | 154 | }); |
| 155 | const queryClient = useQueryClient(); | 155 | const queryClient = useQueryClient(); |
| 156 | const removeWorktreeMutation = useMutation( | 156 | const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); |
| 157 | gitRemoveWorktreeMutationOptions({ api, queryClient }), | ||
| 158 | ); | ||
| 159 | const [addingProject, setAddingProject] = useState(false); | 157 | const [addingProject, setAddingProject] = useState(false); |
| 160 | const [newCwd, setNewCwd] = useState(""); | 158 | const [newCwd, setNewCwd] = useState(""); |
| 161 | const [isPickingFolder, setIsPickingFolder] = useState(false); | 159 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 162 | const [isAddingProject, setIsAddingProject] = useState(false); | 160 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 163 | const pendingApprovalByThreadId = useMemo(() => new Map<string, boolean>(), []); | 161 | const pendingApprovalByThreadId = useMemo(() => new Map<ThreadId, boolean>(), []); |
| 164 | 162 | ||
| 165 | const handleNewThread = useCallback( | 163 | const handleNewThread = useCallback( |
| 166 | ( | 164 | ( |
| 167 | projectId: string, | 165 | projectId: ProjectId, |
| 168 | options?: { | 166 | options?: { |
| 169 | branch?: string | null; | 167 | branch?: string | null; |
| 170 | worktreePath?: string | null; | 168 | worktreePath?: string | null; |
| 171 | }, | 169 | }, |
| 172 | ): Promise<void> => { | 170 | ): Promise<void> => { |
| 171 | const api = readNativeApi(); | ||
| 173 | if (!api) return Promise.resolve(); | 172 | if (!api) return Promise.resolve(); |
| 174 | const threadId = newThreadId(); | 173 | const threadId = newThreadId(); |
| 175 | const createdAt = new Date().toISOString(); | 174 | const createdAt = new Date().toISOString(); |
| ... | @@ -180,7 +179,7 @@ export default function Sidebar() { | ... | @@ -180,7 +179,7 @@ export default function Sidebar() { |
| 180 | type: "thread.create", | 179 | type: "thread.create", |
| 181 | commandId: newCommandId(), | 180 | commandId: newCommandId(), |
| 182 | threadId, | 181 | threadId, |
| 183 | projectId: asProjectId(projectId), | 182 | projectId, |
| 184 | title: "New thread", | 183 | title: "New thread", |
| 185 | model, | 184 | model, |
| 186 | branch: options?.branch ?? null, | 185 | branch: options?.branch ?? null, |
| ... | @@ -202,11 +201,11 @@ export default function Sidebar() { | ... | @@ -202,11 +201,11 @@ export default function Sidebar() { |
| 202 | }); | 201 | }); |
| 203 | })(); | 202 | })(); |
| 204 | }, | 203 | }, |
| 205 | [api, dispatch, navigate, state.projects], | 204 | [dispatch, navigate, state.projects], |
| 206 | ); | 205 | ); |
| 207 | 206 | ||
| 208 | const focusMostRecentThreadForProject = useCallback( | 207 | const focusMostRecentThreadForProject = useCallback( |
| 209 | (projectId: string) => { | 208 | (projectId: ProjectId) => { |
| 210 | const latestThread = state.threads | 209 | const latestThread = state.threads |
| 211 | .filter((thread) => thread.projectId === projectId) | 210 | .filter((thread) => thread.projectId === projectId) |
| 212 | .toSorted((a, b) => { | 211 | .toSorted((a, b) => { |
| ... | @@ -228,6 +227,7 @@ export default function Sidebar() { | ... | @@ -228,6 +227,7 @@ export default function Sidebar() { |
| 228 | async (rawCwd: string) => { | 227 | async (rawCwd: string) => { |
| 229 | const cwd = rawCwd.trim(); | 228 | const cwd = rawCwd.trim(); |
| 230 | if (!cwd || isAddingProject) return; | 229 | if (!cwd || isAddingProject) return; |
| 230 | const api = readNativeApi(); | ||
| 231 | 231 | ||
| 232 | setIsAddingProject(true); | 232 | setIsAddingProject(true); |
| 233 | try { | 233 | try { |
| ... | @@ -240,7 +240,7 @@ export default function Sidebar() { | ... | @@ -240,7 +240,7 @@ export default function Sidebar() { |
| 240 | 240 | ||
| 241 | const projectId = newProjectId(); | 241 | const projectId = newProjectId(); |
| 242 | const createdAt = new Date().toISOString(); | 242 | const createdAt = new Date().toISOString(); |
| 243 | const title = cwd.split(/[/\\]/).filter(Boolean).at(-1) ?? cwd; | 243 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; |
| 244 | await api.orchestration.dispatchCommand({ | 244 | await api.orchestration.dispatchCommand({ |
| 245 | type: "project.create", | 245 | type: "project.create", |
| 246 | commandId: newCommandId(), | 246 | commandId: newCommandId(), |
| ... | @@ -257,14 +257,7 @@ export default function Sidebar() { | ... | @@ -257,14 +257,7 @@ export default function Sidebar() { |
| 257 | setAddingProject(false); | 257 | setAddingProject(false); |
| 258 | } | 258 | } |
| 259 | }, | 259 | }, |
| 260 | [ | 260 | [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects], |
| 261 | api, | ||
| 262 | focusMostRecentThreadForProject, | ||
| 263 | handleNewThread, | ||
| 264 | isAddingProject, | ||
| 265 | state.projects, | ||
| 266 | state.threads, | ||
| 267 | ], | ||
| 268 | ); | 261 | ); |
| 269 | 262 | ||
| 270 | const handleAddProject = () => { | 263 | const handleAddProject = () => { |
| ... | @@ -272,6 +265,7 @@ export default function Sidebar() { | ... | @@ -272,6 +265,7 @@ export default function Sidebar() { |
| 272 | }; | 265 | }; |
| 273 | 266 | ||
| 274 | const handlePickFolder = async () => { | 267 | const handlePickFolder = async () => { |
| 268 | const api = readNativeApi(); | ||
| 275 | if (!api || isPickingFolder) return; | 269 | if (!api || isPickingFolder) return; |
| 276 | setIsPickingFolder(true); | 270 | setIsPickingFolder(true); |
| 277 | try { | 271 | try { |
| ... | @@ -284,7 +278,8 @@ export default function Sidebar() { | ... | @@ -284,7 +278,8 @@ export default function Sidebar() { |
| 284 | }; | 278 | }; |
| 285 | 279 | ||
| 286 | const handleThreadContextMenu = useCallback( | 280 | const handleThreadContextMenu = useCallback( |
| 287 | async (threadId: string, position: { x: number; y: number }) => { | 281 | async (threadId: ThreadId, position: { x: number; y: number }) => { |
| 282 | const api = readNativeApi(); | ||
| 288 | if (!api) return; | 283 | if (!api) return; |
| 289 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | 284 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); |
| 290 | if (clicked !== "delete") return; | 285 | if (clicked !== "delete") return; |
| ... | @@ -324,7 +319,7 @@ export default function Sidebar() { | ... | @@ -324,7 +319,7 @@ export default function Sidebar() { |
| 324 | .dispatchCommand({ | 319 | .dispatchCommand({ |
| 325 | type: "thread.session.stop", | 320 | type: "thread.session.stop", |
| 326 | commandId: newCommandId(), | 321 | commandId: newCommandId(), |
| 327 | threadId: asThreadId(threadId), | 322 | threadId, |
| 328 | createdAt: new Date().toISOString(), | 323 | createdAt: new Date().toISOString(), |
| 329 | }) | 324 | }) |
| 330 | .catch(() => undefined); | 325 | .catch(() => undefined); |
| ... | @@ -344,7 +339,7 @@ export default function Sidebar() { | ... | @@ -344,7 +339,7 @@ export default function Sidebar() { |
| 344 | await api.orchestration.dispatchCommand({ | 339 | await api.orchestration.dispatchCommand({ |
| 345 | type: "thread.delete", | 340 | type: "thread.delete", |
| 346 | commandId: newCommandId(), | 341 | commandId: newCommandId(), |
| 347 | threadId: asThreadId(threadId), | 342 | threadId, |
| 348 | }); | 343 | }); |
| 349 | if (shouldNavigateToFallback) { | 344 | if (shouldNavigateToFallback) { |
| 350 | if (fallbackThreadId) { | 345 | if (fallbackThreadId) { |
| ... | @@ -384,9 +379,7 @@ export default function Sidebar() { | ... | @@ -384,9 +379,7 @@ export default function Sidebar() { |
| 384 | } | 379 | } |
| 385 | }, | 380 | }, |
| 386 | [ | 381 | [ |
| 387 | api, | ||
| 388 | appSettings.confirmThreadDelete, | 382 | appSettings.confirmThreadDelete, |
| 389 | dispatch, | ||
| 390 | navigate, | 383 | navigate, |
| 391 | removeWorktreeMutation, | 384 | removeWorktreeMutation, |
| 392 | routeThreadId, | 385 | routeThreadId, |
| ... | @@ -396,7 +389,8 @@ export default function Sidebar() { | ... | @@ -396,7 +389,8 @@ export default function Sidebar() { |
| 396 | ); | 389 | ); |
| 397 | 390 | ||
| 398 | const handleProjectContextMenu = useCallback( | 391 | const handleProjectContextMenu = useCallback( |
| 399 | async (projectId: string, position: { x: number; y: number }) => { | 392 | async (projectId: ProjectId, position: { x: number; y: number }) => { |
| 393 | const api = readNativeApi(); | ||
| 400 | if (!api) return; | 394 | if (!api) return; |
| 401 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); | 395 | const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position); |
| 402 | if (clicked !== "delete") return; | 396 | if (clicked !== "delete") return; |
| ... | @@ -423,7 +417,7 @@ export default function Sidebar() { | ... | @@ -423,7 +417,7 @@ export default function Sidebar() { |
| 423 | await api.orchestration.dispatchCommand({ | 417 | await api.orchestration.dispatchCommand({ |
| 424 | type: "project.delete", | 418 | type: "project.delete", |
| 425 | commandId: newCommandId(), | 419 | commandId: newCommandId(), |
| 426 | projectId: asProjectId(projectId), | 420 | projectId, |
| 427 | }); | 421 | }); |
| 428 | } catch (error) { | 422 | } catch (error) { |
| 429 | const message = error instanceof Error ? error.message : "Unknown error deleting project."; | 423 | const message = error instanceof Error ? error.message : "Unknown error deleting project."; |
| ... | @@ -435,7 +429,7 @@ export default function Sidebar() { | ... | @@ -435,7 +429,7 @@ export default function Sidebar() { |
| 435 | }); | 429 | }); |
| 436 | } | 430 | } |
| 437 | }, | 431 | }, |
| 438 | [api, state.projects, state.threads], | 432 | [state.projects, state.threads], |
| 439 | ); | 433 | ); |
| 440 | 434 | ||
| 441 | useEffect(() => { | 435 | useEffect(() => { |
| ... | @@ -687,7 +681,7 @@ export default function Sidebar() { | ... | @@ -687,7 +681,7 @@ export default function Sidebar() { |
| 687 | if (event.key === "Escape") setAddingProject(false); | 681 | if (event.key === "Escape") setAddingProject(false); |
| 688 | }} | 682 | }} |
| 689 | /> | 683 | /> |
| 690 | {isElectron && api && ( | 684 | {isElectron && ( |
| 691 | <button | 685 | <button |
| 692 | type="button" | 686 | type="button" |
| 693 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | 687 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" |
apps/web/src/components/ThreadTerminalDrawer.tsx+14-20| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { FitAddon } from "@xterm/addon-fit"; | 1 | import { FitAddon } from "@xterm/addon-fit"; |
| 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; | 2 | import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; |
| 3 | import { type NativeApi } from "@t3tools/contracts"; | 3 | import { type ThreadId } from "@t3tools/contracts"; |
| 4 | import { Terminal, type ITheme } from "@xterm/xterm"; | 4 | import { Terminal, type ITheme } from "@xterm/xterm"; |
| 5 | import { | 5 | import { |
| 6 | type PointerEvent as ReactPointerEvent, | 6 | type PointerEvent as ReactPointerEvent, |
| ... | @@ -25,6 +25,7 @@ import { | ... | @@ -25,6 +25,7 @@ import { |
| 25 | MAX_THREAD_TERMINAL_COUNT, | 25 | MAX_THREAD_TERMINAL_COUNT, |
| 26 | type ThreadTerminalGroup, | 26 | type ThreadTerminalGroup, |
| 27 | } from "../types"; | 27 | } from "../types"; |
| 28 | import { readNativeApi } from "~/nativeApi"; | ||
| 28 | 29 | ||
| 29 | const MIN_DRAWER_HEIGHT = 180; | 30 | const MIN_DRAWER_HEIGHT = 180; |
| 30 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; | 31 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| ... | @@ -107,8 +108,7 @@ function terminalThemeFromApp(): ITheme { | ... | @@ -107,8 +108,7 @@ function terminalThemeFromApp(): ITheme { |
| 107 | } | 108 | } |
| 108 | 109 | ||
| 109 | interface TerminalViewportProps { | 110 | interface TerminalViewportProps { |
| 110 | api: NativeApi; | 111 | threadId: ThreadId; |
| 111 | threadId: string; | ||
| 112 | terminalId: string; | 112 | terminalId: string; |
| 113 | cwd: string; | 113 | cwd: string; |
| 114 | runtimeEnv?: Record<string, string>; | 114 | runtimeEnv?: Record<string, string>; |
| ... | @@ -119,7 +119,6 @@ interface TerminalViewportProps { | ... | @@ -119,7 +119,6 @@ interface TerminalViewportProps { |
| 119 | } | 119 | } |
| 120 | 120 | ||
| 121 | function TerminalViewport({ | 121 | function TerminalViewport({ |
| 122 | api, | ||
| 123 | threadId, | 122 | threadId, |
| 124 | terminalId, | 123 | terminalId, |
| 125 | cwd, | 124 | cwd, |
| ... | @@ -155,6 +154,9 @@ function TerminalViewport({ | ... | @@ -155,6 +154,9 @@ function TerminalViewport({ |
| 155 | terminalRef.current = terminal; | 154 | terminalRef.current = terminal; |
| 156 | fitAddonRef.current = fitAddon; | 155 | fitAddonRef.current = fitAddon; |
| 157 | 156 | ||
| 157 | const api = readNativeApi(); | ||
| 158 | if (!api) return; | ||
| 159 | |||
| 158 | const sendTerminalInput = async (data: string, fallbackError: string) => { | 160 | const sendTerminalInput = async (data: string, fallbackError: string) => { |
| 159 | const activeTerminal = terminalRef.current; | 161 | const activeTerminal = terminalRef.current; |
| 160 | if (!activeTerminal) return; | 162 | if (!activeTerminal) return; |
| ... | @@ -210,14 +212,10 @@ function TerminalViewport({ | ... | @@ -210,14 +212,10 @@ function TerminalViewport({ |
| 210 | end: { x: match.end, y: bufferLineNumber }, | 212 | end: { x: match.end, y: bufferLineNumber }, |
| 211 | }, | 213 | }, |
| 212 | activate: (event: MouseEvent) => { | 214 | activate: (event: MouseEvent) => { |
| 213 | if (!isTerminalLinkActivation(event)) { | 215 | if (!isTerminalLinkActivation(event)) return; |
| 214 | return; | ||
| 215 | } | ||
| 216 | 216 | ||
| 217 | const latestTerminal = terminalRef.current; | 217 | const latestTerminal = terminalRef.current; |
| 218 | if (!latestTerminal) { | 218 | if (!latestTerminal) return; |
| 219 | return; | ||
| 220 | } | ||
| 221 | 219 | ||
| 222 | if (match.kind === "url") { | 220 | if (match.kind === "url") { |
| 223 | void api.shell.openExternal(match.text).catch((error) => { | 221 | void api.shell.openExternal(match.text).catch((error) => { |
| ... | @@ -297,7 +295,7 @@ function TerminalViewport({ | ... | @@ -297,7 +295,7 @@ function TerminalViewport({ |
| 297 | } | 295 | } |
| 298 | }; | 296 | }; |
| 299 | 297 | ||
| 300 | const unsubscribe = api.terminal.onEvent((event) => { | 298 | const unsubscribe = api?.terminal.onEvent((event) => { |
| 301 | if (event.threadId !== threadId || event.terminalId !== terminalId) return; | 299 | if (event.threadId !== threadId || event.terminalId !== terminalId) return; |
| 302 | const activeTerminal = terminalRef.current; | 300 | const activeTerminal = terminalRef.current; |
| 303 | if (!activeTerminal) return; | 301 | if (!activeTerminal) return; |
| ... | @@ -372,7 +370,7 @@ function TerminalViewport({ | ... | @@ -372,7 +370,7 @@ function TerminalViewport({ |
| 372 | fitAddonRef.current = null; | 370 | fitAddonRef.current = null; |
| 373 | terminal.dispose(); | 371 | terminal.dispose(); |
| 374 | }; | 372 | }; |
| 375 | }, [api, cwd, runtimeEnv, terminalId, threadId]); | 373 | }, [cwd, runtimeEnv, terminalId, threadId]); |
| 376 | 374 | ||
| 377 | useEffect(() => { | 375 | useEffect(() => { |
| 378 | if (!autoFocus) return; | 376 | if (!autoFocus) return; |
| ... | @@ -387,9 +385,10 @@ function TerminalViewport({ | ... | @@ -387,9 +385,10 @@ function TerminalViewport({ |
| 387 | }, [autoFocus, focusRequestId]); | 385 | }, [autoFocus, focusRequestId]); |
| 388 | 386 | ||
| 389 | useEffect(() => { | 387 | useEffect(() => { |
| 388 | const api = readNativeApi(); | ||
| 390 | const terminal = terminalRef.current; | 389 | const terminal = terminalRef.current; |
| 391 | const fitAddon = fitAddonRef.current; | 390 | const fitAddon = fitAddonRef.current; |
| 392 | if (!terminal || !fitAddon) return; | 391 | if (!api || !terminal || !fitAddon) return; |
| 393 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; | 392 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; |
| 394 | const frame = window.requestAnimationFrame(() => { | 393 | const frame = window.requestAnimationFrame(() => { |
| 395 | fitAddon.fit(); | 394 | fitAddon.fit(); |
| ... | @@ -408,14 +407,12 @@ function TerminalViewport({ | ... | @@ -408,14 +407,12 @@ function TerminalViewport({ |
| 408 | return () => { | 407 | return () => { |
| 409 | window.cancelAnimationFrame(frame); | 408 | window.cancelAnimationFrame(frame); |
| 410 | }; | 409 | }; |
| 411 | }, [api, drawerHeight, resizeEpoch, terminalId, threadId]); | 410 | }, [drawerHeight, resizeEpoch, terminalId, threadId]); |
| 412 | |||
| 413 | return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />; | 411 | return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />; |
| 414 | } | 412 | } |
| 415 | 413 | ||
| 416 | interface ThreadTerminalDrawerProps { | 414 | interface ThreadTerminalDrawerProps { |
| 417 | api: NativeApi; | 415 | threadId: ThreadId; |
| 418 | threadId: string; | ||
| 419 | cwd: string; | 416 | cwd: string; |
| 420 | runtimeEnv?: Record<string, string>; | 417 | runtimeEnv?: Record<string, string>; |
| 421 | height: number; | 418 | height: number; |
| ... | @@ -464,7 +461,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA | ... | @@ -464,7 +461,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA |
| 464 | } | 461 | } |
| 465 | 462 | ||
| 466 | export default function ThreadTerminalDrawer({ | 463 | export default function ThreadTerminalDrawer({ |
| 467 | api, | ||
| 468 | threadId, | 464 | threadId, |
| 469 | cwd, | 465 | cwd, |
| 470 | runtimeEnv, | 466 | runtimeEnv, |
| ... | @@ -783,7 +779,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -783,7 +779,6 @@ export default function ThreadTerminalDrawer({ |
| 783 | > | 779 | > |
| 784 | <div className="h-full p-1"> | 780 | <div className="h-full p-1"> |
| 785 | <TerminalViewport | 781 | <TerminalViewport |
| 786 | api={api} | ||
| 787 | threadId={threadId} | 782 | threadId={threadId} |
| 788 | terminalId={terminalId} | 783 | terminalId={terminalId} |
| 789 | cwd={cwd} | 784 | cwd={cwd} |
| ... | @@ -801,7 +796,6 @@ export default function ThreadTerminalDrawer({ | ... | @@ -801,7 +796,6 @@ export default function ThreadTerminalDrawer({ |
| 801 | <div className="h-full p-1"> | 796 | <div className="h-full p-1"> |
| 802 | <TerminalViewport | 797 | <TerminalViewport |
| 803 | key={resolvedActiveTerminalId} | 798 | key={resolvedActiveTerminalId} |
| 804 | api={api} | ||
| 805 | threadId={threadId} | 799 | threadId={threadId} |
| 806 | terminalId={resolvedActiveTerminalId} | 800 | terminalId={resolvedActiveTerminalId} |
| 807 | cwd={cwd} | 801 | cwd={cwd} |
apps/web/src/components/ui/command.tsx+4-1| ... | @@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc | ... | @@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc |
| 133 | function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { | 133 | function CommandPanel({ className, ...props }: React.ComponentProps<"div">) { |
| 134 | return ( | 134 | return ( |
| 135 | <div | 135 | <div |
| 136 | className="-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2" | 136 | className={cn( |
| 137 | "-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2", | ||
| 138 | className, | ||
| 139 | )} | ||
| 137 | {...props} | 140 | {...props} |
| 138 | /> | 141 | /> |
| 139 | ); | 142 | ); |
apps/web/src/components/ui/sheet.tsx+1| ... | @@ -49,6 +49,7 @@ function SheetViewport({ | ... | @@ -49,6 +49,7 @@ function SheetViewport({ |
| 49 | side === "left" && "flex justify-start", | 49 | side === "left" && "flex justify-start", |
| 50 | side === "right" && "flex justify-end", | 50 | side === "right" && "flex justify-end", |
| 51 | variant === "inset" && "sm:p-4", | 51 | variant === "inset" && "sm:p-4", |
| 52 | className, | ||
| 52 | )} | 53 | )} |
| 53 | data-slot="sheet-viewport" | 54 | data-slot="sheet-viewport" |
| 54 | {...props} | 55 | {...props} |
apps/web/src/components/ui/toast.tsx+6-4| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | import { Toast } from "@base-ui/react/toast"; | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; | 4 | import { useEffect } from "react"; |
| 5 | import { useParams } from "@tanstack/react-router"; | 5 | import { useParams } from "@tanstack/react-router"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | ||
| 6 | import { | 7 | import { |
| 7 | CircleAlertIcon, | 8 | CircleAlertIcon, |
| 8 | CircleCheckIcon, | 9 | CircleCheckIcon, |
| ... | @@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button"; | ... | @@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button"; |
| 16 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | 17 | import { shouldHideCollapsedToastContent } from "./toast.logic"; |
| 17 | 18 | ||
| 18 | type ThreadToastData = { | 19 | type ThreadToastData = { |
| 19 | threadId?: string | null; | 20 | threadId?: ThreadId | null; |
| 20 | tooltipStyle?: boolean; | 21 | tooltipStyle?: boolean; |
| 21 | dismissAfterVisibleMs?: number; | 22 | dismissAfterVisibleMs?: number; |
| 22 | }; | 23 | }; |
| ... | @@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props { | ... | @@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props { |
| 48 | 49 | ||
| 49 | function shouldRenderForActiveThread( | 50 | function shouldRenderForActiveThread( |
| 50 | data: ThreadToastData | undefined, | 51 | data: ThreadToastData | undefined, |
| 51 | activeThreadId: string | null, | 52 | activeThreadId: ThreadId | null, |
| 52 | ): boolean { | 53 | ): boolean { |
| 53 | const toastThreadId = data?.threadId; | 54 | const toastThreadId = data?.threadId; |
| 54 | if (!toastThreadId) return true; | 55 | if (!toastThreadId) return true; |
| 55 | return toastThreadId === activeThreadId; | 56 | return toastThreadId === activeThreadId; |
| 56 | } | 57 | } |
| 57 | 58 | ||
| 58 | function useActiveThreadIdFromRoute(): string | null { | 59 | function useActiveThreadIdFromRoute(): ThreadId | null { |
| 59 | return useParams({ | 60 | return useParams({ |
| 60 | strict: false, | 61 | strict: false, |
| 61 | select: (params) => params.threadId ?? null, | 62 | select: (params) => |
| 63 | typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null, | ||
| 62 | }); | 64 | }); |
| 63 | } | 65 | } |
| 64 | 66 |
apps/web/src/diffRouteSearch.ts+5-2| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { TurnId } from "@t3tools/contracts"; | ||
| 2 | |||
| 1 | export interface DiffRouteSearch { | 3 | export interface DiffRouteSearch { |
| 2 | diff?: "1"; | 4 | diff?: "1"; |
| 3 | diffTurnId?: string; | 5 | diffTurnId?: TurnId; |
| 4 | diffFilePath?: string; | 6 | diffFilePath?: string; |
| 5 | } | 7 | } |
| 6 | 8 | ||
| ... | @@ -25,7 +27,8 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>( | ... | @@ -25,7 +27,8 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>( |
| 25 | 27 | ||
| 26 | export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch { | 28 | export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch { |
| 27 | const diff = isDiffOpenValue(search.diff) ? "1" : undefined; | 29 | const diff = isDiffOpenValue(search.diff) ? "1" : undefined; |
| 28 | const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined; | 30 | const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; |
| 31 | const diffTurnId = diffTurnIdRaw ? TurnId.makeUnsafe(diffTurnIdRaw) : undefined; | ||
| 29 | const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; | 32 | const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; |
| 30 | 33 | ||
| 31 | return { | 34 | return { |
apps/web/src/historyBootstrap.test.ts+10-7| ... | @@ -1,20 +1,23 @@ | ... | @@ -1,20 +1,23 @@ |
| 1 | import { MessageId } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | ||
| 3 | import { buildBootstrapInput } from "./historyBootstrap"; | 4 | import { buildBootstrapInput } from "./historyBootstrap"; |
| 4 | 5 | ||
| 6 | const messageId = (value: string) => MessageId.makeUnsafe(value); | ||
| 7 | |||
| 5 | describe("buildBootstrapInput", () => { | 8 | describe("buildBootstrapInput", () => { |
| 6 | it("includes full transcript when under budget", () => { | 9 | it("includes full transcript when under budget", () => { |
| 7 | const result = buildBootstrapInput( | 10 | const result = buildBootstrapInput( |
| 8 | [ | 11 | [ |
| 9 | { | 12 | { |
| 10 | id: "u-1", | 13 | id: messageId("u-1"), |
| 11 | role: "user", | 14 | role: "user", |
| 12 | text: "hello", | 15 | text: "hello", |
| 13 | createdAt: "2026-02-09T00:00:00.000Z", | 16 | createdAt: "2026-02-09T00:00:00.000Z", |
| 14 | streaming: false, | 17 | streaming: false, |
| 15 | }, | 18 | }, |
| 16 | { | 19 | { |
| 17 | id: "a-1", | 20 | id: messageId("a-1"), |
| 18 | role: "assistant", | 21 | role: "assistant", |
| 19 | text: "world", | 22 | text: "world", |
| 20 | createdAt: "2026-02-09T00:00:01.000Z", | 23 | createdAt: "2026-02-09T00:00:01.000Z", |
| ... | @@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => { | ... | @@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => { |
| 38 | const result = buildBootstrapInput( | 41 | const result = buildBootstrapInput( |
| 39 | [ | 42 | [ |
| 40 | { | 43 | { |
| 41 | id: "u-1", | 44 | id: messageId("u-1"), |
| 42 | role: "user", | 45 | role: "user", |
| 43 | text: "first question with details", | 46 | text: "first question with details", |
| 44 | createdAt: "2026-02-09T00:00:00.000Z", | 47 | createdAt: "2026-02-09T00:00:00.000Z", |
| 45 | streaming: false, | 48 | streaming: false, |
| 46 | }, | 49 | }, |
| 47 | { | 50 | { |
| 48 | id: "a-1", | 51 | id: messageId("a-1"), |
| 49 | role: "assistant", | 52 | role: "assistant", |
| 50 | text: "first answer with details", | 53 | text: "first answer with details", |
| 51 | createdAt: "2026-02-09T00:00:01.000Z", | 54 | createdAt: "2026-02-09T00:00:01.000Z", |
| 52 | streaming: false, | 55 | streaming: false, |
| 53 | }, | 56 | }, |
| 54 | { | 57 | { |
| 55 | id: "u-2", | 58 | id: messageId("u-2"), |
| 56 | role: "user", | 59 | role: "user", |
| 57 | text: "second question with details", | 60 | text: "second question with details", |
| 58 | createdAt: "2026-02-09T00:00:02.000Z", | 61 | createdAt: "2026-02-09T00:00:02.000Z", |
| ... | @@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => { | ... | @@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => { |
| 75 | const result = buildBootstrapInput( | 78 | const result = buildBootstrapInput( |
| 76 | [ | 79 | [ |
| 77 | { | 80 | { |
| 78 | id: "u-1", | 81 | id: messageId("u-1"), |
| 79 | role: "user", | 82 | role: "user", |
| 80 | text: "old context", | 83 | text: "old context", |
| 81 | createdAt: "2026-02-09T00:00:00.000Z", | 84 | createdAt: "2026-02-09T00:00:00.000Z", |
| ... | @@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => { | ... | @@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => { |
| 96 | const result = buildBootstrapInput( | 99 | const result = buildBootstrapInput( |
| 97 | [ | 100 | [ |
| 98 | { | 101 | { |
| 99 | id: "u-image", | 102 | id: messageId("u-image"), |
| 100 | role: "user", | 103 | role: "user", |
| 101 | text: "", | 104 | text: "", |
| 102 | attachments: [ | 105 | attachments: [ |
apps/web/src/hooks/useNativeApi.ts deleted-6| ... | @@ -1,6 +0,0 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { readNativeApi } from "../nativeApi"; | ||
| 3 | |||
| 4 | export function useNativeApi() { | ||
| 5 | return useMemo(() => readNativeApi(), []); | ||
| 6 | } | ||
apps/web/src/lib/gitReactQuery.ts+36-53| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { GitStackedAction, NativeApi } from "@t3tools/contracts"; | 1 | import type { GitStackedAction } from "@t3tools/contracts"; |
| 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; | 2 | import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "~/nativeApi"; | ||
| 3 | 4 | ||
| 4 | const GIT_STATUS_STALE_TIME_MS = 5_000; | 5 | const GIT_STATUS_STALE_TIME_MS = 5_000; |
| 5 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; | 6 | const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; |
| ... | @@ -16,16 +17,15 @@ export function invalidateGitQueries(queryClient: QueryClient) { | ... | @@ -16,16 +17,15 @@ export function invalidateGitQueries(queryClient: QueryClient) { |
| 16 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); | 17 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); |
| 17 | } | 18 | } |
| 18 | 19 | ||
| 19 | export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) { | 20 | export function gitStatusQueryOptions(cwd: string | null) { |
| 20 | return queryOptions({ | 21 | return queryOptions({ |
| 21 | queryKey: gitQueryKeys.status(cwd), | 22 | queryKey: gitQueryKeys.status(cwd), |
| 22 | queryFn: async () => { | 23 | queryFn: async () => { |
| 23 | if (!api || !cwd) { | 24 | const api = ensureNativeApi(); |
| 24 | throw new Error("Git status is unavailable."); | 25 | if (!cwd) throw new Error("Git status is unavailable."); |
| 25 | } | ||
| 26 | return api.git.status({ cwd }); | 26 | return api.git.status({ cwd }); |
| 27 | }, | 27 | }, |
| 28 | enabled: !!api && !!cwd, | 28 | enabled: cwd !== null, |
| 29 | staleTime: GIT_STATUS_STALE_TIME_MS, | 29 | staleTime: GIT_STATUS_STALE_TIME_MS, |
| 30 | refetchOnWindowFocus: "always", | 30 | refetchOnWindowFocus: "always", |
| 31 | refetchOnReconnect: "always", | 31 | refetchOnReconnect: "always", |
| ... | @@ -33,16 +33,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | | ... | @@ -33,16 +33,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | |
| 33 | }); | 33 | }); |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) { | 36 | export function gitBranchesQueryOptions(cwd: string | null) { |
| 37 | return queryOptions({ | 37 | return queryOptions({ |
| 38 | queryKey: gitQueryKeys.branches(cwd), | 38 | queryKey: gitQueryKeys.branches(cwd), |
| 39 | queryFn: async () => { | 39 | queryFn: async () => { |
| 40 | if (!api || !cwd) { | 40 | const api = ensureNativeApi(); |
| 41 | throw new Error("Git branches are unavailable."); | 41 | if (!cwd) throw new Error("Git branches are unavailable."); |
| 42 | } | ||
| 43 | return api.git.listBranches({ cwd }); | 42 | return api.git.listBranches({ cwd }); |
| 44 | }, | 43 | }, |
| 45 | enabled: !!api && !!cwd, | 44 | enabled: cwd !== null, |
| 46 | staleTime: GIT_BRANCHES_STALE_TIME_MS, | 45 | staleTime: GIT_BRANCHES_STALE_TIME_MS, |
| 47 | refetchOnWindowFocus: true, | 46 | refetchOnWindowFocus: true, |
| 48 | refetchOnReconnect: true, | 47 | refetchOnReconnect: true, |
| ... | @@ -50,15 +49,12 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | ... | @@ -50,15 +49,12 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string |
| 50 | }); | 49 | }); |
| 51 | } | 50 | } |
| 52 | 51 | ||
| 53 | export function gitInitMutationOptions(input: { | 52 | export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 54 | api: NativeApi | undefined; | ||
| 55 | cwd: string | null; | ||
| 56 | queryClient: QueryClient; | ||
| 57 | }) { | ||
| 58 | return mutationOptions({ | 53 | return mutationOptions({ |
| 59 | mutationFn: async () => { | 54 | mutationFn: async () => { |
| 60 | if (!input.api || !input.cwd) throw new Error("Git init is unavailable."); | 55 | const api = ensureNativeApi(); |
| 61 | return input.api.git.init({ cwd: input.cwd }); | 56 | if (!input.cwd) throw new Error("Git init is unavailable."); |
| 57 | return api.git.init({ cwd: input.cwd }); | ||
| 62 | }, | 58 | }, |
| 63 | onSuccess: async () => { | 59 | onSuccess: async () => { |
| 64 | await invalidateGitQueries(input.queryClient); | 60 | await invalidateGitQueries(input.queryClient); |
| ... | @@ -67,14 +63,14 @@ export function gitInitMutationOptions(input: { | ... | @@ -67,14 +63,14 @@ export function gitInitMutationOptions(input: { |
| 67 | } | 63 | } |
| 68 | 64 | ||
| 69 | export function gitCheckoutMutationOptions(input: { | 65 | export function gitCheckoutMutationOptions(input: { |
| 70 | api: NativeApi | undefined; | ||
| 71 | cwd: string | null; | 66 | cwd: string | null; |
| 72 | queryClient: QueryClient; | 67 | queryClient: QueryClient; |
| 73 | }) { | 68 | }) { |
| 74 | return mutationOptions({ | 69 | return mutationOptions({ |
| 75 | mutationFn: async (branch: string) => { | 70 | mutationFn: async (branch: string) => { |
| 76 | if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable."); | 71 | const api = ensureNativeApi(); |
| 77 | return input.api.git.checkout({ cwd: input.cwd, branch }); | 72 | if (!input.cwd) throw new Error("Git checkout is unavailable."); |
| 73 | return api.git.checkout({ cwd: input.cwd, branch }); | ||
| 78 | }, | 74 | }, |
| 79 | onSuccess: async () => { | 75 | onSuccess: async () => { |
| 80 | await invalidateGitQueries(input.queryClient); | 76 | await invalidateGitQueries(input.queryClient); |
| ... | @@ -83,15 +79,15 @@ export function gitCheckoutMutationOptions(input: { | ... | @@ -83,15 +79,15 @@ export function gitCheckoutMutationOptions(input: { |
| 83 | } | 79 | } |
| 84 | 80 | ||
| 85 | export function gitCreateBranchAndCheckoutMutationOptions(input: { | 81 | export function gitCreateBranchAndCheckoutMutationOptions(input: { |
| 86 | api: NativeApi | undefined; | ||
| 87 | cwd: string | null; | 82 | cwd: string | null; |
| 88 | queryClient: QueryClient; | 83 | queryClient: QueryClient; |
| 89 | }) { | 84 | }) { |
| 90 | return mutationOptions({ | 85 | return mutationOptions({ |
| 91 | mutationFn: async (branch: string) => { | 86 | mutationFn: async (branch: string) => { |
| 92 | if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable."); | 87 | const api = ensureNativeApi(); |
| 93 | await input.api.git.createBranch({ cwd: input.cwd, branch }); | 88 | if (!input.cwd) throw new Error("Git branch creation is unavailable."); |
| 94 | return input.api.git.checkout({ cwd: input.cwd, branch }); | 89 | await api.git.createBranch({ cwd: input.cwd, branch }); |
| 90 | return api.git.checkout({ cwd: input.cwd, branch }); | ||
| 95 | }, | 91 | }, |
| 96 | onSuccess: async () => { | 92 | onSuccess: async () => { |
| 97 | await invalidateGitQueries(input.queryClient); | 93 | await invalidateGitQueries(input.queryClient); |
| ... | @@ -100,7 +96,6 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: { | ... | @@ -100,7 +96,6 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: { |
| 100 | } | 96 | } |
| 101 | 97 | ||
| 102 | export function gitRunStackedActionMutationOptions(input: { | 98 | export function gitRunStackedActionMutationOptions(input: { |
| 103 | api: NativeApi | undefined; | ||
| 104 | cwd: string | null; | 99 | cwd: string | null; |
| 105 | queryClient: QueryClient; | 100 | queryClient: QueryClient; |
| 106 | }) { | 101 | }) { |
| ... | @@ -112,10 +107,9 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -112,10 +107,9 @@ export function gitRunStackedActionMutationOptions(input: { |
| 112 | action: GitStackedAction; | 107 | action: GitStackedAction; |
| 113 | commitMessage?: string; | 108 | commitMessage?: string; |
| 114 | }) => { | 109 | }) => { |
| 115 | if (!input.api || !input.cwd) { | 110 | const api = ensureNativeApi(); |
| 116 | throw new Error("Git action is unavailable."); | 111 | if (!input.cwd) throw new Error("Git action is unavailable."); |
| 117 | } | 112 | return api.git.runStackedAction({ |
| 118 | return input.api.git.runStackedAction({ | ||
| 119 | cwd: input.cwd, | 113 | cwd: input.cwd, |
| 120 | action, | 114 | action, |
| 121 | ...(commitMessage ? { commitMessage } : {}), | 115 | ...(commitMessage ? { commitMessage } : {}), |
| ... | @@ -127,15 +121,12 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -127,15 +121,12 @@ export function gitRunStackedActionMutationOptions(input: { |
| 127 | }); | 121 | }); |
| 128 | } | 122 | } |
| 129 | 123 | ||
| 130 | export function gitPullMutationOptions(input: { | 124 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 131 | api: NativeApi | undefined; | ||
| 132 | cwd: string | null; | ||
| 133 | queryClient: QueryClient; | ||
| 134 | }) { | ||
| 135 | return mutationOptions({ | 125 | return mutationOptions({ |
| 136 | mutationFn: async () => { | 126 | mutationFn: async () => { |
| 137 | if (!input.api || !input.cwd) throw new Error("Git pull is unavailable."); | 127 | const api = ensureNativeApi(); |
| 138 | return input.api.git.pull({ cwd: input.cwd }); | 128 | if (!input.cwd) throw new Error("Git pull is unavailable."); |
| 129 | return api.git.pull({ cwd: input.cwd }); | ||
| 139 | }, | 130 | }, |
| 140 | onSettled: async () => { | 131 | onSettled: async () => { |
| 141 | await invalidateGitQueries(input.queryClient); | 132 | await invalidateGitQueries(input.queryClient); |
| ... | @@ -143,10 +134,7 @@ export function gitPullMutationOptions(input: { | ... | @@ -143,10 +134,7 @@ export function gitPullMutationOptions(input: { |
| 143 | }); | 134 | }); |
| 144 | } | 135 | } |
| 145 | 136 | ||
| 146 | export function gitCreateWorktreeMutationOptions(input: { | 137 | export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { |
| 147 | api: NativeApi | undefined; | ||
| 148 | queryClient: QueryClient; | ||
| 149 | }) { | ||
| 150 | return mutationOptions({ | 138 | return mutationOptions({ |
| 151 | mutationFn: async ({ | 139 | mutationFn: async ({ |
| 152 | cwd, | 140 | cwd, |
| ... | @@ -159,10 +147,9 @@ export function gitCreateWorktreeMutationOptions(input: { | ... | @@ -159,10 +147,9 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 159 | newBranch: string; | 147 | newBranch: string; |
| 160 | path?: string | null; | 148 | path?: string | null; |
| 161 | }) => { | 149 | }) => { |
| 162 | if (!input.api) { | 150 | const api = ensureNativeApi(); |
| 163 | throw new Error("Git worktree creation is unavailable."); | 151 | if (!cwd) throw new Error("Git worktree creation is unavailable."); |
| 164 | } | 152 | return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); |
| 165 | return input.api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); | ||
| 166 | }, | 153 | }, |
| 167 | onSettled: async () => { | 154 | onSettled: async () => { |
| 168 | await invalidateGitQueries(input.queryClient); | 155 | await invalidateGitQueries(input.queryClient); |
| ... | @@ -170,16 +157,12 @@ export function gitCreateWorktreeMutationOptions(input: { | ... | @@ -170,16 +157,12 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 170 | }); | 157 | }); |
| 171 | } | 158 | } |
| 172 | 159 | ||
| 173 | export function gitRemoveWorktreeMutationOptions(input: { | 160 | export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { |
| 174 | api: NativeApi | undefined; | ||
| 175 | queryClient: QueryClient; | ||
| 176 | }) { | ||
| 177 | return mutationOptions({ | 161 | return mutationOptions({ |
| 178 | mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { | 162 | mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { |
| 179 | if (!input.api) { | 163 | const api = ensureNativeApi(); |
| 180 | throw new Error("Git worktree removal is unavailable."); | 164 | if (!cwd) throw new Error("Git worktree removal is unavailable."); |
| 181 | } | 165 | return api.git.removeWorktree({ cwd, path, force }); |
| 182 | return input.api.git.removeWorktree({ cwd, path, force }); | ||
| 183 | }, | 166 | }, |
| 184 | onSettled: async () => { | 167 | onSettled: async () => { |
| 185 | await invalidateGitQueries(input.queryClient); | 168 | await invalidateGitQueries(input.queryClient); |
apps/web/src/lib/orchestrationIds.ts-1| ... | @@ -8,7 +8,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID | ... | @@ -8,7 +8,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID |
| 8 | 8 | ||
| 9 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID()); | 9 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID()); |
| 10 | 10 | ||
| 11 | export const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | ||
| 12 | 11 | ||
| 13 | export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 12 | export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 14 | 13 |
apps/web/src/lib/projectReactQuery.ts+6-5| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts"; | 1 | import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; |
| 2 | import { queryOptions } from "@tanstack/react-query"; | 2 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { ensureNativeApi } from "~/nativeApi"; | ||
| 3 | 4 | ||
| 4 | export const projectQueryKeys = { | 5 | export const projectQueryKeys = { |
| 5 | all: ["projects"] as const, | 6 | all: ["projects"] as const, |
| ... | @@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { | ... | @@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { |
| 15 | }; | 16 | }; |
| 16 | 17 | ||
| 17 | export function projectSearchEntriesQueryOptions(input: { | 18 | export function projectSearchEntriesQueryOptions(input: { |
| 18 | api: NativeApi | undefined; | ||
| 19 | cwd: string | null; | 19 | cwd: string | null; |
| 20 | query: string; | 20 | query: string; |
| 21 | enabled?: boolean; | 21 | enabled?: boolean; |
| ... | @@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: { | ... | @@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: { |
| 26 | return queryOptions({ | 26 | return queryOptions({ |
| 27 | queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), | 27 | queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), |
| 28 | queryFn: async () => { | 28 | queryFn: async () => { |
| 29 | if (!input.api || !input.cwd) { | 29 | const api = ensureNativeApi(); |
| 30 | if (!input.cwd) { | ||
| 30 | throw new Error("Workspace entry search is unavailable."); | 31 | throw new Error("Workspace entry search is unavailable."); |
| 31 | } | 32 | } |
| 32 | return input.api.projects.searchEntries({ | 33 | return api.projects.searchEntries({ |
| 33 | cwd: input.cwd, | 34 | cwd: input.cwd, |
| 34 | query: input.query, | 35 | query: input.query, |
| 35 | limit, | 36 | limit, |
| 36 | }); | 37 | }); |
| 37 | }, | 38 | }, |
| 38 | enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd), | 39 | enabled: (input.enabled ?? true) && input.cwd !== null, |
| 39 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, | 40 | staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, |
| 40 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, | 41 | placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, |
| 41 | }); | 42 | }); |
apps/web/src/lib/providerReactQuery.test.ts+57-27| ... | @@ -1,12 +1,31 @@ | ... | @@ -1,12 +1,31 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | 1 | import { ThreadId, type NativeApi } from "@t3tools/contracts"; |
| 2 | import { QueryClient } from "@tanstack/react-query"; | 2 | import { QueryClient } from "@tanstack/react-query"; |
| 3 | import { describe, expect, it, vi } from "vitest"; | 3 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; | 4 | import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; |
| 5 | import * as nativeApi from "../nativeApi"; | ||
| 6 | |||
| 7 | const threadId = ThreadId.makeUnsafe("thread-id"); | ||
| 8 | |||
| 9 | function mockNativeApi(input: { | ||
| 10 | getTurnDiff: ReturnType<typeof vi.fn>; | ||
| 11 | getFullThreadDiff: ReturnType<typeof vi.fn>; | ||
| 12 | }) { | ||
| 13 | vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ | ||
| 14 | orchestration: { | ||
| 15 | getTurnDiff: input.getTurnDiff, | ||
| 16 | getFullThreadDiff: input.getFullThreadDiff, | ||
| 17 | }, | ||
| 18 | } as unknown as NativeApi); | ||
| 19 | } | ||
| 20 | |||
| 21 | afterEach(() => { | ||
| 22 | vi.restoreAllMocks(); | ||
| 23 | }); | ||
| 5 | 24 | ||
| 6 | describe("providerQueryKeys.checkpointDiff", () => { | 25 | describe("providerQueryKeys.checkpointDiff", () => { |
| 7 | it("includes cacheScope so reused turn counts do not collide", () => { | 26 | it("includes cacheScope so reused turn counts do not collide", () => { |
| 8 | const baseInput = { | 27 | const baseInput = { |
| 9 | threadId: "thread-id", | 28 | threadId, |
| 10 | fromTurnCount: 1, | 29 | fromTurnCount: 1, |
| 11 | toTurnCount: 2, | 30 | toTurnCount: 2, |
| 12 | } as const; | 31 | } as const; |
| ... | @@ -29,15 +48,10 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -29,15 +48,10 @@ describe("checkpointDiffQueryOptions", () => { |
| 29 | it("forwards checkpoint range to the provider API", async () => { | 48 | it("forwards checkpoint range to the provider API", async () => { |
| 30 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | 49 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 31 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | 50 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 32 | const api = { | 51 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 33 | orchestration: { | 52 | |
| 34 | getTurnDiff, | 53 | const options = checkpointDiffQueryOptions({ |
| 35 | getFullThreadDiff, | 54 | threadId, |
| 36 | }, | ||
| 37 | } as unknown as NativeApi; | ||
| 38 | |||
| 39 | const options = checkpointDiffQueryOptions(api, { | ||
| 40 | threadId: "thread-id", | ||
| 41 | fromTurnCount: 3, | 55 | fromTurnCount: 3, |
| 42 | toTurnCount: 4, | 56 | toTurnCount: 4, |
| 43 | cacheScope: "turn:abc", | 57 | cacheScope: "turn:abc", |
| ... | @@ -47,7 +61,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -47,7 +61,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 47 | await queryClient.fetchQuery(options); | 61 | await queryClient.fetchQuery(options); |
| 48 | 62 | ||
| 49 | expect(getTurnDiff).toHaveBeenCalledWith({ | 63 | expect(getTurnDiff).toHaveBeenCalledWith({ |
| 50 | threadId: "thread-id", | 64 | threadId, |
| 51 | fromTurnCount: 3, | 65 | fromTurnCount: 3, |
| 52 | toTurnCount: 4, | 66 | toTurnCount: 4, |
| 53 | }); | 67 | }); |
| ... | @@ -57,15 +71,10 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -57,15 +71,10 @@ describe("checkpointDiffQueryOptions", () => { |
| 57 | it("uses explicit full thread diff API when range starts from zero", async () => { | 71 | it("uses explicit full thread diff API when range starts from zero", async () => { |
| 58 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | 72 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 59 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | 73 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); |
| 60 | const api = { | 74 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); |
| 61 | orchestration: { | 75 | |
| 62 | getTurnDiff, | 76 | const options = checkpointDiffQueryOptions({ |
| 63 | getFullThreadDiff, | 77 | threadId, |
| 64 | }, | ||
| 65 | } as unknown as NativeApi; | ||
| 66 | |||
| 67 | const options = checkpointDiffQueryOptions(api, { | ||
| 68 | threadId: "thread-id", | ||
| 69 | fromTurnCount: 0, | 78 | fromTurnCount: 0, |
| 70 | toTurnCount: 2, | 79 | toTurnCount: 2, |
| 71 | cacheScope: "thread:all", | 80 | cacheScope: "thread:all", |
| ... | @@ -75,15 +84,36 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -75,15 +84,36 @@ describe("checkpointDiffQueryOptions", () => { |
| 75 | await queryClient.fetchQuery(options); | 84 | await queryClient.fetchQuery(options); |
| 76 | 85 | ||
| 77 | expect(getFullThreadDiff).toHaveBeenCalledWith({ | 86 | expect(getFullThreadDiff).toHaveBeenCalledWith({ |
| 78 | threadId: "thread-id", | 87 | threadId, |
| 79 | toTurnCount: 2, | 88 | toTurnCount: 2, |
| 80 | }); | 89 | }); |
| 81 | expect(getTurnDiff).not.toHaveBeenCalled(); | 90 | expect(getTurnDiff).not.toHaveBeenCalled(); |
| 82 | }); | 91 | }); |
| 83 | 92 | ||
| 93 | it("fails fast on invalid range and does not call provider RPC", async () => { | ||
| 94 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 95 | const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | ||
| 96 | mockNativeApi({ getTurnDiff, getFullThreadDiff }); | ||
| 97 | |||
| 98 | const options = checkpointDiffQueryOptions({ | ||
| 99 | threadId, | ||
| 100 | fromTurnCount: 4, | ||
| 101 | toTurnCount: 3, | ||
| 102 | cacheScope: "turn:invalid", | ||
| 103 | }); | ||
| 104 | |||
| 105 | const queryClient = new QueryClient(); | ||
| 106 | |||
| 107 | await expect(queryClient.fetchQuery(options)).rejects.toThrow( | ||
| 108 | "Checkpoint diff is unavailable.", | ||
| 109 | ); | ||
| 110 | expect(getTurnDiff).not.toHaveBeenCalled(); | ||
| 111 | expect(getFullThreadDiff).not.toHaveBeenCalled(); | ||
| 112 | }); | ||
| 113 | |||
| 84 | it("retries checkpoint-not-ready errors longer than generic failures", () => { | 114 | it("retries checkpoint-not-ready errors longer than generic failures", () => { |
| 85 | const options = checkpointDiffQueryOptions(undefined, { | 115 | const options = checkpointDiffQueryOptions({ |
| 86 | threadId: "thread-id", | 116 | threadId, |
| 87 | fromTurnCount: 1, | 117 | fromTurnCount: 1, |
| 88 | toTurnCount: 2, | 118 | toTurnCount: 2, |
| 89 | cacheScope: "turn:abc", | 119 | cacheScope: "turn:abc", |
| ... | @@ -106,8 +136,8 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -106,8 +136,8 @@ describe("checkpointDiffQueryOptions", () => { |
| 106 | }); | 136 | }); |
| 107 | 137 | ||
| 108 | it("backs off longer for checkpoint-not-ready errors", () => { | 138 | it("backs off longer for checkpoint-not-ready errors", () => { |
| 109 | const options = checkpointDiffQueryOptions(undefined, { | 139 | const options = checkpointDiffQueryOptions({ |
| 110 | threadId: "thread-id", | 140 | threadId, |
| 111 | fromTurnCount: 1, | 141 | fromTurnCount: 1, |
| 112 | toTurnCount: 2, | 142 | toTurnCount: 2, |
| 113 | cacheScope: "turn:abc", | 143 | cacheScope: "turn:abc", |
apps/web/src/lib/providerReactQuery.ts+32-32| ... | @@ -1,9 +1,14 @@ | ... | @@ -1,9 +1,14 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | 1 | import { |
| 2 | OrchestrationGetFullThreadDiffInput, | ||
| 3 | OrchestrationGetTurnDiffInput, | ||
| 4 | ThreadId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { queryOptions } from "@tanstack/react-query"; | 6 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { asThreadId } from "./orchestrationIds"; | 7 | import { Option, Schema } from "effect"; |
| 8 | import { ensureNativeApi } from "../nativeApi"; | ||
| 4 | 9 | ||
| 5 | export interface CheckpointDiffQueryInput { | 10 | interface CheckpointDiffQueryInput { |
| 6 | threadId: string | null; | 11 | threadId: ThreadId | null; |
| 7 | fromTurnCount: number | null; | 12 | fromTurnCount: number | null; |
| 8 | toTurnCount: number | null; | 13 | toTurnCount: number | null; |
| 9 | cacheScope?: string | null; | 14 | cacheScope?: string | null; |
| ... | @@ -22,6 +27,21 @@ export const providerQueryKeys = { | ... | @@ -22,6 +27,21 @@ export const providerQueryKeys = { |
| 22 | ] as const, | 27 | ] as const, |
| 23 | }; | 28 | }; |
| 24 | 29 | ||
| 30 | function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) { | ||
| 31 | if (input.fromTurnCount === 0) { | ||
| 32 | return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({ | ||
| 33 | threadId: input.threadId, | ||
| 34 | toTurnCount: input.toTurnCount, | ||
| 35 | }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields }))); | ||
| 36 | } | ||
| 37 | |||
| 38 | return Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput)({ | ||
| 39 | threadId: input.threadId, | ||
| 40 | fromTurnCount: input.fromTurnCount, | ||
| 41 | toTurnCount: input.toTurnCount, | ||
| 42 | }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields }))); | ||
| 43 | } | ||
| 44 | |||
| 25 | function asCheckpointErrorMessage(error: unknown): string { | 45 | function asCheckpointErrorMessage(error: unknown): string { |
| 26 | if (error instanceof Error) { | 46 | if (error instanceof Error) { |
| 27 | return error.message; | 47 | return error.message; |
| ... | @@ -41,42 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean { | ... | @@ -41,42 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean { |
| 41 | ); | 61 | ); |
| 42 | } | 62 | } |
| 43 | 63 | ||
| 44 | export function checkpointDiffQueryOptions( | 64 | export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { |
| 45 | api: NativeApi | undefined, | 65 | const decodedRequest = decodeCheckpointDiffRequest(input); |
| 46 | input: CheckpointDiffQueryInput, | ||
| 47 | ) { | ||
| 48 | const hasValidRange = | ||
| 49 | typeof input.fromTurnCount === "number" && | ||
| 50 | typeof input.toTurnCount === "number" && | ||
| 51 | Number.isInteger(input.fromTurnCount) && | ||
| 52 | Number.isInteger(input.toTurnCount) && | ||
| 53 | input.fromTurnCount >= 0 && | ||
| 54 | input.toTurnCount >= 0 && | ||
| 55 | input.fromTurnCount <= input.toTurnCount; | ||
| 56 | 66 | ||
| 57 | return queryOptions({ | 67 | return queryOptions({ |
| 58 | queryKey: providerQueryKeys.checkpointDiff(input), | 68 | queryKey: providerQueryKeys.checkpointDiff(input), |
| 59 | queryFn: async () => { | 69 | queryFn: async () => { |
| 60 | if (!api || !input.threadId || !hasValidRange) { | 70 | const api = ensureNativeApi(); |
| 71 | if (!input.threadId || decodedRequest._tag === "None") { | ||
| 61 | throw new Error("Checkpoint diff is unavailable."); | 72 | throw new Error("Checkpoint diff is unavailable."); |
| 62 | } | 73 | } |
| 63 | const { fromTurnCount, toTurnCount } = input; | 74 | if (decodedRequest.value.kind === "fullThreadDiff") { |
| 64 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { | 75 | return api.orchestration.getFullThreadDiff(decodedRequest.value.input); |
| 65 | throw new Error("Checkpoint diff range is invalid."); | ||
| 66 | } | ||
| 67 | if (fromTurnCount === 0) { | ||
| 68 | return api.orchestration.getFullThreadDiff({ | ||
| 69 | threadId: asThreadId(input.threadId), | ||
| 70 | toTurnCount, | ||
| 71 | }); | ||
| 72 | } | 76 | } |
| 73 | return api.orchestration.getTurnDiff({ | 77 | return api.orchestration.getTurnDiff(decodedRequest.value.input); |
| 74 | threadId: asThreadId(input.threadId), | ||
| 75 | fromTurnCount, | ||
| 76 | toTurnCount, | ||
| 77 | }); | ||
| 78 | }, | 78 | }, |
| 79 | enabled: !!api && !!input.threadId && hasValidRange, | 79 | enabled: !!input.threadId && decodedRequest._tag === "Some", |
| 80 | staleTime: Infinity, | 80 | staleTime: Infinity, |
| 81 | retry: (failureCount, error) => { | 81 | retry: (failureCount, error) => { |
| 82 | if (isCheckpointTemporarilyUnavailable(error)) { | 82 | if (isCheckpointTemporarilyUnavailable(error)) { |
apps/web/src/lib/serverReactQuery.ts+3-6| ... | @@ -1,21 +1,18 @@ | ... | @@ -1,21 +1,18 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | ||
| 2 | import { queryOptions } from "@tanstack/react-query"; | 1 | import { queryOptions } from "@tanstack/react-query"; |
| 2 | import { ensureNativeApi } from "~/nativeApi"; | ||
| 3 | 3 | ||
| 4 | export const serverQueryKeys = { | 4 | export const serverQueryKeys = { |
| 5 | all: ["server"] as const, | 5 | all: ["server"] as const, |
| 6 | config: () => ["server", "config"] as const, | 6 | config: () => ["server", "config"] as const, |
| 7 | }; | 7 | }; |
| 8 | 8 | ||
| 9 | export function serverConfigQueryOptions(api: NativeApi | undefined) { | 9 | export function serverConfigQueryOptions() { |
| 10 | return queryOptions({ | 10 | return queryOptions({ |
| 11 | queryKey: serverQueryKeys.config(), | 11 | queryKey: serverQueryKeys.config(), |
| 12 | queryFn: async () => { | 12 | queryFn: async () => { |
| 13 | if (!api || !api.server || typeof api.server.getConfig !== "function") { | 13 | const api = ensureNativeApi(); |
| 14 | throw new Error("Server config is unavailable."); | ||
| 15 | } | ||
| 16 | return api.server.getConfig(); | 14 | return api.server.getConfig(); |
| 17 | }, | 15 | }, |
| 18 | enabled: !!api && !!api.server && typeof api.server.getConfig === "function", | ||
| 19 | staleTime: Infinity, | 16 | staleTime: Infinity, |
| 20 | }); | 17 | }); |
| 21 | } | 18 | } |
apps/web/src/model-logic.test.ts deleted-33| ... | @@ -1,33 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; | ||
| 3 | |||
| 4 | describe("normalizeModelSlug", () => { | ||
| 5 | it("maps 5.3 aliases to canonical codex slug", () => { | ||
| 6 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | ||
| 7 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("preserves non-aliased slugs", () => { | ||
| 11 | expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 12 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("returns null for empty input", () => { | ||
| 16 | expect(normalizeModelSlug("")).toBeNull(); | ||
| 17 | expect(normalizeModelSlug(" ")).toBeNull(); | ||
| 18 | expect(normalizeModelSlug(null)).toBeNull(); | ||
| 19 | expect(normalizeModelSlug(undefined)).toBeNull(); | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | describe("resolveModelSlug", () => { | ||
| 24 | it("falls back to default for unknown models", () => { | ||
| 25 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("resolves supported hard-coded models", () => { | ||
| 29 | expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 30 | expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 31 | expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2"); | ||
| 32 | }); | ||
| 33 | }); | ||
apps/web/src/model-logic.ts deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | export { | ||
| 2 | DEFAULT_MODEL, | ||
| 3 | MODEL_OPTIONS, | ||
| 4 | normalizeModelSlug, | ||
| 5 | resolveModelSlug, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | ||
| 9 | export type ReasoningEffort = (typeof REASONING_OPTIONS)[number]; | ||
| 10 | export const DEFAULT_REASONING: ReasoningEffort = "high"; | ||
apps/web/src/nativeApi.ts+8| ... | @@ -16,3 +16,11 @@ export function readNativeApi(): NativeApi | undefined { | ... | @@ -16,3 +16,11 @@ export function readNativeApi(): NativeApi | undefined { |
| 16 | cachedApi = createWsNativeApi(); | 16 | cachedApi = createWsNativeApi(); |
| 17 | return cachedApi; | 17 | return cachedApi; |
| 18 | } | 18 | } |
| 19 | |||
| 20 | export function ensureNativeApi(): NativeApi { | ||
| 21 | const api = readNativeApi(); | ||
| 22 | if (!api) { | ||
| 23 | throw new Error("Native API not found"); | ||
| 24 | } | ||
| 25 | return api; | ||
| 26 | } |
apps/web/src/routes/__root.tsx+4-6| ... | @@ -9,7 +9,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; | ... | @@ -9,7 +9,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 9 | import { APP_DISPLAY_NAME } from "../branding"; | 9 | import { APP_DISPLAY_NAME } from "../branding"; |
| 10 | import { Button } from "../components/ui/button"; | 10 | import { Button } from "../components/ui/button"; |
| 11 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; | 11 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; |
| 12 | import { useNativeApi } from "../hooks/useNativeApi"; | 12 | import { readNativeApi } from "../nativeApi"; |
| 13 | import { useStore } from "../store"; | 13 | import { useStore } from "../store"; |
| 14 | import { onServerWelcome } from "../wsNativeApi"; | 14 | import { onServerWelcome } from "../wsNativeApi"; |
| 15 | import { providerQueryKeys } from "../lib/providerReactQuery"; | 15 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| ... | @@ -22,9 +22,7 @@ export const Route = createRootRouteWithContext<{ | ... | @@ -22,9 +22,7 @@ export const Route = createRootRouteWithContext<{ |
| 22 | }); | 22 | }); |
| 23 | 23 | ||
| 24 | function RootRouteView() { | 24 | function RootRouteView() { |
| 25 | const api = useNativeApi(); | 25 | if (!readNativeApi()) { |
| 26 | |||
| 27 | if (!api) { | ||
| 28 | return ( | 26 | return ( |
| 29 | <div className="flex h-screen flex-col bg-background text-foreground"> | 27 | <div className="flex h-screen flex-col bg-background text-foreground"> |
| 30 | <div className="flex flex-1 items-center justify-center"> | 28 | <div className="flex flex-1 items-center justify-center"> |
| ... | @@ -119,11 +117,11 @@ function errorDetails(error: unknown): string { | ... | @@ -119,11 +117,11 @@ function errorDetails(error: unknown): string { |
| 119 | } | 117 | } |
| 120 | 118 | ||
| 121 | function EventRouter() { | 119 | function EventRouter() { |
| 122 | const api = useNativeApi(); | ||
| 123 | const { dispatch } = useStore(); | 120 | const { dispatch } = useStore(); |
| 124 | const queryClient = useQueryClient(); | 121 | const queryClient = useQueryClient(); |
| 125 | 122 | ||
| 126 | useEffect(() => { | 123 | useEffect(() => { |
| 124 | const api = readNativeApi(); | ||
| 127 | if (!api) return; | 125 | if (!api) return; |
| 128 | let disposed = false; | 126 | let disposed = false; |
| 129 | let latestSequence = 0; | 127 | let latestSequence = 0; |
| ... | @@ -177,7 +175,7 @@ function EventRouter() { | ... | @@ -177,7 +175,7 @@ function EventRouter() { |
| 177 | unsubDomainEvent(); | 175 | unsubDomainEvent(); |
| 178 | unsubWelcome(); | 176 | unsubWelcome(); |
| 179 | }; | 177 | }; |
| 180 | }, [api, dispatch, queryClient]); | 178 | }, [dispatch, queryClient]); |
| 181 | 179 | ||
| 182 | return null; | 180 | return null; |
| 183 | } | 181 | } |
apps/web/src/routes/_chat.$threadId.tsx+6-8| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | ||
| 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; | 2 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; |
| 3 | 4 | ||
| ... | @@ -133,9 +134,11 @@ const DiffPanelInlineSidebar = (props: { | ... | @@ -133,9 +134,11 @@ const DiffPanelInlineSidebar = (props: { |
| 133 | function ChatThreadRouteView() { | 134 | function ChatThreadRouteView() { |
| 134 | const { state } = useStore(); | 135 | const { state } = useStore(); |
| 135 | const navigate = useNavigate(); | 136 | const navigate = useNavigate(); |
| 136 | const { threadId } = Route.useParams(); | 137 | const threadId = Route.useParams({ |
| 138 | select: (params) => ThreadId.makeUnsafe(params.threadId), | ||
| 139 | }); | ||
| 137 | const search = Route.useSearch(); | 140 | const search = Route.useSearch(); |
| 138 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; | 141 | const threadExists = state.threads.some((thread) => thread.id === threadId); |
| 139 | const diffOpen = search.diff === "1"; | 142 | const diffOpen = search.diff === "1"; |
| 140 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | 143 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); |
| 141 | const closeDiff = useCallback(() => { | 144 | const closeDiff = useCallback(() => { |
| ... | @@ -159,11 +162,6 @@ function ChatThreadRouteView() { | ... | @@ -159,11 +162,6 @@ function ChatThreadRouteView() { |
| 159 | }, [navigate, threadId]); | 162 | }, [navigate, threadId]); |
| 160 | 163 | ||
| 161 | useEffect(() => { | 164 | useEffect(() => { |
| 162 | if (!threadId) { | ||
| 163 | void navigate({ to: "/", replace: true }); | ||
| 164 | return; | ||
| 165 | } | ||
| 166 | |||
| 167 | if (!state.threadsHydrated) { | 165 | if (!state.threadsHydrated) { |
| 168 | return; | 166 | return; |
| 169 | } | 167 | } |
| ... | @@ -174,7 +172,7 @@ function ChatThreadRouteView() { | ... | @@ -174,7 +172,7 @@ function ChatThreadRouteView() { |
| 174 | } | 172 | } |
| 175 | }, [navigate, state.threadsHydrated, threadExists, threadId]); | 173 | }, [navigate, state.threadsHydrated, threadExists, threadId]); |
| 176 | 174 | ||
| 177 | if (!threadId || !state.threadsHydrated || !threadExists) { | 175 | if (!state.threadsHydrated || !threadExists) { |
| 178 | return null; | 176 | return null; |
| 179 | } | 177 | } |
| 180 | 178 |
apps/web/src/routes/_chat.tsx+1-1| ... | @@ -3,7 +3,7 @@ import { useEffect } from "react"; | ... | @@ -3,7 +3,7 @@ import { useEffect } from "react"; |
| 3 | 3 | ||
| 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; |
| 5 | import ThreadSidebar from "../components/Sidebar"; | 5 | import ThreadSidebar from "../components/Sidebar"; |
| 6 | import { SidebarInset, Sidebar, SidebarProvider } from "~/components/ui/sidebar"; | 6 | import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; |
| 7 | 7 | ||
| 8 | function ChatRouteLayout() { | 8 | function ChatRouteLayout() { |
| 9 | const navigate = useNavigate(); | 9 | const navigate = useNavigate(); |
apps/web/src/session-logic.test.ts+2-34| ... | @@ -1,11 +1,7 @@ | ... | @@ -1,11 +1,7 @@ |
| 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic"; |
| 5 | derivePendingApprovals, | ||
| 6 | deriveTurnDiffFilesFromUnifiedDiff, | ||
| 7 | deriveWorkLogEntries, | ||
| 8 | } from "./session-logic"; | ||
| 9 | 5 | ||
| 10 | function makeActivity(overrides: { | 6 | function makeActivity(overrides: { |
| 11 | id?: string; | 7 | id?: string; |
| ... | @@ -105,7 +101,7 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -105,7 +101,7 @@ describe("deriveWorkLogEntries", () => { |
| 105 | makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }), | 101 | makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }), |
| 106 | ]; | 102 | ]; |
| 107 | 103 | ||
| 108 | const entries = deriveWorkLogEntries(activities, "turn-2"); | 104 | const entries = deriveWorkLogEntries(activities, TurnId.makeUnsafe("turn-2")); |
| 109 | expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]); | 105 | expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]); |
| 110 | }); | 106 | }); |
| 111 | 107 | ||
| ... | @@ -130,31 +126,3 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -130,31 +126,3 @@ describe("deriveWorkLogEntries", () => { |
| 130 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | 126 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 131 | }); | 127 | }); |
| 132 | }); | 128 | }); |
| 133 | |||
| 134 | describe("deriveTurnDiffFilesFromUnifiedDiff", () => { | ||
| 135 | it("extracts per-file +/- counts from unified diff", () => { | ||
| 136 | const diff = [ | ||
| 137 | "diff --git a/a.txt b/a.txt", | ||
| 138 | "index 111..222 100644", | ||
| 139 | "--- a/a.txt", | ||
| 140 | "+++ b/a.txt", | ||
| 141 | "@@ -1 +1,2 @@", | ||
| 142 | "-old", | ||
| 143 | "+new", | ||
| 144 | "+extra", | ||
| 145 | "diff --git a/b.txt b/b.txt", | ||
| 146 | "index 333..444 100644", | ||
| 147 | "--- a/b.txt", | ||
| 148 | "+++ b/b.txt", | ||
| 149 | "@@ -2,2 +2 @@", | ||
| 150 | "-x", | ||
| 151 | "-y", | ||
| 152 | "+z", | ||
| 153 | ].join("\n"); | ||
| 154 | |||
| 155 | expect(deriveTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ | ||
| 156 | { path: "a.txt", additions: 2, deletions: 1 }, | ||
| 157 | { path: "b.txt", additions: 1, deletions: 2 }, | ||
| 158 | ]); | ||
| 159 | }); | ||
| 160 | }); |
apps/web/src/session-logic.ts+16-64| ... | @@ -1,12 +1,11 @@ | ... | @@ -1,12 +1,11 @@ |
| 1 | import type { OrchestrationThreadActivity, ProviderKind } from "@t3tools/contracts"; | 1 | import { |
| 2 | ApprovalRequestId, | ||
| 3 | type OrchestrationThreadActivity, | ||
| 4 | type ProviderKind, | ||
| 5 | type TurnId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | 7 | ||
| 3 | import type { | 8 | import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; |
| 4 | ChatMessage, | ||
| 5 | SessionPhase, | ||
| 6 | ThreadSession, | ||
| 7 | TurnDiffFileChange, | ||
| 8 | TurnDiffSummary, | ||
| 9 | } from "./types"; | ||
| 10 | 9 | ||
| 11 | export const PROVIDER_OPTIONS: Array<{ | 10 | export const PROVIDER_OPTIONS: Array<{ |
| 12 | value: ProviderKind; | 11 | value: ProviderKind; |
| ... | @@ -26,7 +25,7 @@ export interface WorkLogEntry { | ... | @@ -26,7 +25,7 @@ export interface WorkLogEntry { |
| 26 | } | 25 | } |
| 27 | 26 | ||
| 28 | export interface PendingApproval { | 27 | export interface PendingApproval { |
| 29 | requestId: string; | 28 | requestId: ApprovalRequestId; |
| 30 | requestKind: "command" | "file-change"; | 29 | requestKind: "command" | "file-change"; |
| 31 | createdAt: string; | 30 | createdAt: string; |
| 32 | detail?: string; | 31 | detail?: string; |
| ... | @@ -79,7 +78,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str | ... | @@ -79,7 +78,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str |
| 79 | export function derivePendingApprovals( | 78 | export function derivePendingApprovals( |
| 80 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 79 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 81 | ): PendingApproval[] { | 80 | ): PendingApproval[] { |
| 82 | const openByRequestId = new Map<string, PendingApproval>(); | 81 | const openByRequestId = new Map<ApprovalRequestId, PendingApproval>(); |
| 83 | const ordered = [...activities].toSorted((left, right) => | 82 | const ordered = [...activities].toSorted((left, right) => |
| 84 | left.createdAt.localeCompare(right.createdAt), | 83 | left.createdAt.localeCompare(right.createdAt), |
| 85 | ); | 84 | ); |
| ... | @@ -89,7 +88,10 @@ export function derivePendingApprovals( | ... | @@ -89,7 +88,10 @@ export function derivePendingApprovals( |
| 89 | activity.payload && typeof activity.payload === "object" | 88 | activity.payload && typeof activity.payload === "object" |
| 90 | ? (activity.payload as Record<string, unknown>) | 89 | ? (activity.payload as Record<string, unknown>) |
| 91 | : null; | 90 | : null; |
| 92 | const requestId = payload && typeof payload.requestId === "string" ? payload.requestId : null; | 91 | const requestId = |
| 92 | payload && typeof payload.requestId === "string" | ||
| 93 | ? ApprovalRequestId.makeUnsafe(payload.requestId) | ||
| 94 | : null; | ||
| 93 | const requestKind = | 95 | const requestKind = |
| 94 | payload && (payload.requestKind === "command" || payload.requestKind === "file-change") | 96 | payload && (payload.requestKind === "command" || payload.requestKind === "file-change") |
| 95 | ? payload.requestKind | 97 | ? payload.requestKind |
| ... | @@ -118,7 +120,7 @@ export function derivePendingApprovals( | ... | @@ -118,7 +120,7 @@ export function derivePendingApprovals( |
| 118 | 120 | ||
| 119 | export function deriveWorkLogEntries( | 121 | export function deriveWorkLogEntries( |
| 120 | activities: ReadonlyArray<OrchestrationThreadActivity>, | 122 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 121 | latestTurnId: string | undefined, | 123 | latestTurnId: TurnId | undefined, |
| 122 | ): WorkLogEntry[] { | 124 | ): WorkLogEntry[] { |
| 123 | const ordered = [...activities].toSorted((left, right) => | 125 | const ordered = [...activities].toSorted((left, right) => |
| 124 | left.createdAt.localeCompare(right.createdAt), | 126 | left.createdAt.localeCompare(right.createdAt), |
| ... | @@ -163,55 +165,11 @@ export function deriveTimelineEntries( | ... | @@ -163,55 +165,11 @@ export function deriveTimelineEntries( |
| 163 | return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); | 165 | return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| 164 | } | 166 | } |
| 165 | 167 | ||
| 166 | export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { | ||
| 167 | const normalized = diff.replace(/\r\n/g, "\n"); | ||
| 168 | const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; | ||
| 169 | if (headerMatches.length === 0) { | ||
| 170 | return []; | ||
| 171 | } | ||
| 172 | |||
| 173 | const files: TurnDiffFileChange[] = []; | ||
| 174 | for (let index = 0; index < headerMatches.length; index += 1) { | ||
| 175 | const header = headerMatches[index]; | ||
| 176 | if (!header) continue; | ||
| 177 | const start = header.index ?? 0; | ||
| 178 | const nextStart = headerMatches[index + 1]?.index ?? normalized.length; | ||
| 179 | const segment = normalized.slice(start, nextStart); | ||
| 180 | const headerLine = header[0]; | ||
| 181 | if (!headerLine) continue; | ||
| 182 | |||
| 183 | const match = headerLine.match(/^diff --git a\/(.+?) b\/(.+)$/); | ||
| 184 | const filePath = match?.[2] ?? match?.[1]; | ||
| 185 | if (!filePath) continue; | ||
| 186 | |||
| 187 | let additions = 0; | ||
| 188 | let deletions = 0; | ||
| 189 | for (const line of segment.split("\n")) { | ||
| 190 | if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; | ||
| 191 | if (line.startsWith("+")) { | ||
| 192 | additions += 1; | ||
| 193 | continue; | ||
| 194 | } | ||
| 195 | if (line.startsWith("-")) { | ||
| 196 | deletions += 1; | ||
| 197 | } | ||
| 198 | } | ||
| 199 | |||
| 200 | files.push({ | ||
| 201 | path: filePath, | ||
| 202 | additions, | ||
| 203 | deletions, | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | return files.toSorted((left, right) => left.path.localeCompare(right.path)); | ||
| 208 | } | ||
| 209 | |||
| 210 | export function inferCheckpointTurnCountByTurnId( | 168 | export function inferCheckpointTurnCountByTurnId( |
| 211 | summaries: TurnDiffSummary[], | 169 | summaries: TurnDiffSummary[], |
| 212 | ): Record<string, number> { | 170 | ): Record<TurnId, number> { |
| 213 | const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); | 171 | const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); |
| 214 | const result: Record<string, number> = {}; | 172 | const result: Record<TurnId, number> = {}; |
| 215 | for (let index = 0; index < sorted.length; index += 1) { | 173 | for (let index = 0; index < sorted.length; index += 1) { |
| 216 | const summary = sorted[index]; | 174 | const summary = sorted[index]; |
| 217 | if (!summary) continue; | 175 | if (!summary) continue; |
| ... | @@ -220,12 +178,6 @@ export function inferCheckpointTurnCountByTurnId( | ... | @@ -220,12 +178,6 @@ export function inferCheckpointTurnCountByTurnId( |
| 220 | return result; | 178 | return result; |
| 221 | } | 179 | } |
| 222 | 180 | ||
| 223 | export function deriveTurnDiffSummaries( | ||
| 224 | _events: ReadonlyArray<OrchestrationThreadActivity>, | ||
| 225 | ): TurnDiffSummary[] { | ||
| 226 | return []; | ||
| 227 | } | ||
| 228 | |||
| 229 | export function derivePhase(session: ThreadSession | null): SessionPhase { | 181 | export function derivePhase(session: ThreadSession | null): SessionPhase { |
| 230 | if (!session || session.status === "closed") return "disconnected"; | 182 | if (!session || session.status === "closed") return "disconnected"; |
| 231 | if (session.status === "connecting") return "connecting"; | 183 | if (session.status === "connecting") return "connecting"; |
apps/web/src/store.ts+23-16| ... | @@ -8,15 +8,20 @@ import { | ... | @@ -8,15 +8,20 @@ import { |
| 8 | useReducer, | 8 | useReducer, |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | 10 | ||
| 11 | import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts"; | 11 | import { |
| 12 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | 12 | DEFAULT_MODEL, |
| 13 | ProviderSessionId, | ||
| 14 | ThreadId, | ||
| 15 | type OrchestrationReadModel, | ||
| 16 | type OrchestrationSessionStatus, | ||
| 17 | resolveModelSlug, | ||
| 18 | } from "@t3tools/contracts"; | ||
| 13 | import { | 19 | import { |
| 14 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 20 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 15 | DEFAULT_THREAD_TERMINAL_ID, | 21 | DEFAULT_THREAD_TERMINAL_ID, |
| 16 | DEFAULT_RUNTIME_MODE, | 22 | DEFAULT_RUNTIME_MODE, |
| 17 | MAX_THREAD_TERMINAL_COUNT, | 23 | MAX_THREAD_TERMINAL_COUNT, |
| 18 | type ChatMessage, | 24 | type ChatMessage, |
| 19 | type ProjectScript, | ||
| 20 | type Project, | 25 | type Project, |
| 21 | type RuntimeMode, | 26 | type RuntimeMode, |
| 22 | type Thread, | 27 | type Thread, |
| ... | @@ -27,19 +32,19 @@ import { | ... | @@ -27,19 +32,19 @@ import { |
| 27 | 32 | ||
| 28 | type Action = | 33 | type Action = |
| 29 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | 34 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 30 | | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } | 35 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } |
| 31 | | { type: "TOGGLE_PROJECT"; projectId: string } | 36 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } |
| 32 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | 37 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId } |
| 33 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | 38 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean } |
| 34 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | 39 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number } |
| 35 | | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } | 40 | | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } |
| 36 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | 41 | | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } |
| 37 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | 42 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string } |
| 38 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | 43 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string } |
| 39 | | { type: "SET_ERROR"; threadId: string; error: string | null } | 44 | | { type: "SET_ERROR"; threadId: ThreadId; error: string | null } |
| 40 | | { | 45 | | { |
| 41 | type: "SET_THREAD_BRANCH"; | 46 | type: "SET_THREAD_BRANCH"; |
| 42 | threadId: string; | 47 | threadId: ThreadId; |
| 43 | branch: string | null; | 48 | branch: string | null; |
| 44 | worktreePath: string | null; | 49 | worktreePath: string | null; |
| 45 | } | 50 | } |
| ... | @@ -127,7 +132,7 @@ function persistState(state: AppState): void { | ... | @@ -127,7 +132,7 @@ function persistState(state: AppState): void { |
| 127 | 132 | ||
| 128 | function updateThread( | 133 | function updateThread( |
| 129 | threads: Thread[], | 134 | threads: Thread[], |
| 130 | threadId: string, | 135 | threadId: ThreadId, |
| 131 | updater: (t: Thread) => Thread, | 136 | updater: (t: Thread) => Thread, |
| 132 | ): Thread[] { | 137 | ): Thread[] { |
| 133 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); | 138 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| ... | @@ -415,7 +420,9 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -415,7 +420,9 @@ export function reducer(state: AppState, action: Action): AppState { |
| 415 | existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, | 420 | existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 416 | session: thread.session | 421 | session: thread.session |
| 417 | ? { | 422 | ? { |
| 418 | sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, | 423 | sessionId: |
| 424 | thread.session.providerSessionId ?? | ||
| 425 | ProviderSessionId.makeUnsafe(`thread:${thread.id}`), | ||
| 419 | provider: toLegacyProvider(thread.session.providerName), | 426 | provider: toLegacyProvider(thread.session.providerName), |
| 420 | status: toLegacySessionStatus(thread.session.status), | 427 | status: toLegacySessionStatus(thread.session.status), |
| 421 | orchestrationStatus: thread.session.status, | 428 | orchestrationStatus: thread.session.status, |
apps/web/src/threadFactory.ts deleted-42| ... | @@ -1,42 +0,0 @@ | ||
| 1 | import { DEFAULT_MODEL } from "./model-logic"; | ||
| 2 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; | ||
| 3 | |||
| 4 | interface CreateThreadOptions { | ||
| 5 | branch?: string | null; | ||
| 6 | createdAt?: string; | ||
| 7 | model?: string; | ||
| 8 | title?: string; | ||
| 9 | worktreePath?: string | null; | ||
| 10 | } | ||
| 11 | |||
| 12 | export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread { | ||
| 13 | const createdAt = options.createdAt ?? new Date().toISOString(); | ||
| 14 | |||
| 15 | return { | ||
| 16 | id: crypto.randomUUID(), | ||
| 17 | codexThreadId: null, | ||
| 18 | projectId, | ||
| 19 | title: options.title ?? "New thread", | ||
| 20 | model: options.model ?? DEFAULT_MODEL, | ||
| 21 | terminalOpen: false, | ||
| 22 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 23 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 24 | runningTerminalIds: [], | ||
| 25 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | ||
| 26 | terminalGroups: [ | ||
| 27 | { | ||
| 28 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 29 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 30 | }, | ||
| 31 | ], | ||
| 32 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 33 | session: null, | ||
| 34 | messages: [], | ||
| 35 | turnDiffSummaries: [], | ||
| 36 | activities: [], | ||
| 37 | error: null, | ||
| 38 | createdAt, | ||
| 39 | branch: options.branch ?? null, | ||
| 40 | worktreePath: options.worktreePath ?? null, | ||
| 41 | }; | ||
| 42 | } | ||
apps/web/src/types.ts+21-13| ... | @@ -2,6 +2,14 @@ import type { | ... | @@ -2,6 +2,14 @@ import type { |
| 2 | OrchestrationSessionStatus, | 2 | OrchestrationSessionStatus, |
| 3 | OrchestrationThreadActivity, | 3 | OrchestrationThreadActivity, |
| 4 | ProjectScript as ContractProjectScript, | 4 | ProjectScript as ContractProjectScript, |
| 5 | ThreadId, | ||
| 6 | ProjectId, | ||
| 7 | TurnId, | ||
| 8 | MessageId, | ||
| 9 | CheckpointRef, | ||
| 10 | ProviderThreadId, | ||
| 11 | ProviderSessionId, | ||
| 12 | ProviderKind, | ||
| 5 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 6 | 14 | ||
| 7 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 15 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| ... | @@ -29,7 +37,7 @@ export interface ChatImageAttachment { | ... | @@ -29,7 +37,7 @@ export interface ChatImageAttachment { |
| 29 | export type ChatAttachment = ChatImageAttachment; | 37 | export type ChatAttachment = ChatImageAttachment; |
| 30 | 38 | ||
| 31 | export interface ChatMessage { | 39 | export interface ChatMessage { |
| 32 | id: string; | 40 | id: MessageId; |
| 33 | role: "user" | "assistant" | "system"; | 41 | role: "user" | "assistant" | "system"; |
| 34 | text: string; | 42 | text: string; |
| 35 | attachments?: ChatAttachment[]; | 43 | attachments?: ChatAttachment[]; |
| ... | @@ -46,17 +54,17 @@ export interface TurnDiffFileChange { | ... | @@ -46,17 +54,17 @@ export interface TurnDiffFileChange { |
| 46 | } | 54 | } |
| 47 | 55 | ||
| 48 | export interface TurnDiffSummary { | 56 | export interface TurnDiffSummary { |
| 49 | turnId: string; | 57 | turnId: TurnId; |
| 50 | completedAt: string; | 58 | completedAt: string; |
| 51 | status?: string | undefined; | 59 | status?: string | undefined; |
| 52 | files: TurnDiffFileChange[]; | 60 | files: TurnDiffFileChange[]; |
| 53 | checkpointRef?: string | undefined; | 61 | checkpointRef?: CheckpointRef | undefined; |
| 54 | assistantMessageId?: string | undefined; | 62 | assistantMessageId?: MessageId | undefined; |
| 55 | checkpointTurnCount?: number | undefined; | 63 | checkpointTurnCount?: number | undefined; |
| 56 | } | 64 | } |
| 57 | 65 | ||
| 58 | export interface Project { | 66 | export interface Project { |
| 59 | id: string; | 67 | id: ProjectId; |
| 60 | name: string; | 68 | name: string; |
| 61 | cwd: string; | 69 | cwd: string; |
| 62 | model: string; | 70 | model: string; |
| ... | @@ -65,9 +73,9 @@ export interface Project { | ... | @@ -65,9 +73,9 @@ export interface Project { |
| 65 | } | 73 | } |
| 66 | 74 | ||
| 67 | export interface Thread { | 75 | export interface Thread { |
| 68 | id: string; | 76 | id: ThreadId; |
| 69 | codexThreadId: string | null; | 77 | codexThreadId: ProviderThreadId | null; |
| 70 | projectId: string; | 78 | projectId: ProjectId; |
| 71 | title: string; | 79 | title: string; |
| 72 | model: string; | 80 | model: string; |
| 73 | terminalOpen: boolean; | 81 | terminalOpen: boolean; |
| ... | @@ -81,7 +89,7 @@ export interface Thread { | ... | @@ -81,7 +89,7 @@ export interface Thread { |
| 81 | messages: ChatMessage[]; | 89 | messages: ChatMessage[]; |
| 82 | error: string | null; | 90 | error: string | null; |
| 83 | createdAt: string; | 91 | createdAt: string; |
| 84 | latestTurnId?: string | undefined; | 92 | latestTurnId?: TurnId | undefined; |
| 85 | latestTurnStartedAt?: string | undefined; | 93 | latestTurnStartedAt?: string | undefined; |
| 86 | latestTurnCompletedAt?: string | undefined; | 94 | latestTurnCompletedAt?: string | undefined; |
| 87 | latestTurnDurationMs?: number | undefined; | 95 | latestTurnDurationMs?: number | undefined; |
| ... | @@ -93,11 +101,11 @@ export interface Thread { | ... | @@ -93,11 +101,11 @@ export interface Thread { |
| 93 | } | 101 | } |
| 94 | 102 | ||
| 95 | export interface ThreadSession { | 103 | export interface ThreadSession { |
| 96 | sessionId: string; | 104 | sessionId: ProviderSessionId; |
| 97 | provider: "codex" | "claudeCode"; | 105 | provider: ProviderKind; |
| 98 | status: SessionPhase | "error" | "closed"; | 106 | status: SessionPhase | "error" | "closed"; |
| 99 | threadId: string | null; | 107 | threadId: ProviderThreadId | null; |
| 100 | activeTurnId?: string | undefined; | 108 | activeTurnId?: TurnId | undefined; |
| 101 | createdAt: string; | 109 | createdAt: string; |
| 102 | updatedAt: string; | 110 | updatedAt: string; |
| 103 | lastError?: string; | 111 | lastError?: string; |
apps/web/src/worktreeCleanup.test.ts+24-11| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | ||
| 1 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | ||
| 3 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; | 4 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types"; |
| ... | @@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " | ... | @@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " |
| 5 | 6 | ||
| 6 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 7 | return { | 8 | return { |
| 8 | id: "thread-1", | 9 | id: ThreadId.makeUnsafe("thread-1"), |
| 9 | codexThreadId: null, | 10 | codexThreadId: null, |
| 10 | projectId: "project-1", | 11 | projectId: ProjectId.makeUnsafe("project-1"), |
| 11 | title: "Thread", | 12 | title: "Thread", |
| 12 | model: "gpt-5.3-codex", | 13 | model: "gpt-5.3-codex", |
| 13 | terminalOpen: false, | 14 | terminalOpen: false, |
| ... | @@ -36,37 +37,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -36,37 +37,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 36 | 37 | ||
| 37 | describe("getOrphanedWorktreePathForThread", () => { | 38 | describe("getOrphanedWorktreePathForThread", () => { |
| 38 | it("returns null when the target thread does not exist", () => { | 39 | it("returns null when the target thread does not exist", () => { |
| 39 | const result = getOrphanedWorktreePathForThread([], "missing-thread"); | 40 | const result = getOrphanedWorktreePathForThread([], ThreadId.makeUnsafe("missing-thread")); |
| 40 | expect(result).toBeNull(); | 41 | expect(result).toBeNull(); |
| 41 | }); | 42 | }); |
| 42 | 43 | ||
| 43 | it("returns null when the target thread has no worktree", () => { | 44 | it("returns null when the target thread has no worktree", () => { |
| 44 | const threads = [makeThread()]; | 45 | const threads = [makeThread()]; |
| 45 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | 46 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); |
| 46 | expect(result).toBeNull(); | 47 | expect(result).toBeNull(); |
| 47 | }); | 48 | }); |
| 48 | 49 | ||
| 49 | it("returns the path when no other thread links to that worktree", () => { | 50 | it("returns the path when no other thread links to that worktree", () => { |
| 50 | const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })]; | 51 | const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })]; |
| 51 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | 52 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); |
| 52 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); | 53 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); |
| 53 | }); | 54 | }); |
| 54 | 55 | ||
| 55 | it("returns null when another thread links to the same worktree", () => { | 56 | it("returns null when another thread links to the same worktree", () => { |
| 56 | const threads = [ | 57 | const threads = [ |
| 57 | makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), | 58 | makeThread({ |
| 58 | makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-a" }), | 59 | id: ThreadId.makeUnsafe("thread-1"), |
| 60 | worktreePath: "/tmp/repo/worktrees/feature-a", | ||
| 61 | }), | ||
| 62 | makeThread({ | ||
| 63 | id: ThreadId.makeUnsafe("thread-2"), | ||
| 64 | worktreePath: "/tmp/repo/worktrees/feature-a", | ||
| 65 | }), | ||
| 59 | ]; | 66 | ]; |
| 60 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | 67 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); |
| 61 | expect(result).toBeNull(); | 68 | expect(result).toBeNull(); |
| 62 | }); | 69 | }); |
| 63 | 70 | ||
| 64 | it("ignores threads linked to different worktrees", () => { | 71 | it("ignores threads linked to different worktrees", () => { |
| 65 | const threads = [ | 72 | const threads = [ |
| 66 | makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }), | 73 | makeThread({ |
| 67 | makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-b" }), | 74 | id: ThreadId.makeUnsafe("thread-1"), |
| 75 | worktreePath: "/tmp/repo/worktrees/feature-a", | ||
| 76 | }), | ||
| 77 | makeThread({ | ||
| 78 | id: ThreadId.makeUnsafe("thread-2"), | ||
| 79 | worktreePath: "/tmp/repo/worktrees/feature-b", | ||
| 80 | }), | ||
| 68 | ]; | 81 | ]; |
| 69 | const result = getOrphanedWorktreePathForThread(threads, "thread-1"); | 82 | const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1")); |
| 70 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); | 83 | expect(result).toBe("/tmp/repo/worktrees/feature-a"); |
| 71 | }); | 84 | }); |
| 72 | }); | 85 | }); |
apps/web/src/worktreeCleanup.ts+1-1| ... | @@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null { | ... | @@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null { |
| 10 | 10 | ||
| 11 | export function getOrphanedWorktreePathForThread( | 11 | export function getOrphanedWorktreePathForThread( |
| 12 | threads: readonly Thread[], | 12 | threads: readonly Thread[], |
| 13 | threadId: string, | 13 | threadId: Thread["id"], |
| 14 | ): string | null { | 14 | ): string | null { |
| 15 | const targetThread = threads.find((thread) => thread.id === threadId); | 15 | const targetThread = threads.find((thread) => thread.id === threadId); |
| 16 | if (!targetThread) { | 16 | if (!targetThread) { |
apps/web/src/wsTransport.ts+1-6| ... | @@ -13,12 +13,7 @@ const REQUEST_TIMEOUT_MS = 60_000; | ... | @@ -13,12 +13,7 @@ const REQUEST_TIMEOUT_MS = 60_000; |
| 13 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; | 13 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; |
| 14 | const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse)); | 14 | const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse)); |
| 15 | const isWsPushEnvelope = Schema.is(WsPush); | 15 | const isWsPushEnvelope = Schema.is(WsPush); |
| 16 | 16 | const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); | |
| 17 | function isWebSocketResponseEnvelope( | ||
| 18 | message: typeof WsResponse.Type, | ||
| 19 | ): message is WebSocketResponse { | ||
| 20 | return message instanceof WebSocketResponse; | ||
| 21 | } | ||
| 22 | 17 | ||
| 23 | interface WsRequestEnvelope { | 18 | interface WsRequestEnvelope { |
| 24 | id: string; | 19 | id: string; |
packages/contracts/src/editor.ts+3-2| ... | @@ -8,7 +8,8 @@ export const EDITORS = [ | ... | @@ -8,7 +8,8 @@ export const EDITORS = [ |
| 8 | export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); | 8 | export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); |
| 9 | export type EditorId = typeof EditorId.Type; | 9 | export type EditorId = typeof EditorId.Type; |
| 10 | 10 | ||
| 11 | export class OpenInEditorInput extends Schema.Class<OpenInEditorInput>("OpenInEditorInput")({ | 11 | export const OpenInEditorInput = Schema.Struct({ |
| 12 | cwd: Schema.String, | 12 | cwd: Schema.String, |
| 13 | editor: EditorId, | 13 | editor: EditorId, |
| 14 | }) {} | 14 | }); |
| 15 | export type OpenInEditorInput = typeof OpenInEditorInput.Type; |
packages/contracts/src/git.ts+48-48| ... | @@ -18,78 +18,79 @@ export const GitPrStepStatus = Schema.Literals([ | ... | @@ -18,78 +18,79 @@ export const GitPrStepStatus = Schema.Literals([ |
| 18 | "skipped_not_requested", | 18 | "skipped_not_requested", |
| 19 | ]); | 19 | ]); |
| 20 | 20 | ||
| 21 | export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({ | 21 | export const GitBranch = Schema.Struct({ |
| 22 | name: TrimmedNonEmptyString, | 22 | name: TrimmedNonEmptyString, |
| 23 | current: Schema.Boolean, | 23 | current: Schema.Boolean, |
| 24 | isDefault: Schema.Boolean, | 24 | isDefault: Schema.Boolean, |
| 25 | worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr), | 25 | worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 26 | }) {} | 26 | }); |
| 27 | export type GitBranch = typeof GitBranch.Type; | ||
| 27 | 28 | ||
| 28 | export class GitWorktree extends Schema.Class<GitWorktree>("GitWorktree")({ | 29 | export const GitWorktree = Schema.Struct({ |
| 29 | path: TrimmedNonEmptyString, | 30 | path: TrimmedNonEmptyString, |
| 30 | branch: TrimmedNonEmptyString, | 31 | branch: TrimmedNonEmptyString, |
| 31 | }) {} | 32 | }); |
| 33 | export type GitWorktree = typeof GitWorktree.Type; | ||
| 32 | 34 | ||
| 33 | // RPC Inputs | 35 | // RPC Inputs |
| 34 | 36 | ||
| 35 | export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({ | 37 | export const GitStatusInput = Schema.Struct({ |
| 36 | cwd: TrimmedNonEmptyString, | 38 | cwd: TrimmedNonEmptyString, |
| 37 | }) {} | 39 | }); |
| 40 | export type GitStatusInput = typeof GitStatusInput.Type; | ||
| 38 | 41 | ||
| 39 | export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({ | 42 | export const GitPullInput = Schema.Struct({ |
| 40 | cwd: TrimmedNonEmptyString, | 43 | cwd: TrimmedNonEmptyString, |
| 41 | }) {} | 44 | }); |
| 45 | export type GitPullInput = typeof GitPullInput.Type; | ||
| 42 | 46 | ||
| 43 | export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>( | 47 | export const GitRunStackedActionInput = Schema.Struct({ |
| 44 | "GitRunStackedActionInput", | ||
| 45 | )({ | ||
| 46 | cwd: TrimmedNonEmptyString, | 48 | cwd: TrimmedNonEmptyString, |
| 47 | action: GitStackedAction, | 49 | action: GitStackedAction, |
| 48 | commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))), | 50 | commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))), |
| 49 | }) {} | 51 | }); |
| 52 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | ||
| 50 | 53 | ||
| 51 | export class GitListBranchesInput extends Schema.Class<GitListBranchesInput>( | 54 | export const GitListBranchesInput = Schema.Struct({ |
| 52 | "GitListBranchesInput", | ||
| 53 | )({ | ||
| 54 | cwd: TrimmedNonEmptyString, | 55 | cwd: TrimmedNonEmptyString, |
| 55 | }) {} | 56 | }); |
| 57 | export type GitListBranchesInput = typeof GitListBranchesInput.Type; | ||
| 56 | 58 | ||
| 57 | export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>( | 59 | export const GitCreateWorktreeInput = Schema.Struct({ |
| 58 | "GitCreateWorktreeInput", | ||
| 59 | )({ | ||
| 60 | cwd: TrimmedNonEmptyString, | 60 | cwd: TrimmedNonEmptyString, |
| 61 | branch: TrimmedNonEmptyString, | 61 | branch: TrimmedNonEmptyString, |
| 62 | newBranch: TrimmedNonEmptyString, | 62 | newBranch: TrimmedNonEmptyString, |
| 63 | path: TrimmedNonEmptyString.pipe(Schema.NullOr), | 63 | path: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 64 | }) {} | 64 | }); |
| 65 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; | ||
| 65 | 66 | ||
| 66 | export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>( | 67 | export const GitRemoveWorktreeInput = Schema.Struct({ |
| 67 | "GitRemoveWorktreeInput", | ||
| 68 | )({ | ||
| 69 | cwd: TrimmedNonEmptyString, | 68 | cwd: TrimmedNonEmptyString, |
| 70 | path: TrimmedNonEmptyString, | 69 | path: TrimmedNonEmptyString, |
| 71 | force: Schema.optional(Schema.Boolean), | 70 | force: Schema.optional(Schema.Boolean), |
| 72 | }) {} | 71 | }); |
| 72 | export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; | ||
| 73 | 73 | ||
| 74 | export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>( | 74 | export const GitCreateBranchInput = Schema.Struct({ |
| 75 | "GitCreateBranchInput", | ||
| 76 | )({ | ||
| 77 | cwd: TrimmedNonEmptyString, | 75 | cwd: TrimmedNonEmptyString, |
| 78 | branch: TrimmedNonEmptyString, | 76 | branch: TrimmedNonEmptyString, |
| 79 | }) {} | 77 | }); |
| 78 | export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; | ||
| 80 | 79 | ||
| 81 | export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({ | 80 | export const GitCheckoutInput = Schema.Struct({ |
| 82 | cwd: TrimmedNonEmptyString, | 81 | cwd: TrimmedNonEmptyString, |
| 83 | branch: TrimmedNonEmptyString, | 82 | branch: TrimmedNonEmptyString, |
| 84 | }) {} | 83 | }); |
| 84 | export type GitCheckoutInput = typeof GitCheckoutInput.Type; | ||
| 85 | 85 | ||
| 86 | export class GitInitInput extends Schema.Class<GitInitInput>("GitInitInput")({ | 86 | export const GitInitInput = Schema.Struct({ |
| 87 | cwd: TrimmedNonEmptyString, | 87 | cwd: TrimmedNonEmptyString, |
| 88 | }) {} | 88 | }); |
| 89 | export type GitInitInput = typeof GitInitInput.Type; | ||
| 89 | 90 | ||
| 90 | // RPC Results | 91 | // RPC Results |
| 91 | 92 | ||
| 92 | export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusResult")({ | 93 | export const GitStatusResult = Schema.Struct({ |
| 93 | branch: TrimmedNonEmptyString.pipe(Schema.NullOr), | 94 | branch: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 94 | hasWorkingTreeChanges: Schema.Boolean, | 95 | hasWorkingTreeChanges: Schema.Boolean, |
| 95 | workingTree: Schema.Struct({ | 96 | workingTree: Schema.Struct({ |
| ... | @@ -113,24 +114,21 @@ export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusRes | ... | @@ -113,24 +114,21 @@ export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusRes |
| 113 | baseBranch: TrimmedNonEmptyString, | 114 | baseBranch: TrimmedNonEmptyString, |
| 114 | headBranch: TrimmedNonEmptyString, | 115 | headBranch: TrimmedNonEmptyString, |
| 115 | }).pipe(Schema.NullOr), | 116 | }).pipe(Schema.NullOr), |
| 116 | }) {} | 117 | }); |
| 118 | export type GitStatusResult = typeof GitStatusResult.Type; | ||
| 117 | 119 | ||
| 118 | export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>( | 120 | export const GitListBranchesResult = Schema.Struct({ |
| 119 | "GitListBranchesResult", | ||
| 120 | )({ | ||
| 121 | branches: Schema.Array(GitBranch), | 121 | branches: Schema.Array(GitBranch), |
| 122 | isRepo: Schema.Boolean, | 122 | isRepo: Schema.Boolean, |
| 123 | }) {} | 123 | }); |
| 124 | export type GitListBranchesResult = typeof GitListBranchesResult.Type; | ||
| 124 | 125 | ||
| 125 | export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>( | 126 | export const GitCreateWorktreeResult = Schema.Struct({ |
| 126 | "GitCreateWorktreeResult", | ||
| 127 | )({ | ||
| 128 | worktree: GitWorktree, | 127 | worktree: GitWorktree, |
| 129 | }) {} | 128 | }); |
| 129 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; | ||
| 130 | 130 | ||
| 131 | export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>( | 131 | export const GitRunStackedActionResult = Schema.Struct({ |
| 132 | "GitRunStackedActionResult", | ||
| 133 | )({ | ||
| 134 | action: GitStackedAction, | 132 | action: GitStackedAction, |
| 135 | commit: Schema.Struct({ | 133 | commit: Schema.Struct({ |
| 136 | status: GitCommitStepStatus, | 134 | status: GitCommitStepStatus, |
| ... | @@ -151,10 +149,12 @@ export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionR | ... | @@ -151,10 +149,12 @@ export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionR |
| 151 | headBranch: Schema.optional(TrimmedNonEmptyString), | 149 | headBranch: Schema.optional(TrimmedNonEmptyString), |
| 152 | title: Schema.optional(TrimmedNonEmptyString), | 150 | title: Schema.optional(TrimmedNonEmptyString), |
| 153 | }), | 151 | }), |
| 154 | }) {} | 152 | }); |
| 153 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; | ||
| 155 | 154 | ||
| 156 | export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({ | 155 | export const GitPullResult = Schema.Struct({ |
| 157 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), | 156 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 158 | branch: TrimmedNonEmptyString, | 157 | branch: TrimmedNonEmptyString, |
| 159 | upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr), | 158 | upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr), |
| 160 | }) {} | 159 | }); |
| 160 | export type GitPullResult = typeof GitPullResult.Type; |
packages/contracts/src/keybindings.ts+12-14| ... | @@ -44,25 +44,27 @@ export const KeybindingWhen = Schema.Trim.check( | ... | @@ -44,25 +44,27 @@ export const KeybindingWhen = Schema.Trim.check( |
| 44 | Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), | 44 | Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), |
| 45 | ); | 45 | ); |
| 46 | export type KeybindingWhen = typeof KeybindingWhen.Type; | 46 | export type KeybindingWhen = typeof KeybindingWhen.Type; |
| 47 | export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({ | 47 | export const KeybindingRule = Schema.Struct({ |
| 48 | key: KeybindingValue, | 48 | key: KeybindingValue, |
| 49 | command: KeybindingCommand, | 49 | command: KeybindingCommand, |
| 50 | when: Schema.optional(KeybindingWhen), | 50 | when: Schema.optional(KeybindingWhen), |
| 51 | }) {} | 51 | }); |
| 52 | export type KeybindingRule = typeof KeybindingRule.Type; | ||
| 52 | 53 | ||
| 53 | export const KeybindingsConfig = Schema.Array(KeybindingRule).check( | 54 | export const KeybindingsConfig = Schema.Array(KeybindingRule).check( |
| 54 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), | 55 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
| 55 | ); | 56 | ); |
| 56 | export type KeybindingsConfig = typeof KeybindingsConfig.Type; | 57 | export type KeybindingsConfig = typeof KeybindingsConfig.Type; |
| 57 | 58 | ||
| 58 | export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({ | 59 | export const KeybindingShortcut = Schema.Struct({ |
| 59 | key: KeybindingValue, | 60 | key: KeybindingValue, |
| 60 | metaKey: Schema.Boolean, | 61 | metaKey: Schema.Boolean, |
| 61 | ctrlKey: Schema.Boolean, | 62 | ctrlKey: Schema.Boolean, |
| 62 | shiftKey: Schema.Boolean, | 63 | shiftKey: Schema.Boolean, |
| 63 | altKey: Schema.Boolean, | 64 | altKey: Schema.Boolean, |
| 64 | modKey: Schema.Boolean, | 65 | modKey: Schema.Boolean, |
| 65 | }) {} | 66 | }); |
| 67 | export type KeybindingShortcut = typeof KeybindingShortcut.Type; | ||
| 66 | 68 | ||
| 67 | export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([ | 69 | export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([ |
| 68 | Schema.Struct({ | 70 | Schema.Struct({ |
| ... | @@ -90,16 +92,12 @@ export type KeybindingWhenNode = | ... | @@ -90,16 +92,12 @@ export type KeybindingWhenNode = |
| 90 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } | 92 | | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode } |
| 91 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; | 93 | | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode }; |
| 92 | 94 | ||
| 93 | export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>( | 95 | export const ResolvedKeybindingRule = Schema.Struct({ |
| 94 | "ResolvedKeybindingRule", | 96 | command: KeybindingCommand, |
| 95 | )( | 97 | shortcut: KeybindingShortcut, |
| 96 | { | 98 | whenAst: Schema.optional(KeybindingWhenNode), |
| 97 | command: KeybindingCommand, | 99 | }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); |
| 98 | shortcut: KeybindingShortcut, | 100 | export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; |
| 99 | whenAst: Schema.optional(KeybindingWhenNode), | ||
| 100 | }, | ||
| 101 | { parseOptions: { onExcessProperty: "ignore" } }, | ||
| 102 | ) {} | ||
| 103 | 101 | ||
| 104 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( | 102 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( |
| 105 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), | 103 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
packages/contracts/src/model.ts+4| ... | @@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug { | ... | @@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug { |
| 37 | 37 | ||
| 38 | return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL; | 38 | return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL; |
| 39 | } | 39 | } |
| 40 | |||
| 41 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | ||
| 42 | export type ReasoningEffort = (typeof REASONING_OPTIONS)[number]; | ||
| 43 | export const DEFAULT_REASONING: ReasoningEffort = "high"; |
packages/contracts/src/orchestration.test.ts created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Schema } from "effect"; | ||
| 3 | |||
| 4 | import { OrchestrationGetTurnDiffInput, ThreadTurnDiff } from "./orchestration"; | ||
| 5 | |||
| 6 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | ||
| 7 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | ||
| 8 | |||
| 9 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | ||
| 10 | Effect.gen(function* () { | ||
| 11 | const parsed = yield* decodeTurnDiffInput({ | ||
| 12 | threadId: "thread-1", | ||
| 13 | fromTurnCount: 1, | ||
| 14 | toTurnCount: 2, | ||
| 15 | }); | ||
| 16 | assert.strictEqual(parsed.fromTurnCount, 1); | ||
| 17 | assert.strictEqual(parsed.toTurnCount, 2); | ||
| 18 | }), | ||
| 19 | ); | ||
| 20 | |||
| 21 | it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const result = yield* Effect.exit( | ||
| 24 | decodeTurnDiffInput({ | ||
| 25 | threadId: "thread-1", | ||
| 26 | fromTurnCount: 3, | ||
| 27 | toTurnCount: 2, | ||
| 28 | }), | ||
| 29 | ); | ||
| 30 | assert.strictEqual(result._tag, "Failure"); | ||
| 31 | }), | ||
| 32 | ); | ||
| 33 | |||
| 34 | it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () => | ||
| 35 | Effect.gen(function* () { | ||
| 36 | const result = yield* Effect.exit( | ||
| 37 | decodeThreadTurnDiff({ | ||
| 38 | threadId: "thread-1", | ||
| 39 | fromTurnCount: 3, | ||
| 40 | toTurnCount: 2, | ||
| 41 | diff: "patch", | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | assert.strictEqual(result._tag, "Failure"); | ||
| 45 | }), | ||
| 46 | ); | ||
packages/contracts/src/orchestration.ts+22-11| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; |
| 2 | 2 | ||
| 3 | export const ORCHESTRATION_WS_METHODS = { | 3 | export const ORCHESTRATION_WS_METHODS = { |
| 4 | getSnapshot: "orchestration.getSnapshot", | 4 | getSnapshot: "orchestration.getSnapshot", |
| ... | @@ -777,13 +777,24 @@ export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandRecei | ... | @@ -777,13 +777,24 @@ export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandRecei |
| 777 | export const TurnCountRange = Schema.Struct({ | 777 | export const TurnCountRange = Schema.Struct({ |
| 778 | fromTurnCount: NonNegativeInt, | 778 | fromTurnCount: NonNegativeInt, |
| 779 | toTurnCount: NonNegativeInt, | 779 | toTurnCount: NonNegativeInt, |
| 780 | }); | 780 | }).check( |
| 781 | Schema.makeFilter( | ||
| 782 | (input) => | ||
| 783 | input.fromTurnCount <= input.toTurnCount || | ||
| 784 | new SchemaIssue.InvalidValue(Option.some(input.fromTurnCount), { | ||
| 785 | message: "fromTurnCount must be less than or equal to toTurnCount", | ||
| 786 | }), | ||
| 787 | { identifier: "OrchestrationTurnDiffRange" }, | ||
| 788 | ), | ||
| 789 | ); | ||
| 781 | 790 | ||
| 782 | export const ThreadTurnDiff = Schema.Struct({ | 791 | export const ThreadTurnDiff = TurnCountRange.mapFields( |
| 783 | threadId: ThreadId, | 792 | Struct.assign({ |
| 784 | ...TurnCountRange.fields, | 793 | threadId: ThreadId, |
| 785 | diff: Schema.String, | 794 | diff: Schema.String, |
| 786 | }); | 795 | }), |
| 796 | { unsafePreserveChecks: true }, | ||
| 797 | ); | ||
| 787 | 798 | ||
| 788 | export const ProviderSessionRuntimeStatus = Schema.Literals([ | 799 | export const ProviderSessionRuntimeStatus = Schema.Literals([ |
| 789 | "starting", | 800 | "starting", |
| ... | @@ -829,10 +840,10 @@ export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput | ... | @@ -829,10 +840,10 @@ export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput |
| 829 | export const OrchestrationGetSnapshotResult = OrchestrationReadModel; | 840 | export const OrchestrationGetSnapshotResult = OrchestrationReadModel; |
| 830 | export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; | 841 | export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type; |
| 831 | 842 | ||
| 832 | export const OrchestrationGetTurnDiffInput = Schema.Struct({ | 843 | export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields( |
| 833 | threadId: ThreadId, | 844 | Struct.assign({ threadId: ThreadId }), |
| 834 | ...TurnCountRange.fields, | 845 | { unsafePreserveChecks: true }, |
| 835 | }); | 846 | ); |
| 836 | export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type; | 847 | export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type; |
| 837 | 848 | ||
| 838 | export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; | 849 | export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; |
packages/contracts/src/project.ts+9-10| ... | @@ -3,28 +3,27 @@ import { Schema } from "effect"; | ... | @@ -3,28 +3,27 @@ import { Schema } from "effect"; |
| 3 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; | 3 | export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; |
| 4 | const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); | 4 | const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); |
| 5 | 5 | ||
| 6 | export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>( | 6 | export const ProjectSearchEntriesInput = Schema.Struct({ |
| 7 | "ProjectSearchEntriesInput", | ||
| 8 | )({ | ||
| 9 | cwd: TrimmedNonEmptyString, | 7 | cwd: TrimmedNonEmptyString, |
| 10 | query: Schema.NonEmptyString.check(Schema.isMaxLength(256)), | 8 | query: Schema.NonEmptyString.check(Schema.isMaxLength(256)), |
| 11 | limit: Schema.Int.check( | 9 | limit: Schema.Int.check( |
| 12 | Schema.isGreaterThanOrEqualTo(1), | 10 | Schema.isGreaterThanOrEqualTo(1), |
| 13 | Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT), | 11 | Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT), |
| 14 | ), | 12 | ), |
| 15 | }) {} | 13 | }); |
| 14 | export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; | ||
| 16 | 15 | ||
| 17 | export const ProjectEntryKind = Schema.Literals(["file", "directory"]); | 16 | export const ProjectEntryKind = Schema.Literals(["file", "directory"]); |
| 18 | 17 | ||
| 19 | export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({ | 18 | export const ProjectEntry = Schema.Struct({ |
| 20 | path: TrimmedNonEmptyString, | 19 | path: TrimmedNonEmptyString, |
| 21 | kind: ProjectEntryKind, | 20 | kind: ProjectEntryKind, |
| 22 | parentPath: Schema.optional(TrimmedNonEmptyString), | 21 | parentPath: Schema.optional(TrimmedNonEmptyString), |
| 23 | }) {} | 22 | }); |
| 23 | export type ProjectEntry = typeof ProjectEntry.Type; | ||
| 24 | 24 | ||
| 25 | export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>( | 25 | export const ProjectSearchEntriesResult = Schema.Struct({ |
| 26 | "ProjectSearchEntriesResult", | ||
| 27 | )({ | ||
| 28 | entries: Schema.Array(ProjectEntry), | 26 | entries: Schema.Array(ProjectEntry), |
| 29 | truncated: Schema.Boolean, | 27 | truncated: Schema.Boolean, |
| 30 | }) {} | 28 | }); |
| 29 | export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; |
packages/contracts/src/ws.test.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Schema } from "effect"; | ||
| 3 | |||
| 4 | import { ORCHESTRATION_WS_METHODS } from "./orchestration"; | ||
| 5 | import { WebSocketRequest } from "./ws"; | ||
| 6 | |||
| 7 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); | ||
| 8 | |||
| 9 | it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () => | ||
| 10 | Effect.gen(function* () { | ||
| 11 | const parsed = yield* decodeWebSocketRequest({ | ||
| 12 | id: "req-1", | ||
| 13 | body: { | ||
| 14 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | ||
| 15 | threadId: "thread-1", | ||
| 16 | fromTurnCount: 1, | ||
| 17 | toTurnCount: 2, | ||
| 18 | }, | ||
| 19 | }); | ||
| 20 | assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff); | ||
| 21 | }), | ||
| 22 | ); | ||
| 23 | |||
| 24 | it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () => | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const result = yield* Effect.exit( | ||
| 27 | decodeWebSocketRequest({ | ||
| 28 | id: "req-1", | ||
| 29 | body: { | ||
| 30 | _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, | ||
| 31 | threadId: "thread-1", | ||
| 32 | fromTurnCount: 3, | ||
| 33 | toTurnCount: 2, | ||
| 34 | }, | ||
| 35 | }), | ||
| 36 | ); | ||
| 37 | assert.strictEqual(result._tag, "Failure"); | ||
| 38 | }), | ||
| 39 | ); | ||
packages/contracts/src/ws.ts+49-33| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema, Struct } from "effect"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | ClientOrchestrationCommand, | 4 | ClientOrchestrationCommand, |
| ... | @@ -76,43 +76,56 @@ export const WS_CHANNELS = { | ... | @@ -76,43 +76,56 @@ export const WS_CHANNELS = { |
| 76 | 76 | ||
| 77 | // -- Tagged Union of all request body schemas ───────────────────────── | 77 | // -- Tagged Union of all request body schemas ───────────────────────── |
| 78 | 78 | ||
| 79 | export const WebSocketRequestBody = Schema.TaggedUnion({ | 79 | const tagRequestBody = <const Tag extends string, const Fields extends Schema.Struct.Fields>( |
| 80 | tag: Tag, | ||
| 81 | schema: Schema.Struct<Fields>, | ||
| 82 | ) => | ||
| 83 | schema.mapFields( | ||
| 84 | Struct.assign({ _tag: Schema.tag(tag) }), | ||
| 85 | // PreserveChecks is safe here. No existing schema should have checks depending on the tag | ||
| 86 | { unsafePreserveChecks: true }, | ||
| 87 | ); | ||
| 88 | |||
| 89 | export const WebSocketRequestBody = Schema.Union([ | ||
| 80 | // Orchestration methods | 90 | // Orchestration methods |
| 81 | [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand }, | 91 | tagRequestBody( |
| 82 | [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields, | 92 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 83 | [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields, | 93 | Schema.Struct({ command: ClientOrchestrationCommand }), |
| 84 | [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields, | 94 | ), |
| 85 | [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields, | 95 | tagRequestBody(ORCHESTRATION_WS_METHODS.getSnapshot, OrchestrationGetSnapshotInput), |
| 96 | tagRequestBody(ORCHESTRATION_WS_METHODS.getTurnDiff, OrchestrationGetTurnDiffInput), | ||
| 97 | tagRequestBody(ORCHESTRATION_WS_METHODS.getFullThreadDiff, OrchestrationGetFullThreadDiffInput), | ||
| 98 | tagRequestBody(ORCHESTRATION_WS_METHODS.replayEvents, OrchestrationReplayEventsInput), | ||
| 86 | 99 | ||
| 87 | // Project Search | 100 | // Project Search |
| 88 | [WS_METHODS.projectsSearchEntries]: ProjectSearchEntriesInput.fields, | 101 | tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), |
| 89 | 102 | ||
| 90 | // Shell methods | 103 | // Shell methods |
| 91 | [WS_METHODS.shellOpenInEditor]: OpenInEditorInput.fields, | 104 | tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), |
| 92 | 105 | ||
| 93 | // Git methods | 106 | // Git methods |
| 94 | [WS_METHODS.gitPull]: GitPullInput.fields, | 107 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), |
| 95 | [WS_METHODS.gitStatus]: GitStatusInput.fields, | 108 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), |
| 96 | [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields, | 109 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 97 | [WS_METHODS.gitListBranches]: GitListBranchesInput.fields, | 110 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 98 | [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields, | 111 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), |
| 99 | [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields, | 112 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |
| 100 | [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields, | 113 | tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), |
| 101 | [WS_METHODS.gitCheckout]: GitCheckoutInput.fields, | 114 | tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), |
| 102 | [WS_METHODS.gitInit]: GitInitInput.fields, | 115 | tagRequestBody(WS_METHODS.gitInit, GitInitInput), |
| 103 | 116 | ||
| 104 | // Terminal methods | 117 | // Terminal methods |
| 105 | [WS_METHODS.terminalOpen]: TerminalOpenInput.fields, | 118 | tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput), |
| 106 | [WS_METHODS.terminalWrite]: TerminalWriteInput.fields, | 119 | tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput), |
| 107 | [WS_METHODS.terminalResize]: TerminalResizeInput.fields, | 120 | tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput), |
| 108 | [WS_METHODS.terminalClear]: TerminalClearInput.fields, | 121 | tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput), |
| 109 | [WS_METHODS.terminalRestart]: TerminalRestartInput.fields, | 122 | tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), |
| 110 | [WS_METHODS.terminalClose]: TerminalCloseInput.fields, | 123 | tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), |
| 111 | 124 | ||
| 112 | // Server meta | 125 | // Server meta |
| 113 | [WS_METHODS.serverGetConfig]: {}, | 126 | tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), |
| 114 | [WS_METHODS.serverUpsertKeybinding]: KeybindingRule.fields, | 127 | tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), |
| 115 | }); | 128 | ]); |
| 116 | 129 | ||
| 117 | export const WebSocketRequest = Schema.Struct({ | 130 | export const WebSocketRequest = Schema.Struct({ |
| 118 | id: Schema.String, | 131 | id: Schema.String, |
| ... | @@ -120,7 +133,7 @@ export const WebSocketRequest = Schema.Struct({ | ... | @@ -120,7 +133,7 @@ export const WebSocketRequest = Schema.Struct({ |
| 120 | }); | 133 | }); |
| 121 | export type WebSocketRequest = typeof WebSocketRequest.Type; | 134 | export type WebSocketRequest = typeof WebSocketRequest.Type; |
| 122 | 135 | ||
| 123 | export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocketResponse")({ | 136 | export const WebSocketResponse = Schema.Struct({ |
| 124 | id: Schema.String, | 137 | id: Schema.String, |
| 125 | result: Schema.optional(Schema.Unknown), | 138 | result: Schema.optional(Schema.Unknown), |
| 126 | error: Schema.optional( | 139 | error: Schema.optional( |
| ... | @@ -128,13 +141,15 @@ export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocke | ... | @@ -128,13 +141,15 @@ export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocke |
| 128 | message: Schema.String, | 141 | message: Schema.String, |
| 129 | }), | 142 | }), |
| 130 | ), | 143 | ), |
| 131 | }) {} | 144 | }); |
| 145 | export type WebSocketResponse = typeof WebSocketResponse.Type; | ||
| 132 | 146 | ||
| 133 | export class WsPush extends Schema.Class<WsPush>("WsPushEvent")({ | 147 | export const WsPush = Schema.Struct({ |
| 134 | type: Schema.Literal("push"), | 148 | type: Schema.Literal("push"), |
| 135 | channel: Schema.String, | 149 | channel: Schema.String, |
| 136 | data: Schema.Unknown, | 150 | data: Schema.Unknown, |
| 137 | }) {} | 151 | }); |
| 152 | export type WsPush = typeof WsPush.Type; | ||
| 138 | 153 | ||
| 139 | // ── Union of all server → client messages ───────────────────────────── | 154 | // ── Union of all server → client messages ───────────────────────────── |
| 140 | 155 | ||
| ... | @@ -143,7 +158,8 @@ export type WsResponse = typeof WsResponse.Type; | ... | @@ -143,7 +158,8 @@ export type WsResponse = typeof WsResponse.Type; |
| 143 | 158 | ||
| 144 | // ── Server welcome payload ─────────────────────────────────────────── | 159 | // ── Server welcome payload ─────────────────────────────────────────── |
| 145 | 160 | ||
| 146 | export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({ | 161 | export const WsWelcomePayload = Schema.Struct({ |
| 147 | cwd: Schema.String, | 162 | cwd: Schema.String, |
| 148 | projectName: Schema.String, | 163 | projectName: Schema.String, |
| 149 | }) {} | 164 | }); |
| 165 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; |