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 ? (
-
- ) : (
-
-
-
-
- )
- ) : (
-
- )
- ) : 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 ? (
+
+
+
+
+ ) : (
+
+
+
+
+ );
+ }
+
+ 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) {