authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 03:16:14-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 00:16:14-07:00
loga8cd2ad2ebb32ad789e8e0ecd2fc713c2edc38f4
treee5409886e55b9316364096126ee4f7a9efdb4979
parent4a07c1ca9d9033ef35161a1ffbea44d0418ca2bf
signature Signed by PGP key B5690EEEBB952194

fix(web): plans stop hijacking the UI, fold into chat instead (#5558)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

30 files changed, 619 insertions(+), 768 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts-1
......@@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1313import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
1515const clientSettings: ClientSettings = {
16 autoOpenPlanSidebar: false,
1716 confirmThreadArchive: true,
1817 confirmThreadDelete: false,
1918 dismissedProviderUpdateNotificationKeys: [],
apps/server/integration/OrchestrationEngineHarness.integration.ts+5-1
......@@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati
5151import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
5252import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
5353import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
54import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts";
5455import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
5556import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
5657import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
......@@ -310,7 +311,10 @@ export const makeOrchestrationIntegrationHarness = (
310311 checkpointStoreLayer,
311312 providerLayer,
312313 RuntimeReceiptBusTest,
313 ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
314 ).pipe(
315 Layer.provideMerge(ThreadBackgroundLiveness.layer),
316 Layer.provideMerge(ThreadPlanProgress.layer),
317 );
314318 const serverSettingsLayer = ServerSettingsService.layerTest();
315319 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
316320 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3
......@@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4040import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4141import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
4242import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
43import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
4344import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
4445import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
4546import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
......@@ -296,6 +297,7 @@ describe("CheckpointReactor", () => {
296297 const orchestrationLayer = OrchestrationEngineLive.pipe(
297298 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
298299 Layer.provide(ThreadBackgroundLiveness.layer),
300 Layer.provide(ThreadPlanProgress.layer),
299301 Layer.provide(OrchestrationProjectionPipelineLive),
300302 Layer.provide(OrchestrationEventStoreLive),
301303 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -304,6 +306,7 @@ describe("CheckpointReactor", () => {
304306 );
305307 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
306308 Layer.provide(ThreadBackgroundLiveness.layer),
309 Layer.provide(ThreadPlanProgress.layer),
307310 Layer.provide(RepositoryIdentityResolver.layer),
308311 Layer.provide(SqlitePersistenceMemory),
309312 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5
......@@ -32,6 +32,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3232import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3333import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
3434import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
35import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
3536import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3637import {
3738 OrchestrationProjectionPipeline,
......@@ -57,6 +58,7 @@ async function createOrchestrationSystem() {
5758 OrchestrationProjectionSnapshotQueryLive,
5859 ).pipe(
5960 Layer.provide(ThreadBackgroundLiveness.layer),
61 Layer.provide(ThreadPlanProgress.layer),
6062 Layer.provide(OrchestrationEventStoreLive),
6163 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
6264 Layer.provide(RepositoryIdentityResolver.layer),
......@@ -820,6 +822,7 @@ describe("OrchestrationEngine", () => {
820822 OrchestrationEngineLive.pipe(
821823 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
822824 Layer.provide(ThreadBackgroundLiveness.layer),
825 Layer.provide(ThreadPlanProgress.layer),
823826 Layer.provide(OrchestrationProjectionPipelineLive),
824827 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
825828 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -926,6 +929,7 @@ describe("OrchestrationEngine", () => {
926929 OrchestrationEngineLive.pipe(
927930 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
928931 Layer.provide(ThreadBackgroundLiveness.layer),
932 Layer.provide(ThreadPlanProgress.layer),
929933 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
930934 Layer.provide(OrchestrationEventStoreLive),
931935 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -1070,6 +1074,7 @@ describe("OrchestrationEngine", () => {
10701074 OrchestrationEngineLive.pipe(
10711075 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
10721076 Layer.provide(ThreadBackgroundLiveness.layer),
1077 Layer.provide(ThreadPlanProgress.layer),
10731078 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
10741079 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
10751080 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2
......@@ -32,6 +32,7 @@ import {
3232} from "./ProjectionPipeline.ts";
3333import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
3434import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
35import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
3536import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3637import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
3738import { ServerConfig } from "../../config.ts";
......@@ -2673,6 +2674,7 @@ const engineLayer = it.layer(
26732674 OrchestrationEngineLive.pipe(
26742675 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
26752676 Layer.provide(ThreadBackgroundLiveness.layer),
2677 Layer.provide(ThreadPlanProgress.layer),
26762678 Layer.provide(OrchestrationProjectionPipelineLive),
26772679 Layer.provide(OrchestrationEventStoreLive),
26782680 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4
......@@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
1818import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
1919import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
2020import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
21import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
2122import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2223import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts";
2324
......@@ -30,6 +31,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
3031const projectionSnapshotLayer = it.layer(
3132 OrchestrationProjectionSnapshotQueryLive.pipe(
3233 Layer.provide(ThreadBackgroundLiveness.layer),
34 Layer.provide(ThreadPlanProgress.layer),
3335 Layer.provideMerge(RepositoryIdentityResolver.layer),
3436 Layer.provideMerge(SqlitePersistenceMemory),
3537 Layer.provideMerge(NodeServices.layer),
......@@ -447,6 +449,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
447449 hasPendingUserInput: false,
448450 hasActionableProposedPlan: false,
449451 backgroundLiveness: null,
452 planProgress: null,
450453 },
451454 ]);
452455
......@@ -1830,6 +1833,7 @@ it.effect(
18301833 const resolveCalls: string[] = [];
18311834 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
18321835 Layer.provide(ThreadBackgroundLiveness.layer),
1836 Layer.provide(ThreadPlanProgress.layer),
18331837 Layer.provideMerge(
18341838 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
18351839 resolve: (cwd: string) =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+5
......@@ -44,6 +44,7 @@ import {
4444} from "../../persistence/Errors.ts";
4545import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
4646import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
47import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
4748import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
4849import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
4950import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
......@@ -344,6 +345,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
344345
345346const makeProjectionSnapshotQuery = Effect.gen(function* () {
346347 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
348 const threadPlanProgress = yield* ThreadPlanProgressService;
347349 const sql = yield* SqlClient.SqlClient;
348350 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
349351 const repositoryIdentityResolutionConcurrency = 4;
......@@ -1908,6 +1910,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19081910 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
19091911 row.threadId,
19101912 ),
1913 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
19111914 } satisfies OrchestrationThreadShell)
19121915 : Result.failVoid,
19131916 ),
......@@ -2051,6 +2054,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20512054 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
20522055 row.threadId,
20532056 ),
2057 planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId),
20542058 }),
20552059 ),
20562060 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
......@@ -2326,6 +2330,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
23262330 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
23272331 threadRow.value.threadId,
23282332 ),
2333 planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId),
23292334 } satisfies OrchestrationThreadShell);
23302335 });
23312336
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3
......@@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4949import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
5050import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
5151import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
52import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
5253import {
5354 providerErrorLabel,
5455 providerErrorLabelFromInstanceHint,
......@@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => {
347348 const orchestrationLayer = OrchestrationEngineLive.pipe(
348349 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
349350 Layer.provide(ThreadBackgroundLiveness.layer),
351 Layer.provide(ThreadPlanProgress.layer),
350352 Layer.provide(OrchestrationProjectionPipelineLive),
351353 Layer.provide(OrchestrationEventStoreLive),
352354 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => {
355357 );
356358 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
357359 Layer.provide(ThreadBackgroundLiveness.layer),
360 Layer.provide(ThreadPlanProgress.layer),
358361 Layer.provide(RepositoryIdentityResolver.layer),
359362 Layer.provide(SqlitePersistenceMemory),
360363 );
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2
......@@ -45,6 +45,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4545import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4646import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
4747import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
48import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
4849import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
4950import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
5051import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
......@@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => {
241242 // Single shared liveness instance across ingestion (writer), the
242243 // engine, and the snapshot query (reader).
243244 Layer.provideMerge(ThreadBackgroundLiveness.layer),
245 Layer.provideMerge(ThreadPlanProgress.layer),
244246 Layer.provideMerge(SqlitePersistenceMemory),
245247 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
246248 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+17
......@@ -35,6 +35,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio
3535import { isGitRepository } from "../../git/Utils.ts";
3636import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3737import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
38import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
3839import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
3940import {
4041 ProviderRuntimeIngestionService,
......@@ -867,6 +868,7 @@ export function runtimeEventToActivities(
867868
868869const make = Effect.gen(function* () {
869870 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
871 const threadPlanProgress = yield* ThreadPlanProgressService;
870872 const crypto = yield* Crypto.Crypto;
871873 const orchestrationEngine = yield* OrchestrationEngineService;
872874 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
......@@ -1935,6 +1937,21 @@ const make = Effect.gen(function* () {
19351937 yield* rememberTaskDescription(thread.id, event.payload.taskId, description);
19361938 }
19371939 }
1940 // Working-indicator plan progress: current step while the turn runs,
1941 // cleared on settle so a finished plan never lingers as stale UI.
1942 // Events carrying a turn id that conflicts with the active turn are
1943 // stale (superseded turn) and must neither overwrite nor clear the
1944 // active turn's progress; session.exited always clears.
1945 if (event.type === "session.exited") {
1946 threadPlanProgress.clearThreadPlanProgress(thread.id);
1947 } else if (!conflictsWithActiveTurn) {
1948 if (event.type === "turn.plan.updated") {
1949 threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan);
1950 } else if (event.type === "turn.completed" || event.type === "turn.aborted") {
1951 threadPlanProgress.clearThreadPlanProgress(thread.id);
1952 }
1953 }
1954
19381955 // Sidebar background liveness: fed from the same lifecycle stream,
19391956 // read by the shell query at mapping time (no persistence).
19401957 switch (event.type) {
apps/server/src/orchestration/ThreadPlanProgress.test.ts created+44
......@@ -0,0 +1,44 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
3
4describe("ThreadPlanProgress", () => {
5 it("tracks the in-progress step and clears when the plan completes", () => {
6 const progress = ThreadPlanProgress.make();
7 const threadId = "t-plan-1";
8 progress.recordPlanProgress(threadId, [
9 { step: "Audit failure paths", status: "completed" },
10 { step: "Implement the fix", status: "inProgress" },
11 { step: "Run targeted tests", status: "pending" },
12 ]);
13 expect(progress.getThreadPlanProgress(threadId)).toEqual({
14 step: "Implement the fix",
15 completedSteps: 1,
16 totalSteps: 3,
17 });
18
19 progress.recordPlanProgress(threadId, [
20 { step: "Audit failure paths", status: "completed" },
21 { step: "Implement the fix", status: "completed" },
22 { step: "Run targeted tests", status: "completed" },
23 ]);
24 expect(progress.getThreadPlanProgress(threadId)).toBeNull();
25 });
26
27 it("falls back to the first non-completed step when nothing is in progress", () => {
28 const progress = ThreadPlanProgress.make();
29 const threadId = "t-plan-2";
30 progress.recordPlanProgress(threadId, [
31 { step: "First", status: "pending" },
32 { step: "Second", status: "pending" },
33 ]);
34 expect(progress.getThreadPlanProgress(threadId)?.step).toBe("First");
35 });
36
37 it("clearThreadPlanProgress removes the entry (turn settled / session died)", () => {
38 const progress = ThreadPlanProgress.make();
39 const threadId = "t-plan-3";
40 progress.recordPlanProgress(threadId, [{ step: "Only step", status: "inProgress" }]);
41 progress.clearThreadPlanProgress(threadId);
42 expect(progress.getThreadPlanProgress(threadId)).toBeNull();
43 });
44});
apps/server/src/orchestration/ThreadPlanProgress.ts created+76
......@@ -0,0 +1,76 @@
1/**
2 * ThreadPlanProgressService - in-memory per-thread plan progress for the
3 * Working indicators (sidebar rows, in-chat working line).
4 *
5 * Plans are a progress annotation, not a surface of their own: the useful
6 * kernel of a turn.plan.updated event is "which step is the agent on right
7 * now". Ingestion records the current step here and the shell query reads it
8 * at mapping time — no persistence, no migration (same pattern as
9 * ThreadBackgroundLivenessService). Cleared when the turn settles or the
10 * session dies, so a finished plan never lingers as stale UI.
11 *
12 * @module ThreadPlanProgressService
13 */
14import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17
18export interface ThreadPlanProgress {
19 readonly step: string;
20 readonly completedSteps: number;
21 readonly totalSteps: number;
22}
23
24interface PlanStepInput {
25 readonly step: string;
26 readonly status: string;
27}
28
29export class ThreadPlanProgressService extends Context.Service<
30 ThreadPlanProgressService,
31 {
32 /**
33 * Feed one turn.plan.updated payload. An all-completed plan clears the
34 * entry (the turn is wrapping up; nothing is "in progress" anymore).
35 */
36 readonly recordPlanProgress: (threadId: string, plan: ReadonlyArray<PlanStepInput>) => void;
37
38 /** Turn settled or session died: the working indicator reverts to plain. */
39 readonly clearThreadPlanProgress: (threadId: string) => void;
40
41 readonly getThreadPlanProgress: (threadId: string) => ThreadPlanProgress | null;
42 }
43>()("t3/orchestration/ThreadPlanProgress/ThreadPlanProgressService") {}
44
45export function make(): ThreadPlanProgressService["Service"] {
46 const progressByThreadId = new Map<string, ThreadPlanProgress>();
47
48 return {
49 recordPlanProgress: (threadId, plan) => {
50 const totalSteps = plan.length;
51 const completedSteps = plan.filter((step) => step.status === "completed").length;
52 // Current step: the in-progress one, else the first pending one (a
53 // plan that was just written has no in-progress step yet).
54 const current =
55 plan.find((step) => step.status === "inProgress") ??
56 plan.find((step) => step.status !== "completed");
57 if (totalSteps === 0 || completedSteps === totalSteps || current === undefined) {
58 progressByThreadId.delete(threadId);
59 return;
60 }
61 progressByThreadId.set(threadId, {
62 step: current.step,
63 completedSteps,
64 totalSteps,
65 });
66 },
67
68 clearThreadPlanProgress: (threadId) => {
69 progressByThreadId.delete(threadId);
70 },
71
72 getThreadPlanProgress: (threadId) => progressByThreadId.get(threadId) ?? null,
73 };
74}
75
76export const layer = Layer.effect(ThreadPlanProgressService, Effect.sync(make));
apps/server/src/orchestration/runtimeLayer.ts+9-4
......@@ -6,6 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
66import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
77import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
88import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
9import * as ThreadPlanProgress from "./ThreadPlanProgress.ts";
910
1011export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
1112 OrchestrationEventStoreLive,
......@@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
2021 OrchestrationProjectionSnapshotQueryLive,
2122 OrchestrationEventInfrastructureLayerLive,
2223 OrchestrationProjectionPipelineLayerLive,
23 // Shared background-liveness registry: written by runtime ingestion,
24 // read by the snapshot query. provideMerge feeds the same instance to
25 // the snapshot query here and re-exports it for runtime ingestion.
26).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
24 // Shared background-liveness and plan-progress registries: written by
25 // runtime ingestion, read by the snapshot query. provideMerge feeds the
26 // same instance to the snapshot query here and re-exports it for runtime
27 // ingestion.
28).pipe(
29 Layer.provideMerge(ThreadBackgroundLiveness.layer),
30 Layer.provideMerge(ThreadPlanProgress.layer),
31);
2732
2833export const OrchestrationLayerLive = Layer.mergeAll(
2934 OrchestrationInfrastructureLayerLive,
apps/web/src/components/ChatView.tsx+38-169
......@@ -85,7 +85,7 @@ import {
8585 deriveTimelineEntries,
8686 deriveActiveWorkStartedAt,
8787 deriveActivePlanState,
88 findSidebarProposedPlan,
88 deriveTurnPlans,
8989 findLatestProposedPlan,
9090 deriveWorkLogEntries,
9191 hasActionableProposedPlan,
......@@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
121121import { isCommandPaletteOpen } from "../commandPaletteBus";
122122import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
123123import { useMediaQuery } from "../hooks/useMediaQuery";
124import {
125 clearPlanSidebarDismissal,
126 dismissPlanSidebarForTurn,
127 isPlanSidebarDismissedForTurn,
128} from "../planSidebarDismissal";
129124import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
130125import {
131126 selectActiveRightPanel,
......@@ -157,7 +152,6 @@ import {
157152import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
158153import { BranchToolbar } from "./BranchToolbar";
159154import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
160import PlanSidebar from "./PlanSidebar";
161155import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
162156import {
163157 AlarmClockIcon,
......@@ -234,7 +228,6 @@ import {
234228 useProject,
235229 useProjects,
236230 useThread,
237 useThreadProposedPlans,
238231 useThreadRefs,
239232 useThreadShell,
240233} from "../state/entities";
......@@ -451,8 +444,6 @@ type EnvironmentUnavailableState = {
451444 readonly connection: EnvironmentConnectionPresentation;
452445};
453446
454type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
455
456447function eventPathContainsSelector(event: Event, selector: string): boolean {
457448 const path = event.composedPath();
458449 if (path.length === 0 && event.target) {
......@@ -1271,7 +1262,6 @@ function ChatViewContent(props: ChatViewProps) {
12711262 (store) => store.setStickyModelSelection,
12721263 );
12731264 const timestampFormat = settings.timestampFormat;
1274 const autoOpenPlanSidebar = settings.autoOpenPlanSidebar;
12751265 const navigate = useNavigate();
12761266 const { resolvedTheme } = useTheme();
12771267 // Granular store selectors — avoid subscribing to prompt changes.
......@@ -1341,10 +1331,7 @@ function ChatViewContent(props: ChatViewProps) {
13411331 >({});
13421332 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
13431333 useState<Record<string, number>>({});
1344 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
1345 // When set, the thread-change reset effect will open the sidebar instead of closing it.
1346 // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
1347 const planSidebarOpenOnNextThreadRef = useRef(false);
1334 const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
13481335 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
13491336 const [pullRequestDialogState, setPullRequestDialogState] =
13501337 useState<PullRequestDialogState | null>(null);
......@@ -1577,10 +1564,10 @@ function ChatViewContent(props: ChatViewProps) {
15771564 );
15781565 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
15791566 const rightPanelOpen = rightPanelState.isOpen;
1580 const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
1567 const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet;
15811568 const rightPanelMaximized =
15821569 canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey;
1583 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
1570 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet;
15841571
15851572 useEffect(() => {
15861573 if (!activeThreadRef) return;
......@@ -1607,36 +1594,11 @@ function ChatViewContent(props: ChatViewProps) {
16071594 previewPanelOpen,
16081595 ]);
16091596
1610 const planSidebarOpen = activeRightPanelKind === "plan";
1611
16121597 const existingOpenTerminalThreadKeys = useMemo(() => {
16131598 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
16141599 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
16151600 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
16161601 const activeLatestTurn = activeThread?.latestTurn ?? null;
1617 const sourcePlanThreadRef = useMemo(() => {
1618 const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId;
1619 if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) {
1620 return null;
1621 }
1622 return scopeThreadRef(activeThread.environmentId, sourceThreadId);
1623 }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]);
1624 const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef);
1625 const threadPlanCatalog = useMemo<ThreadPlanCatalogEntry[]>(() => {
1626 if (!activeThread) {
1627 return [];
1628 }
1629 const entries: ThreadPlanCatalogEntry[] = [
1630 { id: activeThread.id, proposedPlans: activeThread.proposedPlans },
1631 ];
1632 if (sourcePlanThreadRef) {
1633 entries.push({
1634 id: sourcePlanThreadRef.threadId,
1635 proposedPlans: sourceThreadProposedPlans,
1636 });
1637 }
1638 return entries;
1639 }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]);
16401602 useEffect(() => {
16411603 setMountedTerminalThreadKeys((currentThreadIds) => {
16421604 const nextThreadIds = reconcileMountedTerminalThreadIds({
......@@ -2086,6 +2048,7 @@ function ChatViewContent(props: ChatViewProps) {
20862048 const phase = derivePhase(activeThread?.session ?? null);
20872049 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
20882050 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
2051 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
20892052 // Native subagent fold: memoized by activity-list identity, shared by the
20902053 // Agents surface, live strip, and workflow cards. v2Projection is null
20912054 // until orchestration-v2 lands (source precedence lives in the derive).
......@@ -2148,21 +2111,25 @@ function ChatViewContent(props: ChatViewProps) {
21482111 activeLatestTurn?.turnId ?? null,
21492112 );
21502113 }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]);
2151 const sidebarProposedPlan = useMemo(
2152 () =>
2153 findSidebarProposedPlan({
2154 threads: threadPlanCatalog,
2155 latestTurn: activeLatestTurn,
2156 latestTurnSettled,
2157 threadId: activeThread?.id ?? null,
2158 }),
2159 [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog],
2160 );
21612114 const activePlan = useMemo(
21622115 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
21632116 [activeLatestTurn?.turnId, threadActivities],
21642117 );
2165 const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks";
2118 // Current step for the in-chat working row: only for the running turn's own
2119 // plan (deriveActivePlanState falls back to older turns' plans, which must
2120 // not label fresh work). Falls back to the first pending step so an
2121 // all-pending freshly written plan labels the row, matching the chip and
2122 // the server's planProgress.
2123 const workingStepLabel = useMemo(() => {
2124 if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) {
2125 return null;
2126 }
2127 return (
2128 activePlan.steps.find((step) => step.status === "inProgress")?.step ??
2129 activePlan.steps.find((step) => step.status === "pending")?.step ??
2130 null
2131 );
2132 }, [activeLatestTurn?.turnId, activePlan]);
21662133 const showPlanFollowUpPrompt =
21672134 pendingUserInputs.length === 0 &&
21682135 interactionMode === "plan" &&
......@@ -2431,8 +2398,13 @@ function ChatViewContent(props: ChatViewProps) {
24312398 }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]);
24322399 const timelineEntries = useMemo(
24332400 () =>
2434 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2435 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
2401 deriveTimelineEntries(
2402 timelineMessages,
2403 activeThread?.proposedPlans ?? [],
2404 workLogEntries,
2405 turnPlans,
2406 ),
2407 [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries],
24362408 );
24372409 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
24382410 const draftHeroDockRequested =
......@@ -3142,47 +3114,15 @@ function ChatViewContent(props: ChatViewProps) {
31423114 const toggleInteractionMode = useCallback(() => {
31433115 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
31443116 }, [handleInteractionModeChange, interactionMode]);
3145 const dismissPlanSidebarForCurrentTurn = useCallback(() => {
3146 if (!activeThreadKey) return;
3147 dismissPlanSidebarForTurn(
3148 activeThreadKey,
3149 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__",
3150 );
3151 }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]);
3152 const togglePlanSidebar = useCallback(() => {
3153 if (!activeThreadRef) return;
3154 if (planSidebarOpen) {
3155 dismissPlanSidebarForCurrentTurn();
3156 } else if (activeThreadKey) {
3157 clearPlanSidebarDismissal(activeThreadKey);
3158 }
3159 useRightPanelStore.getState().toggle(activeThreadRef, "plan");
3160 }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]);
3161 const closePlanSidebar = useCallback(() => {
3162 if (!activeThreadRef) return;
3163 setMaximizedRightPanelThreadKey(null);
3164 useRightPanelStore.getState().close(activeThreadRef);
3165 dismissPlanSidebarForCurrentTurn();
3166 }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]);
31673117 const createBrowserSurface = useCallback(() => {
31683118 if (!activeThreadRef) return;
31693119 void addBrowserSurface({ threadRef: activeThreadRef, openPreview });
31703120 }, [activeThreadRef, openPreview]);
31713121 const addDiffSurface = useCallback(() => {
31723122 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
3173 if (planSidebarOpen) {
3174 dismissPlanSidebarForCurrentTurn();
3175 }
31763123 useRightPanelStore.getState().open(activeThreadRef, "diff");
31773124 onDiffPanelOpen?.();
3178 }, [
3179 activeThreadRef,
3180 dismissPlanSidebarForCurrentTurn,
3181 isGitRepo,
3182 isServerThread,
3183 onDiffPanelOpen,
3184 planSidebarOpen,
3185 ]);
3125 }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]);
31863126 const addFilesSurface = useCallback(() => {
31873127 if (!activeThreadRef || !activeProject) return;
31883128 useRightPanelStore.getState().open(activeThreadRef, "files");
......@@ -3318,11 +3258,6 @@ function ChatViewContent(props: ChatViewProps) {
33183258 const activateRightPanelSurface = useCallback(
33193259 (surface: RightPanelSurface) => {
33203260 if (!activeThreadRef) return;
3321 if (surface.kind === "plan") {
3322 clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef));
3323 } else if (planSidebarOpen) {
3324 dismissPlanSidebarForCurrentTurn();
3325 }
33263261 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
33273262 if (surface.kind === "preview" && surface.resourceId) {
33283263 setActivePreviewTab(activeThreadRef, surface.resourceId);
......@@ -3334,20 +3269,16 @@ function ChatViewContent(props: ChatViewProps) {
33343269 onDiffPanelOpen?.();
33353270 }
33363271 },
3337 [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen],
3272 [activeThreadRef, diffOpen, onDiffPanelOpen],
33383273 );
33393274 const toggleRightPanel = useCallback(() => {
33403275 if (!activeThreadRef) return;
33413276 if (rightPanelOpen) {
3342 if (planSidebarOpen) {
3343 closePlanSidebar();
3344 } else {
3345 closePreviewPanel();
3346 }
3277 closePreviewPanel();
33473278 return;
33483279 }
33493280 useRightPanelStore.getState().toggleVisibility(activeThreadRef);
3350 }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]);
3281 }, [activeThreadRef, closePreviewPanel, rightPanelOpen]);
33513282 const toggleRightPanelMaximized = useCallback(() => {
33523283 if (!canMaximizeRightPanel) return;
33533284 setMaximizedRightPanelThreadKey((threadKey) =>
......@@ -3357,10 +3288,6 @@ function ChatViewContent(props: ChatViewProps) {
33573288 const cleanupRightPanelSurfaces = useCallback(
33583289 (surfaces: readonly RightPanelSurface[]) => {
33593290 if (!activeThreadRef) return;
3360 if (surfaces.some((surface) => surface.kind === "plan")) {
3361 dismissPlanSidebarForCurrentTurn();
3362 }
3363
33643291 for (const surface of surfaces) {
33653292 if (surface.kind === "preview" && surface.resourceId) {
33663293 void closePreviewSession({
......@@ -3386,7 +3313,6 @@ function ChatViewContent(props: ChatViewProps) {
33863313 activePreviewState.sessions,
33873314 closePreview,
33883315 closeTerminalMutation,
3389 dismissPlanSidebarForCurrentTurn,
33903316 storeCloseTerminal,
33913317 ],
33923318 );
......@@ -3976,37 +3902,9 @@ function ChatViewContent(props: ChatViewProps) {
39763902 activeTimelineAnchorIndexRef.current = null;
39773903 showScrollDebouncer.current.cancel();
39783904 setShowScrollToBottom(false);
3979 if (planSidebarOpenOnNextThreadRef.current) {
3980 planSidebarOpenOnNextThreadRef.current = false;
3981 if (activeThreadRef) {
3982 clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef));
3983 useRightPanelStore.getState().open(activeThreadRef, "plan");
3984 }
3985 }
39863905 // activeThreadRef resets transitively with the active thread.
39873906 }, [activeThread?.id]);
39883907
3989 // Auto-open the plan sidebar when plan/todo steps arrive for the current turn.
3990 // Don't auto-open for plans carried over from a previous turn (the user can open manually).
3991 useEffect(() => {
3992 if (!autoOpenPlanSidebar) return;
3993 if (!activePlan) return;
3994 if (planSidebarOpen) return;
3995 const latestTurnId = activeLatestTurn?.turnId ?? null;
3996 if (latestTurnId && activePlan.turnId !== latestTurnId) return;
3997 const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3998 if (!activeThreadRef) return;
3999 if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return;
4000 useRightPanelStore.getState().open(activeThreadRef, "plan");
4001 }, [
4002 activePlan,
4003 activeLatestTurn?.turnId,
4004 activeThreadRef,
4005 autoOpenPlanSidebar,
4006 planSidebarOpen,
4007 sidebarProposedPlan?.turnId,
4008 ]);
4009
40103908 useEffect(() => {
40113909 setIsRevertingCheckpoint(false);
40123910 }, [activeThread?.id]);
......@@ -5556,15 +5454,6 @@ function ChatViewContent(props: ChatViewProps) {
55565454
55575455 if (failure === null) {
55585456 acknowledgeActiveThreadWoke();
5559 // Optimistically open the plan sidebar when implementing (not refining).
5560 // "default" mode here means the agent is executing the plan, which produces
5561 // step-tracking activities that the sidebar will display.
5562 if (nextInteractionMode === "default" && autoOpenPlanSidebar) {
5563 if (activeThreadRef) {
5564 clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef));
5565 useRightPanelStore.getState().open(activeThreadRef, "plan");
5566 }
5567 }
55685457 sendInFlightRef.current = false;
55695458 return;
55705459 }
......@@ -5597,7 +5486,6 @@ function ChatViewContent(props: ChatViewProps) {
55975486 setComposerDraftInteractionMode,
55985487 setThreadError,
55995488 startThreadTurn,
5600 autoOpenPlanSidebar,
56015489 environmentId,
56025490 composerRef,
56035491 ],
......@@ -5700,8 +5588,6 @@ function ChatViewContent(props: ChatViewProps) {
57005588 }
57015589
57025590 if (failure === null) {
5703 // Signal that the plan sidebar should open on the new thread when enabled.
5704 planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar;
57055591 const navigateResult = await settlePromise(() =>
57065592 navigate({
57075593 to: "/$environmentId/$threadId",
......@@ -5758,7 +5644,6 @@ function ChatViewContent(props: ChatViewProps) {
57585644 resetLocalDispatch,
57595645 runtimeMode,
57605646 startThreadTurn,
5761 autoOpenPlanSidebar,
57625647 environmentId,
57635648 composerRef,
57645649 ]);
......@@ -5950,12 +5835,12 @@ function ChatViewContent(props: ChatViewProps) {
59505835 <div
59515836 className={cn(
59525837 "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]",
5953 rightPanelOpen && !shouldUsePlanSidebarSheet
5838 rightPanelOpen && !shouldUseRightPanelSheet
59545839 ? "right-2 wco:right-[var(--workspace-controls-right)]"
59555840 : "mr-px",
59565841 )}
59575842 >
5958 {rightPanelOpen && !shouldUsePlanSidebarSheet ? (
5843 {rightPanelOpen && !shouldUseRightPanelSheet ? (
59595844 <RightPanelMaximizeControl
59605845 maximized={rightPanelMaximized}
59615846 onToggle={toggleRightPanelMaximized}
......@@ -6005,18 +5890,6 @@ function ChatViewContent(props: ChatViewProps) {
60055890 initialGitScope={initialDiffPanelGitScope}
60065891 />
60075892 </Suspense>
6008 ) : activeRightPanelSurface?.kind === "plan" ? (
6009 <PlanSidebar
6010 activePlan={activePlan}
6011 activeProposedPlan={sidebarProposedPlan}
6012 label={planSidebarLabel}
6013 environmentId={environmentId}
6014 threadRef={activeThreadRef}
6015 markdownCwd={gitCwd ?? undefined}
6016 workspaceRoot={activeWorkspaceRoot}
6017 timestampFormat={timestampFormat}
6018 mode="embedded"
6019 />
60205893 ) : activeRightPanelSurface?.kind === "agents" ? (
60215894 <AgentsPanel
60225895 model={agentPanelModel}
......@@ -6050,7 +5923,7 @@ function ChatViewContent(props: ChatViewProps) {
60505923
60515924 return (
60525925 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
6053 {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
5926 {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
60545927 <div
60555928 className={cn(
60565929 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",
......@@ -6122,6 +5995,7 @@ function ChatViewContent(props: ChatViewProps) {
61225995 onOpenAgents={addAgentsSurface}
61235996 key={activeThread.id}
61245997 isWorking={isWorking}
5998 workingStepLabel={workingStepLabel}
61255999 activeTurnInProgress={isWorking || !latestTurnSettled}
61266000 activeTurnStartedAt={activeWorkStartedAt}
61276001 listRef={legendListRef}
......@@ -6264,10 +6138,6 @@ function ChatViewContent(props: ChatViewProps) {
62646138 respondingRequestIds={respondingRequestIds}
62656139 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
62666140 activeProposedPlan={activeProposedPlan}
6267 activePlan={activePlan as { turnId?: TurnId } | null}
6268 sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
6269 planSidebarLabel={planSidebarLabel}
6270 planSidebarOpen={planSidebarOpen}
62716141 runtimeMode={runtimeMode}
62726142 interactionMode={interactionMode}
62736143 lockedProvider={lockedProvider}
......@@ -6305,7 +6175,6 @@ function ChatViewContent(props: ChatViewProps) {
63056175 toggleInteractionMode={toggleInteractionMode}
63066176 handleRuntimeModeChange={handleRuntimeModeChange}
63076177 handleInteractionModeChange={handleInteractionModeChange}
6308 togglePlanSidebar={togglePlanSidebar}
63096178 focusComposer={focusComposer}
63106179 scheduleComposerFocus={scheduleComposerFocus}
63116180 setThreadError={setThreadError}
......@@ -6440,7 +6309,7 @@ function ChatViewContent(props: ChatViewProps) {
64406309 ))}
64416310 </div>
64426311
6443 {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (
6312 {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
64446313 <RightPanelTabs
64456314 mode="inline"
64466315 maximized={rightPanelMaximized}
......@@ -6467,8 +6336,8 @@ function ChatViewContent(props: ChatViewProps) {
64676336 {rightPanelContent}
64686337 </RightPanelTabs>
64696338 ) : null}
6470 {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? (
6471 <RightPanelSheet open onClose={planSidebarOpen ? closePlanSidebar : closePreviewPanel}>
6339 {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? (
6340 <RightPanelSheet open onClose={closePreviewPanel}>
64726341 <RightPanelTabs
64736342 mode="sheet"
64746343 layoutControls={panelToggleControls}
apps/web/src/components/PlanSidebar.tsx deleted-284
......@@ -1,284 +0,0 @@
1import { memo, useState, useCallback } from "react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
7import { type TimestampFormat } from "@t3tools/contracts/settings";
8import { Badge } from "./ui/badge";
9import { Button } from "./ui/button";
10import { ScrollArea } from "./ui/scroll-area";
11import ChatMarkdown from "./ChatMarkdown";
12import {
13 CheckIcon,
14 ChevronDownIcon,
15 ChevronRightIcon,
16 EllipsisIcon,
17 LoaderIcon,
18} from "lucide-react";
19import { cn } from "~/lib/utils";
20import type { ActivePlanState } from "../session-logic";
21import type { LatestProposedPlanState } from "../session-logic";
22import { formatTimestamp } from "../timestampFormat";
23import {
24 proposedPlanTitle,
25 buildProposedPlanMarkdownFilename,
26 normalizePlanMarkdownForExport,
27 downloadPlanAsTextFile,
28 stripDisplayedPlanMarkdown,
29} from "../proposedPlan";
30import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
31import { projectEnvironment } from "~/state/projects";
32import { stackedThreadToast, toastManager } from "./ui/toast";
33import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
34import { useAtomCommand } from "~/state/use-atom-command";
35
36function stepStatusIcon(status: string): React.ReactNode {
37 if (status === "completed") {
38 return (
39 <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-success/10 text-success-foreground">
40 <CheckIcon className="size-3" />
41 </span>
42 );
43 }
44 if (status === "inProgress") {
45 return (
46 <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
47 <LoaderIcon className="size-3 animate-spin" />
48 </span>
49 );
50 }
51 return (
52 <span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30">
53 <span className="size-1.5 rounded-full bg-muted-foreground/30" />
54 </span>
55 );
56}
57
58interface PlanSidebarProps {
59 activePlan: ActivePlanState | null;
60 activeProposedPlan: LatestProposedPlanState | null;
61 label?: string;
62 environmentId: EnvironmentId;
63 threadRef?: ScopedThreadRef | undefined;
64 markdownCwd: string | undefined;
65 workspaceRoot: string | undefined;
66 timestampFormat: TimestampFormat;
67 mode?: "sheet" | "sidebar" | "embedded";
68}
69
70const PlanSidebar = memo(function PlanSidebar({
71 activePlan,
72 activeProposedPlan,
73 label = "Plan",
74 environmentId,
75 threadRef,
76 markdownCwd,
77 workspaceRoot,
78 timestampFormat,
79 mode = "sidebar",
80}: PlanSidebarProps) {
81 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
82 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
83 const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, {
84 reportFailure: false,
85 });
86 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" });
87
88 const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
89 const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
90 const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
91
92 const handleCopyPlan = useCallback(() => {
93 if (!planMarkdown) return;
94 copyToClipboard(planMarkdown);
95 }, [planMarkdown, copyToClipboard]);
96
97 const handleDownload = useCallback(() => {
98 if (!planMarkdown) return;
99 const filename = buildProposedPlanMarkdownFilename(planMarkdown);
100 downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown));
101 }, [planMarkdown]);
102
103 const handleSaveToWorkspace = useCallback(() => {
104 if (!workspaceRoot || !planMarkdown) return;
105 const filename = buildProposedPlanMarkdownFilename(planMarkdown);
106 setIsSavingToWorkspace(true);
107 void (async () => {
108 const result = await writeProjectFile({
109 environmentId,
110 input: {
111 cwd: workspaceRoot,
112 relativePath: filename,
113 contents: normalizePlanMarkdownForExport(planMarkdown),
114 },
115 });
116 setIsSavingToWorkspace(false);
117 if (result._tag === "Success") {
118 toastManager.add({
119 type: "success",
120 title: "Plan saved",
121 description: result.value.relativePath,
122 });
123 return;
124 }
125 if (!isAtomCommandInterrupted(result)) {
126 const error = squashAtomCommandFailure(result);
127 toastManager.add(
128 stackedThreadToast({
129 type: "error",
130 title: "Could not save plan",
131 description: error instanceof Error ? error.message : "An error occurred.",
132 }),
133 );
134 }
135 })();
136 }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]);
137
138 return (
139 <div
140 className={cn(
141 "flex min-h-0 flex-col bg-card/50",
142 mode === "sidebar"
143 ? "h-full w-[340px] shrink-0 border-l border-border/70"
144 : "h-full w-full",
145 )}
146 >
147 {/* Header */}
148 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">
149 <div className="flex items-center gap-2">
150 <Badge
151 variant="info"
152 size="sm"
153 className="rounded-md px-1.5 py-0 font-semibold tracking-wide uppercase"
154 >
155 {label}
156 </Badge>
157 {activePlan ? (
158 <span className="text-[11px] text-muted-foreground/60 tabular-nums">
159 {formatTimestamp(activePlan.createdAt, timestampFormat)}
160 </span>
161 ) : null}
162 </div>
163 <div className="flex items-center gap-1">
164 {planMarkdown ? (
165 <Menu>
166 <MenuTrigger
167 render={
168 <Button
169 size="icon-xs"
170 variant="ghost"
171 className="text-muted-foreground/50 hover:text-foreground/70"
172 aria-label="Plan actions"
173 />
174 }
175 >
176 <EllipsisIcon className="size-3.5" />
177 </MenuTrigger>
178 <MenuPopup align="end">
179 <MenuItem onClick={handleCopyPlan}>
180 {isCopied ? "Copied!" : "Copy to clipboard"}
181 </MenuItem>
182 <MenuItem onClick={handleDownload}>Download as markdown</MenuItem>
183 <MenuItem
184 onClick={handleSaveToWorkspace}
185 disabled={!workspaceRoot || isSavingToWorkspace}
186 >
187 Save to workspace
188 </MenuItem>
189 </MenuPopup>
190 </Menu>
191 ) : null}
192 </div>
193 </div>
194
195 {/* Content */}
196 <ScrollArea className="min-h-0 flex-1">
197 <div className="p-3 space-y-4">
198 {/* Explanation */}
199 {activePlan?.explanation ? (
200 <p className="text-[13px] leading-relaxed text-muted-foreground/80">
201 {activePlan.explanation}
202 </p>
203 ) : null}
204
205 {/* Plan Steps */}
206 {activePlan && activePlan.steps.length > 0 ? (
207 <div className="space-y-1">
208 <p className="mb-2 text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase">
209 Steps
210 </p>
211 {activePlan.steps.map((step) => (
212 <div
213 key={`${step.status}:${step.step}`}
214 className={cn(
215 "flex items-center gap-2.5 rounded-lg px-2.5 py-2 transition-colors duration-200",
216 step.status === "inProgress" && "bg-blue-500/5",
217 step.status === "completed" && "bg-emerald-500/5",
218 )}
219 >
220 {stepStatusIcon(step.status)}
221 <p
222 className={cn(
223 "text-[13px] leading-snug",
224 step.status === "completed"
225 ? "text-muted-foreground/50 line-through decoration-muted-foreground/20"
226 : step.status === "inProgress"
227 ? "text-foreground/90"
228 : "text-muted-foreground/70",
229 )}
230 >
231 {step.step}
232 </p>
233 </div>
234 ))}
235 </div>
236 ) : null}
237
238 {/* Proposed Plan Markdown */}
239 {planMarkdown ? (
240 <div className="space-y-2">
241 <button
242 type="button"
243 className="group flex w-full items-center gap-1.5 text-left"
244 onClick={() => setProposedPlanExpanded((v) => !v)}
245 >
246 {proposedPlanExpanded ? (
247 <ChevronDownIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
248 ) : (
249 <ChevronRightIcon className="size-3 shrink-0 text-muted-foreground/40 transition-transform" />
250 )}
251 <span className="text-[10px] font-semibold tracking-widest text-muted-foreground/40 uppercase group-hover:text-muted-foreground/60">
252 {planTitle ?? "Full Plan"}
253 </span>
254 </button>
255 {proposedPlanExpanded ? (
256 <div className="rounded-lg border border-border/50 bg-background/50 p-3">
257 <ChatMarkdown
258 text={displayedPlanMarkdown ?? ""}
259 cwd={markdownCwd}
260 threadRef={threadRef}
261 isStreaming={false}
262 />
263 </div>
264 ) : null}
265 </div>
266 ) : null}
267
268 {/* Empty state */}
269 {!activePlan && !planMarkdown ? (
270 <div className="flex flex-col items-center justify-center py-12 text-center">
271 <p className="text-[13px] text-muted-foreground/40">No active plan yet.</p>
272 <p className="mt-1 text-[11px] text-muted-foreground/30">
273 Plans will appear here when generated.
274 </p>
275 </div>
276 ) : null}
277 </div>
278 </ScrollArea>
279 </div>
280 );
281});
282
283export default PlanSidebar;
284export type { PlanSidebarProps };
apps/web/src/components/RightPanelTabs.tsx+1-5
......@@ -1,6 +1,6 @@
11import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";
22import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
3import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";
3import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";
44import {
55 type MouseEvent as ReactMouseEvent,
66 type ReactElement,
......@@ -213,8 +213,6 @@ function surfaceTitle(
213213 terminalLabelsById.get(surface.activeTerminalId) ??
214214 getTerminalLabel(surface.activeTerminalId)
215215 );
216 case "plan":
217 return "Plan";
218216 case "agents":
219217 return "Agents";
220218 case "preview": {
......@@ -276,8 +274,6 @@ function SurfaceIcon({
276274 );
277275 case "terminal":
278276 return <TerminalSquare className="size-3 shrink-0" />;
279 case "plan":
280 return <ClipboardList className="size-3 shrink-0" />;
281277 case "agents":
282278 return <Bot className="size-3 shrink-0" />;
283279 }
apps/web/src/components/SidebarV2.tsx+12-1
......@@ -1098,7 +1098,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
10981098 ) : null}
10991099 </div>
11001100 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">
1101 {thread.branch ? (
1101 {/* While working, the current plan step outranks the branch:
1102 it's the one line that says what the thread is doing. */}
1103 {status === "working" && thread.planProgress ? (
1104 <span className="min-w-0 flex-1 truncate whitespace-nowrap">
1105 {thread.planProgress.step}
1106 {/* Completed count, matching the transcript chip's n/m. */}
1107 <span className="text-muted-foreground/50 tabular-nums">
1108 {" "}
1109 {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps}
1110 </span>
1111 </span>
1112 ) : thread.branch ? (
11021113 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
11031114 ) : (
11041115 <span className="flex-1" />
apps/web/src/components/chat/ChatComposer.tsx-58
......@@ -10,7 +10,6 @@ import type {
1010 ScopedThreadRef,
1111 ServerProvider,
1212 ThreadId,
13 TurnId,
1413} from "@t3tools/contracts";
1514import {
1615 ProviderDriverKind,
......@@ -195,7 +194,6 @@ import { toastManager } from "../ui/toast";
195194import {
196195 BotIcon,
197196 CircleAlertIcon,
198 ListTodoIcon,
199197 PencilRulerIcon,
200198 type LucideIcon,
201199 LockIcon,
......@@ -300,12 +298,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
300298 showInteractionModeToggle: boolean;
301299 interactionMode: ProviderInteractionMode;
302300 runtimeMode: RuntimeMode;
303 showPlanToggle: boolean;
304 planSidebarLabel: string;
305 planSidebarOpen: boolean;
306301 onToggleInteractionMode: () => void;
307302 onRuntimeModeChange: (mode: RuntimeMode) => void;
308 onTogglePlanSidebar: () => void;
309303}) {
310304 const runtimeModeOption = runtimeModeConfig[props.runtimeMode];
311305 const RuntimeModeIcon = runtimeModeOption.icon;
......@@ -313,9 +307,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
313307 props.interactionMode === "plan"
314308 ? "Plan mode — click to return to normal build mode"
315309 : "Default mode — click to enter plan mode";
316 const planSidebarTooltip = props.planSidebarOpen
317 ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
318 : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`;
319310
320311 const interactionModeToggle = props.showInteractionModeToggle ? (
321312 <>
......@@ -391,36 +382,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
391382 </Tooltip>
392383
393384 {interactionModeToggle}
394
395 {props.showPlanToggle ? (
396 <>
397 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
398 <Tooltip>
399 <TooltipTrigger
400 render={
401 <ComposerControl
402 className={cn(
403 "shrink-0 whitespace-nowrap",
404 props.planSidebarOpen
405 ? "bg-blue-500/10 text-blue-400 hover:bg-blue-500/15 hover:text-blue-300"
406 : "text-muted-foreground/70 hover:text-foreground/80",
407 )}
408 type="button"
409 onClick={props.onTogglePlanSidebar}
410 aria-label={planSidebarTooltip}
411 />
412 }
413 >
414 <ComposerControlIcon
415 icon={ListTodoIcon}
416 className={props.planSidebarOpen ? "text-current opacity-100" : undefined}
417 />
418 <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>
419 </TooltipTrigger>
420 <TooltipPopup side="top">{planSidebarTooltip}</TooltipPopup>
421 </Tooltip>
422 </>
423 ) : null}
424385 </>
425386 );
426387});
......@@ -576,10 +537,6 @@ export interface ChatComposerProps {
576537 // Plan
577538 showPlanFollowUpPrompt: boolean;
578539 activeProposedPlan: Thread["proposedPlans"][number] | null;
579 activePlan: { turnId?: TurnId } | null;
580 sidebarProposedPlan: { turnId?: TurnId } | null;
581 planSidebarLabel: string;
582 planSidebarOpen: boolean;
583540
584541 // Mode
585542 runtimeMode: RuntimeMode;
......@@ -632,7 +589,6 @@ export interface ChatComposerProps {
632589 toggleInteractionMode: () => void;
633590 handleRuntimeModeChange: (mode: RuntimeMode) => void;
634591 handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
635 togglePlanSidebar: () => void;
636592
637593 focusComposer: () => void;
638594 scheduleComposerFocus: () => void;
......@@ -675,10 +631,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
675631 respondingRequestIds,
676632 showPlanFollowUpPrompt,
677633 activeProposedPlan,
678 activePlan,
679 sidebarProposedPlan,
680 planSidebarLabel,
681 planSidebarOpen,
682634 runtimeMode,
683635 interactionMode,
684636 lockedProvider,
......@@ -709,7 +661,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
709661 toggleInteractionMode,
710662 handleRuntimeModeChange,
711663 handleInteractionModeChange,
712 togglePlanSidebar,
713664 focusComposer,
714665 scheduleComposerFocus,
715666 setThreadError,
......@@ -1180,7 +1131,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11801131 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
11811132
11821133 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
1183 const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen);
11841134 const composerFooterActionLayoutKey = useMemo(() => {
11851135 if (activePendingProgress) {
11861136 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
......@@ -3187,15 +3137,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31873137
31883138 {isComposerFooterCompact ? (
31893139 <CompactComposerControlsMenu
3190 activePlan={showPlanSidebarToggle}
31913140 interactionMode={interactionMode}
3192 planSidebarLabel={planSidebarLabel}
3193 planSidebarOpen={planSidebarOpen}
31943141 runtimeMode={runtimeMode}
31953142 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
31963143 traitsMenuContent={providerTraitsMenuContent}
31973144 onToggleInteractionMode={toggleInteractionMode}
3198 onTogglePlanSidebar={togglePlanSidebar}
31993145 onRuntimeModeChange={handleRuntimeModeChange}
32003146 />
32013147 ) : (
......@@ -3210,12 +3156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
32103156 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
32113157 interactionMode={interactionMode}
32123158 runtimeMode={runtimeMode}
3213 showPlanToggle={showPlanSidebarToggle}
3214 planSidebarLabel={planSidebarLabel}
3215 planSidebarOpen={planSidebarOpen}
32163159 onToggleInteractionMode={toggleInteractionMode}
32173160 onRuntimeModeChange={handleRuntimeModeChange}
3218 onTogglePlanSidebar={togglePlanSidebar}
32193161 />
32203162 </>
32213163 )}
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+1-17
......@@ -1,10 +1,9 @@
11import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts";
22import { memo, type ReactNode } from "react";
3import { EllipsisIcon, ListTodoIcon } from "lucide-react";
3import { EllipsisIcon } from "lucide-react";
44import { Button } from "../ui/button";
55import {
66 Menu,
7 MenuItem,
87 MenuPopup,
98 MenuRadioGroup,
109 MenuRadioItem,
......@@ -13,15 +12,11 @@ import {
1312} from "../ui/menu";
1413
1514export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
16 activePlan: boolean;
1715 interactionMode: ProviderInteractionMode;
18 planSidebarLabel: string;
19 planSidebarOpen: boolean;
2016 runtimeMode: RuntimeMode;
2117 showInteractionModeToggle: boolean;
2218 traitsMenuContent?: ReactNode;
2319 onToggleInteractionMode: () => void;
24 onTogglePlanSidebar: () => void;
2520 onRuntimeModeChange: (mode: RuntimeMode) => void;
2621}) {
2722 return (
......@@ -74,17 +69,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
7469 <MenuRadioItem value="auto">Auto</MenuRadioItem>
7570 <MenuRadioItem value="full-access">Full access</MenuRadioItem>
7671 </MenuRadioGroup>
77 {props.activePlan ? (
78 <>
79 <MenuDivider />
80 <MenuItem onClick={props.onTogglePlanSidebar}>
81 <ListTodoIcon className="size-4 shrink-0" />
82 {props.planSidebarOpen
83 ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
84 : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`}
85 </MenuItem>
86 </>
87 ) : null}
8872 </MenuPopup>
8973 </Menu>
9074 );
apps/web/src/components/chat/MessagesTimeline.logic.ts+24
......@@ -4,6 +4,7 @@ import {
44 workEntryIndicatesToolNeutralStatus,
55 workLogEntryIsToolLike,
66 type TimelineEntry,
7 type TurnPlanEntry,
78 type WorkLogEntry,
89} from "../../session-logic";
910import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
......@@ -201,6 +202,12 @@ export type MessagesTimelineRow =
201202 createdAt: string;
202203 proposedPlan: ProposedPlan;
203204 }
205 | {
206 kind: "turn-plan";
207 id: string;
208 createdAt: string;
209 turnPlan: TurnPlanEntry;
210 }
204211 | { kind: "working"; id: string; createdAt: string | null };
205212
206213export interface StableMessagesTimelineRowsState {
......@@ -576,6 +583,16 @@ export function deriveMessagesTimelineRows(input: {
576583 continue;
577584 }
578585
586 if (timelineEntry.kind === "turn-plan") {
587 nextRows.push({
588 kind: "turn-plan",
589 id: timelineEntry.id,
590 createdAt: timelineEntry.createdAt,
591 turnPlan: timelineEntry.turnPlan,
592 });
593 continue;
594 }
595
579596 const assistantTurnStillInProgress =
580597 timelineEntry.message.role === "assistant" &&
581598 unsettledTurnId !== null &&
......@@ -659,6 +676,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
659676 case "proposed-plan":
660677 return a.proposedPlan === (b as typeof a).proposedPlan;
661678
679 case "turn-plan": {
680 const bp = b as typeof a;
681 // Plans rewrite in place: compare the snapshot's identity fields so an
682 // unchanged plan keeps its row reference (virtualization stability).
683 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
684 }
685
662686 case "work":
663687 return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries);
664688
apps/web/src/components/chat/MessagesTimeline.tsx+115-4
......@@ -152,6 +152,8 @@ interface TimelineRowActivityState {
152152 isRevertingCheckpoint: boolean;
153153 activeTurnInProgress: boolean;
154154 latestTurnId: TurnId | null;
155 /** Current plan step label for the working row, when the turn has a plan. */
156 workingStepLabel: string | null;
155157}
156158
157159const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
......@@ -196,6 +198,7 @@ interface MessagesTimelineProps {
196198 agentPanelModel?: AgentPanelModel;
197199 onOpenAgents?: () => void;
198200 isWorking: boolean;
201 workingStepLabel?: string | null;
199202 activeTurnInProgress: boolean;
200203 activeTurnStartedAt: string | null;
201204 listRef: React.RefObject<LegendListRef | null>;
......@@ -240,6 +243,7 @@ interface MessagesTimelineProps {
240243
241244export const MessagesTimeline = memo(function MessagesTimeline({
242245 isWorking,
246 workingStepLabel = null,
243247 activeTurnInProgress,
244248 activeTurnStartedAt,
245249 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
......@@ -508,8 +512,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
508512 isRevertingCheckpoint,
509513 activeTurnInProgress,
510514 latestTurnId: latestTurn?.turnId ?? null,
515 workingStepLabel,
511516 }),
512 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId],
517 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
513518 );
514519
515520 // Stable renderItem — no closure deps. Row components read shared state
......@@ -911,7 +916,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
911916 // they sit closer to the work that follows them.
912917 (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) ||
913918 row.kind === "work" ||
914 row.kind === "work-toggle"
919 row.kind === "work-toggle" ||
920 row.kind === "turn-plan"
915921 ? "pb-2"
916922 : "pb-4",
917923 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
......@@ -929,6 +935,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
929935 <AssistantTimelineRow row={row} />
930936 ) : null}
931937 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
938 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
932939 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
933940 </div>
934941 );
......@@ -1159,16 +1166,117 @@ function ProposedPlanTimelineRow({
11591166 );
11601167}
11611168
1169/**
1170 * Inline folded plan chip: one row per turn that produced plan/todo steps.
1171 * Collapsed by default — a segment bar plus the in-progress step label —
1172 * and expands in place to the full step list. Replaces the old plan sidebar.
1173 */
1174const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1175 row,
1176}: {
1177 row: Extract<TimelineRow, { kind: "turn-plan" }>;
1178}) {
1179 const [expanded, setExpanded] = useState(false);
1180 const { steps } = row.turnPlan.plan;
1181 const completedCount = steps.filter((step) => step.status === "completed").length;
1182 const allDone = completedCount === steps.length;
1183 // Label priority: the in-progress step, else the next pending step (plan
1184 // just created), else the last step (plan finished, rendered muted).
1185 const label =
1186 steps.find((step) => step.status === "inProgress")?.step ??
1187 steps.find((step) => step.status === "pending")?.step ??
1188 steps.at(-1)?.step ??
1189 "Plan";
1190 const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
1191
1192 return (
1193 <div className="min-w-0 px-1 py-0.5">
1194 <button
1195 type="button"
1196 className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1197 aria-expanded={expanded}
1198 onClick={() => setExpanded((value) => !value)}
1199 >
1200 <Chevron className="size-3.5 shrink-0 text-muted-foreground/65" />
1201 {steps.length > 1 ? (
1202 <span aria-hidden className="flex shrink-0 items-center gap-0.5">
1203 {steps.map((step) => (
1204 <span
1205 key={step.step}
1206 className={cn(
1207 "h-[3px] w-2.5 rounded-full",
1208 step.status === "completed"
1209 ? "bg-success"
1210 : step.status === "inProgress"
1211 ? "bg-primary"
1212 : "bg-muted-foreground/25",
1213 )}
1214 />
1215 ))}
1216 </span>
1217 ) : null}
1218 <span
1219 className={cn(
1220 "min-w-0 truncate",
1221 allDone ? "text-muted-foreground/65" : "font-medium text-foreground/85",
1222 )}
1223 >
1224 {label}
1225 </span>
1226 {steps.length > 1 ? (
1227 <span className="shrink-0 text-muted-foreground/50 tabular-nums">
1228 {completedCount}/{steps.length}
1229 </span>
1230 ) : null}
1231 </button>
1232 {expanded ? (
1233 <div className="mt-0.5 space-y-px pl-6">
1234 {steps.map((step) => (
1235 <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5">
1236 <span
1237 className={cn(
1238 "w-3 shrink-0 text-center font-mono text-[10px]",
1239 step.status === "completed"
1240 ? "text-success"
1241 : step.status === "inProgress"
1242 ? "text-primary"
1243 : "text-muted-foreground/40",
1244 )}
1245 aria-hidden
1246 >
1247 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
1248 </span>
1249 <span
1250 className={cn(
1251 "min-w-0",
1252 step.status === "completed"
1253 ? "text-muted-foreground/55"
1254 : step.status === "inProgress"
1255 ? "text-foreground/90"
1256 : "text-muted-foreground/70",
1257 )}
1258 >
1259 {step.step}
1260 </span>
1261 </div>
1262 ))}
1263 </div>
1264 ) : null}
1265 </div>
1266 );
1267});
1268
11621269function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1270 const { workingStepLabel } = use(TimelineRowActivityCtx);
11631271 return (
11641272 <div className="py-0.5 pl-1.5">
1165 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">
1273 <div className="flex min-w-0 items-center gap-2 pt-1 text-[11px] text-muted-foreground/70 tabular-nums">
11661274 <span className="inline-flex items-center gap-[3px]">
11671275 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse" />
11681276 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:200ms]" />
11691277 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-status-pulse [animation-delay:400ms]" />
11701278 </span>
1171 <span>
1279 <span className="shrink-0">
11721280 {row.createdAt ? (
11731281 <>
11741282 Working for <WorkingTimer createdAt={row.createdAt} />
......@@ -1177,6 +1285,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
11771285 "Working..."
11781286 )}
11791287 </span>
1288 {workingStepLabel ? (
1289 <span className="min-w-0 truncate text-muted-foreground/55">· {workingStepLabel}</span>
1290 ) : null}
11801291 </div>
11811292 </div>
11821293 );
apps/web/src/components/settings/SettingsPanels.tsx-31
......@@ -623,9 +623,6 @@ export function useSettingsRestore(onRestored?: () => void) {
623623 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
624624 ? ["Diff whitespace changes"]
625625 : []),
626 ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar
627 ? ["Auto-open task panel"]
628 : []),
629626 ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming
630627 ? ["Assistant output"]
631628 : []),
......@@ -655,7 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) {
655652 [
656653 isTextGenerationModelDirty,
657654 isBackgroundActivityDirty,
658 settings.autoOpenPlanSidebar,
659655 settings.confirmThreadArchive,
660656 settings.confirmThreadDelete,
661657 settings.addProjectBaseDirectory,
......@@ -701,7 +697,6 @@ export function useSettingsRestore(onRestored?: () => void) {
701697 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
702698 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
703699 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
704 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
705700 enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming,
706701 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
707702 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
......@@ -1897,32 +1892,6 @@ export function GeneralSettingsPanel() {
18971892 }
18981893 />
18991894
1900 <SettingsRow
1901 {...searchableSetting("auto-open-task-panel")}
1902 description="Open the right-side plan and task panel automatically when steps appear."
1903 resetAction={
1904 settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? (
1905 <SettingResetButton
1906 label="auto-open task panel"
1907 onClick={() =>
1908 updateSettings({
1909 autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar,
1910 })
1911 }
1912 />
1913 ) : null
1914 }
1915 control={
1916 <Switch
1917 checked={settings.autoOpenPlanSidebar}
1918 onCheckedChange={(checked) =>
1919 updateSettings({ autoOpenPlanSidebar: Boolean(checked) })
1920 }
1921 aria-label="Open the task panel automatically"
1922 />
1923 }
1924 />
1925
19261895 <SettingsRow
19271896 {...searchableSetting("new-threads")}
19281897 description="Pick the default workspace mode for newly created draft threads."
apps/web/src/components/settings/settingsSearch.ts-5
......@@ -110,11 +110,6 @@ export const SETTINGS_SEARCH_ITEMS = [
110110 title: "Provider update checks",
111111 to: "/settings/general",
112112 },
113 {
114 id: "auto-open-task-panel",
115 title: "Auto-open task panel",
116 to: "/settings/general",
117 },
118113 {
119114 id: "new-threads",
120115 title: "New threads",
apps/web/src/planSidebarDismissal.ts deleted-21
......@@ -1,21 +0,0 @@
1/**
2 * Tracks which turn's plan sidebar the user explicitly dismissed, per thread.
3 *
4 * Kept outside React state so a dismissal survives leaving and re-entering a
5 * thread (ChatView resets its per-thread refs on navigation). Dismissals are
6 * keyed by turn, so when a new turn produces fresh plan steps the sidebar
7 * still auto-opens.
8 */
9const dismissedTurnByThreadKey = new Map<string, string>();
10
11export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void {
12 dismissedTurnByThreadKey.set(threadKey, turnKey);
13}
14
15export function clearPlanSidebarDismissal(threadKey: string): void {
16 dismissedTurnByThreadKey.delete(threadKey);
17}
18
19export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean {
20 return dismissedTurnByThreadKey.get(threadKey) === turnKey;
21}
apps/web/src/rightPanelStore.test.ts+47-12
......@@ -102,6 +102,41 @@ describe("rightPanelStore", () => {
102102 });
103103 });
104104
105 it("drops persisted plan surfaces and does not reopen an empty panel", () => {
106 expect(
107 migratePersistedRightPanelState({
108 byThreadKey: {
109 "env-1:thread-A": {
110 isOpen: true,
111 activeSurfaceId: "plan",
112 surfaces: [{ id: "plan", kind: "plan" }],
113 },
114 "env-1:thread-B": {
115 isOpen: true,
116 activeSurfaceId: "plan",
117 surfaces: [
118 { id: "plan", kind: "plan" },
119 { id: "diff", kind: "diff" },
120 ],
121 },
122 },
123 }),
124 ).toEqual({
125 byThreadKey: {
126 "env-1:thread-A": {
127 isOpen: false,
128 activeSurfaceId: null,
129 surfaces: [],
130 },
131 "env-1:thread-B": {
132 isOpen: true,
133 activeSurfaceId: "diff",
134 surfaces: [{ id: "diff", kind: "diff" }],
135 },
136 },
137 });
138 });
139
105140 it("open sets the active panel for a thread", () => {
106141 useRightPanelStore.getState().open(refA, "preview");
107142 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
......@@ -109,7 +144,7 @@ describe("rightPanelStore", () => {
109144 });
110145
111146 it("opening a different kind keeps both surfaces and activates the new one", () => {
112 useRightPanelStore.getState().open(refA, "plan");
147 useRightPanelStore.getState().open(refA, "agents");
113148 useRightPanelStore.getState().open(refA, "preview");
114149 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview");
115150 expect(
......@@ -119,7 +154,7 @@ describe("rightPanelStore", () => {
119154
120155 it("reopening an inactive singleton activates its existing surface", () => {
121156 useRightPanelStore.getState().open(refA, "diff");
122 useRightPanelStore.getState().open(refA, "plan");
157 useRightPanelStore.getState().open(refA, "agents");
123158 useRightPanelStore.getState().open(refA, "diff");
124159
125160 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
......@@ -127,7 +162,7 @@ describe("rightPanelStore", () => {
127162 activeSurfaceId: "diff",
128163 surfaces: [
129164 { id: "diff", kind: "diff" },
130 { id: "plan", kind: "plan" },
165 { id: "agents", kind: "agents" },
131166 ],
132167 });
133168 });
......@@ -207,15 +242,15 @@ describe("rightPanelStore", () => {
207242
208243 it("removes persisted file surfaces when their workspace no longer exists", () => {
209244 useRightPanelStore.getState().openFile(refA, "src/index.ts");
210 useRightPanelStore.getState().open(refA, "plan");
245 useRightPanelStore.getState().open(refA, "agents");
211246 useRightPanelStore.getState().openFile(refA, "README.md");
212247
213248 useRightPanelStore.getState().reconcileFileSurfaces(refA, false);
214249
215250 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
216251 isOpen: true,
217 activeSurfaceId: "plan",
218 surfaces: [{ id: "plan", kind: "plan" }],
252 activeSurfaceId: "agents",
253 surfaces: [{ id: "agents", kind: "agents" }],
219254 });
220255
221256 useRightPanelStore.getState().openFile(refB, "conductor.json");
......@@ -228,13 +263,13 @@ describe("rightPanelStore", () => {
228263 });
229264
230265 it("close hides the panel without clearing its selected surface", () => {
231 useRightPanelStore.getState().open(refA, "plan");
266 useRightPanelStore.getState().open(refA, "agents");
232267 useRightPanelStore.getState().close(refA);
233268 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
234269 expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
235270 isOpen: false,
236 activeSurfaceId: "plan",
237 surfaces: [{ id: "plan", kind: "plan" }],
271 activeSurfaceId: "agents",
272 surfaces: [{ id: "agents", kind: "agents" }],
238273 });
239274 });
240275
......@@ -264,12 +299,12 @@ describe("rightPanelStore", () => {
264299
265300 it("toggle to a different kind switches active", () => {
266301 useRightPanelStore.getState().toggle(refA, "preview");
267 useRightPanelStore.getState().toggle(refA, "plan");
268 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan");
302 useRightPanelStore.getState().toggle(refA, "agents");
303 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents");
269304 });
270305
271306 it("removeThread clears persisted state", () => {
272 useRightPanelStore.getState().open(refA, "plan");
307 useRightPanelStore.getState().open(refA, "agents");
273308 useRightPanelStore.getState().removeThread(refA);
274309 expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull();
275310 });
apps/web/src/rightPanelStore.ts+17-9
......@@ -5,7 +5,7 @@
55 * surface descriptors and the active surface, while each feature continues to
66 * own its durable resource state. Browser surfaces point at preview tab ids,
77 * terminal surfaces point at terminal session ids, file surfaces point at
8 * workspace paths, and diff/plan/files remain singleton surfaces.
8 * workspace paths, and diff/files remain singleton surfaces.
99 */
1010import { scopedThreadKey } from "@t3tools/client-runtime/environment";
1111import type { ScopedThreadRef } from "@t3tools/contracts";
......@@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware";
1515import { resolveStorage } from "./lib/storage";
1616
1717export const RIGHT_PANEL_KINDS = [
18 "plan",
1918 "diff",
2019 "files",
2120 "file",
......@@ -45,11 +44,11 @@ export type RightPanelSurface =
4544 revealLine: number | null;
4645 revealRequestId: number;
4746 }
48 | { id: "plan"; kind: "plan" }
4947 | { id: "agents"; kind: "agents" };
5048
5149const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
52const RIGHT_PANEL_STORAGE_VERSION = 8;
50// v9 removed the "plan" surface kind (plans render inline in the transcript).
51const RIGHT_PANEL_STORAGE_VERSION = 9;
5352
5453export interface ThreadRightPanelState {
5554 isOpen: boolean;
......@@ -99,8 +98,6 @@ const singletonSurface = (
9998 return { id: "diff", kind };
10099 case "files":
101100 return { id: "files", kind };
102 case "plan":
103 return { id: "plan", kind };
104101 case "agents":
105102 return { id: "agents", kind };
106103 }
......@@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
181178 threadState && typeof threadState === "object" ? threadState : null;
182179 const surfaces = Array.isArray(validThreadState?.surfaces)
183180 ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {
181 // Dropped surface kind: plans now render inline in the
182 // transcript (v9).
183 if ((surface as { kind?: string }).kind === "plan") return [];
184184 if (surface.kind === "file") {
185185 const revealLine =
186186 typeof surface.revealLine === "number" &&
......@@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
229229 ];
230230 })
231231 : [];
232 const activeSurfaceId = surfaces.some(
232 const persistedActiveSurfaceId = surfaces.some(
233233 (surface) => surface.id === validThreadState?.activeSurfaceId,
234234 )
235235 ? (validThreadState?.activeSurfaceId ?? null)
236236 : null;
237 // A migration that dropped every surface (e.g. plan-only panels
238 // in v9) must not reopen an empty panel.
237239 const isOpen =
238 typeof validThreadState?.isOpen === "boolean"
240 surfaces.length > 0 &&
241 (typeof validThreadState?.isOpen === "boolean"
239242 ? validThreadState.isOpen
240 : activeSurfaceId !== null;
243 : persistedActiveSurfaceId !== null);
244 // An open panel needs an active surface: if migration dropped
245 // the persisted one (e.g. plan was active), fall back to the
246 // first survivor instead of rendering an open empty panel.
247 const activeSurfaceId =
248 persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null);
241249 return [threadKey, { isOpen, surfaces, activeSurfaceId }];
242250 },
243251 ),
apps/web/src/session-logic.test.ts+90-98
......@@ -11,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test";
1111import {
1212 deriveActiveWorkStartedAt,
1313 deriveActivePlanState,
14 deriveTurnPlans,
1415 derivePendingApprovals,
1516 derivePendingUserInputs,
1617 deriveTimelineEntries,
1718 deriveWorkLogEntries,
1819 findLatestProposedPlan,
19 findSidebarProposedPlan,
2020 hasActionableProposedPlan,
2121 isLatestTurnSettled,
2222 workEntryIndicatesToolFailure,
......@@ -410,6 +410,95 @@ describe("deriveActivePlanState", () => {
410410 });
411411});
412412
413describe("deriveTurnPlans", () => {
414 it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => {
415 const activities: OrchestrationThreadActivity[] = [
416 makeActivity({
417 id: "plan-1a",
418 createdAt: "2026-02-23T00:00:01.000Z",
419 kind: "turn.plan.updated",
420 summary: "Plan updated",
421 tone: "info",
422 turnId: "turn-1",
423 payload: {
424 plan: [{ step: "Inspect code", status: "inProgress" }],
425 },
426 }),
427 makeActivity({
428 id: "plan-1b",
429 createdAt: "2026-02-23T00:00:05.000Z",
430 kind: "turn.plan.updated",
431 summary: "Plan updated",
432 tone: "info",
433 turnId: "turn-1",
434 payload: {
435 plan: [{ step: "Inspect code", status: "completed" }],
436 },
437 }),
438 makeActivity({
439 id: "plan-2a",
440 createdAt: "2026-02-23T00:01:00.000Z",
441 kind: "turn.plan.updated",
442 summary: "Plan updated",
443 tone: "info",
444 turnId: "turn-2",
445 payload: {
446 plan: [{ step: "Ship it", status: "pending" }],
447 },
448 }),
449 ];
450
451 const turnPlans = deriveTurnPlans(activities);
452 expect(turnPlans).toHaveLength(2);
453 expect(turnPlans[0]).toMatchObject({
454 id: "turn-plan:turn-1",
455 createdAt: "2026-02-23T00:00:01.000Z",
456 turnId: "turn-1",
457 });
458 expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]);
459 expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]);
460 });
461
462 it("skips activities without parseable steps", () => {
463 const activities: OrchestrationThreadActivity[] = [
464 makeActivity({
465 id: "plan-bad",
466 createdAt: "2026-02-23T00:00:01.000Z",
467 kind: "turn.plan.updated",
468 summary: "Plan updated",
469 tone: "info",
470 turnId: "turn-1",
471 payload: { plan: [] },
472 }),
473 ];
474 expect(deriveTurnPlans(activities)).toEqual([]);
475 });
476
477 it("drops a turn's chip when a later snapshot clears the plan", () => {
478 const activities: OrchestrationThreadActivity[] = [
479 makeActivity({
480 id: "plan-set",
481 createdAt: "2026-02-23T00:00:01.000Z",
482 kind: "turn.plan.updated",
483 summary: "Plan updated",
484 tone: "info",
485 turnId: "turn-1",
486 payload: { plan: [{ step: "Inspect code", status: "inProgress" }] },
487 }),
488 makeActivity({
489 id: "plan-clear",
490 createdAt: "2026-02-23T00:00:02.000Z",
491 kind: "turn.plan.updated",
492 summary: "Plan updated",
493 tone: "info",
494 turnId: "turn-1",
495 payload: { plan: [] },
496 }),
497 ];
498 expect(deriveTurnPlans(activities)).toEqual([]);
499 });
500});
501
413502describe("findLatestProposedPlan", () => {
414503 it("prefers the latest proposed plan for the active turn", () => {
415504 expect(
......@@ -515,103 +604,6 @@ describe("hasActionableProposedPlan", () => {
515604 });
516605});
517606
518describe("findSidebarProposedPlan", () => {
519 it("prefers the running turn source proposed plan when available on the same thread", () => {
520 expect(
521 findSidebarProposedPlan({
522 threads: [
523 {
524 id: ThreadId.make("thread-1"),
525 proposedPlans: [
526 {
527 id: "plan-1",
528 turnId: TurnId.make("turn-plan"),
529 planMarkdown: "# Source plan",
530 implementedAt: "2026-02-23T00:00:03.000Z",
531 implementationThreadId: ThreadId.make("thread-2"),
532 createdAt: "2026-02-23T00:00:01.000Z",
533 updatedAt: "2026-02-23T00:00:02.000Z",
534 },
535 ],
536 },
537 {
538 id: ThreadId.make("thread-2"),
539 proposedPlans: [
540 {
541 id: "plan-2",
542 turnId: TurnId.make("turn-other"),
543 planMarkdown: "# Latest elsewhere",
544 implementedAt: null,
545 implementationThreadId: null,
546 createdAt: "2026-02-23T00:00:04.000Z",
547 updatedAt: "2026-02-23T00:00:05.000Z",
548 },
549 ],
550 },
551 ],
552 latestTurn: {
553 turnId: TurnId.make("turn-implementation"),
554 sourceProposedPlan: {
555 threadId: ThreadId.make("thread-1"),
556 planId: "plan-1",
557 },
558 },
559 latestTurnSettled: false,
560 threadId: ThreadId.make("thread-1"),
561 }),
562 ).toEqual({
563 id: "plan-1",
564 turnId: "turn-plan",
565 planMarkdown: "# Source plan",
566 implementedAt: "2026-02-23T00:00:03.000Z",
567 implementationThreadId: "thread-2",
568 createdAt: "2026-02-23T00:00:01.000Z",
569 updatedAt: "2026-02-23T00:00:02.000Z",
570 });
571 });
572
573 it("falls back to the latest proposed plan once the turn is settled", () => {
574 expect(
575 findSidebarProposedPlan({
576 threads: [
577 {
578 id: ThreadId.make("thread-1"),
579 proposedPlans: [
580 {
581 id: "plan-1",
582 turnId: TurnId.make("turn-plan"),
583 planMarkdown: "# Older",
584 implementedAt: null,
585 implementationThreadId: null,
586 createdAt: "2026-02-23T00:00:01.000Z",
587 updatedAt: "2026-02-23T00:00:02.000Z",
588 },
589 {
590 id: "plan-2",
591 turnId: TurnId.make("turn-latest"),
592 planMarkdown: "# Latest",
593 implementedAt: null,
594 implementationThreadId: null,
595 createdAt: "2026-02-23T00:00:03.000Z",
596 updatedAt: "2026-02-23T00:00:04.000Z",
597 },
598 ],
599 },
600 ],
601 latestTurn: {
602 turnId: TurnId.make("turn-implementation"),
603 sourceProposedPlan: {
604 threadId: ThreadId.make("thread-1"),
605 planId: "plan-1",
606 },
607 },
608 latestTurnSettled: true,
609 threadId: ThreadId.make("thread-1"),
610 })?.planMarkdown,
611 ).toBe("# Latest");
612 });
613});
614
615607describe("workEntryIndicatesToolFailure", () => {
616608 const base = {
617609 id: "w1",
apps/web/src/session-logic.ts+85-46
......@@ -151,6 +151,12 @@ export type TimelineEntry =
151151 createdAt: string;
152152 proposedPlan: ProposedPlan;
153153 }
154 | {
155 id: string;
156 kind: "turn-plan";
157 createdAt: string;
158 turnPlan: TurnPlanEntry;
159 }
154160 | {
155161 id: string;
156162 kind: "work";
......@@ -532,26 +538,10 @@ export function derivePendingUserInputs(
532538 );
533539}
534540
535export function deriveActivePlanState(
536 activities: ReadonlyArray<OrchestrationThreadActivity>,
537 latestTurnId: TurnId | undefined,
538): ActivePlanState | null {
539 const ordered = [...activities].toSorted(compareActivitiesByOrder);
540 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
541 // Prefer plan from the current turn; fall back to the most recent plan from any turn
542 // so that TodoWrite tasks persist across follow-up messages.
543 const latest = Option.firstSomeOf([
544 ...(latestTurnId
545 ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)
546 : Option.none()),
547 Arr.last(allPlanActivities),
548 ]).pipe(Option.getOrNull);
549 if (!latest) {
550 return null;
551 }
541function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null {
552542 const payload =
553 latest.payload && typeof latest.payload === "object"
554 ? (latest.payload as Record<string, unknown>)
543 activity.payload && typeof activity.payload === "object"
544 ? (activity.payload as Record<string, unknown>)
555545 : null;
556546 const rawPlan = payload?.plan;
557547 if (!Array.isArray(rawPlan)) {
......@@ -580,8 +570,8 @@ export function deriveActivePlanState(
580570 return null;
581571 }
582572 return {
583 createdAt: latest.createdAt,
584 turnId: latest.turnId,
573 createdAt: activity.createdAt,
574 turnId: activity.turnId,
585575 ...(payload && "explanation" in payload
586576 ? { explanation: payload.explanation as string | null }
587577 : {}),
......@@ -589,6 +579,72 @@ export function deriveActivePlanState(
589579 };
590580}
591581
582export function deriveActivePlanState(
583 activities: ReadonlyArray<OrchestrationThreadActivity>,
584 latestTurnId: TurnId | undefined,
585): ActivePlanState | null {
586 const ordered = [...activities].toSorted(compareActivitiesByOrder);
587 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
588 // Prefer plan from the current turn; fall back to the most recent plan from any turn
589 // so that TodoWrite tasks persist across follow-up messages.
590 const latest = Option.firstSomeOf([
591 ...(latestTurnId
592 ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId)
593 : Option.none()),
594 Arr.last(allPlanActivities),
595 ]).pipe(Option.getOrNull);
596 if (!latest) {
597 return null;
598 }
599 return planStateFromActivity(latest);
600}
601
602export interface TurnPlanEntry {
603 /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */
604 id: string;
605 /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */
606 createdAt: string;
607 turnId: TurnId | null;
608 plan: ActivePlanState;
609}
610
611/**
612 * One inline plan chip per turn that produced plan/todo steps: the latest
613 * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less
614 * plan activities collapse into a single chip keyed by thread order.
615 */
616export function deriveTurnPlans(
617 activities: ReadonlyArray<OrchestrationThreadActivity>,
618): TurnPlanEntry[] {
619 const ordered = [...activities].toSorted(compareActivitiesByOrder);
620 const byTurn = new Map<string, TurnPlanEntry>();
621 for (const activity of ordered) {
622 if (activity.kind !== "turn.plan.updated") {
623 continue;
624 }
625 const plan = planStateFromActivity(activity);
626 const key = activity.turnId ?? "no-turn";
627 if (!plan) {
628 // A later snapshot with no steps clears the turn's plan; keeping the
629 // stale entry would freeze the chip on a withdrawn plan.
630 byTurn.delete(key);
631 continue;
632 }
633 const existing = byTurn.get(key);
634 if (existing) {
635 existing.plan = plan;
636 } else {
637 byTurn.set(key, {
638 id: `turn-plan:${key}`,
639 createdAt: activity.createdAt,
640 turnId: activity.turnId,
641 plan,
642 });
643 }
644 }
645 return [...byTurn.values()];
646}
647
592648export function findLatestProposedPlan(
593649 proposedPlans: ReadonlyArray<ProposedPlan>,
594650 latestTurnId: TurnId | string | null | undefined,
......@@ -619,30 +675,6 @@ export function findLatestProposedPlan(
619675 return toLatestProposedPlanState(latestPlan);
620676}
621677
622export function findSidebarProposedPlan(input: {
623 threads: ReadonlyArray<Pick<Thread, "id" | "proposedPlans">>;
624 latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "sourceProposedPlan"> | null;
625 latestTurnSettled: boolean;
626 threadId: ThreadId | string | null | undefined;
627}): LatestProposedPlanState | null {
628 const activeThreadPlans =
629 input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? [];
630
631 if (!input.latestTurnSettled) {
632 const sourceProposedPlan = input.latestTurn?.sourceProposedPlan;
633 if (sourceProposedPlan) {
634 const sourcePlan = input.threads
635 .find((thread) => thread.id === sourceProposedPlan.threadId)
636 ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId);
637 if (sourcePlan) {
638 return toLatestProposedPlanState(sourcePlan);
639 }
640 }
641 }
642
643 return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null);
644}
645
646678export function hasActionableProposedPlan(
647679 proposedPlan: LatestProposedPlanState | Pick<ProposedPlan, "implementedAt"> | null,
648680): boolean {
......@@ -1542,6 +1574,7 @@ export function deriveTimelineEntries(
15421574 messages: ReadonlyArray<ChatMessage>,
15431575 proposedPlans: ReadonlyArray<ProposedPlan>,
15441576 workEntries: ReadonlyArray<WorkLogEntry>,
1577 turnPlans: ReadonlyArray<TurnPlanEntry> = [],
15451578): TimelineEntry[] {
15461579 const messageRows: TimelineEntry[] = messages.map((message) => ({
15471580 id: message.id,
......@@ -1555,13 +1588,19 @@ export function deriveTimelineEntries(
15551588 createdAt: proposedPlan.createdAt,
15561589 proposedPlan,
15571590 }));
1591 const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({
1592 id: turnPlan.id,
1593 kind: "turn-plan",
1594 createdAt: turnPlan.createdAt,
1595 turnPlan,
1596 }));
15581597 const workRows: TimelineEntry[] = workEntries.map((entry) => ({
15591598 id: entry.id,
15601599 kind: "work",
15611600 createdAt: entry.createdAt,
15621601 entry,
15631602 }));
1564 return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
1603 return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) =>
15651604 a.createdAt.localeCompare(b.createdAt),
15661605 );
15671606}
packages/contracts/src/orchestration.ts+14
......@@ -446,6 +446,20 @@ export const OrchestrationThreadShell = Schema.Struct({
446446 * live work. Optional so old servers/clients interop; absent = none.
447447 */
448448 backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))),
449 /**
450 * Current plan step while a turn runs, for the Working indicators
451 * (sidebar row, in-chat working line). Cleared when the turn settles —
452 * never persists as stale UI. Optional so old servers/clients interop.
453 */
454 planProgress: Schema.optional(
455 Schema.NullOr(
456 Schema.Struct({
457 step: TrimmedNonEmptyString,
458 completedSteps: NonNegativeInt,
459 totalSteps: NonNegativeInt,
460 }),
461 ),
462 ),
449463});
450464export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
451465
packages/contracts/src/settings.ts-2
......@@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200))
111111export type FontFamilyPreference = typeof FontFamilyPreference.Type;
112112
113113export const ClientSettingsSchema = Schema.Struct({
114 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
115114 confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
116115 confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
117116 dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe(
......@@ -747,7 +746,6 @@ export const ServerSettingsPatch = Schema.Struct({
747746export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
748747
749748export const ClientSettingsPatch = Schema.Struct({
750 autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean),
751749 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),
752750 confirmThreadDelete: Schema.optionalKey(Schema.Boolean),
753751 diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),