diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..f4690ed14 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,12 +2,14 @@ import "../index.css"; import { + EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, type ProjectId, type ServerConfig, type ThreadId, + TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -356,6 +358,43 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithActivePlan(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-active-plan-target" as MessageId, + targetText: "active plan thread", + }); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + activities: [ + { + id: EventId.makeUnsafe("active-plan-browser-test"), + createdAt: isoAt(1_050), + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: TurnId.makeUnsafe("turn-active-plan"), + sequence: 1, + payload: { + explanation: "Keep the plan row visible while trimming its footprint.", + plan: [ + { step: "Keep timeline rows aligned", status: "inProgress" }, + { step: "Preserve header context when collapsed", status: "pending" }, + { step: "Retain stable collapse state across remounts", status: "pending" }, + ], + }, + }, + ], + updatedAt: isoAt(1_051), + }) + : thread, + ), + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -1247,4 +1286,58 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("collapses the active plan container to its header row", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithActivePlan(), + }); + + try { + const collapseButton = await waitForElement( + () => + document.querySelector('button[aria-label="Collapse plan"]') as HTMLButtonElement | null, + "Unable to find Collapse plan button.", + ); + + await waitForElement( + () => document.querySelector('[data-active-plan-card="true"]') as HTMLDivElement | null, + "Unable to find active plan card.", + ); + + expect(document.body.textContent).toContain("Keep timeline rows aligned"); + collapseButton.click(); + + await vi.waitFor( + () => { + const activePlanRow = document.querySelector( + '[data-active-plan-card="true"]', + ) as HTMLDivElement | null; + expect(activePlanRow?.textContent).toContain("Plan"); + expect(activePlanRow?.textContent).toContain("Updated"); + expect(activePlanRow?.textContent).not.toContain("Keep timeline rows aligned"); + }, + { timeout: 8_000, interval: 16 }, + ); + + const expandButton = await waitForElement( + () => + document.querySelector('button[aria-label="Expand plan"]') as HTMLButtonElement | null, + "Unable to find Expand plan button.", + ); + expandButton.click(); + + await vi.waitFor( + () => { + const activePlanRow = document.querySelector( + '[data-active-plan-card="true"]', + ) as HTMLDivElement | null; + expect(activePlanRow?.textContent).toContain("Keep timeline rows aligned"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..255cb1d2b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -132,6 +132,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { ActivePlanCard } from "./chat/ActivePlanCard"; import { ChatHeader } from "./chat/ChatHeader"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; @@ -264,6 +265,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [collapsedActivePlanByTurnId, setCollapsedActivePlanByTurnId] = useState< + Record + >({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. @@ -638,6 +642,10 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const activePlanCollapseKey = activePlan ? (activePlan.turnId ?? activePlan.createdAt) : null; + const activePlanCollapsed = activePlanCollapseKey + ? (collapsedActivePlanByTurnId[activePlanCollapseKey] ?? false) + : false; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -3162,6 +3170,12 @@ export default function ChatView({ threadId }: ChatViewProps) { [groupId]: !existing[groupId], })); }, []); + const onToggleActivePlanCollapsed = useCallback((planKey: string) => { + setCollapsedActivePlanByTurnId((existing) => ({ + ...existing, + [planKey]: !(existing[planKey] ?? false), + })); + }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -3274,9 +3288,23 @@ export default function ChatView({ threadId }: ChatViewProps) { onTouchEnd={onMessagesTouchEnd} onTouchCancel={onMessagesTouchEnd} > + {activePlan ? ( +
+ { + if (activePlanCollapseKey) { + onToggleActivePlanCollapsed(activePlanCollapseKey); + } + }} + /> +
+ ) : null} 0} + hasMessages={timelineEntries.length > 0 || activePlan !== null} isWorking={isWorking} activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnStartedAt={activeWorkStartedAt} diff --git a/apps/web/src/components/chat/ActivePlanCard.tsx b/apps/web/src/components/chat/ActivePlanCard.tsx new file mode 100644 index 000000000..61309aab7 --- /dev/null +++ b/apps/web/src/components/chat/ActivePlanCard.tsx @@ -0,0 +1,122 @@ +import { memo } from "react"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import type { TimestampFormat } from "../../appSettings"; +import type { ActivePlanState } from "../../session-logic"; +import { formatTimestamp } from "../../timestampFormat"; +import { cn } from "~/lib/utils"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; + +interface ActivePlanCardProps { + activePlan: ActivePlanState; + collapsed: boolean; + timestampFormat: TimestampFormat; + onToggleCollapsed: () => void; +} + +export const ActivePlanCard = memo(function ActivePlanCard({ + activePlan, + collapsed, + timestampFormat, + onToggleCollapsed, +}: ActivePlanCardProps) { + const keyedSteps = buildKeyedSteps(activePlan.steps); + + return ( +
+
+
+ Plan +

+ Updated {formatTimestamp(activePlan.createdAt, timestampFormat)} +

+
+ +
+ {collapsed ? null : ( +
+ {activePlan.explanation?.trim() ? ( +

+ {activePlan.explanation} +

+ ) : null} +
+ {keyedSteps.map(({ key, step }) => ( +
+
+ + {statusLabel(step.status)} + +

+ {step.step} +

+
+
+ ))} +
+
+ )} +
+ ); +}); + +function statusLabel(status: ActivePlanState["steps"][number]["status"]): string { + if (status === "inProgress") return "In progress"; + if (status === "completed") return "Completed"; + return "Pending"; +} + +function buildKeyedSteps(steps: ActivePlanState["steps"]): Array<{ + key: string; + step: ActivePlanState["steps"][number]; +}> { + const occurrenceCountByBaseKey = new Map(); + + return steps.map((step) => { + const baseKey = `${step.status}:${step.step}`; + const nextOccurrence = (occurrenceCountByBaseKey.get(baseKey) ?? 0) + 1; + occurrenceCountByBaseKey.set(baseKey, nextOccurrence); + + return { + key: `${baseKey}:${nextOccurrence}`, + step, + }; + }); +}