diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e2fd573fe8..0f0250e48f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -256,6 +256,61 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-plan-target" as MessageId, + targetText: "plan thread", + }); + const planMarkdown = [ + "# Ship plan mode follow-up", + "", + "- Step 1: capture the thread-open trace", + "- Step 2: identify the main-thread bottleneck", + "- Step 3: keep collapsed cards cheap", + "- Step 4: render the full markdown only on demand", + "- Step 5: preserve export and save actions", + "- Step 6: add regression coverage", + "- Step 7: verify route transitions stay responsive", + "- Step 8: confirm no server-side work changed", + "- Step 9: confirm short plans still render normally", + "- Step 10: confirm long plans stay collapsed by default", + "- Step 11: confirm preview text is still useful", + "- Step 12: confirm plan follow-up flow still works", + "- Step 13: confirm timeline virtualization still behaves", + "- Step 14: confirm theme styling still looks correct", + "- Step 15: confirm save dialog behavior is unchanged", + "- Step 16: confirm download behavior is unchanged", + "- Step 17: confirm code fences do not parse until expand", + "- Step 18: confirm preview truncation ends cleanly", + "- Step 19: confirm markdown links still open in editor after expand", + "- Step 20: confirm deep hidden detail only appears after expand", + "", + "```ts", + "export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';", + "```", + ].join("\n"); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + proposedPlans: [ + { + id: "plan-browser-test", + turnId: null, + planMarkdown, + createdAt: isoAt(1_000), + updatedAt: isoAt(1_001), + }, + ], + updatedAt: isoAt(1_001), + }) + : thread, + ), + }; +} + function resolveWsRpc(tag: string): unknown { if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; @@ -867,4 +922,41 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("keeps long proposed plans lightweight until the user expands them", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithLongProposedPlan(), + }); + + try { + await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Expand plan", + ) as HTMLButtonElement | null, + "Unable to find Expand plan button.", + ); + + expect(document.body.textContent).not.toContain("deep hidden detail only after expand"); + + const expandButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Expand plan", + ) as HTMLButtonElement | null, + "Unable to find Expand plan button.", + ); + expandButton.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("deep hidden detail only after expand"); + }, + { 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 5fb47b1a9a..2eaeb64f07 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -89,6 +89,7 @@ import { } from "../pendingUserInput"; import { useStore } from "../store"; import { + buildCollapsedProposedPlanPreviewMarkdown, buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, @@ -96,6 +97,7 @@ import { normalizePlanMarkdownForExport, proposedPlanTitle, resolvePlanFollowUpSubmission, + stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { @@ -201,11 +203,7 @@ import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { - getAppModelOptions, - resolveAppModelSelection, - useAppSettings, -} from "../appSettings"; +import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -292,8 +290,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { return "text-muted-foreground/40"; } - - interface ExpandedImageItem { src: string; name: string; @@ -1963,8 +1959,6 @@ export default function ChatView({ threadId }: ChatViewProps) { planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); - - useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); @@ -2688,9 +2682,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), provider: selectedProvider, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2968,9 +2960,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, @@ -3079,9 +3069,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), - ...(providerOptionsForDispatch - ? { providerOptions: providerOptionsForDispatch } - : {}), + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", @@ -3503,513 +3491,537 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Chat column */}
- - {/* Messages */} -
- 0} - isWorking={isWorking} - activeTurnInProgress={isWorking || !latestTurnSettled} - activeTurnStartedAt={activeWorkStartedAt} - scrollContainer={messagesScrollElement} - timelineEntries={timelineEntries} - completionDividerBeforeEntryId={completionDividerBeforeEntryId} - completionSummary={completionSummary} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - nowIso={nowIso} - expandedWorkGroups={expandedWorkGroups} - onToggleWorkGroup={onToggleWorkGroup} - onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - markdownCwd={gitCwd ?? undefined} - resolvedTheme={resolvedTheme} - workspaceRoot={activeProject?.cwd ?? undefined} - /> -
- - {/* Input bar */} -
-
+ {/* Messages */}
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} + 0} + isWorking={isWorking} + activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnStartedAt={activeWorkStartedAt} + scrollContainer={messagesScrollElement} + timelineEntries={timelineEntries} + completionDividerBeforeEntryId={completionDividerBeforeEntryId} + completionSummary={completionSummary} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + nowIso={nowIso} + expandedWorkGroups={expandedWorkGroups} + onToggleWorkGroup={onToggleWorkGroup} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onExpandTimelineImage} + markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} + workspaceRoot={activeProject?.cwd ?? undefined} + /> +
- {/* Textarea area */} -
+ - {composerMenuOpen && !isComposerApprovalState && ( -
- -
- )} - - {!isComposerApprovalState && pendingUserInputs.length === 0 && composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} - -
- - {/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : (
+ {activePendingApproval ? ( +
+ +
+ ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} + + {/* Textarea area */}
- {/* Provider/model picker */} - + +
+ )} + + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.length > 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} + +
- {isComposerFooterCompact ? ( - + - ) : ( - <> - {selectedProvider === "codex" && selectedEffort != null ? ( - <> - - - - ) : null} +
+ ) : ( +
+
+ {/* Provider/model picker */} + - + {isComposerFooterCompact ? ( + + ) : ( + <> + {selectedProvider === "codex" && selectedEffort != null ? ( + <> + + + + ) : null} - + - + - + - {(activePlan || activeProposedPlan || planSidebarOpen) ? ( - <> - - - ) : null} - - )} -
- {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - Preparing worktree... - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} - + {activePlan || activeProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )}
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( - - ) : ( -
+ {isPreparingWorktree ? ( + + Preparing worktree... + + ) : null} + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} - - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in new thread - - -
- ) - ) : ( - + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( + + ) : ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in new thread + + + +
+ ) ) : ( - - )} - - ) - ) : null} -
+ {isConnecting || isSendBusy ? ( + + ) : ( + + )} + + ) + ) : null} +
+
+ )}
- )} +
- - - - {isGitRepo && ( - - )} - {/* end chat column */} + {isGitRepo && ( + + )} + + {/* end chat column */} {/* Plan sidebar */} {planSidebarOpen ? ( @@ -4028,7 +4040,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }} /> ) : null} - {/* end horizontal flex container */} + + {/* end horizontal flex container */} {(() => { if (!terminalState.terminalOpen || !activeProject) { @@ -4465,10 +4478,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement - ) { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } // If the user has started typing a custom answer in the contenteditable @@ -4542,12 +4552,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}
- {isSelected ? ( - - ) : null} + {isSelected ? : null} ); })} @@ -4758,6 +4768,10 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan"; const lineCount = planMarkdown.split("\n").length; const canCollapse = planMarkdown.length > 900 || lineCount > 20; + const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); + const collapsedPreview = canCollapse + ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 }) + : null; const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); const saveContents = normalizePlanMarkdownForExport(planMarkdown); @@ -4847,7 +4861,11 @@ const ProposedPlanCard = memo(function ProposedPlanCard({
- + {canCollapse && !expanded ? ( + + ) : ( + + )} {canCollapse && !expanded ? (
) : null} @@ -5609,7 +5627,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { variant="ghost" className={cn( "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80", - props.compact ? "max-w-[10.5rem]" : "sm:px-3", + props.compact ? "max-w-42" : "sm:px-3", )} disabled={props.disabled} /> @@ -5618,7 +5636,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {