diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f2191..26c3127de5f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c3f77d677b1..5e1b32cb1e8 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; @@ -306,7 +307,10 @@ export const makeOrchestrationIntegrationHarness = ( checkpointStoreLayer, providerLayer, RuntimeReceiptBusTest, - ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); + ).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), + ); const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ddb525cd547..08ea1437bb2 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -296,6 +297,7 @@ describe("CheckpointReactor", () => { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -304,6 +306,7 @@ describe("CheckpointReactor", () => { ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 857f5b887fc..19290d6ec40 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -32,6 +32,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline, @@ -57,6 +58,7 @@ async function createOrchestrationSystem() { OrchestrationProjectionSnapshotQueryLive, ).pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), @@ -820,6 +822,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -926,6 +929,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -1070,6 +1074,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 9c4caf4c97d..154ad38ba85 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -32,6 +32,7 @@ import { } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; @@ -2666,6 +2667,7 @@ const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b7b630a16fd..4b2d72a2264 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -29,6 +30,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -446,6 +448,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { hasPendingUserInput: false, hasActionableProposedPlan: false, backgroundLiveness: null, + planProgress: null, }, ]); @@ -1829,6 +1832,7 @@ it.effect( const resolveCalls: string[] = []; const layer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (cwd: string) => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 2d8a98d8c6f..eb1af030ce1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -44,6 +44,7 @@ import { } from "../../persistence/Errors.ts"; import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; +import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; @@ -310,6 +311,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; + const threadPlanProgress = yield* ThreadPlanProgressService; const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; @@ -1683,6 +1685,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -1826,6 +1829,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", @@ -2101,6 +2105,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( threadRow.value.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), } satisfies OrchestrationThreadShell); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f355bfc45ae..2b4d3771605 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { providerErrorLabel, providerErrorLabelFromInstanceHint, @@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => { ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 31e30c4d1ad..dfc47320768 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,6 +45,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => { // Single shared liveness instance across ingestion (writer), the // engine, and the snapshot query (reader). Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0420420939e..e62f74ce85a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -35,6 +35,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; +import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionService, @@ -826,6 +827,7 @@ export function runtimeEventToActivities( const make = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; + const threadPlanProgress = yield* ThreadPlanProgressService; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -1894,6 +1896,21 @@ const make = Effect.gen(function* () { yield* rememberTaskDescription(thread.id, event.payload.taskId, description); } } + // Working-indicator plan progress: current step while the turn runs, + // cleared on settle so a finished plan never lingers as stale UI. + // Events carrying a turn id that conflicts with the active turn are + // stale (superseded turn) and must neither overwrite nor clear the + // active turn's progress; session.exited always clears. + if (event.type === "session.exited") { + threadPlanProgress.clearThreadPlanProgress(thread.id); + } else if (!conflictsWithActiveTurn) { + if (event.type === "turn.plan.updated") { + threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan); + } else if (event.type === "turn.completed" || event.type === "turn.aborted") { + threadPlanProgress.clearThreadPlanProgress(thread.id); + } + } + // Sidebar background liveness: fed from the same lifecycle stream, // read by the shell query at mapping time (no persistence). switch (event.type) { diff --git a/apps/server/src/orchestration/ThreadPlanProgress.test.ts b/apps/server/src/orchestration/ThreadPlanProgress.test.ts new file mode 100644 index 00000000000..99545327672 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPlanProgress.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; + +describe("ThreadPlanProgress", () => { + it("tracks the in-progress step and clears when the plan completes", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-1"; + progress.recordPlanProgress(threadId, [ + { step: "Audit failure paths", status: "completed" }, + { step: "Implement the fix", status: "inProgress" }, + { step: "Run targeted tests", status: "pending" }, + ]); + expect(progress.getThreadPlanProgress(threadId)).toEqual({ + step: "Implement the fix", + completedSteps: 1, + totalSteps: 3, + }); + + progress.recordPlanProgress(threadId, [ + { step: "Audit failure paths", status: "completed" }, + { step: "Implement the fix", status: "completed" }, + { step: "Run targeted tests", status: "completed" }, + ]); + expect(progress.getThreadPlanProgress(threadId)).toBeNull(); + }); + + it("falls back to the first non-completed step when nothing is in progress", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-2"; + progress.recordPlanProgress(threadId, [ + { step: "First", status: "pending" }, + { step: "Second", status: "pending" }, + ]); + expect(progress.getThreadPlanProgress(threadId)?.step).toBe("First"); + }); + + it("clearThreadPlanProgress removes the entry (turn settled / session died)", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-3"; + progress.recordPlanProgress(threadId, [{ step: "Only step", status: "inProgress" }]); + progress.clearThreadPlanProgress(threadId); + expect(progress.getThreadPlanProgress(threadId)).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/ThreadPlanProgress.ts b/apps/server/src/orchestration/ThreadPlanProgress.ts new file mode 100644 index 00000000000..1c638bf89a6 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPlanProgress.ts @@ -0,0 +1,76 @@ +/** + * ThreadPlanProgressService - in-memory per-thread plan progress for the + * Working indicators (sidebar rows, in-chat working line). + * + * Plans are a progress annotation, not a surface of their own: the useful + * kernel of a turn.plan.updated event is "which step is the agent on right + * now". Ingestion records the current step here and the shell query reads it + * at mapping time — no persistence, no migration (same pattern as + * ThreadBackgroundLivenessService). Cleared when the turn settles or the + * session dies, so a finished plan never lingers as stale UI. + * + * @module ThreadPlanProgressService + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export interface ThreadPlanProgress { + readonly step: string; + readonly completedSteps: number; + readonly totalSteps: number; +} + +interface PlanStepInput { + readonly step: string; + readonly status: string; +} + +export class ThreadPlanProgressService extends Context.Service< + ThreadPlanProgressService, + { + /** + * Feed one turn.plan.updated payload. An all-completed plan clears the + * entry (the turn is wrapping up; nothing is "in progress" anymore). + */ + readonly recordPlanProgress: (threadId: string, plan: ReadonlyArray) => void; + + /** Turn settled or session died: the working indicator reverts to plain. */ + readonly clearThreadPlanProgress: (threadId: string) => void; + + readonly getThreadPlanProgress: (threadId: string) => ThreadPlanProgress | null; + } +>()("t3/orchestration/ThreadPlanProgress/ThreadPlanProgressService") {} + +export function make(): ThreadPlanProgressService["Service"] { + const progressByThreadId = new Map(); + + return { + recordPlanProgress: (threadId, plan) => { + const totalSteps = plan.length; + const completedSteps = plan.filter((step) => step.status === "completed").length; + // Current step: the in-progress one, else the first pending one (a + // plan that was just written has no in-progress step yet). + const current = + plan.find((step) => step.status === "inProgress") ?? + plan.find((step) => step.status !== "completed"); + if (totalSteps === 0 || completedSteps === totalSteps || current === undefined) { + progressByThreadId.delete(threadId); + return; + } + progressByThreadId.set(threadId, { + step: current.step, + completedSteps, + totalSteps, + }); + }, + + clearThreadPlanProgress: (threadId) => { + progressByThreadId.delete(threadId); + }, + + getThreadPlanProgress: (threadId) => progressByThreadId.get(threadId) ?? null, + }; +} + +export const layer = Layer.effect(ThreadPlanProgressService, Effect.sync(make)); diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index 0bc624ec365..779042e2f68 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -6,6 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, @@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, - // Shared background-liveness registry: written by runtime ingestion, - // read by the snapshot query. provideMerge feeds the same instance to - // the snapshot query here and re-exports it for runtime ingestion. -).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); + // Shared background-liveness and plan-progress registries: written by + // runtime ingestion, read by the snapshot query. provideMerge feeds the + // same instance to the snapshot query here and re-exports it for runtime + // ingestion. +).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), +); export const OrchestrationLayerLive = Layer.mergeAll( OrchestrationInfrastructureLayerLive, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7b59530c955..74ca7961fbe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,7 +85,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - findSidebarProposedPlan, + deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { - clearPlanSidebarDismissal, - dismissPlanSidebarForTurn, - isPlanSidebarDismissedForTurn, -} from "../planSidebarDismissal"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanel, @@ -157,7 +152,6 @@ import { import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -230,7 +224,6 @@ import { useProject, useProjects, useThread, - useThreadProposedPlans, useThreadRefs, useThreadShell, } from "../state/entities"; @@ -441,8 +434,6 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; -type ThreadPlanCatalogEntry = Pick; - function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { @@ -1244,7 +1235,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -1314,10 +1304,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); + const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1550,10 +1537,10 @@ function ChatViewContent(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1580,36 +1567,11 @@ function ChatViewContent(props: ChatViewProps) { previewPanelOpen, ]); - const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const sourcePlanThreadRef = useMemo(() => { - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { - return null; - } - return scopeThreadRef(activeThread.environmentId, sourceThreadId); - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); - const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); - const threadPlanCatalog = useMemo(() => { - if (!activeThread) { - return []; - } - const entries: ThreadPlanCatalogEntry[] = [ - { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, - ]; - if (sourcePlanThreadRef) { - entries.push({ - id: sourcePlanThreadRef.threadId, - proposedPlans: sourceThreadProposedPlans, - }); - } - return entries; - }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -2051,6 +2013,7 @@ function ChatViewContent(props: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). @@ -2113,21 +2076,25 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const sidebarProposedPlan = useMemo( - () => - findSidebarProposedPlan({ - threads: threadPlanCatalog, - latestTurn: activeLatestTurn, - latestTurnSettled, - threadId: activeThread?.id ?? null, - }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], - ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; + // Current step for the in-chat working row: only for the running turn's own + // plan (deriveActivePlanState falls back to older turns' plans, which must + // not label fresh work). Falls back to the first pending step so an + // all-pending freshly written plan labels the row, matching the chip and + // the server's planProgress. + const workingStepLabel = useMemo(() => { + if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { + return null; + } + return ( + activePlan.steps.find((step) => step.status === "inProgress")?.step ?? + activePlan.steps.find((step) => step.status === "pending")?.step ?? + null + ); + }, [activeLatestTurn?.turnId, activePlan]); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -2396,8 +2363,13 @@ function ChatViewContent(props: ChatViewProps) { }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + turnPlans, + ), + [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -3103,47 +3075,15 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const dismissPlanSidebarForCurrentTurn = useCallback(() => { - if (!activeThreadKey) return; - dismissPlanSidebarForTurn( - activeThreadKey, - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__", - ); - }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]); - const togglePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } else if (activeThreadKey) { - clearPlanSidebarDismissal(activeThreadKey); - } - useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); - const closePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); - dismissPlanSidebarForCurrentTurn(); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { if (!activeThreadRef) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [ - activeThreadRef, - dismissPlanSidebarForCurrentTurn, - isGitRepo, - isServerThread, - onDiffPanelOpen, - planSidebarOpen, - ]); + }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -3279,11 +3219,6 @@ function ChatViewContent(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - if (surface.kind === "plan") { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - } else if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -3295,20 +3230,16 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], + [activeThreadRef, diffOpen, onDiffPanelOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { - if (planSidebarOpen) { - closePlanSidebar(); - } else { - closePreviewPanel(); - } + closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); + }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -3318,10 +3249,6 @@ function ChatViewContent(props: ChatViewProps) { const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - if (surfaces.some((surface) => surface.kind === "plan")) { - dismissPlanSidebarForCurrentTurn(); - } - for (const surface of surfaces) { if (surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ @@ -3347,7 +3274,6 @@ function ChatViewContent(props: ChatViewProps) { activePreviewState.sessions, closePreview, closeTerminalMutation, - dismissPlanSidebarForCurrentTurn, storeCloseTerminal, ], ); @@ -3846,37 +3772,9 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - if (planSidebarOpenOnNextThreadRef.current) { - planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); - // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. - // Don't auto-open for plans carried over from a previous turn (the user can open manually). - useEffect(() => { - if (!autoOpenPlanSidebar) return; - if (!activePlan) return; - if (planSidebarOpen) return; - const latestTurnId = activeLatestTurn?.turnId ?? null; - if (latestTurnId && activePlan.turnId !== latestTurnId) return; - const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - if (!activeThreadRef) return; - if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return; - useRightPanelStore.getState().open(activeThreadRef, "plan"); - }, [ - activePlan, - activeLatestTurn?.turnId, - activeThreadRef, - autoOpenPlanSidebar, - planSidebarOpen, - sidebarProposedPlan?.turnId, - ]); - useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -5415,15 +5313,6 @@ function ChatViewContent(props: ChatViewProps) { if (failure === null) { acknowledgeActiveThreadWoke(); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default" && autoOpenPlanSidebar) { - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } sendInFlightRef.current = false; return; } @@ -5456,7 +5345,6 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ], @@ -5559,8 +5447,6 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { - // Signal that the plan sidebar should open on the new thread when enabled. - planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", @@ -5617,7 +5503,6 @@ function ChatViewContent(props: ChatViewProps) { resetLocalDispatch, runtimeMode, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ]); @@ -5809,12 +5694,12 @@ function ChatViewContent(props: ChatViewProps) {
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( + {rightPanelOpen && !shouldUseRightPanelSheet ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( - ) : activeRightPanelSurface?.kind === "agents" ? ( - {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
- {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - label?: string; - environmentId: EnvironmentId; - threadRef?: ScopedThreadRef | undefined; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar" | "embedded"; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - label = "Plan", - environmentId, - threadRef, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { - reportFailure: false, - }); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - if (!workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void (async () => { - const result = await writeProjectFile({ - environmentId, - input: { - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }, - }); - setIsSavingToWorkspace(false); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.value.relativePath, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); - - return ( -
- {/* Header */} -
-
- - {label} - - {activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null} -
-
- {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} -
-
- - {/* Content */} - -
- {/* Explanation */} - {activePlan?.explanation ? ( -

- {activePlan.explanation} -

- ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} -

-
- ))} -
- ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
- - {proposedPlanExpanded ? ( -
- -
- ) : null} -
- ) : null} - - {/* Empty state */} - {!activePlan && !planMarkdown ? ( -
-

No active plan yet.

-

- Plans will appear here when generated. -

-
- ) : null} -
-
-
- ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b0f18f6e126..b9345ab8c3c 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,6 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -213,8 +213,6 @@ function surfaceTitle( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); - case "plan": - return "Plan"; case "agents": return "Agents"; case "preview": { @@ -276,8 +274,6 @@ function SurfaceIcon({ ); case "terminal": return ; - case "plan": - return ; case "agents": return ; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 462bfde13b7..aff76bd13b1 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1098,7 +1098,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
- {thread.branch ? ( + {/* While working, the current plan step outranks the branch: + it's the one line that says what the thread is doing. */} + {status === "working" && thread.planProgress ? ( + + {thread.planProgress.step} + {/* Completed count, matching the transcript chip's n/m. */} + + {" "} + {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} + + + ) : thread.branch ? ( {thread.branch} ) : ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f6d34315dac..6f3a6ec22cd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -10,7 +10,6 @@ import type { ScopedThreadRef, ServerProvider, ThreadId, - TurnId, } from "@t3tools/contracts"; import { ProviderDriverKind, @@ -195,7 +194,6 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, - ListTodoIcon, PencilRulerIcon, type LucideIcon, LockIcon, @@ -300,12 +298,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; - showPlanToggle: boolean; - planSidebarLabel: string; - planSidebarOpen: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; - onTogglePlanSidebar: () => void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -313,9 +307,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode"; - const planSidebarTooltip = props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; const interactionModeToggle = props.showInteractionModeToggle ? ( <> @@ -391,36 +382,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {interactionModeToggle} - - {props.showPlanToggle ? ( - <> - - - - } - > - - {props.planSidebarLabel} - - {planSidebarTooltip} - - - ) : null} ); }); @@ -576,10 +537,6 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; - activePlan: { turnId?: TurnId } | null; - sidebarProposedPlan: { turnId?: TurnId } | null; - planSidebarLabel: string; - planSidebarOpen: boolean; // Mode runtimeMode: RuntimeMode; @@ -632,7 +589,6 @@ export interface ChatComposerProps { toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; - togglePlanSidebar: () => void; focusComposer: () => void; scheduleComposerFocus: () => void; @@ -675,10 +631,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) respondingRequestIds, showPlanFollowUpPrompt, activeProposedPlan, - activePlan, - sidebarProposedPlan, - planSidebarLabel, - planSidebarOpen, runtimeMode, interactionMode, lockedProvider, @@ -709,7 +661,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode, handleRuntimeModeChange, handleInteractionModeChange, - togglePlanSidebar, focusComposer, scheduleComposerFocus, setThreadError, @@ -1180,7 +1131,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; @@ -3187,15 +3137,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {isComposerFooterCompact ? ( ) : ( @@ -3210,12 +3156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} interactionMode={interactionMode} runtimeMode={runtimeMode} - showPlanToggle={showPlanSidebarToggle} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} - onTogglePlanSidebar={togglePlanSidebar} /> )} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index b808f562920..20b57dea8c3 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,10 +1,9 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { EllipsisIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, - MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -13,15 +12,11 @@ import { } from "../ui/menu"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { - activePlan: boolean; interactionMode: ProviderInteractionMode; - planSidebarLabel: string; - planSidebarOpen: boolean; runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { return ( @@ -74,17 +69,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Auto Full access - {props.activePlan ? ( - <> - - - - {props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} - - - ) : null} ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index e5ecdbd2004..718c8519723 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -4,6 +4,7 @@ import { workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, + type TurnPlanEntry, type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; @@ -175,6 +176,12 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } + | { + kind: "turn-plan"; + id: string; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { @@ -550,6 +557,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.kind === "turn-plan") { + nextRows.push({ + kind: "turn-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + turnPlan: timelineEntry.turnPlan, + }); + continue; + } + const assistantTurnStillInProgress = timelineEntry.message.role === "assistant" && unsettledTurnId !== null && @@ -633,6 +650,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; + case "turn-plan": { + const bp = b as typeof a; + // Plans rewrite in place: compare the snapshot's identity fields so an + // unchanged plan keeps its row reference (virtualization stability). + return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; + } + case "work": return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c952eb3d128..906509832c2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -152,6 +152,8 @@ interface TimelineRowActivityState { isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; latestTurnId: TurnId | null; + /** Current plan step label for the working row, when the turn has a plan. */ + workingStepLabel: string | null; } const TimelineRowCtx = createContext(null!); @@ -169,6 +171,7 @@ interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; + workingStepLabel?: string | null; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; @@ -204,6 +207,7 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, + workingStepLabel = null, activeTurnInProgress, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, @@ -470,8 +474,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isRevertingCheckpoint, activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, + workingStepLabel, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], + [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], ); // Stable renderItem — no closure deps. Row components read shared state @@ -861,7 +866,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time // they sit closer to the work that follows them. (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "turn-plan" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -879,6 +885,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} + {row.kind === "turn-plan" ? : null} {row.kind === "working" ? : null}
); @@ -1109,16 +1116,117 @@ function ProposedPlanTimelineRow({ ); } +/** + * Inline folded plan chip: one row per turn that produced plan/todo steps. + * Collapsed by default — a segment bar plus the in-progress step label — + * and expands in place to the full step list. Replaces the old plan sidebar. + */ +const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ + row, +}: { + row: Extract; +}) { + const [expanded, setExpanded] = useState(false); + const { steps } = row.turnPlan.plan; + const completedCount = steps.filter((step) => step.status === "completed").length; + const allDone = completedCount === steps.length; + // Label priority: the in-progress step, else the next pending step (plan + // just created), else the last step (plan finished, rendered muted). + const label = + steps.find((step) => step.status === "inProgress")?.step ?? + steps.find((step) => step.status === "pending")?.step ?? + steps.at(-1)?.step ?? + "Plan"; + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + + return ( +
+ + {expanded ? ( +
+ {steps.map((step) => ( +
+ + {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} + + + {step.step} + +
+ ))} +
+ ) : null} +
+ ); +}); + function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return (
-
+
- + {row.createdAt ? ( <> Working for @@ -1127,6 +1235,9 @@ function WorkingTimelineRow({ row }: { row: Extract + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe319568..b1c50e8717a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -623,9 +623,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), - ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar - ? ["Auto-open task panel"] - : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -655,7 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) { [ isTextGenerationModelDirty, isBackgroundActivityDirty, - settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -701,7 +697,6 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1897,32 +1892,6 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, - }) - } - /> - ) : null - } - control={ - - updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) - } - aria-label="Open the task panel automatically" - /> - } - /> - (); - -export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void { - dismissedTurnByThreadKey.set(threadKey, turnKey); -} - -export function clearPlanSidebarDismissal(threadKey: string): void { - dismissedTurnByThreadKey.delete(threadKey); -} - -export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean { - return dismissedTurnByThreadKey.get(threadKey) === turnKey; -} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c7457cfd304..69831242f2f 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -102,6 +102,41 @@ describe("rightPanelStore", () => { }); }); + it("drops persisted plan surfaces and does not reopen an empty panel", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [ + { id: "plan", kind: "plan" }, + { id: "diff", kind: "diff" }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: false, + activeSurfaceId: null, + surfaces: [], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }, + }, + }); + }); + it("open sets the active panel for a thread", () => { useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); @@ -109,7 +144,7 @@ describe("rightPanelStore", () => { }); it("opening a different kind keeps both surfaces and activates the new one", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); expect( @@ -119,7 +154,7 @@ describe("rightPanelStore", () => { it("reopening an inactive singleton activates its existing surface", () => { useRightPanelStore.getState().open(refA, "diff"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "diff"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ @@ -127,7 +162,7 @@ describe("rightPanelStore", () => { activeSurfaceId: "diff", surfaces: [ { id: "diff", kind: "diff" }, - { id: "plan", kind: "plan" }, + { id: "agents", kind: "agents" }, ], }); }); @@ -207,15 +242,15 @@ describe("rightPanelStore", () => { it("removes persisted file surfaces when their workspace no longer exists", () => { useRightPanelStore.getState().openFile(refA, "src/index.ts"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().openFile(refA, "README.md"); useRightPanelStore.getState().reconcileFileSurfaces(refA, false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); useRightPanelStore.getState().openFile(refB, "conductor.json"); @@ -228,13 +263,13 @@ describe("rightPanelStore", () => { }); it("close hides the panel without clearing its selected surface", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().close(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: false, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); }); @@ -264,12 +299,12 @@ describe("rightPanelStore", () => { it("toggle to a different kind switches active", () => { useRightPanelStore.getState().toggle(refA, "preview"); - useRightPanelStore.getState().toggle(refA, "plan"); - expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); + useRightPanelStore.getState().toggle(refA, "agents"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents"); }); it("removeThread clears persisted state", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().removeThread(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index cccb7238ca8..2e72c7b4e10 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -5,7 +5,7 @@ * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at - * workspace paths, and diff/plan/files remain singleton surfaces. + * workspace paths, and diff/files remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; export const RIGHT_PANEL_KINDS = [ - "plan", "diff", "files", "file", @@ -45,11 +44,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 8; +// v9 removed the "plan" surface kind (plans render inline in the transcript). +const RIGHT_PANEL_STORAGE_VERSION = 9; export interface ThreadRightPanelState { isOpen: boolean; @@ -99,8 +98,6 @@ const singletonSurface = ( return { id: "diff", kind }; case "files": return { id: "files", kind }; - case "plan": - return { id: "plan", kind }; case "agents": return { id: "agents", kind }; } @@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap((surface) => { + // Dropped surface kind: plans now render inline in the + // transcript (v9). + if ((surface as { kind?: string }).kind === "plan") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && @@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): { ]; }) : []; - const activeSurfaceId = surfaces.some( + const persistedActiveSurfaceId = surfaces.some( (surface) => surface.id === validThreadState?.activeSurfaceId, ) ? (validThreadState?.activeSurfaceId ?? null) : null; + // A migration that dropped every surface (e.g. plan-only panels + // in v9) must not reopen an empty panel. const isOpen = - typeof validThreadState?.isOpen === "boolean" + surfaces.length > 0 && + (typeof validThreadState?.isOpen === "boolean" ? validThreadState.isOpen - : activeSurfaceId !== null; + : persistedActiveSurfaceId !== null); + // An open panel needs an active surface: if migration dropped + // the persisted one (e.g. plan was active), fall back to the + // first survivor instead of rendering an open empty panel. + const activeSurfaceId = + persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); return [threadKey, { isOpen, surfaces, activeSurfaceId }]; }, ), diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3732afbd338..f5effff6602 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -11,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveActiveWorkStartedAt, deriveActivePlanState, + deriveTurnPlans, derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, deriveWorkLogEntries, findLatestProposedPlan, - findSidebarProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, workEntryIndicatesToolFailure, @@ -410,6 +410,95 @@ describe("deriveActivePlanState", () => { }); }); +describe("deriveTurnPlans", () => { + it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-1a", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "inProgress" }], + }, + }), + makeActivity({ + id: "plan-1b", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "completed" }], + }, + }), + makeActivity({ + id: "plan-2a", + createdAt: "2026-02-23T00:01:00.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-2", + payload: { + plan: [{ step: "Ship it", status: "pending" }], + }, + }), + ]; + + const turnPlans = deriveTurnPlans(activities); + expect(turnPlans).toHaveLength(2); + expect(turnPlans[0]).toMatchObject({ + id: "turn-plan:turn-1", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + }); + expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]); + expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]); + }); + + it("skips activities without parseable steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-bad", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); + + it("drops a turn's chip when a later snapshot clears the plan", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-set", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [{ step: "Inspect code", status: "inProgress" }] }, + }), + makeActivity({ + id: "plan-clear", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); +}); + describe("findLatestProposedPlan", () => { it("prefers the latest proposed plan for the active turn", () => { expect( @@ -515,103 +604,6 @@ describe("hasActionableProposedPlan", () => { }); }); -describe("findSidebarProposedPlan", () => { - it("prefers the running turn source proposed plan when available on the same thread", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: ThreadId.make("thread-2"), - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - ], - }, - { - id: ThreadId.make("thread-2"), - proposedPlans: [ - { - id: "plan-2", - turnId: TurnId.make("turn-other"), - planMarkdown: "# Latest elsewhere", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:04.000Z", - updatedAt: "2026-02-23T00:00:05.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: false, - threadId: ThreadId.make("thread-1"), - }), - ).toEqual({ - id: "plan-1", - turnId: "turn-plan", - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: "thread-2", - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }); - }); - - it("falls back to the latest proposed plan once the turn is settled", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Older", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - { - id: "plan-2", - turnId: TurnId.make("turn-latest"), - planMarkdown: "# Latest", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:03.000Z", - updatedAt: "2026-02-23T00:00:04.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: true, - threadId: ThreadId.make("thread-1"), - })?.planMarkdown, - ).toBe("# Latest"); - }); -}); - describe("workEntryIndicatesToolFailure", () => { const base = { id: "w1", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a1ff70bc043..4d0a76cf133 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -151,6 +151,12 @@ export type TimelineEntry = createdAt: string; proposedPlan: ProposedPlan; } + | { + id: string; + kind: "turn-plan"; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { id: string; kind: "work"; @@ -532,26 +538,10 @@ export function derivePendingUserInputs( ); } -export function deriveActivePlanState( - activities: ReadonlyArray, - latestTurnId: TurnId | undefined, -): ActivePlanState | null { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); - // Prefer plan from the current turn; fall back to the most recent plan from any turn - // so that TodoWrite tasks persist across follow-up messages. - const latest = Option.firstSomeOf([ - ...(latestTurnId - ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) - : Option.none()), - Arr.last(allPlanActivities), - ]).pipe(Option.getOrNull); - if (!latest) { - return null; - } +function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null { const payload = - latest.payload && typeof latest.payload === "object" - ? (latest.payload as Record) + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) : null; const rawPlan = payload?.plan; if (!Array.isArray(rawPlan)) { @@ -580,8 +570,8 @@ export function deriveActivePlanState( return null; } return { - createdAt: latest.createdAt, - turnId: latest.turnId, + createdAt: activity.createdAt, + turnId: activity.turnId, ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}), @@ -589,6 +579,72 @@ export function deriveActivePlanState( }; } +export function deriveActivePlanState( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): ActivePlanState | null { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); + // Prefer plan from the current turn; fall back to the most recent plan from any turn + // so that TodoWrite tasks persist across follow-up messages. + const latest = Option.firstSomeOf([ + ...(latestTurnId + ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) + : Option.none()), + Arr.last(allPlanActivities), + ]).pipe(Option.getOrNull); + if (!latest) { + return null; + } + return planStateFromActivity(latest); +} + +export interface TurnPlanEntry { + /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */ + id: string; + /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */ + createdAt: string; + turnId: TurnId | null; + plan: ActivePlanState; +} + +/** + * One inline plan chip per turn that produced plan/todo steps: the latest + * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less + * plan activities collapse into a single chip keyed by thread order. + */ +export function deriveTurnPlans( + activities: ReadonlyArray, +): TurnPlanEntry[] { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const byTurn = new Map(); + for (const activity of ordered) { + if (activity.kind !== "turn.plan.updated") { + continue; + } + const plan = planStateFromActivity(activity); + const key = activity.turnId ?? "no-turn"; + if (!plan) { + // A later snapshot with no steps clears the turn's plan; keeping the + // stale entry would freeze the chip on a withdrawn plan. + byTurn.delete(key); + continue; + } + const existing = byTurn.get(key); + if (existing) { + existing.plan = plan; + } else { + byTurn.set(key, { + id: `turn-plan:${key}`, + createdAt: activity.createdAt, + turnId: activity.turnId, + plan, + }); + } + } + return [...byTurn.values()]; +} + export function findLatestProposedPlan( proposedPlans: ReadonlyArray, latestTurnId: TurnId | string | null | undefined, @@ -619,30 +675,6 @@ export function findLatestProposedPlan( return toLatestProposedPlanState(latestPlan); } -export function findSidebarProposedPlan(input: { - threads: ReadonlyArray>; - latestTurn: Pick | null; - latestTurnSettled: boolean; - threadId: ThreadId | string | null | undefined; -}): LatestProposedPlanState | null { - const activeThreadPlans = - input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; - - if (!input.latestTurnSettled) { - const sourceProposedPlan = input.latestTurn?.sourceProposedPlan; - if (sourceProposedPlan) { - const sourcePlan = input.threads - .find((thread) => thread.id === sourceProposedPlan.threadId) - ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId); - if (sourcePlan) { - return toLatestProposedPlanState(sourcePlan); - } - } - } - - return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null); -} - export function hasActionableProposedPlan( proposedPlan: LatestProposedPlanState | Pick | null, ): boolean { @@ -1542,6 +1574,7 @@ export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, workEntries: ReadonlyArray, + turnPlans: ReadonlyArray = [], ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1555,13 +1588,19 @@ export function deriveTimelineEntries( createdAt: proposedPlan.createdAt, proposedPlan, })); + const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({ + id: turnPlan.id, + kind: "turn-plan", + createdAt: turnPlan.createdAt, + turnPlan, + })); const workRows: TimelineEntry[] = workEntries.map((entry) => ({ id: entry.id, kind: "work", createdAt: entry.createdAt, entry, })); - return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => + return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt), ); } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c9baa6ac670..9adeb77baf7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -445,6 +445,20 @@ export const OrchestrationThreadShell = Schema.Struct({ * live work. Optional so old servers/clients interop; absent = none. */ backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))), + /** + * Current plan step while a turn runs, for the Working indicators + * (sidebar row, in-chat working line). Cleared when the turn settles — + * never persists as stale UI. Optional so old servers/clients interop. + */ + planProgress: Schema.optional( + Schema.NullOr( + Schema.Struct({ + step: TrimmedNonEmptyString, + completedSteps: NonNegativeInt, + totalSteps: NonNegativeInt, + }), + ), + ), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb..7679ab6e492 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ - autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -747,7 +746,6 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ - autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean),