From d6d55f873fd92e9eb991d0da04954a9886c3a55f Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:40:48 +0530 Subject: [PATCH 1/8] Extract compact composer primary actions - Move send, stop, and plan follow-up controls into a shared component - Add compact pending-question and implementation action variants to avoid overflow --- apps/web/src/components/ChatView.tsx | 180 ++----------- .../chat/ComposerPrimaryActions.test.tsx | 72 +++++ .../chat/ComposerPrimaryActions.tsx | 251 ++++++++++++++++++ 3 files changed, 351 insertions(+), 152 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPrimaryActions.test.tsx create mode 100644 apps/web/src/components/chat/ComposerPrimaryActions.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..fd73e7ace2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -98,7 +98,6 @@ import { } from "lucide-react"; import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; @@ -152,6 +151,7 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; +import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; @@ -4006,7 +4006,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Right side: send / stop button */}
{activeContextWindow ? ( @@ -4016,156 +4016,32 @@ export default function ChatView({ threadId }: ChatViewProps) { Preparing worktree... ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} - -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( - - ) : ( -
- - - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in a new thread - - - -
- ) - ) : ( - - ) - ) : null} + 0} + isSendBusy={isSendBusy} + isConnecting={isConnecting} + isPreparingWorktree={isPreparingWorktree} + hasSendableContent={composerSendState.hasSendableContent} + onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} + onInterrupt={() => void onInterrupt()} + onImplementPlanInNewThread={() => void onImplementPlanInNewThread()} + />
)} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx new file mode 100644 index 0000000000..2ace62a961 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -0,0 +1,72 @@ +import type { ComponentProps } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; + +const noop = vi.fn(); + +function renderActions(overrides: Partial> = {}) { + return renderToStaticMarkup( + , + ); +} + +describe("ComposerPrimaryActions", () => { + it("uses compact pending-question controls when space is tight", () => { + const html = renderActions({ + compact: true, + pendingAction: { + questionIndex: 1, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + }); + + expect(html).toContain('aria-label="Previous question"'); + expect(html).toContain(">Submit<"); + expect(html).not.toContain(">Previous<"); + expect(html).not.toContain("Submit answers"); + }); + + it("keeps full pending-question copy in the expanded footer", () => { + const html = renderActions({ + pendingAction: { + questionIndex: 1, + isLastQuestion: true, + canAdvance: true, + isResponding: false, + isComplete: true, + }, + }); + + expect(html).toContain(">Previous<"); + expect(html).toContain("Submit answers"); + }); + + it("uses separate pills for compact implement actions", () => { + const html = renderActions({ + compact: true, + showPlanFollowUpPrompt: true, + }); + + expect(html).toContain(">Implement<"); + expect(html).toContain('aria-label="Implementation actions"'); + expect(html).not.toContain("rounded-l-none"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx new file mode 100644 index 0000000000..0051a19acc --- /dev/null +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -0,0 +1,251 @@ +import { memo } from "react"; +import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; + +interface PendingActionState { + questionIndex: number; + isLastQuestion: boolean; + canAdvance: boolean; + isResponding: boolean; + isComplete: boolean; +} + +interface ComposerPrimaryActionsProps { + compact: boolean; + pendingAction: PendingActionState | null; + isRunning: boolean; + showPlanFollowUpPrompt: boolean; + promptHasText: boolean; + isSendBusy: boolean; + isConnecting: boolean; + isPreparingWorktree: boolean; + hasSendableContent: boolean; + onPreviousPendingQuestion: () => void; + onInterrupt: () => void; + onImplementPlanInNewThread: () => void; +} + +const formatPendingPrimaryActionLabel = (input: { + compact: boolean; + isLastQuestion: boolean; + isResponding: boolean; +}) => { + if (input.isResponding) { + return "Submitting..."; + } + if (input.compact) { + return input.isLastQuestion ? "Submit" : "Next"; + } + return input.isLastQuestion ? "Submit answers" : "Next question"; +}; + +export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ + compact, + pendingAction, + isRunning, + showPlanFollowUpPrompt, + promptHasText, + isSendBusy, + isConnecting, + isPreparingWorktree, + hasSendableContent, + onPreviousPendingQuestion, + onInterrupt, + onImplementPlanInNewThread, +}: ComposerPrimaryActionsProps) { + if (pendingAction) { + return ( +
+ {pendingAction.questionIndex > 0 ? ( + compact ? ( + + ) : ( + + ) + ) : null} + +
+ ); + } + + if (isRunning) { + return ( + + ); + } + + if (showPlanFollowUpPrompt) { + if (promptHasText) { + return ( + + ); + } + + return compact ? ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in a new thread + + + +
+ ) : ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in a new thread + + + +
+ ); + } + + return ( + + ); +}); From d9ef0f84c14da80fe01cf29eb4260bde47c0353d Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:20:44 +0530 Subject: [PATCH 2/8] Tighten composer footer layout - Raise the wide-actions compact threshold - Force compact mode when footer content no longer fits - Keep composer actions on one line and add fit tests --- apps/web/src/components/ChatView.tsx | 77 ++++++++++++++----- .../chat/ComposerPrimaryActions.tsx | 6 +- .../components/composerFooterLayout.test.ts | 33 ++++++++ .../src/components/composerFooterLayout.ts | 17 +++- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fd73e7ace2..0edeab8fcd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -139,7 +139,10 @@ import { type TerminalContextSelection, } from "../lib/terminalContext"; import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; -import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; +import { + shouldForceCompactComposerFooterForFit, + shouldUseCompactComposerFooter, +} from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -388,6 +391,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); + const composerFooterRef = useRef(null); + const composerFooterLeadingRef = useRef(null); + const composerFooterActionsRef = useRef(null); const composerImagesRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); @@ -758,6 +764,28 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const composerFooterActionLayoutKey = useMemo(() => { + if (activePendingProgress) { + return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; + } + if (phase === "running") { + return "running"; + } + if (showPlanFollowUpPrompt) { + return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; + } + return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; + }, [ + activePendingIsResponding, + activePendingProgress, + composerSendState.hasSendableContent, + isConnecting, + isPreparingWorktree, + isSendBusy, + phase, + prompt, + showPlanFollowUpPrompt, + ]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -1892,22 +1920,29 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerForm = composerFormRef.current; if (!composerForm) return; const measureComposerFormWidth = () => composerForm.clientWidth; + const measureFooterCompactness = () => { + const heuristicCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }); + if (heuristicCompact) { + return true; + } + return shouldForceCompactComposerFooterForFit({ + footerWidth: composerFooterRef.current?.clientWidth ?? null, + leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, + actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, + }); + }; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - setIsComposerFooterCompact( - shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }), - ); + setIsComposerFooterCompact(measureFooterCompactness()); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; - const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }); + const nextCompact = measureFooterCompactness(); setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); const nextHeight = entry.contentRect.height; @@ -1923,7 +1958,12 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { observer.disconnect(); }; - }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); + }, [ + activeThread?.id, + composerFooterActionLayoutKey, + composerFooterHasWideActions, + scheduleStickToBottom, + ]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -3680,7 +3720,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
) : (
@@ -4005,8 +4045,9 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Right side: send / stop button */}
{activeContextWindow ? ( diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 0051a19acc..003fd800a0 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -57,7 +57,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ }: ComposerPrimaryActionsProps) { if (pendingAction) { return ( -
+
{pendingAction.questionIndex > 0 ? ( compact ? (
) : ( -
+
- - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in a new thread - - - -
- ) : ( -
+ return ( +