authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 15:09:04-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 15:20:07-07:00
logeb7c428a2d3438f2ba72fb83c5df750236517a77
tree40e9594552753ca1a164626c808507d22d4d68f0
parent9fb9467ed1cf296c964a7a7d7a99ab1ad4e5e422

Defer proposed plan rendering until expand in ChatView

- Keep long plan cards collapsed with lightweight preview text by default - Render full plan markdown only after explicit expand to reduce thread open slowness - Add tests for preview/strip helpers and browser coverage for expand behavior

5 files changed, 725 insertions(+), 496 deletions(-)

apps/web/src/components/ChatView.browser.tsx+92
...@@ -256,6 +256,61 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {...@@ -256,6 +256,61 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
256 };256 };
257}257}
258258
259function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
260 const snapshot = createSnapshotForTargetUser({
261 targetMessageId: "msg-user-plan-target" as MessageId,
262 targetText: "plan thread",
263 });
264 const planMarkdown = [
265 "# Ship plan mode follow-up",
266 "",
267 "- Step 1: capture the thread-open trace",
268 "- Step 2: identify the main-thread bottleneck",
269 "- Step 3: keep collapsed cards cheap",
270 "- Step 4: render the full markdown only on demand",
271 "- Step 5: preserve export and save actions",
272 "- Step 6: add regression coverage",
273 "- Step 7: verify route transitions stay responsive",
274 "- Step 8: confirm no server-side work changed",
275 "- Step 9: confirm short plans still render normally",
276 "- Step 10: confirm long plans stay collapsed by default",
277 "- Step 11: confirm preview text is still useful",
278 "- Step 12: confirm plan follow-up flow still works",
279 "- Step 13: confirm timeline virtualization still behaves",
280 "- Step 14: confirm theme styling still looks correct",
281 "- Step 15: confirm save dialog behavior is unchanged",
282 "- Step 16: confirm download behavior is unchanged",
283 "- Step 17: confirm code fences do not parse until expand",
284 "- Step 18: confirm preview truncation ends cleanly",
285 "- Step 19: confirm markdown links still open in editor after expand",
286 "- Step 20: confirm deep hidden detail only appears after expand",
287 "",
288 "```ts",
289 "export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';",
290 "```",
291 ].join("\n");
292
293 return {
294 ...snapshot,
295 threads: snapshot.threads.map((thread) =>
296 thread.id === THREAD_ID
297 ? Object.assign({}, thread, {
298 proposedPlans: [
299 {
300 id: "plan-browser-test",
301 turnId: null,
302 planMarkdown,
303 createdAt: isoAt(1_000),
304 updatedAt: isoAt(1_001),
305 },
306 ],
307 updatedAt: isoAt(1_001),
308 })
309 : thread,
310 ),
311 };
312}
313
259function resolveWsRpc(tag: string): unknown {314function resolveWsRpc(tag: string): unknown {
260 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {315 if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
261 return fixture.snapshot;316 return fixture.snapshot;
...@@ -867,4 +922,41 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -867,4 +922,41 @@ describe("ChatView timeline estimator parity (full app)", () => {
867 await mounted.cleanup();922 await mounted.cleanup();
868 }923 }
869 });924 });
925
926 it("keeps long proposed plans lightweight until the user expands them", async () => {
927 const mounted = await mountChatView({
928 viewport: DEFAULT_VIEWPORT,
929 snapshot: createSnapshotWithLongProposedPlan(),
930 });
931
932 try {
933 await waitForElement(
934 () =>
935 Array.from(document.querySelectorAll("button")).find(
936 (button) => button.textContent?.trim() === "Expand plan",
937 ) as HTMLButtonElement | null,
938 "Unable to find Expand plan button.",
939 );
940
941 expect(document.body.textContent).not.toContain("deep hidden detail only after expand");
942
943 const expandButton = await waitForElement(
944 () =>
945 Array.from(document.querySelectorAll("button")).find(
946 (button) => button.textContent?.trim() === "Expand plan",
947 ) as HTMLButtonElement | null,
948 "Unable to find Expand plan button.",
949 );
950 expandButton.click();
951
952 await vi.waitFor(
953 () => {
954 expect(document.body.textContent).toContain("deep hidden detail only after expand");
955 },
956 { timeout: 8_000, interval: 16 },
957 );
958 } finally {
959 await mounted.cleanup();
960 }
961 });
870});962});
apps/web/src/components/ChatView.tsx+537-495
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31} from "@t3tools/shared/model";31} from "@t3tools/shared/model";
32import {32import {
33 memo,33 memo,
34 startTransition,
34 useCallback,35 useCallback,
35 useEffect,36 useEffect,
36 useLayoutEffect,37 useLayoutEffect,
...@@ -89,6 +90,7 @@ import {...@@ -89,6 +90,7 @@ import {
89} from "../pendingUserInput";90} from "../pendingUserInput";
90import { useStore } from "../store";91import { useStore } from "../store";
91import {92import {
93 buildCollapsedProposedPlanPreviewMarkdown,
92 buildPlanImplementationThreadTitle,94 buildPlanImplementationThreadTitle,
93 buildPlanImplementationPrompt,95 buildPlanImplementationPrompt,
94 buildProposedPlanMarkdownFilename,96 buildProposedPlanMarkdownFilename,
...@@ -96,6 +98,7 @@ import {...@@ -96,6 +98,7 @@ import {
96 normalizePlanMarkdownForExport,98 normalizePlanMarkdownForExport,
97 proposedPlanTitle,99 proposedPlanTitle,
98 resolvePlanFollowUpSubmission,100 resolvePlanFollowUpSubmission,
101 stripDisplayedPlanMarkdown,
99} from "../proposedPlan";102} from "../proposedPlan";
100import { truncateTitle } from "../truncateTitle";103import { truncateTitle } from "../truncateTitle";
101import {104import {
...@@ -201,11 +204,7 @@ import { Toggle } from "./ui/toggle";...@@ -201,11 +204,7 @@ import { Toggle } from "./ui/toggle";
201import { SidebarTrigger } from "./ui/sidebar";204import { SidebarTrigger } from "./ui/sidebar";
202import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";205import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
203import { readNativeApi } from "~/nativeApi";206import { readNativeApi } from "~/nativeApi";
204import {207import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings";
205 getAppModelOptions,
206 resolveAppModelSelection,
207 useAppSettings,
208} from "../appSettings";
209import {208import {
210 type ComposerImageAttachment,209 type ComposerImageAttachment,
211 type DraftThreadEnvMode,210 type DraftThreadEnvMode,
...@@ -292,8 +291,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {...@@ -292,8 +291,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
292 return "text-muted-foreground/40";291 return "text-muted-foreground/40";
293}292}
294293
295
296
297interface ExpandedImageItem {294interface ExpandedImageItem {
298 src: string;295 src: string;
299 name: string;296 name: string;
...@@ -1963,8 +1960,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1963,8 +1960,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1963 planSidebarDismissedForTurnRef.current = null;1960 planSidebarDismissedForTurnRef.current = null;
1964 }, [activeThread?.id]);1961 }, [activeThread?.id]);
19651962
1966
1967
1968 useEffect(() => {1963 useEffect(() => {
1969 if (!composerMenuOpen) {1964 if (!composerMenuOpen) {
1970 setComposerHighlightedItemId(null);1965 setComposerHighlightedItemId(null);
...@@ -2688,9 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2688,9 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2688 ...(selectedModelOptionsForDispatch2683 ...(selectedModelOptionsForDispatch
2689 ? { modelOptions: selectedModelOptionsForDispatch }2684 ? { modelOptions: selectedModelOptionsForDispatch }
2690 : {}),2685 : {}),
2691 ...(providerOptionsForDispatch2686 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2692 ? { providerOptions: providerOptionsForDispatch }
2693 : {}),
2694 provider: selectedProvider,2687 provider: selectedProvider,
2695 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2688 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2696 runtimeMode,2689 runtimeMode,
...@@ -2968,9 +2961,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2968,9 +2961,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2968 ...(selectedModelOptionsForDispatch2961 ...(selectedModelOptionsForDispatch
2969 ? { modelOptions: selectedModelOptionsForDispatch }2962 ? { modelOptions: selectedModelOptionsForDispatch }
2970 : {}),2963 : {}),
2971 ...(providerOptionsForDispatch2964 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2972 ? { providerOptions: providerOptionsForDispatch }
2973 : {}),
2974 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2965 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2975 runtimeMode,2966 runtimeMode,
2976 interactionMode: nextInteractionMode,2967 interactionMode: nextInteractionMode,
...@@ -3079,9 +3070,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3079,9 +3070,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3079 ...(selectedModelOptionsForDispatch3070 ...(selectedModelOptionsForDispatch
3080 ? { modelOptions: selectedModelOptionsForDispatch }3071 ? { modelOptions: selectedModelOptionsForDispatch }
3081 : {}),3072 : {}),
3082 ...(providerOptionsForDispatch3073 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
3083 ? { providerOptions: providerOptionsForDispatch }
3084 : {}),
3085 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",3074 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
3086 runtimeMode,3075 runtimeMode,
3087 interactionMode: "default",3076 interactionMode: "default",
...@@ -3503,513 +3492,537 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3503,513 +3492,537 @@ export default function ChatView({ threadId }: ChatViewProps) {
3503 <div className="flex min-h-0 min-w-0 flex-1">3492 <div className="flex min-h-0 min-w-0 flex-1">
3504 {/* Chat column */}3493 {/* Chat column */}
3505 <div className="flex min-h-0 min-w-0 flex-1 flex-col">3494 <div className="flex min-h-0 min-w-0 flex-1 flex-col">
35063495 {/* Messages */}
3507 {/* Messages */}
3508 <div
3509 ref={setMessagesScrollContainerRef}
3510 className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4"
3511 onScroll={onMessagesScroll}
3512 onClickCapture={onMessagesClickCapture}
3513 onWheel={onMessagesWheel}
3514 onPointerDown={onMessagesPointerDown}
3515 onPointerUp={onMessagesPointerUp}
3516 onPointerCancel={onMessagesPointerCancel}
3517 onTouchStart={onMessagesTouchStart}
3518 onTouchMove={onMessagesTouchMove}
3519 onTouchEnd={onMessagesTouchEnd}
3520 onTouchCancel={onMessagesTouchEnd}
3521 >
3522 <MessagesTimeline
3523 key={activeThread.id}
3524 hasMessages={timelineEntries.length > 0}
3525 isWorking={isWorking}
3526 activeTurnInProgress={isWorking || !latestTurnSettled}
3527 activeTurnStartedAt={activeWorkStartedAt}
3528 scrollContainer={messagesScrollElement}
3529 timelineEntries={timelineEntries}
3530 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
3531 completionSummary={completionSummary}
3532 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
3533 nowIso={nowIso}
3534 expandedWorkGroups={expandedWorkGroups}
3535 onToggleWorkGroup={onToggleWorkGroup}
3536 onOpenTurnDiff={onOpenTurnDiff}
3537 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
3538 onRevertUserMessage={onRevertUserMessage}
3539 isRevertingCheckpoint={isRevertingCheckpoint}
3540 onImageExpand={onExpandTimelineImage}
3541 markdownCwd={gitCwd ?? undefined}
3542 resolvedTheme={resolvedTheme}
3543 workspaceRoot={activeProject?.cwd ?? undefined}
3544 />
3545 </div>
3546
3547 {/* Input bar */}
3548 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
3549 <form
3550 ref={composerFormRef}
3551 onSubmit={onSend}
3552 className="mx-auto w-full min-w-0 max-w-3xl"
3553 data-chat-composer-form="true"
3554 >
3555 <div3496 <div
3556 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45 ${3497 ref={setMessagesScrollContainerRef}
3557 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"3498 className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4"
3558 }`}3499 onScroll={onMessagesScroll}
3559 onDragEnter={onComposerDragEnter}3500 onClickCapture={onMessagesClickCapture}
3560 onDragOver={onComposerDragOver}3501 onWheel={onMessagesWheel}
3561 onDragLeave={onComposerDragLeave}3502 onPointerDown={onMessagesPointerDown}
3562 onDrop={onComposerDrop}3503 onPointerUp={onMessagesPointerUp}
3504 onPointerCancel={onMessagesPointerCancel}
3505 onTouchStart={onMessagesTouchStart}
3506 onTouchMove={onMessagesTouchMove}
3507 onTouchEnd={onMessagesTouchEnd}
3508 onTouchCancel={onMessagesTouchEnd}
3563 >3509 >
3564 {activePendingApproval ? (3510 <MessagesTimeline
3565 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">3511 key={activeThread.id}
3566 <ComposerPendingApprovalPanel3512 hasMessages={timelineEntries.length > 0}
3567 approval={activePendingApproval}3513 isWorking={isWorking}
3568 pendingCount={pendingApprovals.length}3514 activeTurnInProgress={isWorking || !latestTurnSettled}
3569 />3515 activeTurnStartedAt={activeWorkStartedAt}
3570 </div>3516 scrollContainer={messagesScrollElement}
3571 ) : pendingUserInputs.length > 0 ? (3517 timelineEntries={timelineEntries}
3572 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">3518 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
3573 <ComposerPendingUserInputPanel3519 completionSummary={completionSummary}
3574 pendingUserInputs={pendingUserInputs}3520 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
3575 respondingRequestIds={respondingUserInputRequestIds}3521 nowIso={nowIso}
3576 answers={activePendingDraftAnswers}3522 expandedWorkGroups={expandedWorkGroups}
3577 questionIndex={activePendingQuestionIndex}3523 onToggleWorkGroup={onToggleWorkGroup}
3578 onSelectOption={onSelectActivePendingUserInputOption}3524 onOpenTurnDiff={onOpenTurnDiff}
3579 onAdvance={onAdvanceActivePendingUserInput}3525 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
3580 />3526 onRevertUserMessage={onRevertUserMessage}
3581 </div>3527 isRevertingCheckpoint={isRevertingCheckpoint}
3582 ) : showPlanFollowUpPrompt && activeProposedPlan ? (3528 onImageExpand={onExpandTimelineImage}
3583 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">3529 markdownCwd={gitCwd ?? undefined}
3584 <ComposerPlanFollowUpBanner3530 resolvedTheme={resolvedTheme}
3585 key={activeProposedPlan.id}3531 workspaceRoot={activeProject?.cwd ?? undefined}
3586 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}3532 />
3587 />3533 </div>
3588 </div>
3589 ) : null}
35903534
3591 {/* Textarea area */}3535 {/* Input bar */}
3592 <div3536 <div className={cn("px-3 pt-1.5 sm:px-5 sm:pt-2", isGitRepo ? "pb-1" : "pb-3 sm:pb-4")}>
3593 className={cn(3537 <form
3594 "relative px-3 pb-2 sm:px-4",3538 ref={composerFormRef}
3595 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",3539 onSubmit={onSend}
3596 )}3540 className="mx-auto w-full min-w-0 max-w-3xl"
3541 data-chat-composer-form="true"
3597 >3542 >
3598 {composerMenuOpen && !isComposerApprovalState && (
3599 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
3600 <ComposerCommandMenu
3601 items={composerMenuItems}
3602 resolvedTheme={resolvedTheme}
3603 isLoading={isComposerMenuLoading}
3604 triggerKind={composerTriggerKind}
3605 activeItemId={activeComposerMenuItem?.id ?? null}
3606 onHighlightedItemChange={onComposerMenuItemHighlighted}
3607 onSelect={onSelectComposerItem}
3608 />
3609 </div>
3610 )}
3611
3612 {!isComposerApprovalState && pendingUserInputs.length === 0 && composerImages.length > 0 && (
3613 <div className="mb-3 flex flex-wrap gap-2">
3614 {composerImages.map((image) => (
3615 <div
3616 key={image.id}
3617 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
3618 >
3619 {image.previewUrl ? (
3620 <button
3621 type="button"
3622 className="h-full w-full cursor-zoom-in"
3623 aria-label={`Preview ${image.name}`}
3624 onClick={() => {
3625 const preview = buildExpandedImagePreview(composerImages, image.id);
3626 if (!preview) return;
3627 setExpandedImage(preview);
3628 }}
3629 >
3630 <img
3631 src={image.previewUrl}
3632 alt={image.name}
3633 className="h-full w-full object-cover"
3634 />
3635 </button>
3636 ) : (
3637 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
3638 {image.name}
3639 </div>
3640 )}
3641 {nonPersistedComposerImageIdSet.has(image.id) && (
3642 <Tooltip>
3643 <TooltipTrigger
3644 render={
3645 <span
3646 role="img"
3647 aria-label="Draft attachment may not persist"
3648 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3649 >
3650 <CircleAlertIcon className="size-3" />
3651 </span>
3652 }
3653 />
3654 <TooltipPopup
3655 side="top"
3656 className="max-w-64 whitespace-normal leading-tight"
3657 >
3658 Draft attachment could not be saved locally and may be lost on
3659 navigation.
3660 </TooltipPopup>
3661 </Tooltip>
3662 )}
3663 <Button
3664 variant="ghost"
3665 size="icon-xs"
3666 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3667 onClick={() => removeComposerImage(image.id)}
3668 aria-label={`Remove ${image.name}`}
3669 >
3670 <XIcon />
3671 </Button>
3672 </div>
3673 ))}
3674 </div>
3675 )}
3676 <ComposerPromptEditor
3677 ref={composerEditorRef}
3678 value={
3679 isComposerApprovalState
3680 ? ""
3681 : activePendingProgress
3682 ? activePendingProgress.customAnswer
3683 : prompt
3684 }
3685 cursor={composerCursor}
3686 onChange={onPromptChange}
3687 onCommandKeyDown={onComposerCommandKey}
3688 onPaste={onComposerPaste}
3689 placeholder={
3690 isComposerApprovalState
3691 ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")
3692 : activePendingProgress
3693 ? "Type your own answer, or leave this blank to use the selected option"
3694 : showPlanFollowUpPrompt && activeProposedPlan
3695 ? "Add feedback to refine the plan, or leave this blank to implement it"
3696 : phase === "disconnected"
3697 ? "Ask for follow-up changes or attach images"
3698 : "Ask anything, @tag files/folders, or use /model"
3699 }
3700 disabled={isConnecting || isComposerApprovalState}
3701 />
3702 </div>
3703
3704 {/* Bottom toolbar */}
3705 {activePendingApproval ? (
3706 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
3707 <ComposerPendingApprovalActions
3708 requestId={activePendingApproval.requestId}
3709 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3710 onRespondToApproval={onRespondToApproval}
3711 />
3712 </div>
3713 ) : (
3714 <div3543 <div
3715 data-chat-composer-footer="true"3544 className={`group rounded-[20px] border bg-card transition-colors duration-200 focus-within:border-ring/45 ${
3716 className={cn(3545 isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border"
3717 "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3",3546 }`}
3718 isComposerFooterCompact ? "gap-1.5" : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0",3547 onDragEnter={onComposerDragEnter}
3719 )}3548 onDragOver={onComposerDragOver}
3549 onDragLeave={onComposerDragLeave}
3550 onDrop={onComposerDrop}
3720 >3551 >
3552 {activePendingApproval ? (
3553 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3554 <ComposerPendingApprovalPanel
3555 approval={activePendingApproval}
3556 pendingCount={pendingApprovals.length}
3557 />
3558 </div>
3559 ) : pendingUserInputs.length > 0 ? (
3560 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3561 <ComposerPendingUserInputPanel
3562 pendingUserInputs={pendingUserInputs}
3563 respondingRequestIds={respondingUserInputRequestIds}
3564 answers={activePendingDraftAnswers}
3565 questionIndex={activePendingQuestionIndex}
3566 onSelectOption={onSelectActivePendingUserInputOption}
3567 onAdvance={onAdvanceActivePendingUserInput}
3568 />
3569 </div>
3570 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
3571 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
3572 <ComposerPlanFollowUpBanner
3573 key={activeProposedPlan.id}
3574 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3575 />
3576 </div>
3577 ) : null}
3578
3579 {/* Textarea area */}
3721 <div3580 <div
3722 className={cn(3581 className={cn(
3723 "flex min-w-0 flex-1 items-center",3582 "relative px-3 pb-2 sm:px-4",
3724 isComposerFooterCompact3583 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
3725 ? "gap-1 overflow-hidden"
3726 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
3727 )}3584 )}
3728 >3585 >
3729 {/* Provider/model picker */}3586 {composerMenuOpen && !isComposerApprovalState && (
3730 <ProviderModelPicker3587 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
3731 compact={isComposerFooterCompact}3588 <ComposerCommandMenu
3732 provider={selectedProvider}3589 items={composerMenuItems}
3733 model={selectedModelForPickerWithCustomFallback}3590 resolvedTheme={resolvedTheme}
3734 lockedProvider={lockedProvider}3591 isLoading={isComposerMenuLoading}
3735 modelOptionsByProvider={modelOptionsByProvider}3592 triggerKind={composerTriggerKind}
3736 onProviderModelChange={onProviderModelSelect}3593 activeItemId={activeComposerMenuItem?.id ?? null}
3594 onHighlightedItemChange={onComposerMenuItemHighlighted}
3595 onSelect={onSelectComposerItem}
3596 />
3597 </div>
3598 )}
3599
3600 {!isComposerApprovalState &&
3601 pendingUserInputs.length === 0 &&
3602 composerImages.length > 0 && (
3603 <div className="mb-3 flex flex-wrap gap-2">
3604 {composerImages.map((image) => (
3605 <div
3606 key={image.id}
3607 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
3608 >
3609 {image.previewUrl ? (
3610 <button
3611 type="button"
3612 className="h-full w-full cursor-zoom-in"
3613 aria-label={`Preview ${image.name}`}
3614 onClick={() => {
3615 const preview = buildExpandedImagePreview(
3616 composerImages,
3617 image.id,
3618 );
3619 if (!preview) return;
3620 setExpandedImage(preview);
3621 }}
3622 >
3623 <img
3624 src={image.previewUrl}
3625 alt={image.name}
3626 className="h-full w-full object-cover"
3627 />
3628 </button>
3629 ) : (
3630 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70">
3631 {image.name}
3632 </div>
3633 )}
3634 {nonPersistedComposerImageIdSet.has(image.id) && (
3635 <Tooltip>
3636 <TooltipTrigger
3637 render={
3638 <span
3639 role="img"
3640 aria-label="Draft attachment may not persist"
3641 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3642 >
3643 <CircleAlertIcon className="size-3" />
3644 </span>
3645 }
3646 />
3647 <TooltipPopup
3648 side="top"
3649 className="max-w-64 whitespace-normal leading-tight"
3650 >
3651 Draft attachment could not be saved locally and may be lost on
3652 navigation.
3653 </TooltipPopup>
3654 </Tooltip>
3655 )}
3656 <Button
3657 variant="ghost"
3658 size="icon-xs"
3659 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3660 onClick={() => removeComposerImage(image.id)}
3661 aria-label={`Remove ${image.name}`}
3662 >
3663 <XIcon />
3664 </Button>
3665 </div>
3666 ))}
3667 </div>
3668 )}
3669 <ComposerPromptEditor
3670 ref={composerEditorRef}
3671 value={
3672 isComposerApprovalState
3673 ? ""
3674 : activePendingProgress
3675 ? activePendingProgress.customAnswer
3676 : prompt
3677 }
3678 cursor={composerCursor}
3679 onChange={onPromptChange}
3680 onCommandKeyDown={onComposerCommandKey}
3681 onPaste={onComposerPaste}
3682 placeholder={
3683 isComposerApprovalState
3684 ? (activePendingApproval?.detail ??
3685 "Resolve this approval request to continue")
3686 : activePendingProgress
3687 ? "Type your own answer, or leave this blank to use the selected option"
3688 : showPlanFollowUpPrompt && activeProposedPlan
3689 ? "Add feedback to refine the plan, or leave this blank to implement it"
3690 : phase === "disconnected"
3691 ? "Ask for follow-up changes or attach images"
3692 : "Ask anything, @tag files/folders, or use /model"
3693 }
3694 disabled={isConnecting || isComposerApprovalState}
3737 />3695 />
3696 </div>
37383697
3739 {isComposerFooterCompact ? (3698 {/* Bottom toolbar */}
3740 <CompactComposerControlsMenu3699 {activePendingApproval ? (
3741 activePlan={Boolean(activePlan || activeProposedPlan || planSidebarOpen)}3700 <div className="flex items-center justify-end gap-2 px-2.5 pb-2.5 sm:px-3 sm:pb-3">
3742 interactionMode={interactionMode}3701 <ComposerPendingApprovalActions
3743 planSidebarOpen={planSidebarOpen}3702 requestId={activePendingApproval.requestId}
3744 runtimeMode={runtimeMode}3703 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3745 selectedEffort={selectedEffort}3704 onRespondToApproval={onRespondToApproval}
3746 selectedProvider={selectedProvider}
3747 selectedCodexFastModeEnabled={selectedCodexFastModeEnabled}
3748 reasoningOptions={reasoningOptions}
3749 onEffortSelect={onEffortSelect}
3750 onCodexFastModeChange={onCodexFastModeChange}
3751 onToggleInteractionMode={toggleInteractionMode}
3752 onTogglePlanSidebar={togglePlanSidebar}
3753 onToggleRuntimeMode={toggleRuntimeMode}
3754 />3705 />
3755 ) : (3706 </div>
3756 <>3707 ) : (
3757 {selectedProvider === "codex" && selectedEffort != null ? (3708 <div
3758 <>3709 data-chat-composer-footer="true"
3759 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3710 className={cn(
3760 <CodexTraitsPicker3711 "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3",
3761 effort={selectedEffort}3712 isComposerFooterCompact
3762 fastModeEnabled={selectedCodexFastModeEnabled}3713 ? "gap-1.5"
3763 options={reasoningOptions}3714 : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0",
3764 onEffortChange={onEffortSelect}3715 )}
3765 onFastModeChange={onCodexFastModeChange}3716 >
3766 />3717 <div
3767 </>3718 className={cn(
3768 ) : null}3719 "flex min-w-0 flex-1 items-center",
3720 isComposerFooterCompact
3721 ? "gap-1 overflow-hidden"
3722 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
3723 )}
3724 >
3725 {/* Provider/model picker */}
3726 <ProviderModelPicker
3727 compact={isComposerFooterCompact}
3728 provider={selectedProvider}
3729 model={selectedModelForPickerWithCustomFallback}
3730 lockedProvider={lockedProvider}
3731 modelOptionsByProvider={modelOptionsByProvider}
3732 onProviderModelChange={onProviderModelSelect}
3733 />
37693734
3770 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3735 {isComposerFooterCompact ? (
3736 <CompactComposerControlsMenu
3737 activePlan={Boolean(activePlan || activeProposedPlan || planSidebarOpen)}
3738 interactionMode={interactionMode}
3739 planSidebarOpen={planSidebarOpen}
3740 runtimeMode={runtimeMode}
3741 selectedEffort={selectedEffort}
3742 selectedProvider={selectedProvider}
3743 selectedCodexFastModeEnabled={selectedCodexFastModeEnabled}
3744 reasoningOptions={reasoningOptions}
3745 onEffortSelect={onEffortSelect}
3746 onCodexFastModeChange={onCodexFastModeChange}
3747 onToggleInteractionMode={toggleInteractionMode}
3748 onTogglePlanSidebar={togglePlanSidebar}
3749 onToggleRuntimeMode={toggleRuntimeMode}
3750 />
3751 ) : (
3752 <>
3753 {selectedProvider === "codex" && selectedEffort != null ? (
3754 <>
3755 <Separator
3756 orientation="vertical"
3757 className="mx-0.5 hidden h-4 sm:block"
3758 />
3759 <CodexTraitsPicker
3760 effort={selectedEffort}
3761 fastModeEnabled={selectedCodexFastModeEnabled}
3762 options={reasoningOptions}
3763 onEffortChange={onEffortSelect}
3764 onFastModeChange={onCodexFastModeChange}
3765 />
3766 </>
3767 ) : null}
37713768
3772 <Button3769 <Separator
3773 variant="ghost"3770 orientation="vertical"
3774 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"3771 className="mx-0.5 hidden h-4 sm:block"
3775 size="sm"3772 />
3776 type="button"
3777 onClick={toggleInteractionMode}
3778 title={
3779 interactionMode === "plan"
3780 ? "Plan mode — click to return to normal chat mode"
3781 : "Default mode — click to enter plan mode"
3782 }
3783 >
3784 <BotIcon />
3785 <span className="sr-only sm:not-sr-only">
3786 {interactionMode === "plan" ? "Plan" : "Chat"}
3787 </span>
3788 </Button>
37893773
3790 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3774 <Button
3775 variant="ghost"
3776 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3777 size="sm"
3778 type="button"
3779 onClick={toggleInteractionMode}
3780 title={
3781 interactionMode === "plan"
3782 ? "Plan mode — click to return to normal chat mode"
3783 : "Default mode — click to enter plan mode"
3784 }
3785 >
3786 <BotIcon />
3787 <span className="sr-only sm:not-sr-only">
3788 {interactionMode === "plan" ? "Plan" : "Chat"}
3789 </span>
3790 </Button>
37913791
3792 <Button3792 <Separator
3793 variant="ghost"3793 orientation="vertical"
3794 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"3794 className="mx-0.5 hidden h-4 sm:block"
3795 size="sm"3795 />
3796 type="button"
3797 onClick={() =>
3798 void handleRuntimeModeChange(
3799 runtimeMode === "full-access" ? "approval-required" : "full-access",
3800 )
3801 }
3802 title={
3803 runtimeMode === "full-access"
3804 ? "Full access — click to require approvals"
3805 : "Approval required — click for full access"
3806 }
3807 >
3808 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3809 <span className="sr-only sm:not-sr-only">
3810 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3811 </span>
3812 </Button>
38133796
3814 {(activePlan || activeProposedPlan || planSidebarOpen) ? (
3815 <>
3816 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3817 <Button3797 <Button
3818 variant="ghost"3798 variant="ghost"
3819 className={cn(3799 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3820 "shrink-0 whitespace-nowrap px-2 sm:px-3",
3821 planSidebarOpen
3822 ? "text-blue-400 hover:text-blue-300"
3823 : "text-muted-foreground/70 hover:text-foreground/80",
3824 )}
3825 size="sm"3800 size="sm"
3826 type="button"3801 type="button"
3827 onClick={togglePlanSidebar}3802 onClick={() =>
3828 title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}3803 void handleRuntimeModeChange(
3804 runtimeMode === "full-access" ? "approval-required" : "full-access",
3805 )
3806 }
3807 title={
3808 runtimeMode === "full-access"
3809 ? "Full access — click to require approvals"
3810 : "Approval required — click for full access"
3811 }
3829 >3812 >
3830 <ListTodoIcon />3813 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3831 <span className="sr-only sm:not-sr-only">Plan</span>3814 <span className="sr-only sm:not-sr-only">
3815 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3816 </span>
3832 </Button>3817 </Button>
3833 </>
3834 ) : null}
3835 </>
3836 )}
3837 </div>
38383818
3839 {/* Right side: send / stop button */}3819 {activePlan || activeProposedPlan || planSidebarOpen ? (
3840 <div data-chat-composer-actions="right" className="flex shrink-0 items-center gap-2">3820 <>
3841 {isPreparingWorktree ? (3821 <Separator
3842 <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>3822 orientation="vertical"
3843 ) : null}3823 className="mx-0.5 hidden h-4 sm:block"
3844 {activePendingProgress ? (3824 />
3845 <div className="flex items-center gap-2">3825 <Button
3846 {activePendingProgress.questionIndex > 0 ? (3826 variant="ghost"
3847 <Button3827 className={cn(
3848 size="sm"3828 "shrink-0 whitespace-nowrap px-2 sm:px-3",
3849 variant="outline"3829 planSidebarOpen
3850 className="rounded-full"3830 ? "text-blue-400 hover:text-blue-300"
3851 onClick={onPreviousActivePendingUserInputQuestion}3831 : "text-muted-foreground/70 hover:text-foreground/80",
3852 disabled={activePendingIsResponding}3832 )}
3853 >3833 size="sm"
3854 Previous3834 type="button"
3855 </Button>3835 onClick={togglePlanSidebar}
3856 ) : null}3836 title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
3857 <Button3837 >
3858 type="submit"3838 <ListTodoIcon />
3859 size="sm"3839 <span className="sr-only sm:not-sr-only">Plan</span>
3860 className="rounded-full px-4"3840 </Button>
3861 disabled={3841 </>
3862 activePendingIsResponding ||3842 ) : null}
3863 (activePendingProgress.isLastQuestion3843 </>
3864 ? !activePendingResolvedAnswers3844 )}
3865 : !activePendingProgress.canAdvance)
3866 }
3867 >
3868 {activePendingIsResponding
3869 ? "Submitting..."
3870 : activePendingProgress.isLastQuestion
3871 ? "Submit answers"
3872 : "Next question"}
3873 </Button>
3874 </div>3845 </div>
3875 ) : phase === "running" ? (3846
3876 <button3847 {/* Right side: send / stop button */}
3877 type="button"3848 <div
3878 className="flex size-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"3849 data-chat-composer-actions="right"
3879 onClick={() => void onInterrupt()}3850 className="flex shrink-0 items-center gap-2"
3880 aria-label="Stop generation"
3881 >3851 >
3882 <svg3852 {isPreparingWorktree ? (
3883 width="12"3853 <span className="text-muted-foreground/70 text-xs">
3884 height="12"3854 Preparing worktree...
3885 viewBox="0 0 12 12"3855 </span>
3886 fill="currentColor"3856 ) : null}
3887 aria-hidden="true"3857 {activePendingProgress ? (
3888 >3858 <div className="flex items-center gap-2">
3889 <rect x="2" y="2" width="8" height="8" rx="1.5" />3859 {activePendingProgress.questionIndex > 0 ? (
3890 </svg>3860 <Button
3891 </button>3861 size="sm"
3892 ) : pendingUserInputs.length === 0 ? (3862 variant="outline"
3893 showPlanFollowUpPrompt ? (3863 className="rounded-full"
3894 prompt.trim().length > 0 ? (3864 onClick={onPreviousActivePendingUserInputQuestion}
3895 <Button3865 disabled={activePendingIsResponding}
3896 type="submit"3866 >
3897 size="sm"3867 Previous
3898 className="h-9 rounded-full px-4 sm:h-8"3868 </Button>
3899 disabled={isSendBusy || isConnecting}3869 ) : null}
3900 >
3901 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
3902 </Button>
3903 ) : (
3904 <div className="flex items-center">
3905 <Button3870 <Button
3906 type="submit"3871 type="submit"
3907 size="sm"3872 size="sm"
3908 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"3873 className="rounded-full px-4"
3909 disabled={isSendBusy || isConnecting}3874 disabled={
3875 activePendingIsResponding ||
3876 (activePendingProgress.isLastQuestion
3877 ? !activePendingResolvedAnswers
3878 : !activePendingProgress.canAdvance)
3879 }
3910 >3880 >
3911 {isConnecting || isSendBusy ? "Sending..." : "Implement"}3881 {activePendingIsResponding
3882 ? "Submitting..."
3883 : activePendingProgress.isLastQuestion
3884 ? "Submit answers"
3885 : "Next question"}
3912 </Button>3886 </Button>
3913 <Menu>
3914 <MenuTrigger
3915 render={
3916 <Button
3917 size="sm"
3918 variant="default"
3919 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
3920 aria-label="Implementation actions"
3921 disabled={isSendBusy || isConnecting}
3922 />
3923 }
3924 >
3925 <ChevronDownIcon className="size-3.5" />
3926 </MenuTrigger>
3927 <MenuPopup align="end" side="top">
3928 <MenuItem
3929 disabled={isSendBusy || isConnecting}
3930 onClick={() => void onImplementPlanInNewThread()}
3931 >
3932 Implement in new thread
3933 </MenuItem>
3934 </MenuPopup>
3935 </Menu>
3936 </div>3887 </div>
3937 )3888 ) : phase === "running" ? (
3938 ) : (3889 <button
3939 <button3890 type="button"
3940 type="submit"3891 className="flex size-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8"
3941 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"3892 onClick={() => void onInterrupt()}
3942 disabled={3893 aria-label="Stop generation"
3943 isSendBusy ||3894 >
3944 isConnecting ||
3945 (!prompt.trim() && composerImages.length === 0)
3946 }
3947 aria-label={
3948 isConnecting
3949 ? "Connecting"
3950 : isPreparingWorktree
3951 ? "Preparing worktree"
3952 : isSendBusy
3953 ? "Sending"
3954 : "Send message"
3955 }
3956 >
3957 {isConnecting || isSendBusy ? (
3958 <svg3895 <svg
3959 width="14"3896 width="12"
3960 height="14"3897 height="12"
3961 viewBox="0 0 14 14"3898 viewBox="0 0 12 12"
3962 fill="none"3899 fill="currentColor"
3963 className="animate-spin"
3964 aria-hidden="true"3900 aria-hidden="true"
3965 >3901 >
3966 <circle3902 <rect x="2" y="2" width="8" height="8" rx="1.5" />
3967 cx="7"
3968 cy="7"
3969 r="5.5"
3970 stroke="currentColor"
3971 strokeWidth="1.5"
3972 strokeLinecap="round"
3973 strokeDasharray="20 12"
3974 />
3975 </svg>3903 </svg>
3904 </button>
3905 ) : pendingUserInputs.length === 0 ? (
3906 showPlanFollowUpPrompt ? (
3907 prompt.trim().length > 0 ? (
3908 <Button
3909 type="submit"
3910 size="sm"
3911 className="h-9 rounded-full px-4 sm:h-8"
3912 disabled={isSendBusy || isConnecting}
3913 >
3914 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
3915 </Button>
3916 ) : (
3917 <div className="flex items-center">
3918 <Button
3919 type="submit"
3920 size="sm"
3921 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
3922 disabled={isSendBusy || isConnecting}
3923 >
3924 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
3925 </Button>
3926 <Menu>
3927 <MenuTrigger
3928 render={
3929 <Button
3930 size="sm"
3931 variant="default"
3932 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
3933 aria-label="Implementation actions"
3934 disabled={isSendBusy || isConnecting}
3935 />
3936 }
3937 >
3938 <ChevronDownIcon className="size-3.5" />
3939 </MenuTrigger>
3940 <MenuPopup align="end" side="top">
3941 <MenuItem
3942 disabled={isSendBusy || isConnecting}
3943 onClick={() => void onImplementPlanInNewThread()}
3944 >
3945 Implement in new thread
3946 </MenuItem>
3947 </MenuPopup>
3948 </Menu>
3949 </div>
3950 )
3976 ) : (3951 ) : (
3977 <svg3952 <button
3978 width="14"3953 type="submit"
3979 height="14"3954 className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
3980 viewBox="0 0 14 14"3955 disabled={
3981 fill="none"3956 isSendBusy ||
3982 aria-hidden="true"3957 isConnecting ||
3958 (!prompt.trim() && composerImages.length === 0)
3959 }
3960 aria-label={
3961 isConnecting
3962 ? "Connecting"
3963 : isPreparingWorktree
3964 ? "Preparing worktree"
3965 : isSendBusy
3966 ? "Sending"
3967 : "Send message"
3968 }
3983 >3969 >
3984 <path3970 {isConnecting || isSendBusy ? (
3985 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"3971 <svg
3986 stroke="currentColor"3972 width="14"
3987 strokeWidth="1.8"3973 height="14"
3988 strokeLinecap="round"3974 viewBox="0 0 14 14"
3989 strokeLinejoin="round"3975 fill="none"
3990 />3976 className="animate-spin"
3991 </svg>3977 aria-hidden="true"
3992 )}3978 >
3993 </button>3979 <circle
3994 )3980 cx="7"
3995 ) : null}3981 cy="7"
3996 </div>3982 r="5.5"
3983 stroke="currentColor"
3984 strokeWidth="1.5"
3985 strokeLinecap="round"
3986 strokeDasharray="20 12"
3987 />
3988 </svg>
3989 ) : (
3990 <svg
3991 width="14"
3992 height="14"
3993 viewBox="0 0 14 14"
3994 fill="none"
3995 aria-hidden="true"
3996 >
3997 <path
3998 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
3999 stroke="currentColor"
4000 strokeWidth="1.8"
4001 strokeLinecap="round"
4002 strokeLinejoin="round"
4003 />
4004 </svg>
4005 )}
4006 </button>
4007 )
4008 ) : null}
4009 </div>
4010 </div>
4011 )}
3997 </div>4012 </div>
3998 )}4013 </form>
3999 </div>4014 </div>
4000 </form>
4001 </div>
4002
4003 {isGitRepo && (
4004 <BranchToolbar
4005 threadId={activeThread.id}
4006 onEnvModeChange={onEnvModeChange}
4007 envLocked={envLocked}
4008 onComposerFocusRequest={scheduleComposerFocus}
4009 />
4010 )}
40114015
4012 </div>{/* end chat column */}4016 {isGitRepo && (
4017 <BranchToolbar
4018 threadId={activeThread.id}
4019 onEnvModeChange={onEnvModeChange}
4020 envLocked={envLocked}
4021 onComposerFocusRequest={scheduleComposerFocus}
4022 />
4023 )}
4024 </div>
4025 {/* end chat column */}
40134026
4014 {/* Plan sidebar */}4027 {/* Plan sidebar */}
4015 {planSidebarOpen ? (4028 {planSidebarOpen ? (
...@@ -4028,7 +4041,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4028,7 +4041,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
4028 }}4041 }}
4029 />4042 />
4030 ) : null}4043 ) : null}
4031 </div>{/* end horizontal flex container */}4044 </div>
4045 {/* end horizontal flex container */}
40324046
4033 {(() => {4047 {(() => {
4034 if (!terminalState.terminalOpen || !activeProject) {4048 if (!terminalState.terminalOpen || !activeProject) {
...@@ -4465,10 +4479,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -4465,10 +4479,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
4465 const handler = (event: globalThis.KeyboardEvent) => {4479 const handler = (event: globalThis.KeyboardEvent) => {
4466 if (event.metaKey || event.ctrlKey || event.altKey) return;4480 if (event.metaKey || event.ctrlKey || event.altKey) return;
4467 const target = event.target;4481 const target = event.target;
4468 if (4482 if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
4469 target instanceof HTMLInputElement ||
4470 target instanceof HTMLTextAreaElement
4471 ) {
4472 return;4483 return;
4473 }4484 }
4474 // If the user has started typing a custom answer in the contenteditable4485 // If the user has started typing a custom answer in the contenteditable
...@@ -4542,12 +4553,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -4542,12 +4553,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
4542 <div className="min-w-0 flex-1">4553 <div className="min-w-0 flex-1">
4543 <span className="text-sm font-medium">{option.label}</span>4554 <span className="text-sm font-medium">{option.label}</span>
4544 {option.description && option.description !== option.label ? (4555 {option.description && option.description !== option.label ? (
4545 <span className="ml-2 text-xs text-muted-foreground/50">{option.description}</span>4556 <span className="ml-2 text-xs text-muted-foreground/50">
4557 {option.description}
4558 </span>
4546 ) : null}4559 ) : null}
4547 </div>4560 </div>
4548 {isSelected ? (4561 {isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null}
4549 <CheckIcon className="size-3.5 shrink-0 text-blue-400" />
4550 ) : null}
4551 </button>4562 </button>
4552 );4563 );
4553 })}4564 })}
...@@ -4754,13 +4765,34 @@ const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -4754,13 +4765,34 @@ const ProposedPlanCard = memo(function ProposedPlanCard({
4754 const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);4765 const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
4755 const [savePath, setSavePath] = useState("");4766 const [savePath, setSavePath] = useState("");
4756 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);4767 const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false);
4768 const [renderCollapsedMarkdown, setRenderCollapsedMarkdown] = useState(false);
4757 const savePathInputId = useId();4769 const savePathInputId = useId();
4758 const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan";4770 const title = proposedPlanTitle(planMarkdown) ?? "Proposed plan";
4759 const lineCount = planMarkdown.split("\n").length;4771 const lineCount = planMarkdown.split("\n").length;
4760 const canCollapse = planMarkdown.length > 900 || lineCount > 20;4772 const canCollapse = planMarkdown.length > 900 || lineCount > 20;
4773 const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown);
4774 const collapsedPreview = canCollapse
4775 ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 })
4776 : null;
4761 const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown);4777 const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown);
4762 const saveContents = normalizePlanMarkdownForExport(planMarkdown);4778 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
47634779
4780 useEffect(() => {
4781 if (!canCollapse || expanded) {
4782 setRenderCollapsedMarkdown(false);
4783 return;
4784 }
4785 setRenderCollapsedMarkdown(false);
4786 const frame = window.requestAnimationFrame(() => {
4787 startTransition(() => {
4788 setRenderCollapsedMarkdown(true);
4789 });
4790 });
4791 return () => {
4792 window.cancelAnimationFrame(frame);
4793 };
4794 }, [canCollapse, expanded, collapsedPreview]);
4795
4764 const handleDownload = () => {4796 const handleDownload = () => {
4765 downloadPlanAsTextFile(downloadFilename, saveContents);4797 downloadPlanAsTextFile(downloadFilename, saveContents);
4766 };4798 };
...@@ -4847,7 +4879,17 @@ const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -4847,7 +4879,17 @@ const ProposedPlanCard = memo(function ProposedPlanCard({
4847 </div>4879 </div>
4848 <div className="mt-4">4880 <div className="mt-4">
4849 <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>4881 <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>
4850 <ChatMarkdown text={planMarkdown} cwd={cwd} isStreaming={false} />4882 {canCollapse && !expanded ? (
4883 renderCollapsedMarkdown ? (
4884 <ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} />
4885 ) : (
4886 <pre className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground/80">
4887 {collapsedPreview}
4888 </pre>
4889 )
4890 ) : (
4891 <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} />
4892 )}
4851 {canCollapse && !expanded ? (4893 {canCollapse && !expanded ? (
4852 <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" />4894 <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" />
4853 ) : null}4895 ) : null}
...@@ -5601,7 +5643,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5601,7 +5643,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5601 }5643 }
5602 setIsMenuOpen(open);5644 setIsMenuOpen(open);
5603 }}5645 }}
5604 >5646 >max-w-42
5605 <MenuTrigger5647 <MenuTrigger
5606 render={5648 render={
5607 <Button5649 <Button
...@@ -5610,7 +5652,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5610,7 +5652,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5610 className={cn(5652 className={cn(
5611 "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80",5653 "min-w-0 shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80",
5612 props.compact ? "max-w-[10.5rem]" : "sm:px-3",5654 props.compact ? "max-w-[10.5rem]" : "sm:px-3",
5613 )}5655 )}max-w-36
5614 disabled={props.disabled}5656 disabled={props.disabled}
5615 />5657 />
5616 }5658 }
apps/web/src/components/PlanSidebar.tsx+3-1
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 buildProposedPlanMarkdownFilename,20 buildProposedPlanMarkdownFilename,
21 normalizePlanMarkdownForExport,21 normalizePlanMarkdownForExport,
22 downloadPlanAsTextFile,22 downloadPlanAsTextFile,
23 stripDisplayedPlanMarkdown,
23} from "../proposedPlan";24} from "../proposedPlan";
24import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";25import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
25import { readNativeApi } from "~/nativeApi";26import { readNativeApi } from "~/nativeApi";
...@@ -67,6 +68,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -67,6 +68,7 @@ const PlanSidebar = memo(function PlanSidebar({
67 const [copied, setCopied] = useState(false);68 const [copied, setCopied] = useState(false);
6869
69 const planMarkdown = activeProposedPlan?.planMarkdown ?? null;70 const planMarkdown = activeProposedPlan?.planMarkdown ?? null;
71 const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null;
70 const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;72 const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null;
7173
72 const handleCopyPlan = useCallback(() => {74 const handleCopyPlan = useCallback(() => {
...@@ -237,7 +239,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -237,7 +239,7 @@ const PlanSidebar = memo(function PlanSidebar({
237 {proposedPlanExpanded ? (239 {proposedPlanExpanded ? (
238 <div className="rounded-lg border border-border/50 bg-background/50 p-3">240 <div className="rounded-lg border border-border/50 bg-background/50 p-3">
239 <ChatMarkdown241 <ChatMarkdown
240 text={planMarkdown}242 text={displayedPlanMarkdown ?? ""}
241 cwd={markdownCwd}243 cwd={markdownCwd}
242 isStreaming={false}244 isStreaming={false}
243 />245 />
apps/web/src/proposedPlan.test.ts+34
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 buildCollapsedProposedPlanPreviewMarkdown,
4 buildPlanImplementationThreadTitle,5 buildPlanImplementationThreadTitle,
5 buildPlanImplementationPrompt,6 buildPlanImplementationPrompt,
6 buildProposedPlanMarkdownFilename,7 buildProposedPlanMarkdownFilename,
7 proposedPlanTitle,8 proposedPlanTitle,
8 resolvePlanFollowUpSubmission,9 resolvePlanFollowUpSubmission,
10 stripDisplayedPlanMarkdown,
9} from "./proposedPlan";11} from "./proposedPlan";
1012
11describe("proposedPlanTitle", () => {13describe("proposedPlanTitle", () => {
...@@ -26,6 +28,38 @@ describe("buildPlanImplementationPrompt", () => {...@@ -26,6 +28,38 @@ describe("buildPlanImplementationPrompt", () => {
26 });28 });
27});29});
2830
31describe("buildCollapsedProposedPlanPreviewMarkdown", () => {
32 it("drops the redundant title heading and preserves the following markdown lines", () => {
33 expect(
34 buildCollapsedProposedPlanPreviewMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n- step 2", {
35 maxLines: 4,
36 }),
37 ).toBe("- step 1\n- step 2");
38 });
39
40 it("appends an overflow marker when the preview truncates remaining content", () => {
41 expect(
42 buildCollapsedProposedPlanPreviewMarkdown("# Integrate RPC\n\n- step 1\n- step 2\n- step 3", {
43 maxLines: 2,
44 }),
45 ).toBe("- step 1\n- step 2\n\n...");
46 });
47});
48
49describe("stripDisplayedPlanMarkdown", () => {
50 it("drops the leading title heading from displayed plan markdown", () => {
51 expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n")).toBe(
52 "- step 1",
53 );
54 });
55
56 it("preserves non-summary headings after dropping the title heading", () => {
57 expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe(
58 "## Scope\n\n- step 1",
59 );
60 });
61});
62
29describe("resolvePlanFollowUpSubmission", () => {63describe("resolvePlanFollowUpSubmission", () => {
30 it("switches to default mode when implementing the ready plan without extra text", () => {64 it("switches to default mode when implementing the ready plan without extra text", () => {
31 expect(65 expect(
apps/web/src/proposedPlan.ts+59
...@@ -3,6 +3,65 @@ export function proposedPlanTitle(planMarkdown: string): string | null {...@@ -3,6 +3,65 @@ export function proposedPlanTitle(planMarkdown: string): string | null {
3 return heading && heading.length > 0 ? heading : null;3 return heading && heading.length > 0 ? heading : null;
4}4}
55
6export function stripDisplayedPlanMarkdown(planMarkdown: string): string {
7 const lines = planMarkdown.trimEnd().split(/\r?\n/);
8 const sourceLines =
9 lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
10 while (sourceLines[0]?.trim().length === 0) {
11 sourceLines.shift();
12 }
13 const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/);
14 if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") {
15 sourceLines.shift();
16 while (sourceLines[0]?.trim().length === 0) {
17 sourceLines.shift();
18 }
19 }
20 return sourceLines.join("\n");
21}
22
23export function buildCollapsedProposedPlanPreviewMarkdown(
24 planMarkdown: string,
25 options?: {
26 maxLines?: number;
27 },
28): string {
29 const maxLines = options?.maxLines ?? 8;
30 const lines = stripDisplayedPlanMarkdown(planMarkdown)
31 .trimEnd()
32 .split(/\r?\n/)
33 .map((line) => line.trimEnd());
34 const previewLines: string[] = [];
35 let visibleLineCount = 0;
36 let hasMoreContent = false;
37
38 for (const line of lines) {
39 const isVisibleLine = line.trim().length > 0;
40 if (isVisibleLine && visibleLineCount >= maxLines) {
41 hasMoreContent = true;
42 break;
43 }
44 previewLines.push(line);
45 if (isVisibleLine) {
46 visibleLineCount += 1;
47 }
48 }
49
50 while (previewLines.length > 0 && previewLines.at(-1)?.trim().length === 0) {
51 previewLines.pop();
52 }
53
54 if (previewLines.length === 0) {
55 return proposedPlanTitle(planMarkdown) ?? "Plan preview unavailable.";
56 }
57
58 if (hasMoreContent) {
59 previewLines.push("", "...");
60 }
61
62 return previewLines.join("\n");
63}
64
6function sanitizePlanFileSegment(input: string): string {65function sanitizePlanFileSegment(input: string): string {
7 const sanitized = input66 const sanitized = input
8 .toLowerCase()67 .toLowerCase()