From 1f42046e1b11f366d90df1353e740b6d1be484a2 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:01:55 -0400 Subject: [PATCH 1/3] Show usage loading on mobile and preserve running turn state - Add mobile loading states and responsive usage/sidebar UI - Track checkpoint completion explicitly so intermediate diffs do not finish turns - Persist completed checkpoint timestamps and settle stopped projection turns - Update orchestration contracts, seed data, and coverage --- apps/server/src/cli/marketingStudioSeed.ts | 2 + .../Layers/CheckpointReactor.test.ts | 84 ++++ .../orchestration/Layers/CheckpointReactor.ts | 33 +- .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 192 ++++++++ .../Layers/ProjectionPipeline.ts | 88 +++- .../Layers/ProjectionSnapshotQuery.ts | 4 +- .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.test.ts | 26 + .../Layers/ProviderRuntimeIngestion.ts | 4 + apps/server/src/orchestration/decider.ts | 2 + .../src/orchestration/projector.test.ts | 130 +++++ apps/server/src/orchestration/projector.ts | 29 +- .../Layers/ProjectionCheckpoints.ts | 19 +- .../src/persistence/Layers/ProjectionTurns.ts | 24 +- apps/server/src/persistence/Migrations.ts | 2 + .../045_SettleStoppedProjectionTurns.test.ts | 81 +++ ...46_ProjectionTurnsCheckpointCompletedAt.ts | 22 + .../persistence/Services/ProjectionTurns.ts | 2 + .../src/provider/ExternalThreadImport.ts | 1 + apps/web/src/components/ChatView.browser.tsx | 104 +++- apps/web/src/components/CommandPalette.tsx | 4 + .../src/components/CommandPaletteResults.tsx | 41 ++ apps/web/src/components/Sidebar.tsx | 84 +++- .../file-viewer/FileViewerOverlay.tsx | 74 ++- .../settings/ConnectionsSettings.tsx | 39 +- .../settings/DiagnosticsSettings.tsx | 268 ++++++---- .../settings/ExtensionsSettings.tsx | 118 ++++- .../settings/SettingsPanels.browser.tsx | 35 +- .../components/settings/SettingsPanels.tsx | 63 ++- apps/web/src/components/ui/skeleton.tsx | 2 +- .../components/usage/UsageView.browser.tsx | 107 +++- apps/web/src/components/usage/UsageView.tsx | 464 ++++++++++++++---- apps/web/src/store.test.ts | 31 +- apps/web/src/store.ts | 42 +- packages/contracts/src/orchestration.ts | 15 + 36 files changed, 1914 insertions(+), 325 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts diff --git a/apps/server/src/cli/marketingStudioSeed.ts b/apps/server/src/cli/marketingStudioSeed.ts index 1aa2386f1..90eab4b21 100644 --- a/apps/server/src/cli/marketingStudioSeed.ts +++ b/apps/server/src/cli/marketingStudioSeed.ts @@ -249,6 +249,7 @@ const seedThreads = (input: MarketingStudioSeedInput) => threadId, messageId: assistantMessageId, turnId, + completesTurn: true, createdAt: completedAt, }); } @@ -422,6 +423,7 @@ const seedThreads = (input: MarketingStudioSeedInput) => ], ...(scenario.assistantText !== undefined ? { assistantMessageId } : {}), checkpointTurnCount: 1, + completesTurn: true, createdAt: completedAt, }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 7a6bb19f8..314b32eb2 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -612,6 +612,67 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); + it("captures but does not finalize non-terminal provider diff placeholders", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId("turn-mid-diff-placeholder"); + const createdAt = "2026-01-01T00:00:05.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-mid-diff-placeholder"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: turnId, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-mid-diff-placeholder"), + threadId, + turnId, + completedAt: createdAt, + checkpointRef: asCheckpointRef("provider-diff:evt-mid-diff-placeholder"), + status: "missing", + files: [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }], + assistantMessageId: MessageId.make("assistant:mid-diff-placeholder"), + checkpointTurnCount: 1, + completesTurn: false, + createdAt, + }), + ); + await harness.drain(); + + await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 1)); + const snapshot = await harness.readModel(); + const thread = snapshot.threads.find((entry) => entry.id === threadId); + expect(thread?.latestTurn).toMatchObject({ + turnId, + state: "running", + completedAt: null, + }); + expect(thread?.checkpoints).toEqual([ + expect.objectContaining({ + turnId, + checkpointRef: checkpointRefForThreadTurn(threadId, 1), + status: "ready", + files: [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }], + }), + ]); + }); + it("skips changed-file summaries from a shared checkout while another session is active", async () => { const harness = await createHarness({ seedFilesystemCheckpoints: false, @@ -720,6 +781,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "EXTERNAL.md", kind: "modified", additions: 1, deletions: 0 }], checkpointTurnCount: 1, + completesTurn: true, createdAt: "2026-01-01T00:02:00.000Z", }), ); @@ -795,6 +857,7 @@ describe("CheckpointReactor", () => { status: "missing", files: providerFiles, checkpointTurnCount: 1, + completesTurn: false, createdAt, }), ); @@ -885,6 +948,7 @@ describe("CheckpointReactor", () => { status: "missing", files: providerFiles, checkpointTurnCount: 1, + completesTurn: false, createdAt, }), ); @@ -960,6 +1024,7 @@ describe("CheckpointReactor", () => { status: "missing", files: [], checkpointTurnCount: 1, + completesTurn: false, createdAt, }), ); @@ -1103,6 +1168,7 @@ describe("CheckpointReactor", () => { status: "missing", files: [], checkpointTurnCount: 1, + completesTurn: false, createdAt, }), ); @@ -1173,6 +1239,7 @@ describe("CheckpointReactor", () => { status: "missing", files: providerFiles, checkpointTurnCount: 1, + completesTurn: false, createdAt, }), ); @@ -1663,6 +1730,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -1677,6 +1745,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [], checkpointTurnCount: 2, + completesTurn: true, createdAt, }), ); @@ -1784,6 +1853,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -1798,6 +1868,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [], checkpointTurnCount: 2, + completesTurn: true, createdAt, }), ); @@ -1854,6 +1925,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -1868,6 +1940,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [], checkpointTurnCount: 2, + completesTurn: true, createdAt, }), ); @@ -1962,6 +2035,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -1979,6 +2053,7 @@ describe("CheckpointReactor", () => { { path: "created-by-thread.txt", kind: "added", additions: 1, deletions: 0 }, ], checkpointTurnCount: 2, + completesTurn: true, createdAt, }), ); @@ -2077,6 +2152,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -2094,6 +2170,7 @@ describe("CheckpointReactor", () => { { path: "created-by-thread.txt", kind: "added", additions: 1, deletions: 0 }, ], checkpointTurnCount: 2, + completesTurn: true, createdAt, }), ); @@ -2190,6 +2267,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "lines.txt", kind: "modified", additions: 1, deletions: 1 }], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -2279,6 +2357,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "notes.md", kind: "modified", additions: 4, deletions: 0 }], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -2389,6 +2468,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); @@ -2406,6 +2486,7 @@ describe("CheckpointReactor", () => { { path: "shared.txt", kind: "modified", additions: 1, deletions: 1 }, ], checkpointTurnCount: 2, + completesTurn: true, createdAt, }), ); @@ -2420,6 +2501,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "shared.txt", kind: "modified", additions: 1, deletions: 0 }], checkpointTurnCount: 3, + completesTurn: true, createdAt, }), ); @@ -2527,6 +2609,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [...files], checkpointTurnCount: turnCount, + completesTurn: true, createdAt, }), ); @@ -2571,6 +2654,7 @@ describe("CheckpointReactor", () => { status: "ready", files: [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }], checkpointTurnCount: turnCount, + completesTurn: true, createdAt, }), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index ef6a6b11f..abde564a1 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -445,6 +445,7 @@ const make = Effect.gen(function* () { readonly assistantMessageId: MessageId | undefined; readonly providerSummaryFiles: ReadonlyArray | undefined; readonly refreshSharedCheckoutSummaryFromCheckpoint: boolean; + readonly completesTurn: boolean; /** When the turn's diff window opened (turn start). Undefined falls back * to the instantaneous live-session check alone. */ readonly turnWindowStartIso: string | undefined; @@ -601,6 +602,7 @@ const make = Effect.gen(function* () { files, assistantMessageId, checkpointTurnCount: input.turnCount, + completesTurn: input.completesTurn, createdAt: input.createdAt, }); yield* appendCheckpointFileChangeActivity({ @@ -619,13 +621,15 @@ const make = Effect.gen(function* () { status: input.status, createdAt: input.createdAt, }); - yield* receiptBus.publish({ - type: "turn.processing.quiesced", - threadId: input.threadId, - turnId: input.turnId, - checkpointTurnCount: input.turnCount, - createdAt: input.createdAt, - }); + if (input.completesTurn) { + yield* receiptBus.publish({ + type: "turn.processing.quiesced", + threadId: input.threadId, + turnId: input.turnId, + checkpointTurnCount: input.turnCount, + createdAt: input.createdAt, + }); + } yield* orchestrationEngine.dispatch({ type: "thread.activity.append", @@ -702,6 +706,7 @@ const make = Effect.gen(function* () { assistantMessageId: undefined, providerSummaryFiles, refreshSharedCheckoutSummaryFromCheckpoint: true, + completesTurn: true, turnWindowStartIso: turnWindowStartIsoForThread(thread, turnId), createdAt: event.createdAt, }); @@ -734,6 +739,11 @@ const make = Effect.gen(function* () { return; } + const activeTurnId = thread.session?.activeTurnId ?? null; + const completesTurn = + event.payload.completesTurn !== undefined + ? event.payload.completesTurn + : activeTurnId === null || !sameId(activeTurnId, turnId); // If a real checkpoint already exists for this turn, skip. if ( thread.checkpoints.some( @@ -768,6 +778,7 @@ const make = Effect.gen(function* () { assistantMessageId: event.payload.assistantMessageId ?? undefined, providerSummaryFiles: event.payload.files, refreshSharedCheckoutSummaryFromCheckpoint: false, + completesTurn, turnWindowStartIso: turnWindowStartIsoForThread(thread, turnId), createdAt: event.payload.completedAt, }); @@ -1071,11 +1082,9 @@ const make = Effect.gen(function* () { return; } - // When ProviderRuntimeIngestion creates a placeholder checkpoint (status "missing") - // from a turn.diff.updated runtime event, capture the real git checkpoint to - // replace it. The providerService.streamEvents PubSub does not reliably deliver - // turn.completed runtime events to this reactor (shared subscription), so - // reacting to the domain event is the reliable path. + // Provider diff notifications create a non-terminal placeholder. Capture a + // real checkpoint for diff/revert fidelity, but propagate completesTurn so + // that capture cannot settle the still-running provider turn. if (event.type === "thread.turn-diff-completed") { yield* captureCheckpointFromPlaceholder(event).pipe( Effect.catch((error) => diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index d824d6c5c..aba4cbe78 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -824,6 +824,7 @@ describe("OrchestrationEngine", () => { status: "ready", files: [], checkpointTurnCount: 1, + completesTurn: true, createdAt, }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1adbfb385..fa61f602a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2503,6 +2503,198 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }), ); + it.effect("keeps a provider diff placeholder from completing the active turn projection", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const placeholderAt = "2026-01-01T00:00:02.000Z"; + const completedAt = "2026-01-01T00:00:05.000Z"; + const threadId = ThreadId.make("thread-provider-diff-placeholder"); + const turnId = TurnId.make("turn-provider-diff-placeholder"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-provider-diff-placeholder-project"), + projectId: ProjectId.make("project-provider-diff-placeholder"), + title: "Provider Diff Placeholder Project", + workspaceRoot: "/tmp/project-provider-diff-placeholder", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-provider-diff-placeholder-thread"), + threadId, + projectId: ProjectId.make("project-provider-diff-placeholder"), + title: "Provider Diff Placeholder Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-provider-diff-placeholder-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: turnId, + lastError: null, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("cmd-provider-diff-placeholder-assistant-delta"), + threadId, + messageId: MessageId.make("assistant-provider-diff-placeholder"), + turnId, + delta: "Intermediate progress update", + createdAt: placeholderAt, + }); + yield* engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-provider-diff-placeholder-assistant-complete"), + threadId, + messageId: MessageId.make("assistant-provider-diff-placeholder"), + turnId, + completesTurn: false, + createdAt: placeholderAt, + }); + + const assistantSegmentTurnRows = yield* sql<{ + readonly state: string; + readonly completedAt: string | null; + readonly assistantMessageId: string | null; + }>` + SELECT + state, + completed_at AS "completedAt", + assistant_message_id AS "assistantMessageId" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + `; + assert.deepEqual(assistantSegmentTurnRows, [ + { + state: "running", + completedAt: null, + assistantMessageId: "assistant-provider-diff-placeholder", + }, + ]); + + yield* engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-provider-diff-placeholder"), + threadId, + turnId, + completedAt: placeholderAt, + checkpointRef: CheckpointRef.make("provider-diff:evt-provider-diff-placeholder"), + status: "missing", + files: [ + { + path: "apps/web/src/components/usage/UsageView.tsx", + kind: "modified", + additions: 12, + deletions: 3, + }, + ], + assistantMessageId: MessageId.make("assistant-provider-diff-placeholder"), + checkpointTurnCount: 1, + completesTurn: false, + createdAt: placeholderAt, + }); + + const placeholderTurnRows = yield* sql<{ + readonly state: string; + readonly completedAt: string | null; + readonly checkpointStatus: string | null; + readonly checkpointFilesJson: string; + }>` + SELECT + state, + completed_at AS "completedAt", + checkpoint_status AS "checkpointStatus", + checkpoint_files_json AS "checkpointFilesJson" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + `; + assert.equal(placeholderTurnRows[0]?.state, "running"); + assert.equal(placeholderTurnRows[0]?.completedAt, null); + assert.equal(placeholderTurnRows[0]?.checkpointStatus, "missing"); + assert.deepEqual(JSON.parse(placeholderTurnRows[0]?.checkpointFilesJson ?? "[]"), [ + { + path: "apps/web/src/components/usage/UsageView.tsx", + kind: "modified", + additions: 12, + deletions: 3, + }, + ]); + + yield* engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-provider-diff-terminal"), + threadId, + turnId, + completedAt, + checkpointRef: CheckpointRef.make( + "refs/threadlines/checkpoints/thread-provider-diff-placeholder/turn/1", + ), + status: "ready", + files: [ + { + path: "apps/web/src/components/usage/UsageView.tsx", + kind: "modified", + additions: 14, + deletions: 3, + }, + ], + assistantMessageId: MessageId.make("assistant-provider-diff-placeholder"), + checkpointTurnCount: 1, + completesTurn: true, + createdAt: completedAt, + }); + + const completedTurnRows = yield* sql<{ + readonly state: string; + readonly completedAt: string | null; + readonly checkpointStatus: string | null; + }>` + SELECT + state, + completed_at AS "completedAt", + checkpoint_status AS "checkpointStatus" + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + `; + assert.deepEqual(completedTurnRows, [ + { state: "completed", completedAt, checkpointStatus: "ready" }, + ]); + + const threadRows = yield* sql<{ readonly latestTurnId: string | null }>` + SELECT latest_turn_id AS "latestTurnId" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(threadRows, [{ latestTurnId: "turn-provider-diff-placeholder" }]); + }), + ); + it.effect("interrupts an unfinished latest turn when its session stops", () => Effect.gen(function* () { const engine = yield* OrchestrationEngineService; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3bd4dbef9..bf28bf998 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -129,6 +129,32 @@ function activityMayAffectThreadShellSummary(activity: { readonly kind: string } } } +function legacyTurnDiffEventCompletesTurn(input: { + readonly explicitCompletesTurn?: boolean | undefined; + readonly activeTurnId: string | null; + readonly eventTurnId: string; +}): boolean { + if (input.explicitCompletesTurn !== undefined) { + return input.explicitCompletesTurn; + } + return input.activeTurnId === null || input.activeTurnId !== input.eventTurnId; +} + +function assistantMessageEventCompletesTurn(input: { + readonly streaming: boolean; + readonly explicitCompletesTurn?: boolean | undefined; + readonly activeTurnId: string | null; + readonly eventTurnId: string; +}): boolean { + if (input.streaming) { + return false; + } + if (input.explicitCompletesTurn !== undefined) { + return input.explicitCompletesTurn; + } + return input.activeTurnId === null || input.activeTurnId !== input.eventTurnId; +} + function deriveHasActionableProposedPlan(input: { readonly latestTurnId: string | null; readonly proposedPlans: ReadonlyArray; @@ -851,6 +877,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } + const existingSession = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const completesTurn = legacyTurnDiffEventCompletesTurn({ + explicitCompletesTurn: event.payload.completesTurn, + activeTurnId: Option.isSome(existingSession) + ? existingSession.value.activeTurnId + : null, + eventTurnId: event.payload.turnId, + }); + if (!completesTurn) { + yield* refreshThreadShellSummary(event.payload.threadId); + return; + } yield* projectionThreadRepository.upsert({ ...existingRow.value, latestTurnId: event.payload.turnId, @@ -1273,6 +1313,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti checkpointRef: null, checkpointStatus: null, checkpointFiles: [], + checkpointCompletedAt: null, }); } @@ -1286,6 +1327,17 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (event.payload.turnId === null || event.payload.role !== "assistant") { return; } + const existingSession = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const completesTurn = assistantMessageEventCompletesTurn({ + streaming: event.payload.streaming, + explicitCompletesTurn: event.payload.completesTurn, + activeTurnId: Option.isSome(existingSession) + ? existingSession.value.activeTurnId + : null, + eventTurnId: event.payload.turnId, + }); const existingTurn = yield* projectionTurnRepository.getByTurnId({ threadId: event.payload.threadId, turnId: event.payload.turnId, @@ -1294,14 +1346,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionTurnRepository.upsertByTurnId({ ...existingTurn.value, assistantMessageId: event.payload.messageId, - state: event.payload.streaming + state: !completesTurn ? existingTurn.value.state : existingTurn.value.state === "interrupted" ? "interrupted" : existingTurn.value.state === "error" ? "error" : "completed", - completedAt: event.payload.streaming + completedAt: !completesTurn ? existingTurn.value.completedAt : (existingTurn.value.completedAt ?? event.payload.updatedAt), startedAt: existingTurn.value.startedAt ?? event.payload.createdAt, @@ -1316,14 +1368,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti sourceProposedPlanThreadId: null, sourceProposedPlanId: null, assistantMessageId: event.payload.messageId, - state: event.payload.streaming ? "running" : "completed", + state: completesTurn ? "completed" : "running", requestedAt: event.payload.createdAt, startedAt: event.payload.createdAt, - completedAt: event.payload.streaming ? null : event.payload.updatedAt, + completedAt: completesTurn ? event.payload.updatedAt : null, checkpointTurnCount: null, checkpointRef: null, checkpointStatus: null, checkpointFiles: [], + checkpointCompletedAt: null, }); return; } @@ -1361,6 +1414,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti checkpointRef: null, checkpointStatus: null, checkpointFiles: [], + checkpointCompletedAt: null, }); return; } @@ -1370,7 +1424,23 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, turnId: event.payload.turnId, }); - const nextState = event.payload.status === "error" ? "error" : "completed"; + const existingSession = yield* projectionThreadSessionRepository.getByThreadId({ + threadId: event.payload.threadId, + }); + const completesTurn = legacyTurnDiffEventCompletesTurn({ + explicitCompletesTurn: event.payload.completesTurn, + activeTurnId: Option.isSome(existingSession) + ? existingSession.value.activeTurnId + : null, + eventTurnId: event.payload.turnId, + }); + const nextState = completesTurn + ? event.payload.status === "error" + ? "error" + : "completed" + : Option.isSome(existingTurn) + ? existingTurn.value.state + : "running"; yield* projectionTurnRepository.clearCheckpointTurnConflict({ threadId: event.payload.threadId, turnId: event.payload.turnId, @@ -1386,9 +1456,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti checkpointRef: event.payload.checkpointRef, checkpointStatus: event.payload.status, checkpointFiles: event.payload.files, + checkpointCompletedAt: event.payload.completedAt, startedAt: existingTurn.value.startedAt ?? event.payload.completedAt, requestedAt: existingTurn.value.requestedAt ?? event.payload.completedAt, - completedAt: event.payload.completedAt, + completedAt: completesTurn + ? event.payload.completedAt + : existingTurn.value.completedAt, }); return; } @@ -1402,11 +1475,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti state: nextState, requestedAt: event.payload.completedAt, startedAt: event.payload.completedAt, - completedAt: event.payload.completedAt, + completedAt: completesTurn ? event.payload.completedAt : null, checkpointTurnCount: event.payload.checkpointTurnCount, checkpointRef: event.payload.checkpointRef, checkpointStatus: event.payload.status, checkpointFiles: event.payload.files, + checkpointCompletedAt: event.payload.completedAt, }); return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 728c6883c..eb0e454d8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -792,7 +792,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpoint_status AS "status", checkpoint_files_json AS "files", assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" + checkpoint_completed_at AS "completedAt" FROM projection_turns WHERE checkpoint_turn_count IS NOT NULL ORDER BY thread_id ASC, checkpoint_turn_count ASC @@ -1281,7 +1281,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpoint_status AS "status", checkpoint_files_json AS "files", assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" + checkpoint_completed_at AS "completedAt" FROM projection_turns WHERE thread_id = ${threadId} AND checkpoint_turn_count IS NOT NULL diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 96acfbae8..2d3d9f873 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -836,6 +836,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.make("thread-1"), messageId: asMessageId("assistant-message-1"), turnId, + completesTurn: true, createdAt: "2026-01-01T00:00:03.000Z", }), ); @@ -1360,6 +1361,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.make("thread-1"), messageId: asMessageId("assistant-message-native-fork"), turnId: asTurnId("codex-turn-3"), + completesTurn: true, createdAt: now, }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 4ff34a1d6..9bcb902aa 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3903,6 +3903,19 @@ describe("ProviderRuntimeIngestion", () => { status: "completed", }, }); + const afterItemCompletion = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-complete-dedup" && !message.streaming, + ), + ); + expect(afterItemCompletion.session?.activeTurnId).toBe("turn-complete-dedup"); + expect(afterItemCompletion.latestTurn).toMatchObject({ + turnId: "turn-complete-dedup", + state: "running", + completedAt: null, + }); + harness.emit({ type: "turn.completed", eventId: asEventId("evt-turn-completed-for-complete-dedup"), @@ -3941,6 +3954,12 @@ describe("ProviderRuntimeIngestion", () => { ); }); expect(completionEvents).toHaveLength(1); + const completionEvent = completionEvents[0]; + expect(completionEvent?.type).toBe("thread.message-sent"); + if (completionEvent?.type !== "thread.message-sent") { + throw new Error("Expected one assistant message completion event"); + } + expect(completionEvent.payload.completesTurn).toBe(false); }); it("maps canonical request events into approval activities with requestKind", async () => { @@ -4404,6 +4423,13 @@ describe("ProviderRuntimeIngestion", () => { const placeholder = afterFirst.checkpoints.find( (entry: ProviderRuntimeTestCheckpoint) => entry.turnId === "turn-cumulative", ); + expect(afterFirst.session?.status).toBe("running"); + expect(afterFirst.session?.activeTurnId).toBe("turn-cumulative"); + expect(afterFirst.latestTurn).toMatchObject({ + turnId: "turn-cumulative", + state: "running", + completedAt: null, + }); expect(placeholder?.files).toEqual([ { path: "file.txt", kind: "modified", additions: 1, deletions: 0 }, ]); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b57a92ff5..8433f2409 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1304,6 +1304,7 @@ const make = Effect.gen(function* () { files: input.files, assistantMessageId, checkpointTurnCount, + completesTurn: false, createdAt: input.now, }); const activity = checkpointFileChangeActivity({ @@ -1830,6 +1831,7 @@ const make = Effect.gen(function* () { finalDeltaCommandTag: string; fallbackText?: string; hasProjectedMessage?: boolean; + completesTurn?: boolean; }) => Effect.gen(function* () { const bufferedText = yield* takeBufferedAssistantText(input.messageId); @@ -1860,6 +1862,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, messageId: input.messageId, ...(input.turnId ? { turnId: input.turnId } : {}), + completesTurn: input.completesTurn ?? false, createdAt: input.createdAt, }); } @@ -2821,6 +2824,7 @@ const make = Effect.gen(function* () { commandTag: "assistant-complete-finalize", finalDeltaCommandTag: "assistant-delta-finalize-fallback", hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, + completesTurn: event.type === "turn.completed", }), { concurrency: 1 }, ).pipe(Effect.asVoid); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a307a504d..040de186a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1340,6 +1340,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" text: "", turnId: command.turnId ?? null, streaming: false, + completesTurn: command.completesTurn, createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -1455,6 +1456,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" files: command.files, assistantMessageId: command.assistantMessageId ?? null, completedAt: command.completedAt, + completesTurn: command.completesTurn, }, }; } diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index cb6363a89..89e809509 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -503,6 +503,136 @@ describe("orchestration projector", () => { expect(afterStopped.threads[0]?.session?.status).toBe("stopped"); }); + it("keeps the latest turn running when a provider diff placeholder arrives mid-turn", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const startedAt = "2026-02-23T08:00:05.000Z"; + const placeholderAt = "2026-02-23T08:00:07.000Z"; + const completedAt = "2026-02-23T08:00:10.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const afterRunning = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: startedAt, + commandId: "cmd-running", + payload: { + threadId: "thread-1", + session: { + threadId: "thread-1", + status: "running", + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "approval-required", + activeTurnId: "turn-1", + lastError: null, + updatedAt: startedAt, + }, + }, + }), + ), + ); + + const afterPlaceholder = await Effect.runPromise( + projectEvent( + afterRunning, + makeEvent({ + sequence: 3, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: placeholderAt, + commandId: "cmd-placeholder", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "provider-diff:event-1", + status: "missing", + files: [{ path: "apps/web/src/App.tsx", kind: "modified", additions: 3, deletions: 1 }], + assistantMessageId: "assistant-msg-1", + completedAt: placeholderAt, + completesTurn: false, + }, + }), + ), + ); + + expect(afterPlaceholder.threads[0]?.latestTurn).toMatchObject({ + turnId: "turn-1", + state: "running", + completedAt: null, + }); + expect(afterPlaceholder.threads[0]?.checkpoints[0]?.files).toEqual([ + { path: "apps/web/src/App.tsx", kind: "modified", additions: 3, deletions: 1 }, + ]); + + const afterComplete = await Effect.runPromise( + projectEvent( + afterPlaceholder, + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: completedAt, + commandId: "cmd-complete", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/threadlines/checkpoints/thread-1/turn/1", + status: "ready", + files: [{ path: "apps/web/src/App.tsx", kind: "modified", additions: 4, deletions: 1 }], + assistantMessageId: "assistant-msg-1", + completedAt, + completesTurn: true, + }, + }), + ), + ); + + expect(afterComplete.threads[0]?.latestTurn).toMatchObject({ + turnId: "turn-1", + state: "completed", + completedAt, + }); + expect(afterComplete.threads[0]?.checkpoints[0]?.status).toBe("ready"); + }); + it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => { const createdAt = "2026-02-23T08:00:00.000Z"; const updatedAt = "2026-02-23T08:00:05.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index e9da3dc95..9e5169c33 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -53,6 +53,17 @@ function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error" return "completed" as const; } +function turnDiffEventCompletesTurn( + thread: Pick, + payload: { readonly turnId: string; readonly completesTurn?: boolean | undefined }, +) { + if (payload.completesTurn !== undefined) { + return payload.completesTurn; + } + const activeTurnId = thread.session?.activeTurnId ?? null; + return activeTurnId === null || activeTurnId !== payload.turnId; +} + function updateThread( threads: ReadonlyArray, threadId: ThreadId, @@ -740,12 +751,9 @@ export function projectEvent( ] .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount) .slice(-MAX_THREAD_CHECKPOINTS); - - return { - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - checkpoints, - latestTurn: { + const completesTurn = turnDiffEventCompletesTurn(thread, payload); + const latestTurn = completesTurn + ? { turnId: payload.turnId, state: checkpointStatusToLatestTurnState(payload.status), requestedAt: @@ -758,7 +766,14 @@ export function projectEvent( : payload.completedAt, completedAt: payload.completedAt, assistantMessageId: payload.assistantMessageId, - }, + } + : thread.latestTurn; + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + checkpoints, + latestTurn, updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts index 0a75e33d8..1b6a8c0e5 100644 --- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -42,7 +42,8 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { checkpoint_turn_count = NULL, checkpoint_ref = NULL, checkpoint_status = NULL, - checkpoint_files_json = '[]' + checkpoint_files_json = '[]', + checkpoint_completed_at = NULL WHERE thread_id = ${threadId} AND checkpoint_turn_count = ${checkpointTurnCount} `, @@ -64,7 +65,8 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { checkpoint_turn_count, checkpoint_ref, checkpoint_status, - checkpoint_files_json + checkpoint_files_json, + checkpoint_completed_at ) VALUES ( ${row.threadId}, @@ -78,7 +80,8 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { ${row.checkpointTurnCount}, ${row.checkpointRef}, ${row.status}, - ${row.files} + ${row.files}, + ${row.completedAt} ) ON CONFLICT (thread_id, turn_id) DO UPDATE SET @@ -88,7 +91,8 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { checkpoint_turn_count = excluded.checkpoint_turn_count, checkpoint_ref = excluded.checkpoint_ref, checkpoint_status = excluded.checkpoint_status, - checkpoint_files_json = excluded.checkpoint_files_json + checkpoint_files_json = excluded.checkpoint_files_json, + checkpoint_completed_at = excluded.checkpoint_completed_at `, }); @@ -105,7 +109,7 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { checkpoint_status AS "status", checkpoint_files_json AS "files", assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" + checkpoint_completed_at AS "completedAt" FROM projection_turns WHERE thread_id = ${threadId} AND checkpoint_turn_count IS NOT NULL @@ -126,7 +130,7 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { checkpoint_status AS "status", checkpoint_files_json AS "files", assistant_message_id AS "assistantMessageId", - completed_at AS "completedAt" + checkpoint_completed_at AS "completedAt" FROM projection_turns WHERE thread_id = ${threadId} AND checkpoint_turn_count = ${checkpointTurnCount} @@ -142,7 +146,8 @@ const makeProjectionCheckpointRepository = Effect.gen(function* () { checkpoint_turn_count = NULL, checkpoint_ref = NULL, checkpoint_status = NULL, - checkpoint_files_json = '[]' + checkpoint_files_json = '[]', + checkpoint_completed_at = NULL WHERE thread_id = ${threadId} AND checkpoint_turn_count IS NOT NULL `, diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index 999eeae01..1a0cbc582 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -61,7 +61,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count, checkpoint_ref, checkpoint_status, - checkpoint_files_json + checkpoint_files_json, + checkpoint_completed_at ) VALUES ( ${row.threadId}, @@ -77,7 +78,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ${row.checkpointTurnCount}, ${row.checkpointRef}, ${row.checkpointStatus}, - ${row.checkpointFiles} + ${row.checkpointFiles}, + ${row.checkpointCompletedAt} ) ON CONFLICT (thread_id, turn_id) DO UPDATE SET @@ -92,7 +94,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count = excluded.checkpoint_turn_count, checkpoint_ref = excluded.checkpoint_ref, checkpoint_status = excluded.checkpoint_status, - checkpoint_files_json = excluded.checkpoint_files_json + checkpoint_files_json = excluded.checkpoint_files_json, + checkpoint_completed_at = excluded.checkpoint_completed_at `, }); @@ -126,7 +129,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count, checkpoint_ref, checkpoint_status, - checkpoint_files_json + checkpoint_files_json, + checkpoint_completed_at ) VALUES ( ${row.threadId}, @@ -142,7 +146,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { NULL, NULL, NULL, - '[]' + '[]', + NULL ) `, }); @@ -188,7 +193,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count AS "checkpointTurnCount", checkpoint_ref AS "checkpointRef", checkpoint_status AS "checkpointStatus", - checkpoint_files_json AS "checkpointFiles" + checkpoint_files_json AS "checkpointFiles", + checkpoint_completed_at AS "checkpointCompletedAt" FROM projection_turns WHERE thread_id = ${threadId} ORDER BY @@ -221,7 +227,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count AS "checkpointTurnCount", checkpoint_ref AS "checkpointRef", checkpoint_status AS "checkpointStatus", - checkpoint_files_json AS "checkpointFiles" + checkpoint_files_json AS "checkpointFiles", + checkpoint_completed_at AS "checkpointCompletedAt" FROM projection_turns WHERE thread_id = ${threadId} AND turn_id = ${turnId} @@ -238,7 +245,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { checkpoint_turn_count = NULL, checkpoint_ref = NULL, checkpoint_status = NULL, - checkpoint_files_json = '[]' + checkpoint_files_json = '[]', + checkpoint_completed_at = NULL WHERE thread_id = ${threadId} AND checkpoint_turn_count = ${checkpointTurnCount} AND (turn_id IS NULL OR turn_id <> ${turnId}) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c4af6f632..3646d9974 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -58,6 +58,7 @@ import Migration0042 from "./Migrations/042_ProjectionThreadsInboxLifecycle.ts"; import Migration0043 from "./Migrations/043_ProjectionThreadSessionCheckoutCwd.ts"; import Migration0044 from "./Migrations/044_ProjectionThreadsEffectiveCwdSource.ts"; import Migration0045 from "./Migrations/045_SettleStoppedProjectionTurns.ts"; +import Migration0046 from "./Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts"; /** * Migration loader with all migrations defined inline. @@ -115,6 +116,7 @@ export const migrationEntries = [ [43, "ProjectionThreadSessionCheckoutCwd", Migration0043], [44, "ProjectionThreadsEffectiveCwdSource", Migration0044], [45, "SettleStoppedProjectionTurns", Migration0045], + [46, "ProjectionTurnsCheckpointCompletedAt", Migration0046], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/045_SettleStoppedProjectionTurns.test.ts b/apps/server/src/persistence/Migrations/045_SettleStoppedProjectionTurns.test.ts index e15576a92..b042a6105 100644 --- a/apps/server/src/persistence/Migrations/045_SettleStoppedProjectionTurns.test.ts +++ b/apps/server/src/persistence/Migrations/045_SettleStoppedProjectionTurns.test.ts @@ -190,4 +190,85 @@ layer("045_SettleStoppedProjectionTurns", (it) => { ]); }), ); + + it.effect("backfills checkpoint capture time separately from turn completion", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 45 }); + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES + ( + 'thread-checkpoint', + 'turn-checkpoint', + NULL, + 'assistant-checkpoint', + 'completed', + '2026-06-01T00:00:00.000Z', + '2026-06-01T00:00:00.000Z', + '2026-06-01T00:00:05.000Z', + 1, + 'refs/threadlines/checkpoints/thread-checkpoint/turn/1', + 'ready', + '[]' + ), + ( + 'thread-no-checkpoint', + 'turn-no-checkpoint', + NULL, + NULL, + 'completed', + '2026-06-01T00:00:00.000Z', + '2026-06-01T00:00:00.000Z', + '2026-06-01T00:00:03.000Z', + NULL, + NULL, + NULL, + '[]' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 46 }); + + const rows = yield* sql<{ + readonly turnId: string; + readonly completedAt: string | null; + readonly checkpointCompletedAt: string | null; + }>` + SELECT + turn_id AS "turnId", + completed_at AS "completedAt", + checkpoint_completed_at AS "checkpointCompletedAt" + FROM projection_turns + WHERE thread_id IN ('thread-checkpoint', 'thread-no-checkpoint') + ORDER BY turn_id ASC + `; + assert.deepStrictEqual(rows, [ + { + turnId: "turn-checkpoint", + completedAt: "2026-06-01T00:00:05.000Z", + checkpointCompletedAt: "2026-06-01T00:00:05.000Z", + }, + { + turnId: "turn-no-checkpoint", + completedAt: "2026-06-01T00:00:03.000Z", + checkpointCompletedAt: null, + }, + ]); + }), + ); }); diff --git a/apps/server/src/persistence/Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts b/apps/server/src/persistence/Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts new file mode 100644 index 000000000..9806312e7 --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Checkpoint capture time and turn completion time describe different + * lifecycle boundaries. A provider can publish several checkpoint summaries + * while its turn is still running, so they cannot share one nullable column. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_turns + ADD COLUMN checkpoint_completed_at TEXT + `; + + yield* sql` + UPDATE projection_turns + SET checkpoint_completed_at = completed_at + WHERE checkpoint_turn_count IS NOT NULL + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionTurns.ts b/apps/server/src/persistence/Services/ProjectionTurns.ts index 6e6351e4f..c6999b670 100644 --- a/apps/server/src/persistence/Services/ProjectionTurns.ts +++ b/apps/server/src/persistence/Services/ProjectionTurns.ts @@ -48,6 +48,7 @@ export const ProjectionTurn = Schema.Struct({ checkpointRef: Schema.NullOr(CheckpointRef), checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), checkpointFiles: Schema.Array(OrchestrationCheckpointFile), + checkpointCompletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionTurn = typeof ProjectionTurn.Type; @@ -66,6 +67,7 @@ export const ProjectionTurnById = Schema.Struct({ checkpointRef: Schema.NullOr(CheckpointRef), checkpointStatus: Schema.NullOr(OrchestrationCheckpointStatus), checkpointFiles: Schema.Array(OrchestrationCheckpointFile), + checkpointCompletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionTurnById = typeof ProjectionTurnById.Type; diff --git a/apps/server/src/provider/ExternalThreadImport.ts b/apps/server/src/provider/ExternalThreadImport.ts index 29136301d..1d1496892 100644 --- a/apps/server/src/provider/ExternalThreadImport.ts +++ b/apps/server/src/provider/ExternalThreadImport.ts @@ -118,6 +118,7 @@ export function importExternalProviderThread( threadId: input.threadId, messageId, turnId, + completesTurn: true, createdAt: message.createdAt, }), ), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 23198a623..b3e2e2108 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -144,6 +144,7 @@ let fixture: TestFixture; const rpcHarness = new BrowserWsRpcHarness(); const wsRequests = rpcHarness.requests; let customWsRpcResolver: ((body: NormalizedWsRpcRequestBody) => unknown | undefined) | null = null; +let suppressInitialShellSnapshot = false; const wsLink = ws.link(/ws(s)?:\/\/.*/); const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); @@ -2029,6 +2030,7 @@ async function mountChatView(options: { configureFixture?: (fixture: TestFixture) => void; resolveRpc?: (body: NormalizedWsRpcRequestBody) => unknown | undefined; initialPath?: string; + waitForBootstrap?: boolean; }): Promise { fixture = buildFixture(options.snapshot); options.configureFixture?.(fixture); @@ -2064,7 +2066,9 @@ async function mountChatView(options: { ); await waitForWsClient(); - await waitForAppBootstrap(); + if (options.waitForBootstrap !== false) { + await waitForAppBootstrap(); + } await waitForLayout(); const cleanup = async () => { @@ -2136,6 +2140,9 @@ describe("ChatView timeline estimator parity (full app)", () => { ]; } if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { + if (suppressInitialShellSnapshot) { + return []; + } return [ { kind: "snapshot", @@ -2166,6 +2173,7 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; + suppressInitialShellSnapshot = false; __resetEnvironmentApiOverridesForTests(); resetSavedEnvironmentRegistryStoreForTests(); resetSavedEnvironmentRuntimeStoreForTests(); @@ -2207,9 +2215,54 @@ describe("ChatView timeline estimator parity (full app)", () => { afterEach(() => { customWsRpcResolver = null; + suppressInitialShellSnapshot = false; document.body.innerHTML = ""; }); + it("shows neutral sidebar row shapes until the first workspace snapshot arrives", async () => { + suppressInitialShellSnapshot = true; + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-sidebar-loading" as MessageId, + targetText: "sidebar loading skeleton", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + initialPath: "/usage", + waitForBootstrap: false, + }); + + try { + const loadingState = page.getByRole("status", { name: "Loading projects and threads" }); + await expect.element(loadingState).toBeVisible(); + + const loadingElement = document.querySelector( + '[data-testid="sidebar-loading-skeleton"]', + ); + expect(loadingElement).not.toBeNull(); + expect(loadingElement?.querySelector("button, a, [role='button']")).toBeNull(); + expect(loadingElement?.textContent?.trim()).toBe(""); + expect( + loadingElement?.querySelector('[data-testid="sidebar-loading-live-rows"]')?.children.length, + ).toBeGreaterThan(0); + expect( + loadingElement?.querySelector('[data-testid="sidebar-loading-wrapped-rows"]')?.children + .length, + ).toBeGreaterThan(0); + + suppressInitialShellSnapshot = false; + rpcHarness.emitStreamValue(ORCHESTRATION_WS_METHODS.subscribeShell, { + kind: "snapshot", + snapshot: toShellSnapshot(snapshot), + }); + + await expect.element(loadingState).not.toBeInTheDocument(); + await expect.element(page.getByTestId("inbox-thread-list")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("renders locked single-environment mobile run context as a static workspace label", async () => { const mounted = await mountChatView({ viewport: COMPACT_FOOTER_VIEWPORT, @@ -6770,6 +6823,28 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("opens a project-scoped Codex session browser and imports the selected conversation", async () => { + const externalThreadsResult = { + data: [ + { + providerInstanceId: ProviderInstanceId.make("codex"), + providerThreadId: "native-codex-thread", + sessionId: "native-codex-session", + source: "cli" as const, + name: "Finish parser migration", + preview: "Finish the parser migration and verify its tests", + cwd: "/repo/project", + cliVersion: "0.145.0", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-03T12:00:00.000Z", + status: "idle" as const, + canImport: true, + }, + ], + }; + let resolveExternalThreads!: (value: typeof externalThreadsResult) => void; + const externalThreadsPromise = new Promise((resolve) => { + resolveExternalThreads = resolve; + }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -6797,24 +6872,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }, resolveRpc: (body) => { if (body._tag === WS_METHODS.serverListExternalProviderThreads) { - return { - data: [ - { - providerInstanceId: ProviderInstanceId.make("codex"), - providerThreadId: "native-codex-thread", - sessionId: "native-codex-session", - source: "cli", - name: "Finish parser migration", - preview: "Finish the parser migration and verify its tests", - cwd: "/repo/project", - cliVersion: "0.145.0", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-03T12:00:00.000Z", - status: "idle", - canImport: true, - }, - ], - }; + return externalThreadsPromise; } if (body._tag === WS_METHODS.serverImportExternalProviderThread) { return { @@ -6836,9 +6894,17 @@ describe("ChatView timeline estimator parity (full app)", () => { await palette.getByText("Bring in Codex conversation…", { exact: true }).click(); await waitForCommandPaletteInput("Search other Codex conversations in Project..."); + await expect + .element(palette.getByTestId("command-palette-results-skeleton")) + .toBeInTheDocument(); + resolveExternalThreads(externalThreadsResult); + await expect .element(palette.getByText("Finish parser migration", { exact: true })) .toBeInTheDocument(); + await expect + .element(palette.getByTestId("command-palette-results-skeleton")) + .not.toBeInTheDocument(); await expect .element(palette.getByText("Codex CLI · Finish the parser migration and verify its tests")) .toBeInTheDocument(); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0ec109393..8e7063c91 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2033,6 +2033,9 @@ function OpenCommandPaletteDialog() { remoteProjectInputPlaceholder(addProjectCloneFlow) ?? currentView?.inputPlaceholder ?? getCommandPaletteInputPlaceholder(paletteMode); + const commandResultsLoading = + (codexSessionFlow !== null && externalSessionsQuery.isPending) || + (isGitHubRepositorySelectionStep && gitHubRepositoriesQuery.isPending); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const hasHighlightedRemoteRepositoryItem = @@ -2357,6 +2360,7 @@ function OpenCommandPaletteDialog() { groups={displayedGroups} highlightedItemValue={highlightedItemValue} isActionsOnly={isActionsOnly} + isLoading={commandResultsLoading} keybindings={keybindings} query={deferredQuery} onExecuteItem={executeItem} diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 71c8f95f1..aada8a29c 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -16,6 +16,7 @@ import { CommandList, CommandShortcut, } from "./ui/command"; +import { Skeleton } from "./ui/skeleton"; import { cn } from "~/lib/utils"; interface CommandPaletteResultsProps { @@ -23,13 +24,25 @@ interface CommandPaletteResultsProps { groups: ReadonlyArray; highlightedItemValue?: string | null; isActionsOnly: boolean; + isLoading?: boolean | undefined; keybindings: ResolvedKeybindingsConfig; query: string; onExecuteItem: (item: CommandPaletteActionItem | CommandPaletteSubmenuItem) => void; } +const COMMAND_PALETTE_LOADING_ROWS = [ + { title: "w-36", detail: "w-56" }, + { title: "w-44", detail: "w-64" }, + { title: "w-32", detail: "w-48" }, + { title: "w-40", detail: "w-52" }, +] as const; + export function CommandPaletteResults(props: CommandPaletteResultsProps) { if (props.groups.length === 0) { + if (props.isLoading) { + return ; + } + return (
{props.emptyStateMessage ?? @@ -67,6 +80,34 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { ); } +function CommandPaletteResultsSkeleton() { + return ( +
+ +
+ {COMMAND_PALETTE_LOADING_ROWS.map((row) => ( +
+ + + + + + +
+ ))} +
+
+ ); +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; query: string; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b900f56d7..2b6864bc8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -69,6 +69,7 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { Kbd } from "./ui/kbd"; +import { Skeleton } from "./ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -149,6 +150,51 @@ const DONE_REVEAL_STEP = 20; // The queued-turn grace window is measured in minutes, so a coarse clock is // enough to keep "can this be marked done" honest without re-rendering often. const INBOX_CLOCK_INTERVAL_MS = 30_000; +const SIDEBAR_LOADING_LIVE_ROW_WIDTHS = ["w-36", "w-28", "w-40"] as const; +const SIDEBAR_LOADING_WRAPPED_ROW_WIDTHS = ["w-32", "w-24"] as const; + +function SidebarInboxLoadingSkeleton() { + return ( +
+ {/* These are representative row shapes, not a prediction of the user's + counts or activity. No status dot is drawn because that would make + neutral loading rows look like work is already running. */} +
+ {SIDEBAR_LOADING_LIVE_ROW_WIDTHS.map((width) => ( +
+
+ + + +
+
+ +
+
+ ))} +
+
+ + + +
+
+ {SIDEBAR_LOADING_WRAPPED_ROW_WIDTHS.map((width) => ( +
+ + + +
+ ))} +
+
+ ); +} interface InboxEntry { thread: SidebarThreadSummary; @@ -1475,25 +1521,25 @@ export default function Sidebar() { /> {liveEntries.length === 0 && visibleDraftSessionCount === 0 ? ( -
- - {!bootstrapComplete - ? "Loading projects" - : hasWorkspaceProjects - ? "No threads yet" - : "No projects yet"} - - {hasWorkspaceProjects || !bootstrapComplete ? null : ( - - )} -
+ !bootstrapComplete ? ( + + ) : ( +
+ + {hasWorkspaceProjects ? "No threads yet" : "No projects yet"} + + {hasWorkspaceProjects ? null : ( + + )} +
+ ) ) : (
    {visibleLiveEntries.map((entry) => ( diff --git a/apps/web/src/components/file-viewer/FileViewerOverlay.tsx b/apps/web/src/components/file-viewer/FileViewerOverlay.tsx index 0338541b9..3f2148ac7 100644 --- a/apps/web/src/components/file-viewer/FileViewerOverlay.tsx +++ b/apps/web/src/components/file-viewer/FileViewerOverlay.tsx @@ -98,6 +98,7 @@ import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { Group } from "../ui/group"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; import { ScrollArea } from "../ui/scroll-area"; +import { Skeleton } from "../ui/skeleton"; import { Toggle } from "../ui/toggle"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -320,6 +321,69 @@ const TreeRow = memo(function TreeRow({ }); const FILE_VIEWER_SEARCH_LIMIT = 60; +const FILE_TREE_SKELETON_ROWS = [ + { id: "root-source", depth: 0, width: "w-28" }, + { id: "source-component", depth: 1, width: "w-36" }, + { id: "source-lib", depth: 1, width: "w-24" }, + { id: "root-tests", depth: 0, width: "w-32" }, + { id: "tests-browser", depth: 1, width: "w-40" }, + { id: "browser-first", depth: 2, width: "w-28" }, + { id: "browser-second", depth: 2, width: "w-44" }, + { id: "root-config", depth: 0, width: "w-24" }, +] as const; +const FILE_CODE_SKELETON_ROWS = [ + { id: "line-1", width: "w-8/12" }, + { id: "line-2", width: "w-11/12" }, + { id: "line-3", width: "w-7/12" }, + { id: "line-4", width: "w-10/12" }, + { id: "line-5", width: "w-9/12" }, + { id: "line-6", width: "w-6/12" }, + { id: "line-7", width: "w-11/12" }, + { id: "line-8", width: "w-8/12" }, +] as const; + +function FileTreeSkeleton() { + return ( +
    + {FILE_TREE_SKELETON_ROWS.map((row) => ( +
    + + + +
    + ))} +
    + ); +} + +function FileCodeSkeleton({ label }: { label: string }) { + return ( +
    +
    + {FILE_CODE_SKELETON_ROWS.map((row) => ( +
    + + +
    + ))} +
    +
    + ); +} function FileViewerTree({ context, @@ -533,7 +597,7 @@ function FileViewerTree({ )) ) ) : entriesQuery.isPending ? ( -

    Loading project files...

    + ) : entriesQuery.isError ? (

    Unable to list project files. @@ -926,8 +990,8 @@ function FileViewerPreview({ if (fileQuery.isPending) { return ( -

    - Loading {basenameOf(activePath)}... +
    +
    ); } @@ -1007,9 +1071,7 @@ function FileViewerPreview({ wordWrap={wordWrap} /> ) : !highlighterReady ? ( -
    - Loading {basenameOf(activePath)}... -
    + ) : ( void; }; +function ConnectedDevicesSkeleton({ presentation }: { presentation: AccessSectionPresentation }) { + return ( +
    + +
    + ); +} + const PairingClientsList = memo(function PairingClientsList({ endpointUrl, endpoints, @@ -1290,11 +1315,15 @@ const PairingClientsList = memo(function PairingClientsList({ {/* An empty section with no copy at all reads as "nothing is paired", which is a lie while the access snapshot is still in flight. */} {pairingLinks.length === 0 && clientSessions.length === 0 ? ( -
    -

    - {isLoading ? "Loading devices..." : "No phones or tablets are connected yet."} -

    -
    + isLoading ? ( + + ) : ( +
    +

    + No phones or tablets are connected yet. +

    +
    + ) ) : null} ); diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 41a4b88af..c5c7c62b6 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -35,6 +35,7 @@ import { useSlowRpcAckRequests } from "../../rpc/requestLatencyState"; import { Button } from "../ui/button"; import { InfoPopover } from "../ui/info-popover"; import { ScrollArea } from "../ui/scroll-area"; +import { Skeleton } from "../ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; @@ -208,6 +209,60 @@ function EmptyRows({ label }: { label: string }) { return
    {label}
    ; } +function StatsGridSkeleton({ count = 4 }: { count?: number }) { + return ( + + {["first", "second", "third", "fourth", "fifth"].slice(0, count).map((key) => ( + + ))} + + ); +} + +const DIAGNOSTICS_SKELETON_ROW_WIDTHS = [ + { id: "first", widths: ["w-8/12", "w-6/12", "w-9/12"] }, + { id: "second", widths: ["w-10/12", "w-7/12", "w-5/12"] }, +] as const; + +function DiagnosticsSectionSkeleton() { + return ( + + ); +} + +function DiagnosticsChartSkeleton() { + return ( + + ); +} + function ExpandableText({ text, className, @@ -1032,6 +1087,7 @@ export function DiagnosticsSettingsPanel() { const slowSpansByName = data?.slowSpansByName ?? []; const slowTraces = data?.slowTraces ?? []; const isProcessInitialLoading = isProcessPending && processData === null; + const isResourceInitialLoading = isResourcePending && resourceData === null; const signalProcess = useCallback( (pid: number, signal: ServerProcessSignal) => { if ( @@ -1090,6 +1146,11 @@ export function DiagnosticsSettingsPanel() { return ( + {isProcessInitialLoading || isResourceInitialLoading || isInitialLoading ? ( + + Loading diagnostics + + ) : null} } > - - - - - - + {isProcessInitialLoading ? ( + + ) : ( + + + + + + + )} {processDiagnosticsError || processError ? (
    {processDiagnosticsError ? ( @@ -1139,16 +1204,16 @@ export function DiagnosticsSettingsPanel() { ) : null}
    ) : null} - + {isProcessInitialLoading ? ( + + ) : ( + + )}
    } > - - - - - - + {isResourceInitialLoading ? ( + + ) : ( + + + + + + + )} {processResourceError || resourceError ? (
    {processResourceError ? ( @@ -1204,15 +1273,20 @@ export function DiagnosticsSettingsPanel() { ) : null}
    ) : null} - - + {isResourceInitialLoading ? ( + <> + + + + ) : ( + <> + + + + )}
    @@ -1247,34 +1321,38 @@ export function DiagnosticsSettingsPanel() {
    } > - - - 0 ? "danger" : "default"} - /> - 0 ? "warning" : "default"} - /> - - 0 ? "warning" : "default"} - /> - + {isInitialLoading ? ( + + ) : ( + + + 0 ? "danger" : "default"} + /> + 0 ? "warning" : "default"} + /> + + 0 ? "warning" : "default"} + /> + + )} {openLogsDirectoryError || traceDiagnosticsError || error ? (
    {openLogsDirectoryError ? ( @@ -1330,6 +1408,8 @@ export function DiagnosticsSettingsPanel() { ))} + ) : isInitialLoading ? ( + ) : ( )} @@ -1358,6 +1438,8 @@ export function DiagnosticsSettingsPanel() { ))} + ) : isInitialLoading ? ( + ) : ( ))} + ) : isInitialLoading ? ( + ) : ( ))} + ) : isInitialLoading ? ( + ) : ( ))} + ) : isInitialLoading ? ( + ) : ( )} @@ -1492,6 +1580,8 @@ export function DiagnosticsSettingsPanel() { ))} + ) : isInitialLoading ? ( + ) : ( + ) : isInitialLoading ? ( + ) : ( ))} + ) : isInitialLoading ? ( + ) : ( )} diff --git a/apps/web/src/components/settings/ExtensionsSettings.tsx b/apps/web/src/components/settings/ExtensionsSettings.tsx index 5caee6ef7..d2c3dfd02 100644 --- a/apps/web/src/components/settings/ExtensionsSettings.tsx +++ b/apps/web/src/components/settings/ExtensionsSettings.tsx @@ -100,6 +100,7 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { Skeleton } from "../ui/skeleton"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; @@ -2907,6 +2908,83 @@ function InstalledStrip({ ); } +function InstalledStripSkeleton() { + return ( + + ); +} + +function ProviderInventorySkeleton() { + return ( + + ); +} + +function ConnectionsTableSkeleton() { + return ( + + ); +} + interface ExtensionAttentionEntry { readonly key: string; readonly title: string; @@ -4303,6 +4381,7 @@ export function ExtensionsSettingsPanel() { }, [manualProviderThreadId, refresh]); const hasInventory = inventory !== null; + const isInitialInventoryLoading = cwd.trim().length > 0 && !hasInventory && error === null; const selectedItemActionKey = selectedItem ? extensionItemActionKey(selectedItem) : null; const selectedItemLastAction = selectedItemActionKey ? actionHistoryByItem[selectedItemActionKey] @@ -4440,6 +4519,11 @@ export function ExtensionsSettingsPanel() { return ( + {isInitialInventoryLoading ? ( + + Loading plugins and connections + + ) : null} } @@ -4539,11 +4623,15 @@ export function ExtensionsSettingsPanel() { Browse catalog
    - + {isInitialInventoryLoading ? ( + + ) : ( + + )}
@@ -4560,7 +4648,9 @@ export function ExtensionsSettingsPanel() { ) : null } > - {inventory?.providers.length ? ( + {isInitialInventoryLoading ? ( + + ) : inventory?.providers.length ? ( inventory.providers.map((provider) => (
- + {isInitialInventoryLoading ? ( + + ) : ( + + )}
diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 22fad5891..4c1b30652 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1495,7 +1495,7 @@ describe("GeneralSettingsPanel observability", () => { // An audit read the pre-snapshot section as "nothing paired" because it was // rendered completely empty, and revoke stayed disabled with it. - it("says devices are loading until the access snapshot lands", async () => { + it("shows a device-row skeleton until the access snapshot lands", async () => { window.desktopBridge = createDesktopBridgeStub({ serverExposureState: { mode: "network-accessible", @@ -1546,7 +1546,7 @@ describe("GeneralSettingsPanel observability", () => { , ); - await expect.element(page.getByText("Loading devices...")).toBeInTheDocument(); + await expect.element(page.getByTestId("connected-devices-skeleton")).toBeInTheDocument(); await expect .element(page.getByText("No phones or tablets are connected yet.")) .not.toBeInTheDocument(); @@ -1557,7 +1557,7 @@ describe("GeneralSettingsPanel observability", () => { authAccessHarness.emitSnapshot(); await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument(); - await expect.element(page.getByText("Loading devices...")).not.toBeInTheDocument(); + await expect.element(page.getByTestId("connected-devices-skeleton")).not.toBeInTheDocument(); await expect .element(page.getByRole("button", { name: "Remove other devices", exact: true })) .not.toBeDisabled(); @@ -1728,6 +1728,35 @@ describe("GeneralSettingsPanel observability", () => { expect(openInEditor).toHaveBeenCalledWith("/repo/project/.threadlines/logs", "cursor"); }); + it("uses native-layout skeletons while diagnostics are initially loading", async () => { + const pendingDiagnostics = new Promise(() => {}); + window.nativeApi = { + persistence: { + getClientSettings: vi.fn().mockResolvedValue(null), + setClientSettings: vi.fn().mockResolvedValue(undefined), + }, + server: { + getProcessDiagnostics: vi.fn().mockReturnValue(pendingDiagnostics), + getProcessResourceHistory: vi.fn().mockReturnValue(pendingDiagnostics), + getTraceDiagnostics: vi.fn().mockReturnValue(pendingDiagnostics), + }, + } as unknown as LocalApi; + + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await renderWithTestRouter( + + + , + ); + + await expect + .element(page.getByText("Loading diagnostics", { exact: true })) + .toBeInTheDocument(); + expect(await page.getByTestId("diagnostics-loading-skeleton").all()).toHaveLength(10); + await expect.element(page.getByText("Loading live processes...")).not.toBeInTheDocument(); + }); + it("shows Claude configuration fields in provider settings", async () => { setServerConfigSnapshot(createBaseServerConfig()); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 55ff539da..325b6640c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -71,6 +71,7 @@ import { import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Skeleton } from "../ui/skeleton"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -1668,6 +1669,36 @@ function AutoArchiveCandidatePreview({ ); } +function ArchivedThreadsSkeleton() { + return ( +
+ +
+ ); +} + export function ArchivedThreadsPanel() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); @@ -2168,27 +2199,19 @@ export function ArchivedThreadsPanel() { {archivedGroups.length === 0 ? ( - - {isLoadingArchive ? ( - - ) : ( + {isLoadingArchive ? ( + + ) : ( + - )} - {isLoadingArchive - ? "Loading archived threads" - : archiveError - ? "Could not load archived threads" - : "No archived threads"} - - } - description={ - isLoadingArchive - ? "Checking connected environments." - : (archiveError ?? "Archived threads will appear here.") - } - /> + {archiveError ? "Could not load archived threads" : "No archived threads"} + + } + description={archiveError ?? "Archived threads will appear here."} + /> + )} ) : ( archivedGroups.map(({ project, threads: projectThreads }) => ( diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx index 28613647b..4b6d99e00 100644 --- a/apps/web/src/components/ui/skeleton.tsx +++ b/apps/web/src/components/ui/skeleton.tsx @@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
Promise children }); const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }); + const usageRoute = createRoute({ getParentRoute: () => rootRoute, path: "/usage" }); const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree: rootRoute.addChildren([indexRoute, usageRoute]), + history: createMemoryHistory({ initialEntries: [...(options?.initialEntries ?? ["/"])] }), }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - return render( + const rendered = render( , ); + return { ...rendered, router }; } -beforeEach(() => { +beforeEach(async () => { + await page.viewport(1280, 720); resetPrimaryEnvironmentDescriptorForTests(); resetSavedEnvironmentRegistryStoreForTests(); __resetEnvironmentApiOverridesForTests(); @@ -174,6 +180,97 @@ afterEach(() => { }); describe("UsageView", () => { + it("uses the page layout as a skeleton while usage is loading", async () => { + let finishSummary: (() => void) | undefined; + const summary = vi.fn( + (input: UsageSummaryInput) => + new Promise((resolve) => { + finishSummary = () => resolve(summaryFor(input, () => [])); + }), + ); + registerEnvironments(summary); + + renderWithProviders(); + + await expect.element(page.getByRole("status", { name: "Loading usage" })).toBeInTheDocument(); + const chartSkeleton = page.getByTestId("usage-chart-skeleton"); + await expect.element(chartSkeleton).toBeInTheDocument(); + const chartPlotSkeleton = page.getByTestId("usage-chart-plot-skeleton"); + const chartDataSkeleton = page.getByTestId("usage-chart-data-skeleton"); + expect(getComputedStyle(chartDataSkeleton.element()).clipPath).toContain("polygon"); + expect(chartPlotSkeleton.element().querySelectorAll('[data-slot="skeleton"]')).toHaveLength(1); + await expect.element(page.getByText("API-equivalent cost")).toBeInTheDocument(); + await expect.element(chartSkeleton.getByText("Claude Code")).toBeInTheDocument(); + await expect.element(chartSkeleton.getByText("Codex")).toBeInTheDocument(); + await expect.element(page.getByText("Reading provider transcripts…")).not.toBeInTheDocument(); + expect(document.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(10); + + finishSummary?.(); + + await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$0.00*"); + await expect + .element(page.getByRole("status", { name: "Loading usage" })) + .not.toBeInTheDocument(); + }); + + it("keeps usage stats in one row without overflow, then stacks them on phones", async () => { + await page.viewport(657, 800); + const summary = vi.fn(async (input: UsageSummaryInput) => + summaryFor(input, (days) => { + const day = days[days.length - 1] ?? input.untilDay; + return [ + bucket({ + day, + provider: "claude", + model: "claude-fable-5", + costUsd: 12, + totalTokens: 1_000_000, + }), + ]; + }), + ); + registerEnvironments(summary); + + renderWithProviders(); + + await expect.element(page.getByTestId("usage-total-cost")).toHaveTextContent("$12.00*"); + const cells = page.getByTestId("usage-stat").elements(); + expect(cells).toHaveLength(5); + const tops = cells.map((cell) => Math.round(cell.getBoundingClientRect().top)); + expect(new Set(tops).size).toBe(1); + const band = page.getByTestId("usage-stats-band").element(); + expect(band.scrollWidth).toBeLessThanOrEqual(band.clientWidth); + + await page.viewport(390, 800); + await vi.waitFor(() => { + const mobileTops = cells.map((cell) => Math.round(cell.getBoundingClientRect().top)); + expect(mobileTops.every((top, index) => index === 0 || top > mobileTops[index - 1]!)).toBe( + true, + ); + expect(band.scrollWidth).toBeLessThanOrEqual(band.clientWidth); + }); + }); + + it("shows a mobile back button that returns to the previous route", async () => { + await page.viewport(390, 800); + const summary = vi.fn( + async () => + new Promise(() => { + // Keep the route stable; this test is about navigation chrome. + }), + ); + registerEnvironments(summary); + + const mounted = renderWithProviders(, { initialEntries: ["/", "/usage"] }); + + const backButton = page.getByTestId("usage-mobile-back"); + await expect.element(backButton).toBeVisible(); + await backButton.click(); + await vi.waitFor(() => { + expect(mounted.router.state.location.pathname).toBe("/"); + }); + }); + it("renders the hero, the stat band, the priced models, and the silent machine", async () => { const summary = vi.fn(async (input: UsageSummaryInput) => summaryFor(input, (days) => { diff --git a/apps/web/src/components/usage/UsageView.tsx b/apps/web/src/components/usage/UsageView.tsx index 3d76da0bf..a7eab0be2 100644 --- a/apps/web/src/components/usage/UsageView.tsx +++ b/apps/web/src/components/usage/UsageView.tsx @@ -11,9 +11,11 @@ import { formatPercent, } from "@threadlines/shared/usageFormat"; import { useQuery } from "@tanstack/react-query"; -import { RotateCwIcon } from "lucide-react"; -import { useMemo, useState, type ReactNode } from "react"; +import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; +import { ArrowLeftIcon, RotateCwIcon } from "lucide-react"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { isElectron } from "../../env"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useRelativeTimeTick } from "../../hooks/useRelativeTimeTick"; import { cn } from "../../lib/utils"; @@ -25,6 +27,8 @@ import { import { formatRelativeTimeLabel } from "../../timestampFormat"; import { DesktopPageTitlebar } from "../DesktopPageTitlebar"; import { ClaudeAI, OpenAI, type Icon } from "../Icons"; +import { Button } from "../ui/button"; +import { Skeleton } from "../ui/skeleton"; import { buildUsageAreaChart, buildUsageDayRows, @@ -54,6 +58,15 @@ import { const SECTION_LABEL_CLASS = "font-mono text-[10px] uppercase tracking-wider text-muted-foreground/55 select-none"; const NUMBER_CLASS = "font-mono tabular-nums"; +const USAGE_STATS_BAND_CLASS = + "mt-8 grid w-full min-w-0 max-w-full grid-cols-1 divide-y divide-border/60 overflow-x-clip border-y border-border/60 sm:grid-cols-5 sm:divide-x sm:divide-y-0"; +const USAGE_STAT_CELL_CLASS = + "grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 gap-y-0.5 py-2.5 sm:flex sm:flex-col sm:items-stretch sm:gap-1 sm:px-2 sm:py-3.5 sm:first:pl-0 sm:last:pr-0 min-[1000px]:px-4"; +const USAGE_STAT_LABEL_CLASS = "min-w-0 whitespace-nowrap text-[9px] min-[1000px]:text-[10px]"; +const USAGE_STAT_VALUE_CLASS = + "shrink-0 text-base leading-tight text-foreground min-[1000px]:text-[20px]"; +const USAGE_STAT_CONTEXT_CLASS = + "col-span-2 min-w-0 [overflow-wrap:anywhere] text-[11px] leading-snug text-muted-foreground/55 sm:col-auto min-[1000px]:text-xs"; /** Scan freshness is a relative label, so it needs a slow clock of its own. */ const FRESHNESS_TICK_MS = 30_000; /** The window a first-time reader gets: a week is too short to see a pattern. */ @@ -80,6 +93,18 @@ export function UsageView() { const [windowDays, setWindowDays] = useState(DEFAULT_WINDOW_DAYS); const [chartMode, setChartMode] = useState("cost"); const [breakdown, setBreakdown] = useState("models"); + const navigate = useNavigate(); + const router = useRouter(); + const canGoBack = useCanGoBack(); + const handleBackClick = useCallback(() => { + if (canGoBack) { + // Through the router's history so memory/hash histories stay inside the + // current app document, matching Settings' back behavior. + router.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, navigate, router]); // One slow clock for the whole page: scan freshness is the only honest signal // about a warm scan, and it must not go stale on screen. useRelativeTimeTick(FRESHNESS_TICK_MS); @@ -123,6 +148,23 @@ export function UsageView() { return (
+ {!isElectron ? ( +
+
+ + Usage +
+
+ ) : null} {/* The pane-wide element scrolls so the scrollbar hugs the pane's edge (like Settings); the reading column centers inside it. */} @@ -138,6 +180,8 @@ export function UsageView() { > {formatUsageDateRange(view.window.sinceDay, view.window.untilDay)} + ) : targets.length > 0 && !usageQuery.isError ? ( + ) : null}
@@ -145,10 +189,10 @@ export function UsageView() {

No computers are connected, so there is nothing to report yet.

+ ) : usageQuery.isError ? ( +

Usage could not be read.

) : !merged || !view || !chart ? ( -

- {usageQuery.isError ? "Usage could not be read." : "Reading provider transcripts…"} -

+ ) : ( <>
@@ -191,49 +235,20 @@ export function UsageView() { mode={chartMode} onModeChange={setChartMode} windowControls={ -
-
- {USAGE_WINDOW_DAY_OPTIONS.map((option) => ( - - ))} -
- -
+ void usageQuery.refetch()} + /> } />
- {/* A gapped grid until all five cells truly fit on one line, then - the single divided row. Wrapping divide-x cells collide, and - the sidebar eats ~260px of any viewport breakpoint, so the - one-row layout waits for a comfortably wide window. */} -
+ {/* Phones use compact label/value rows so none of the context is + lost. From tablet widths upward, all five stats stay in one + divided row; type and padding tighten until the pane widens. */} +
{stats.map((stat) => ( ))} @@ -386,6 +401,307 @@ export function UsageView() { ); } +function UsageWindowControls({ + windowDays, + isFetching = false, + disabled = false, + onWindowDaysChange, + onRefresh, +}: { + readonly windowDays: UsageWindowDays; + readonly isFetching?: boolean; + readonly disabled?: boolean; + readonly onWindowDaysChange?: (windowDays: UsageWindowDays) => void; + readonly onRefresh?: () => void; +}) { + return ( +
+
+ {USAGE_WINDOW_DAY_OPTIONS.map((option) => ( + + ))} +
+ +
+ ); +} + +function UsageLoadingSkeleton({ + chartMode, + windowDays, +}: { + readonly chartMode: UsageChartMode; + readonly windowDays: UsageWindowDays; +}) { + return ( + <> +
+
+ {USAGE_HERO_LABELS[chartMode]} + + {chartMode === "cost" ? : null} +
+ {USAGE_PROVIDER_READING_ORDER.map((provider) => { + const ProviderIcon = USAGE_PROVIDER_ICONS[provider]; + return ( +
+
+ + + {USAGE_PROVIDER_LABELS[provider]} + + +
+ + +
+ ); + })} +
+
+ + +
+ +
+ {USAGE_LOADING_STATS.map((stat) => ( +
+ {stat.label} + + {stat.hasContext ? ( + + ) : null} +
+ ))} +
+ +
+
+

+ Models · sessions +

+
+
+ {USAGE_BREAKDOWNS.map((option, index) => ( + + {index > 0 ? | : null} + + + ))} +
+
+
+ {USAGE_PROVIDER_READING_ORDER.map((provider, index) => { + const ProviderIcon = USAGE_PROVIDER_ICONS[provider]; + return ( +
+ + + + + + + +
+ ); + })} +
+
+ +
+

Machines

+
+
+ + + + + {USAGE_PROVIDER_READING_ORDER.map((provider) => ( + + + {USAGE_PROVIDER_SHORT_LABELS[provider]} + + + + + ))} +
+
+
+ + + + ); +} + +function UsageChartSkeleton({ + chartMode, + windowDays, +}: { + readonly chartMode: UsageChartMode; + readonly windowDays: UsageWindowDays; +}) { + return ( +
+
+ +
+ + +
+ {[0, 1, 2, 3].map((index) => ( +
+ ))} + {/* One clipped data silhouette reads as a loading chart rather than a + rectangular panel layered over the plot. The grid remains visible + through it, matching the final chart's area-fill relationship. */} + +
+
+ + window start + + + midpoint + + + today + +
+
+ ); +} + +const USAGE_LOADING_STATS: readonly { + readonly label: UsageStat["label"]; + readonly hasContext: boolean; +}[] = [ + { label: "Processed tokens", hasContext: true }, + { label: "Cached input", hasContext: true }, + { label: "Uncached input", hasContext: true }, + { label: "Output", hasContext: true }, + { label: "Cache savings", hasContext: true }, +]; + +function UsageChartHeader({ + mode, + disabled = false, + onModeChange, +}: { + readonly mode: UsageChartMode; + readonly disabled?: boolean; + readonly onModeChange?: (mode: UsageChartMode) => void; +}) { + return ( +
+

{USAGE_CHART_TITLES[mode]}

+
+
+ {USAGE_CHART_MODES.map((option, index) => ( + + {index > 0 ? | : null} + + + ))} +
+
+ {USAGE_PROVIDER_READING_ORDER.map((provider) => { + const ProviderIcon = USAGE_PROVIDER_ICONS[provider]; + return ( + + + {USAGE_PROVIDER_LABELS[provider]} + + ); + })} +
+
+ ); +} + /** * Provider name, its slice of the selected measure, and how wide that slice is. * The row follows the page's cost|tokens mode: the leading figure and the bar @@ -477,48 +793,7 @@ function UsageChart({ {/* The window picker lives with the graph it narrows, right above the cost|tokens toggle rather than stranded in the page header. */} {windowControls ?
{windowControls}
: null} -
-

{USAGE_CHART_TITLES[mode]}

-
-
- {USAGE_CHART_MODES.map((option, index) => ( - - {index > 0 ? | : null} - - - ))} -
-
- {USAGE_PROVIDER_READING_ORDER.map((provider) => { - const ProviderIcon = USAGE_PROVIDER_ICONS[provider]; - return ( - - {/* The glyphs' built-in brand fills already match the series - hues, so they replace the swatch dots without a legend key - being lost. */} - - {USAGE_PROVIDER_LABELS[provider]} - - ); - })} -
-
+
{chart.gridlines.map((gridline) => ( @@ -684,19 +959,10 @@ function UsageChart({ function UsageStatCell({ stat }: { readonly stat: UsageStat }) { return ( -
- {stat.label} - - {stat.value} - - {stat.context ? ( - // Wraps on a phone, truncates on desktop where the band is one row and - // a two-line cell would stagger its neighbours. - {stat.context} - ) : null} +
+ {stat.label} + {stat.value} + {stat.context ? {stat.context} : null}
); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 8b7469393..e60e06eec 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1127,7 +1127,7 @@ describe("incremental orchestration updates", () => { ); }); - it("applies replay batches in sequence and updates session state", () => { + it("keeps legacy assistant completion events non-terminal while their turn is active", () => { const thread = makeThread({ latestTurn: { turnId: TurnId.make("turn-1"), @@ -1180,8 +1180,35 @@ describe("incremental orchestration updates", () => { ); expect(threadsOf(next)[0]?.session?.status).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); + expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ + turnId: TurnId.make("turn-1"), + state: "running", + completedAt: null, + assistantMessageId: MessageId.make("assistant-1"), + }); expect(threadsOf(next)[0]?.messages).toHaveLength(1); + + const completed = applyOrchestrationEvent( + next, + makeEvent("thread.message-sent", { + threadId: thread.id, + messageId: MessageId.make("assistant-1"), + role: "assistant", + text: "done", + turnId: TurnId.make("turn-1"), + streaming: false, + completesTurn: true, + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:04.000Z", + }), + localEnvironmentId, + ); + + expect(threadsOf(completed)[0]?.latestTurn).toMatchObject({ + turnId: TurnId.make("turn-1"), + state: "completed", + completedAt: "2026-02-27T00:00:04.000Z", + }); }); it("does not regress latestTurn when an older turn diff completes late", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 489e35c1a..537b8c99d 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1035,6 +1035,32 @@ function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error" return "completed" as const; } +function turnDiffEventCompletesTurn( + thread: Pick, + payload: { readonly turnId: string; readonly completesTurn?: boolean | undefined }, +): boolean { + if (payload.completesTurn !== undefined) { + return payload.completesTurn; + } + const activeTurnId = thread.session?.activeTurnId ?? null; + return activeTurnId === null || activeTurnId !== payload.turnId; +} + +function assistantMessageEventCompletesTurn(input: { + readonly streaming: boolean; + readonly completesTurn?: boolean | undefined; + readonly activeTurnId: string | null; + readonly eventTurnId: string; +}): boolean { + if (input.streaming) { + return false; + } + if (input.completesTurn !== undefined) { + return input.completesTurn; + } + return input.activeTurnId === null || input.activeTurnId !== input.eventTurnId; +} + function compareActivities( left: Thread["activities"][number], right: Thread["activities"][number], @@ -1758,6 +1784,12 @@ function applyEnvironmentOrchestrationEvent( event.payload.messageId, ) : thread.turnDiffSummaries; + const completesTurn = assistantMessageEventCompletesTurn({ + streaming: event.payload.streaming, + completesTurn: event.payload.completesTurn, + activeTurnId: thread.session?.activeTurnId ?? null, + eventTurnId: event.payload.turnId ?? "", + }); const latestTurn: Thread["latestTurn"] = event.payload.role === "assistant" && event.payload.turnId !== null && @@ -1765,8 +1797,8 @@ function applyEnvironmentOrchestrationEvent( ? buildLatestTurn({ previous: thread.latestTurn, turnId: event.payload.turnId, - state: event.payload.streaming - ? "running" + state: !completesTurn + ? (thread.latestTurn?.state ?? "running") : thread.latestTurn?.state === "interrupted" ? "interrupted" : thread.latestTurn?.state === "error" @@ -1781,7 +1813,7 @@ function applyEnvironmentOrchestrationEvent( ? (thread.latestTurn.startedAt ?? event.payload.createdAt) : event.payload.createdAt, sourceProposedPlan: thread.pendingSourceProposedPlan, - completedAt: event.payload.streaming + completedAt: !completesTurn ? thread.latestTurn?.turnId === event.payload.turnId ? (thread.latestTurn.completedAt ?? null) : null @@ -1903,8 +1935,10 @@ function applyEnvironmentOrchestrationEvent( (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), ) .slice(-MAX_THREAD_CHECKPOINTS); + const completesTurn = turnDiffEventCompletesTurn(thread, event.payload); const latestTurn = - thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId + completesTurn && + (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? buildLatestTurn({ previous: thread.latestTurn, turnId: event.payload.turnId, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 8945c2532..df2540f45 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1381,6 +1381,9 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ threadId: ThreadId, messageId: MessageId, turnId: Schema.optional(TurnId), + /** True only when the authoritative provider turn (or imported historical + * turn) has settled. Completing one live assistant segment is non-terminal. */ + completesTurn: Schema.Boolean, createdAt: IsoDateTime, }); @@ -1403,6 +1406,14 @@ const ThreadTurnDiffCompleteCommand = Schema.Struct({ files: Schema.Array(OrchestrationCheckpointFile), assistantMessageId: Schema.optional(MessageId), checkpointTurnCount: NonNegativeInt, + /** + * False for provider-emitted mid-turn checkpoint updates. Those events + * refresh changed-file metadata, but the provider turn is still running. + * + * Persisted events make this optional below for backwards-compatible replay, + * but every newly decided command must state the lifecycle intent. + */ + completesTurn: Schema.Boolean, createdAt: IsoDateTime, }); @@ -1649,6 +1660,9 @@ export const ThreadMessageSentPayload = Schema.Struct({ skills: Schema.optional(ChatSkillReferenceList), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + /** Missing means legacy behavior for events written before assistant + * segments carried an explicit turn-settlement decision. */ + completesTurn: Schema.optional(Schema.Boolean), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -1794,6 +1808,7 @@ export const ThreadTurnDiffCompletedPayload = Schema.Struct({ files: Schema.Array(OrchestrationCheckpointFile), assistantMessageId: Schema.NullOr(MessageId), completedAt: IsoDateTime, + completesTurn: Schema.optional(Schema.Boolean), }); export const ThreadTurnDiffSummaryUpdatedPayload = Schema.Struct({ From 746c076262fdbcb0da7bd72eba1c9e59f36b3db2 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:14:22 -0400 Subject: [PATCH 2/3] Fix plugin inventory result narrowing --- apps/server/src/provider/providerExtensions.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/providerExtensions.ts b/apps/server/src/provider/providerExtensions.ts index 5b3a474f7..d1454d6f4 100644 --- a/apps/server/src/provider/providerExtensions.ts +++ b/apps/server/src/provider/providerExtensions.ts @@ -1473,14 +1473,15 @@ const readCodexAppServerInventory = Effect.fn("providerExtensions.readCodexAppSe { concurrency: 2 }, ).pipe( Effect.map(([primaryResult, localResult]) => { - const response = Result.isSuccess(primaryResult) - ? Result.isSuccess(localResult) + let response: CodexSchema.V2PluginListResponse; + if (Result.isFailure(primaryResult)) { + if (Result.isFailure(localResult)) return Result.fail(primaryResult.failure); + response = localResult.success; + } else { + response = Result.isSuccess(localResult) ? mergeCodexPluginCatalogResponses(primaryResult.success, localResult.success) - : primaryResult.success - : Result.isSuccess(localResult) - ? localResult.success - : undefined; - if (!response) return Result.fail(primaryResult.failure); + : primaryResult.success; + } return Result.succeed({ ...mapCodexPluginInventory(response), loadErrorMessage: codexMarketplaceLoadErrorMessage(response), From 11191b4b2fac3b137d01dab89a20fd5d5f6d8209 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:03:08 -0400 Subject: [PATCH 3/3] Fix checkpoint projection tests --- .../Layers/CheckpointReactor.test.ts | 16 ++---- .../Layers/ProjectionSnapshotQuery.test.ts | 52 ++++++++++++------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 314b32eb2..0162731b2 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -867,7 +867,6 @@ describe("CheckpointReactor", () => { const thread = await waitForThread( harness.readModel, (entry) => - entry.latestTurn?.turnId === "turn-shared-provider-summary" && entry.checkpoints.length === 1 && entry.checkpoints[0]?.status === "ready" && entry.activities.some( @@ -955,10 +954,7 @@ describe("CheckpointReactor", () => { await waitForThread( harness.readModel, - (entry) => - entry.latestTurn?.turnId === "turn-shared-refresh-same-path-summary" && - entry.checkpoints.length === 1 && - entry.checkpoints[0]?.status === "ready", + (entry) => entry.checkpoints.length === 1 && entry.checkpoints[0]?.status === "ready", ); fs.writeFileSync(path.join(harness.cwd, "README.md"), "final\nmore\n", "utf8"); @@ -1031,10 +1027,7 @@ describe("CheckpointReactor", () => { await waitForThread( harness.readModel, - (entry) => - entry.latestTurn?.turnId === "turn-empty-provider-summary" && - entry.checkpoints.length === 1 && - entry.checkpoints[0]?.status === "ready", + (entry) => entry.checkpoints.length === 1 && entry.checkpoints[0]?.status === "ready", ); harness.provider.emit({ @@ -1246,10 +1239,7 @@ describe("CheckpointReactor", () => { await waitForThread( harness.readModel, - (entry) => - entry.latestTurn?.turnId === "turn-refresh-summary" && - entry.checkpoints.length === 1 && - entry.checkpoints[0]?.status === "ready", + (entry) => entry.checkpoints.length === 1 && entry.checkpoints[0]?.status === "ready", ); fs.writeFileSync(path.join(harness.cwd, "README.md"), "final\n", "utf8"); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d5224f915..d30be647f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -277,7 +277,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { checkpoint_turn_count, checkpoint_ref, checkpoint_status, - checkpoint_files_json + checkpoint_files_json, + checkpoint_completed_at ) VALUES ( 'thread-1', @@ -293,7 +294,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 'checkpoint-1', 'ready', - '[{"path":"README.md","kind":"modified","additions":2,"deletions":1}]' + '[{"path":"README.md","kind":"modified","additions":2,"deletions":1}]', + '2026-02-24T00:00:08.000Z' ) `; @@ -864,7 +866,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { checkpoint_turn_count, checkpoint_ref, checkpoint_status, - checkpoint_files_json + checkpoint_files_json, + checkpoint_completed_at ) VALUES ( @@ -881,7 +884,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 'checkpoint-a', 'ready', - '[]' + '[]', + '2026-03-02T00:00:04.000Z' ), ( 'thread-context', @@ -897,7 +901,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 2, 'checkpoint-b', 'ready', - '[]' + '[]', + '2026-03-02T00:00:05.000Z' ) `; @@ -1714,7 +1719,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { checkpoint_turn_count, checkpoint_ref, checkpoint_status, - checkpoint_files_json + checkpoint_files_json, + checkpoint_completed_at ) VALUES ( @@ -1731,7 +1737,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 5, 'checkpoint-5', 'ready', - '[]' + '[]', + '2026-04-02T00:00:20.000Z' ), ( 'thread-1', @@ -1747,7 +1754,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, NULL, - '[]' + '[]', + NULL ) `; @@ -2162,7 +2170,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { checkpoint_turn_count, checkpoint_ref, checkpoint_status, - checkpoint_files_json + checkpoint_files_json, + checkpoint_completed_at ) VALUES ( @@ -2179,7 +2188,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 'checkpoint-1', 'ready', - '[{"path":"a.ts","kind":"modified","additions":10,"deletions":2},{"path":"b.ts","kind":"added","additions":5,"deletions":0}]' + '[{"path":"a.ts","kind":"modified","additions":10,"deletions":2},{"path":"b.ts","kind":"added","additions":5,"deletions":0}]', + '2026-07-01T00:00:11.000Z' ), ( 'thread-busy', @@ -2195,7 +2205,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 2, 'checkpoint-2', 'ready', - '[{"path":"a.ts","kind":"modified","additions":1,"deletions":7}]' + '[{"path":"a.ts","kind":"modified","additions":1,"deletions":7}]', + '2026-07-01T00:00:21.000Z' ), ( 'thread-busy', @@ -2211,7 +2222,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 3, 'provider-diff:event-3', 'missing', - '[{"path":"c.ts","kind":"modified","additions":4,"deletions":3}]' + '[{"path":"c.ts","kind":"modified","additions":4,"deletions":3}]', + '2026-07-01T00:00:31.000Z' ), ( 'thread-busy', @@ -2227,7 +2239,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 4, 'checkpoint-4', 'ready', - '[]' + '[]', + '2026-07-01T00:00:41.000Z' ), ( 'thread-quiet', @@ -2243,7 +2256,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 1, 'checkpoint-5', 'ready', - '[]' + '[]', + '2026-07-01T00:00:51.000Z' ) `; @@ -2408,23 +2422,25 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { thread_id, turn_id, pending_message_id, source_proposed_plan_thread_id, source_proposed_plan_id, assistant_message_id, state, requested_at, started_at, completed_at, checkpoint_turn_count, checkpoint_ref, - checkpoint_status, checkpoint_files_json + checkpoint_status, checkpoint_files_json, checkpoint_completed_at ) VALUES ( 'thread-root', 'turn-1', NULL, NULL, NULL, 'message-1', 'completed', '2026-07-01T00:00:10.000Z', '2026-07-01T00:00:10.000Z', - '2026-07-01T00:00:11.000Z', 1, 'checkpoint-1', 'ready', '[]' + '2026-07-01T00:00:11.000Z', 1, 'checkpoint-1', 'ready', '[]', + '2026-07-01T00:00:11.000Z' ), ( 'thread-root', 'turn-2', NULL, NULL, NULL, 'message-2', 'completed', '2026-07-01T00:00:20.000Z', '2026-07-01T00:00:20.000Z', - '2026-07-01T00:00:21.000Z', 2, 'checkpoint-2', 'ready', '[]' + '2026-07-01T00:00:21.000Z', 2, 'checkpoint-2', 'ready', '[]', + '2026-07-01T00:00:21.000Z' ), ( 'thread-worktree', 'turn-3', NULL, NULL, NULL, NULL, 'running', '2026-07-01T00:00:30.000Z', '2026-07-01T00:00:30.000Z', - NULL, NULL, NULL, NULL, '[]' + NULL, NULL, NULL, NULL, '[]', NULL ) `;