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 */}
-
+ ) : (
+
+
+ {/* 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" ? (
-
+ )}
- )}
+
-
-
-
- {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: {
diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx
index 5ab2f5e32c..b2a9744f07 100644
--- a/apps/web/src/components/PlanSidebar.tsx
+++ b/apps/web/src/components/PlanSidebar.tsx
@@ -20,6 +20,7 @@ import {
buildProposedPlanMarkdownFilename,
normalizePlanMarkdownForExport,
downloadPlanAsTextFile,
+ stripDisplayedPlanMarkdown,
} from "../proposedPlan";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
import { readNativeApi } from "~/nativeApi";
@@ -67,6 +68,7 @@ const PlanSidebar = memo(function PlanSidebar({
const [copied, setCopied] = useState(false);
const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
+ const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
const handleCopyPlan = useCallback(() => {
@@ -237,7 +239,7 @@ const PlanSidebar = memo(function PlanSidebar({
{proposedPlanExpanded ? (
diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts
index b8431bdbcc..e5e917bd4c 100644
--- a/apps/web/src/proposedPlan.test.ts
+++ b/apps/web/src/proposedPlan.test.ts
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import {
+ buildCollapsedProposedPlanPreviewMarkdown,
buildPlanImplementationThreadTitle,
buildPlanImplementationPrompt,
buildProposedPlanMarkdownFilename,
proposedPlanTitle,
resolvePlanFollowUpSubmission,
+ stripDisplayedPlanMarkdown,
} from "./proposedPlan";
describe("proposedPlanTitle", () => {
@@ -26,6 +28,38 @@ describe("buildPlanImplementationPrompt", () => {
});
});
+describe("buildCollapsedProposedPlanPreviewMarkdown", () => {
+ it("drops the redundant title heading and preserves the following markdown lines", () => {
+ expect(
+ buildCollapsedProposedPlanPreviewMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n- step 2", {
+ maxLines: 4,
+ }),
+ ).toBe("- step 1\n- step 2");
+ });
+
+ it("appends an overflow marker when the preview truncates remaining content", () => {
+ expect(
+ buildCollapsedProposedPlanPreviewMarkdown("# Integrate RPC\n\n- step 1\n- step 2\n- step 3", {
+ maxLines: 2,
+ }),
+ ).toBe("- step 1\n- step 2\n\n...");
+ });
+});
+
+describe("stripDisplayedPlanMarkdown", () => {
+ it("drops the leading title heading from displayed plan markdown", () => {
+ expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n")).toBe(
+ "- step 1",
+ );
+ });
+
+ it("preserves non-summary headings after dropping the title heading", () => {
+ expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe(
+ "## Scope\n\n- step 1",
+ );
+ });
+});
+
describe("resolvePlanFollowUpSubmission", () => {
it("switches to default mode when implementing the ready plan without extra text", () => {
expect(
diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts
index 3bd4f62e60..8a5f18b6f0 100644
--- a/apps/web/src/proposedPlan.ts
+++ b/apps/web/src/proposedPlan.ts
@@ -3,6 +3,65 @@ export function proposedPlanTitle(planMarkdown: string): string | null {
return heading && heading.length > 0 ? heading : null;
}
+export function stripDisplayedPlanMarkdown(planMarkdown: string): string {
+ const lines = planMarkdown.trimEnd().split(/\r?\n/);
+ const sourceLines =
+ lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
+ while (sourceLines[0]?.trim().length === 0) {
+ sourceLines.shift();
+ }
+ const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/);
+ if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") {
+ sourceLines.shift();
+ while (sourceLines[0]?.trim().length === 0) {
+ sourceLines.shift();
+ }
+ }
+ return sourceLines.join("\n");
+}
+
+export function buildCollapsedProposedPlanPreviewMarkdown(
+ planMarkdown: string,
+ options?: {
+ maxLines?: number;
+ },
+): string {
+ const maxLines = options?.maxLines ?? 8;
+ const lines = stripDisplayedPlanMarkdown(planMarkdown)
+ .trimEnd()
+ .split(/\r?\n/)
+ .map((line) => line.trimEnd());
+ const previewLines: string[] = [];
+ let visibleLineCount = 0;
+ let hasMoreContent = false;
+
+ for (const line of lines) {
+ const isVisibleLine = line.trim().length > 0;
+ if (isVisibleLine && visibleLineCount >= maxLines) {
+ hasMoreContent = true;
+ break;
+ }
+ previewLines.push(line);
+ if (isVisibleLine) {
+ visibleLineCount += 1;
+ }
+ }
+
+ while (previewLines.length > 0 && previewLines.at(-1)?.trim().length === 0) {
+ previewLines.pop();
+ }
+
+ if (previewLines.length === 0) {
+ return proposedPlanTitle(planMarkdown) ?? "Plan preview unavailable.";
+ }
+
+ if (hasMoreContent) {
+ previewLines.push("", "...");
+ }
+
+ return previewLines.join("\n");
+}
+
function sanitizePlanFileSegment(input: string): string {
const sanitized = input
.toLowerCase()