diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 880bf8a507f..e745128e331 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -243,8 +243,8 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; -import { MessagesTimeline } from "./chat/MessagesTimeline"; -import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; +import { MessagesTimeline, TurnPlanTimelineRow } from "./chat/MessagesTimeline"; +import { excludePinnedTurnPlan, resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -2239,6 +2239,22 @@ function ChatViewContent(props: ChatViewProps) { threadError, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const pinnedActiveTurnPlan = useMemo(() => { + const activeTurnId = activeLatestTurn?.turnId ?? null; + if ( + phase !== "running" || + activeLatestTurn?.state !== "running" || + activeTurnId === null || + activePlan?.turnId !== activeTurnId + ) { + return null; + } + return turnPlans.find((turnPlan) => turnPlan.turnId === activeTurnId) ?? null; + }, [activeLatestTurn?.state, activeLatestTurn?.turnId, activePlan, phase, turnPlans]); + const turnPlansForTimeline = useMemo( + () => excludePinnedTurnPlan(turnPlans, pinnedActiveTurnPlan?.turnId ?? null), + [pinnedActiveTurnPlan?.turnId, turnPlans], + ); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -2490,9 +2506,9 @@ function ChatViewContent(props: ChatViewProps) { timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries, - turnPlans, + turnPlansForTimeline, ), - [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], + [activeThread?.proposedPlans, timelineMessages, turnPlansForTimeline, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -6185,6 +6201,11 @@ function ChatViewContent(props: ChatViewProps) { {threadSyncPhase && !activeEnvironmentUnavailable ? ( ) : null} + {pinnedActiveTurnPlan ? ( +
+ +
+ ) : null}
{ + it("removes only the currently pinned turn plan", () => { + const turnPlans = [ + { + id: "turn-plan:turn-1", + createdAt: "2026-01-01T00:00:00Z", + turnId: TurnId.make("turn-1"), + plan: { + createdAt: "2026-01-01T00:00:00Z", + turnId: TurnId.make("turn-1"), + steps: [{ step: "Inspect", status: "inProgress" as const }], + }, + }, + { + id: "turn-plan:turn-2", + createdAt: "2026-01-01T00:01:00Z", + turnId: TurnId.make("turn-2"), + plan: { + createdAt: "2026-01-01T00:01:00Z", + turnId: TurnId.make("turn-2"), + steps: [{ step: "Ship", status: "pending" as const }], + }, + }, + ]; + + expect(excludePinnedTurnPlan(turnPlans, TurnId.make("turn-1")).map((plan) => plan.id)).toEqual([ + "turn-plan:turn-2", + ]); + expect(excludePinnedTurnPlan(turnPlans, null)).toHaveLength(2); + }); +}); + describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { const result = computeMessageDurationStart([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6bc0a2a6203..3b1924d57cb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -210,6 +210,20 @@ export type MessagesTimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; +/** + * The active turn's plan is rendered in the composer overlay while it runs. + * Keep completed and historical turn plans in the chronological timeline. + */ +export function excludePinnedTurnPlan( + turnPlans: ReadonlyArray, + pinnedTurnId: TurnId | null, +): TurnPlanEntry[] { + if (pinnedTurnId === null) { + return [...turnPlans]; + } + return turnPlans.filter((turnPlan) => turnPlan.turnId !== pinnedTurnId); +} + export interface StableMessagesTimelineRowsState { byId: Map; result: MessagesTimelineRow[]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c6e28dcef5c..f8c9b40a106 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -37,6 +37,7 @@ import { workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, + type TurnPlanEntry, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; import { @@ -952,7 +953,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} - {row.kind === "turn-plan" ? : null} + {row.kind === "turn-plan" ? : null} {row.kind === "working" ? : null}
); @@ -1188,13 +1189,13 @@ function ProposedPlanTimelineRow({ * 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, +export const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ + turnPlan, }: { - row: Extract; + turnPlan: TurnPlanEntry; }) { const [expanded, setExpanded] = useState(false); - const { steps } = row.turnPlan.plan; + const { steps } = 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