authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-04-01 07:10:14+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-31 18:40:14-07:00
log0bc94bc24ec221e2ea7c370c9bfb2b06af9ae573
treeff1144c59584720902f29b44a00577eee9c58199
parent19eb9e7227b2daad4e77fc2ae91814c1dae0875d
signature Signed by PGP key B5690EEEBB952194

fixing the input ui at certain endpoints causing the button to overflow. (#1574)


6 files changed, 831 insertions(+), 173 deletions(-)

apps/web/src/components/ChatView.browser.tsx+315-1
......@@ -11,6 +11,7 @@ import {
1111 type ProjectId,
1212 type ServerConfig,
1313 type ThreadId,
14 type TurnId,
1415 type WsWelcomePayload,
1516 WS_CHANNELS,
1617 WS_METHODS,
......@@ -79,6 +80,20 @@ const DEFAULT_VIEWPORT: ViewportSpec = {
7980 textTolerancePx: 44,
8081 attachmentTolerancePx: 56,
8182};
83const WIDE_FOOTER_VIEWPORT: ViewportSpec = {
84 name: "wide-footer",
85 width: 1_400,
86 height: 1_100,
87 textTolerancePx: 44,
88 attachmentTolerancePx: 56,
89};
90const COMPACT_FOOTER_VIEWPORT: ViewportSpec = {
91 name: "compact-footer",
92 width: 430,
93 height: 932,
94 textTolerancePx: 56,
95 attachmentTolerancePx: 56,
96};
8297const TEXT_VIEWPORT_MATRIX = [
8398 DEFAULT_VIEWPORT,
8499 { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 },
......@@ -102,6 +117,7 @@ interface MountedChatView {
102117 cleanup: () => Promise<void>;
103118 measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
104119 setViewport: (viewport: ViewportSpec) => Promise<void>;
120 setContainerSize: (viewport: Pick<ViewportSpec, "width" | "height">) => Promise<void>;
105121 router: ReturnType<typeof getRouter>;
106122}
107123
......@@ -513,6 +529,114 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
513529 };
514530}
515531
532function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
533 const snapshot = createSnapshotForTargetUser({
534 targetMessageId: "msg-user-pending-input-target" as MessageId,
535 targetText: "question thread",
536 });
537
538 return {
539 ...snapshot,
540 threads: snapshot.threads.map((thread) =>
541 thread.id === THREAD_ID
542 ? Object.assign({}, thread, {
543 interactionMode: "plan",
544 activities: [
545 {
546 id: EventId.makeUnsafe("activity-user-input-requested"),
547 tone: "info",
548 kind: "user-input.requested",
549 summary: "User input requested",
550 payload: {
551 requestId: "req-browser-user-input",
552 questions: [
553 {
554 id: "scope",
555 header: "Scope",
556 question: "What should this change cover?",
557 options: [
558 {
559 label: "Tight",
560 description: "Touch only the footer layout logic.",
561 },
562 {
563 label: "Broad",
564 description: "Also adjust the related composer controls.",
565 },
566 ],
567 },
568 {
569 id: "risk",
570 header: "Risk",
571 question: "How aggressive should the imaginary plan be?",
572 options: [
573 {
574 label: "Conservative",
575 description: "Favor reliability and low-risk changes.",
576 },
577 {
578 label: "Balanced",
579 description: "Mix quick wins with one structural improvement.",
580 },
581 ],
582 },
583 ],
584 },
585 turnId: null,
586 sequence: 1,
587 createdAt: isoAt(1_000),
588 },
589 ],
590 updatedAt: isoAt(1_000),
591 })
592 : thread,
593 ),
594 };
595}
596
597function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
598 const snapshot = createSnapshotForTargetUser({
599 targetMessageId: "msg-user-plan-follow-up-target" as MessageId,
600 targetText: "plan follow-up thread",
601 });
602
603 return {
604 ...snapshot,
605 threads: snapshot.threads.map((thread) =>
606 thread.id === THREAD_ID
607 ? Object.assign({}, thread, {
608 interactionMode: "plan",
609 latestTurn: {
610 turnId: "turn-plan-follow-up" as TurnId,
611 state: "completed",
612 requestedAt: isoAt(1_000),
613 startedAt: isoAt(1_001),
614 completedAt: isoAt(1_010),
615 assistantMessageId: null,
616 },
617 proposedPlans: [
618 {
619 id: "plan-follow-up-browser-test",
620 turnId: "turn-plan-follow-up" as TurnId,
621 planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.",
622 implementedAt: null,
623 implementationThreadId: null,
624 createdAt: isoAt(1_002),
625 updatedAt: isoAt(1_003),
626 },
627 ],
628 session: {
629 ...thread.session,
630 status: "ready",
631 updatedAt: isoAt(1_010),
632 },
633 updatedAt: isoAt(1_010),
634 })
635 : thread,
636 ),
637 };
638}
639
516640function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
517641 const customResult = customWsRpcResolver?.(body);
518642 if (customResult !== undefined) {
......@@ -694,6 +818,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
694818 );
695819}
696820
821async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> {
822 return waitForElement(
823 () => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`),
824 `Unable to find composer menu item "${itemId}".`,
825 );
826}
827
697828async function waitForSendButton(): Promise<HTMLButtonElement> {
698829 return waitForElement(
699830 () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
......@@ -701,6 +832,62 @@ async function waitForSendButton(): Promise<HTMLButtonElement> {
701832 );
702833}
703834
835function findComposerProviderModelPicker(): HTMLButtonElement | null {
836 return document.querySelector<HTMLButtonElement>('[data-chat-provider-model-picker="true"]');
837}
838
839function findButtonByText(text: string): HTMLButtonElement | null {
840 return (Array.from(document.querySelectorAll("button")).find(
841 (button) => button.textContent?.trim() === text,
842 ) ?? null) as HTMLButtonElement | null;
843}
844
845async function waitForButtonByText(text: string): Promise<HTMLButtonElement> {
846 return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`);
847}
848
849function findButtonContainingText(text: string): HTMLButtonElement | null {
850 return (Array.from(document.querySelectorAll("button")).find((button) =>
851 button.textContent?.includes(text),
852 ) ?? null) as HTMLButtonElement | null;
853}
854
855async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> {
856 return waitForElement(
857 () => findButtonContainingText(text),
858 `Unable to find button containing "${text}".`,
859 );
860}
861
862async function expectComposerActionsContained(): Promise<void> {
863 const footer = await waitForElement(
864 () => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'),
865 "Unable to find composer footer.",
866 );
867 const actions = await waitForElement(
868 () => document.querySelector<HTMLElement>('[data-chat-composer-actions="right"]'),
869 "Unable to find composer actions container.",
870 );
871
872 await vi.waitFor(
873 () => {
874 const footerRect = footer.getBoundingClientRect();
875 const actionButtons = Array.from(actions.querySelectorAll<HTMLButtonElement>("button"));
876 expect(actionButtons.length).toBeGreaterThanOrEqual(1);
877
878 const buttonRects = actionButtons.map((button) => button.getBoundingClientRect());
879 const firstTop = buttonRects[0]?.top ?? 0;
880
881 for (const rect of buttonRects) {
882 expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5);
883 expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5);
884 expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5);
885 }
886 },
887 { timeout: 8_000, interval: 16 },
888 );
889}
890
704891async function waitForInteractionModeButton(
705892 expectedLabel: "Chat" | "Plan",
706893): Promise<HTMLButtonElement> {
......@@ -864,7 +1051,8 @@ async function mountChatView(options: {
8641051
8651052 const host = document.createElement("div");
8661053 host.style.position = "fixed";
867 host.style.inset = "0";
1054 host.style.top = "0";
1055 host.style.left = "0";
8681056 host.style.width = "100vw";
8691057 host.style.height = "100vh";
8701058 host.style.display = "grid";
......@@ -897,6 +1085,11 @@ async function mountChatView(options: {
8971085 await setViewport(viewport);
8981086 await waitForProductionStyles();
8991087 },
1088 setContainerSize: async (viewport) => {
1089 host.style.width = `${viewport.width}px`;
1090 host.style.height = `${viewport.height}px`;
1091 await waitForLayout();
1092 },
9001093 router,
9011094 };
9021095}
......@@ -2341,4 +2534,125 @@ describe("ChatView timeline estimator parity (full app)", () => {
23412534 await mounted.cleanup();
23422535 }
23432536 });
2537
2538 it("keeps pending-question footer actions inside the composer after a real resize", async () => {
2539 const mounted = await mountChatView({
2540 viewport: WIDE_FOOTER_VIEWPORT,
2541 snapshot: createSnapshotWithPendingUserInput(),
2542 });
2543
2544 try {
2545 const firstOption = await waitForButtonContainingText("Tight");
2546 firstOption.click();
2547
2548 await waitForButtonByText("Previous");
2549 await waitForButtonByText("Submit answers");
2550
2551 await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT);
2552 await expectComposerActionsContained();
2553 } finally {
2554 await mounted.cleanup();
2555 }
2556 });
2557
2558 it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => {
2559 const mounted = await mountChatView({
2560 viewport: WIDE_FOOTER_VIEWPORT,
2561 snapshot: createSnapshotWithPlanFollowUpPrompt(),
2562 });
2563
2564 try {
2565 const footer = await waitForElement(
2566 () => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'),
2567 "Unable to find composer footer.",
2568 );
2569 const initialModelPicker = await waitForElement(
2570 findComposerProviderModelPicker,
2571 "Unable to find provider model picker.",
2572 );
2573 const initialModelPickerOffset =
2574 initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left;
2575
2576 await waitForButtonByText("Implement");
2577 await waitForElement(
2578 () =>
2579 document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'),
2580 "Unable to find implementation actions trigger.",
2581 );
2582
2583 await mounted.setContainerSize({
2584 width: 440,
2585 height: WIDE_FOOTER_VIEWPORT.height,
2586 });
2587 await expectComposerActionsContained();
2588
2589 const implementButton = await waitForButtonByText("Implement");
2590 const implementActionsButton = await waitForElement(
2591 () =>
2592 document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'),
2593 "Unable to find implementation actions trigger.",
2594 );
2595
2596 await vi.waitFor(
2597 () => {
2598 const implementRect = implementButton.getBoundingClientRect();
2599 const implementActionsRect = implementActionsButton.getBoundingClientRect();
2600 const compactModelPicker = findComposerProviderModelPicker();
2601 expect(compactModelPicker).toBeTruthy();
2602
2603 const compactModelPickerOffset =
2604 compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left;
2605
2606 expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1);
2607 expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1);
2608 expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual(
2609 1,
2610 );
2611 },
2612 { timeout: 8_000, interval: 16 },
2613 );
2614 } finally {
2615 await mounted.cleanup();
2616 }
2617 });
2618
2619 it("keeps the slash-command menu visible above the composer", async () => {
2620 const mounted = await mountChatView({
2621 viewport: DEFAULT_VIEWPORT,
2622 snapshot: createSnapshotForTargetUser({
2623 targetMessageId: "msg-user-command-menu-target" as MessageId,
2624 targetText: "command menu thread",
2625 }),
2626 });
2627
2628 try {
2629 await waitForComposerEditor();
2630 await page.getByTestId("composer-editor").fill("/");
2631
2632 const menuItem = await waitForComposerMenuItem("slash:model");
2633 const composerForm = await waitForElement(
2634 () => document.querySelector<HTMLElement>('[data-chat-composer-form="true"]'),
2635 "Unable to find composer form.",
2636 );
2637
2638 await vi.waitFor(
2639 () => {
2640 const menuRect = menuItem.getBoundingClientRect();
2641 const composerRect = composerForm.getBoundingClientRect();
2642 const hitTarget = document.elementFromPoint(
2643 menuRect.left + menuRect.width / 2,
2644 menuRect.top + menuRect.height / 2,
2645 );
2646
2647 expect(menuRect.width).toBeGreaterThan(0);
2648 expect(menuRect.height).toBeGreaterThan(0);
2649 expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom);
2650 expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true);
2651 },
2652 { timeout: 8_000, interval: 16 },
2653 );
2654 } finally {
2655 await mounted.cleanup();
2656 }
2657 });
23442658});
apps/web/src/components/ChatView.tsx+119-169
......@@ -106,7 +106,6 @@ import {
106106} from "lucide-react";
107107import { Button } from "./ui/button";
108108import { Separator } from "./ui/separator";
109import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
110109import { cn, randomUUID } from "~/lib/utils";
111110import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
112111import { toastManager } from "./ui/toast";
......@@ -148,7 +147,12 @@ import {
148147 type TerminalContextSelection,
149148} from "../lib/terminalContext";
150149import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
151import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
150import {
151 resolveComposerFooterContentWidth,
152 shouldForceCompactComposerFooterForFit,
153 shouldUseCompactComposerPrimaryActions,
154 shouldUseCompactComposerFooter,
155} from "./composerFooterLayout";
152156import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
153157import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
154158import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
......@@ -160,6 +164,7 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider
160164import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
161165import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
162166import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
167import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions";
163168import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
164169import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
165170import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
......@@ -499,6 +504,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
499504 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
500505 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
501506 const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
507 const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false);
502508 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
503509 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
504510 // When set, the thread-change reset effect will open the sidebar instead of closing it.
......@@ -541,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
541547 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
542548 const composerFormRef = useRef<HTMLFormElement>(null);
543549 const composerFormHeightRef = useRef(0);
550 const composerFooterRef = useRef<HTMLDivElement>(null);
551 const composerFooterLeadingRef = useRef<HTMLDivElement>(null);
552 const composerFooterActionsRef = useRef<HTMLDivElement>(null);
544553 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
545554 const composerSelectLockRef = useRef(false);
546555 const composerMenuOpenRef = useRef(false);
......@@ -930,6 +939,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
930939 pendingUserInputs.length > 0 ||
931940 (showPlanFollowUpPrompt && activeProposedPlan !== null);
932941 const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null;
942 const composerFooterActionLayoutKey = useMemo(() => {
943 if (activePendingProgress) {
944 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
945 }
946 if (phase === "running") {
947 return "running";
948 }
949 if (showPlanFollowUpPrompt) {
950 return prompt.trim().length > 0 ? "plan:refine" : "plan:implement";
951 }
952 return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`;
953 }, [
954 activePendingIsResponding,
955 activePendingProgress,
956 composerSendState.hasSendableContent,
957 isConnecting,
958 isPreparingWorktree,
959 isSendBusy,
960 phase,
961 prompt,
962 showPlanFollowUpPrompt,
963 ]);
933964 const lastSyncedPendingInputRef = useRef<{
934965 requestId: string | null;
935966 questionId: string | null;
......@@ -2038,23 +2069,56 @@ export default function ChatView({ threadId }: ChatViewProps) {
20382069 const composerForm = composerFormRef.current;
20392070 if (!composerForm) return;
20402071 const measureComposerFormWidth = () => composerForm.clientWidth;
2072 const measureFooterCompactness = () => {
2073 const composerFormWidth = measureComposerFormWidth();
2074 const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, {
2075 hasWideActions: composerFooterHasWideActions,
2076 });
2077 const footer = composerFooterRef.current;
2078 const footerStyle = footer ? window.getComputedStyle(footer) : null;
2079 const footerContentWidth = resolveComposerFooterContentWidth({
2080 footerWidth: footer?.clientWidth ?? null,
2081 paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null,
2082 paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null,
2083 });
2084 const fitInput = {
2085 footerContentWidth,
2086 leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null,
2087 actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null,
2088 };
2089 const nextFooterCompact =
2090 heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput);
2091 const nextPrimaryActionsCompact =
2092 nextFooterCompact &&
2093 shouldUseCompactComposerPrimaryActions(composerFormWidth, {
2094 hasWideActions: composerFooterHasWideActions,
2095 });
2096
2097 return {
2098 primaryActionsCompact: nextPrimaryActionsCompact,
2099 footerCompact: nextFooterCompact,
2100 };
2101 };
20412102
20422103 composerFormHeightRef.current = composerForm.getBoundingClientRect().height;
2043 setIsComposerFooterCompact(
2044 shouldUseCompactComposerFooter(measureComposerFormWidth(), {
2045 hasWideActions: composerFooterHasWideActions,
2046 }),
2047 );
2104 const initialCompactness = measureFooterCompactness();
2105 setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact);
2106 setIsComposerFooterCompact(initialCompactness.footerCompact);
20482107 if (typeof ResizeObserver === "undefined") return;
20492108
20502109 const observer = new ResizeObserver((entries) => {
20512110 const [entry] = entries;
20522111 if (!entry) return;
20532112
2054 const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), {
2055 hasWideActions: composerFooterHasWideActions,
2056 });
2057 setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact));
2113 const nextCompactness = measureFooterCompactness();
2114 setIsComposerPrimaryActionsCompact((previous) =>
2115 previous === nextCompactness.primaryActionsCompact
2116 ? previous
2117 : nextCompactness.primaryActionsCompact,
2118 );
2119 setIsComposerFooterCompact((previous) =>
2120 previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact,
2121 );
20582122
20592123 const nextHeight = entry.contentRect.height;
20602124 const previousHeight = composerFormHeightRef.current;
......@@ -2069,7 +2133,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
20692133 return () => {
20702134 observer.disconnect();
20712135 };
2072 }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]);
2136 }, [
2137 activeThread?.id,
2138 composerFooterActionLayoutKey,
2139 composerFooterHasWideActions,
2140 scheduleStickToBottom,
2141 ]);
20732142 useEffect(() => {
20742143 if (!shouldAutoScrollRef.current) return;
20752144 scheduleStickToBottom();
......@@ -3788,7 +3857,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
37883857 <form
37893858 ref={composerFormRef}
37903859 onSubmit={onSend}
3791 className="mx-auto w-full min-w-0 max-w-3xl"
3860 className="mx-auto w-full min-w-0 max-w-[52rem]"
37923861 data-chat-composer-form="true"
37933862 >
37943863 <div
......@@ -3971,19 +4040,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
39714040 </div>
39724041 ) : (
39734042 <div
4043 ref={composerFooterRef}
39744044 data-chat-composer-footer="true"
4045 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
39754046 className={cn(
3976 "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3",
3977 isComposerFooterCompact
3978 ? "gap-1.5"
3979 : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0",
4047 "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-hidden px-2.5 pb-2.5 sm:px-3 sm:pb-3",
4048 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
39804049 )}
39814050 >
39824051 <div
4052 ref={composerFooterLeadingRef}
39834053 className={cn(
39844054 "flex min-w-0 flex-1 items-center",
39854055 isComposerFooterCompact
3986 ? "gap-1 overflow-hidden"
4056 ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
39874057 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
39884058 )}
39894059 >
......@@ -4113,8 +4183,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
41134183
41144184 {/* Right side: send / stop button */}
41154185 <div
4186 ref={composerFooterActionsRef}
41164187 data-chat-composer-actions="right"
4117 className="flex shrink-0 items-center gap-2"
4188 data-chat-composer-primary-actions-compact={
4189 isComposerPrimaryActionsCompact ? "true" : "false"
4190 }
4191 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
41184192 >
41194193 {activeContextWindow ? (
41204194 <ContextWindowMeter usage={activeContextWindow} />
......@@ -4124,156 +4198,32 @@ export default function ChatView({ threadId }: ChatViewProps) {
41244198 Preparing worktree...
41254199 </span>
41264200 ) : null}
4127 {activePendingProgress ? (
4128 <div className="flex items-center gap-2">
4129 {activePendingProgress.questionIndex > 0 ? (
4130 <Button
4131 size="sm"
4132 variant="outline"
4133 className="rounded-full"
4134 onClick={onPreviousActivePendingUserInputQuestion}
4135 disabled={activePendingIsResponding}
4136 >
4137 Previous
4138 </Button>
4139 ) : null}
4140 <Button
4141 type="submit"
4142 size="sm"
4143 className="rounded-full px-4"
4144 disabled={
4145 activePendingIsResponding ||
4146 (activePendingProgress.isLastQuestion
4147 ? !activePendingResolvedAnswers
4148 : !activePendingProgress.canAdvance)
4149 }
4150 >
4151 {activePendingIsResponding
4152 ? "Submitting..."
4153 : activePendingProgress.isLastQuestion
4154 ? "Submit answers"
4155 : "Next question"}
4156 </Button>
4157 </div>
4158 ) : phase === "running" ? (
4159 <button
4160 type="button"
4161 className="flex size-8 cursor-pointer 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"
4162 onClick={() => void onInterrupt()}
4163 aria-label="Stop generation"
4164 >
4165 <svg
4166 width="12"
4167 height="12"
4168 viewBox="0 0 12 12"
4169 fill="currentColor"
4170 aria-hidden="true"
4171 >
4172 <rect x="2" y="2" width="8" height="8" rx="1.5" />
4173 </svg>
4174 </button>
4175 ) : pendingUserInputs.length === 0 ? (
4176 showPlanFollowUpPrompt ? (
4177 prompt.trim().length > 0 ? (
4178 <Button
4179 type="submit"
4180 size="sm"
4181 className="h-9 rounded-full px-4 sm:h-8"
4182 disabled={isSendBusy || isConnecting}
4183 >
4184 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
4185 </Button>
4186 ) : (
4187 <div className="flex items-center">
4188 <Button
4189 type="submit"
4190 size="sm"
4191 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
4192 disabled={isSendBusy || isConnecting}
4193 >
4194 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
4195 </Button>
4196 <Menu>
4197 <MenuTrigger
4198 render={
4199 <Button
4200 size="sm"
4201 variant="default"
4202 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
4203 aria-label="Implementation actions"
4204 disabled={isSendBusy || isConnecting}
4205 />
4206 }
4207 >
4208 <ChevronDownIcon className="size-3.5" />
4209 </MenuTrigger>
4210 <MenuPopup align="end" side="top">
4211 <MenuItem
4212 disabled={isSendBusy || isConnecting}
4213 onClick={() => void onImplementPlanInNewThread()}
4214 >
4215 Implement in a new thread
4216 </MenuItem>
4217 </MenuPopup>
4218 </Menu>
4219 </div>
4220 )
4221 ) : (
4222 <button
4223 type="submit"
4224 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
4225 disabled={
4226 isSendBusy || isConnecting || !composerSendState.hasSendableContent
4227 }
4228 aria-label={
4229 isConnecting
4230 ? "Connecting"
4231 : isPreparingWorktree
4232 ? "Preparing worktree"
4233 : isSendBusy
4234 ? "Sending"
4235 : "Send message"
4236 }
4237 >
4238 {isConnecting || isSendBusy ? (
4239 <svg
4240 width="14"
4241 height="14"
4242 viewBox="0 0 14 14"
4243 fill="none"
4244 className="animate-spin"
4245 aria-hidden="true"
4246 >
4247 <circle
4248 cx="7"
4249 cy="7"
4250 r="5.5"
4251 stroke="currentColor"
4252 strokeWidth="1.5"
4253 strokeLinecap="round"
4254 strokeDasharray="20 12"
4255 />
4256 </svg>
4257 ) : (
4258 <svg
4259 width="14"
4260 height="14"
4261 viewBox="0 0 14 14"
4262 fill="none"
4263 aria-hidden="true"
4264 >
4265 <path
4266 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
4267 stroke="currentColor"
4268 strokeWidth="1.8"
4269 strokeLinecap="round"
4270 strokeLinejoin="round"
4271 />
4272 </svg>
4273 )}
4274 </button>
4275 )
4276 ) : null}
4201 <ComposerPrimaryActions
4202 compact={isComposerPrimaryActionsCompact}
4203 pendingAction={
4204 activePendingProgress
4205 ? {
4206 questionIndex: activePendingProgress.questionIndex,
4207 isLastQuestion: activePendingProgress.isLastQuestion,
4208 canAdvance: activePendingProgress.canAdvance,
4209 isResponding: activePendingIsResponding,
4210 isComplete: Boolean(activePendingResolvedAnswers),
4211 }
4212 : null
4213 }
4214 isRunning={phase === "running"}
4215 showPlanFollowUpPrompt={
4216 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
4217 }
4218 promptHasText={prompt.trim().length > 0}
4219 isSendBusy={isSendBusy}
4220 isConnecting={isConnecting}
4221 isPreparingWorktree={isPreparingWorktree}
4222 hasSendableContent={composerSendState.hasSendableContent}
4223 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
4224 onInterrupt={() => void onInterrupt()}
4225 onImplementPlanInNewThread={() => void onImplementPlanInNewThread()}
4226 />
42774227 </div>
42784228 </div>
42794229 )}
apps/web/src/components/chat/ComposerPrimaryActions.tsx created+217
......@@ -0,0 +1,217 @@
1import { memo } from "react";
2import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { cn } from "~/lib/utils";
4import { Button } from "../ui/button";
5import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
6
7interface PendingActionState {
8 questionIndex: number;
9 isLastQuestion: boolean;
10 canAdvance: boolean;
11 isResponding: boolean;
12 isComplete: boolean;
13}
14
15interface ComposerPrimaryActionsProps {
16 compact: boolean;
17 pendingAction: PendingActionState | null;
18 isRunning: boolean;
19 showPlanFollowUpPrompt: boolean;
20 promptHasText: boolean;
21 isSendBusy: boolean;
22 isConnecting: boolean;
23 isPreparingWorktree: boolean;
24 hasSendableContent: boolean;
25 onPreviousPendingQuestion: () => void;
26 onInterrupt: () => void;
27 onImplementPlanInNewThread: () => void;
28}
29
30const formatPendingPrimaryActionLabel = (input: {
31 compact: boolean;
32 isLastQuestion: boolean;
33 isResponding: boolean;
34}) => {
35 if (input.isResponding) {
36 return "Submitting...";
37 }
38 if (input.compact) {
39 return input.isLastQuestion ? "Submit" : "Next";
40 }
41 return input.isLastQuestion ? "Submit answers" : "Next question";
42};
43
44export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
45 compact,
46 pendingAction,
47 isRunning,
48 showPlanFollowUpPrompt,
49 promptHasText,
50 isSendBusy,
51 isConnecting,
52 isPreparingWorktree,
53 hasSendableContent,
54 onPreviousPendingQuestion,
55 onInterrupt,
56 onImplementPlanInNewThread,
57}: ComposerPrimaryActionsProps) {
58 if (pendingAction) {
59 return (
60 <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}>
61 {pendingAction.questionIndex > 0 ? (
62 compact ? (
63 <Button
64 size="icon-sm"
65 variant="outline"
66 className="rounded-full"
67 onClick={onPreviousPendingQuestion}
68 disabled={pendingAction.isResponding}
69 aria-label="Previous question"
70 >
71 <ChevronLeftIcon className="size-3.5" />
72 </Button>
73 ) : (
74 <Button
75 size="sm"
76 variant="outline"
77 className="rounded-full"
78 onClick={onPreviousPendingQuestion}
79 disabled={pendingAction.isResponding}
80 >
81 Previous
82 </Button>
83 )
84 ) : null}
85 <Button
86 type="submit"
87 size="sm"
88 className={cn("rounded-full", compact ? "px-3" : "px-4")}
89 disabled={
90 pendingAction.isResponding ||
91 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
92 }
93 >
94 {formatPendingPrimaryActionLabel({
95 compact,
96 isLastQuestion: pendingAction.isLastQuestion,
97 isResponding: pendingAction.isResponding,
98 })}
99 </Button>
100 </div>
101 );
102 }
103
104 if (isRunning) {
105 return (
106 <button
107 type="button"
108 className="flex size-8 cursor-pointer 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"
109 onClick={onInterrupt}
110 aria-label="Stop generation"
111 >
112 <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
113 <rect x="2" y="2" width="8" height="8" rx="1.5" />
114 </svg>
115 </button>
116 );
117 }
118
119 if (showPlanFollowUpPrompt) {
120 if (promptHasText) {
121 return (
122 <Button
123 type="submit"
124 size="sm"
125 className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")}
126 disabled={isSendBusy || isConnecting}
127 >
128 {isConnecting || isSendBusy ? "Sending..." : "Refine"}
129 </Button>
130 );
131 }
132
133 return (
134 <div data-chat-composer-implement-actions="true" className="flex items-center justify-end">
135 <Button
136 type="submit"
137 size="sm"
138 className={cn("h-9 rounded-l-full rounded-r-none sm:h-8", compact ? "px-3" : "px-4")}
139 disabled={isSendBusy || isConnecting}
140 >
141 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
142 </Button>
143 <Menu>
144 <MenuTrigger
145 render={
146 <Button
147 size="sm"
148 variant="default"
149 className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8"
150 aria-label="Implementation actions"
151 disabled={isSendBusy || isConnecting}
152 />
153 }
154 >
155 <ChevronDownIcon className="size-3.5" />
156 </MenuTrigger>
157 <MenuPopup align="end" side="top">
158 <MenuItem
159 disabled={isSendBusy || isConnecting}
160 onClick={() => void onImplementPlanInNewThread()}
161 >
162 Implement in a new thread
163 </MenuItem>
164 </MenuPopup>
165 </Menu>
166 </div>
167 );
168 }
169
170 return (
171 <button
172 type="submit"
173 className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8"
174 disabled={isSendBusy || isConnecting || !hasSendableContent}
175 aria-label={
176 isConnecting
177 ? "Connecting"
178 : isPreparingWorktree
179 ? "Preparing worktree"
180 : isSendBusy
181 ? "Sending"
182 : "Send message"
183 }
184 >
185 {isConnecting || isSendBusy ? (
186 <svg
187 width="14"
188 height="14"
189 viewBox="0 0 14 14"
190 fill="none"
191 className="animate-spin"
192 aria-hidden="true"
193 >
194 <circle
195 cx="7"
196 cy="7"
197 r="5.5"
198 stroke="currentColor"
199 strokeWidth="1.5"
200 strokeLinecap="round"
201 strokeDasharray="20 12"
202 />
203 </svg>
204 ) : (
205 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
206 <path
207 d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
208 stroke="currentColor"
209 strokeWidth="1.8"
210 strokeLinecap="round"
211 strokeLinejoin="round"
212 />
213 </svg>
214 )}
215 </button>
216 );
217});
apps/web/src/components/chat/ProviderModelPicker.tsx+3-2
......@@ -98,6 +98,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
9898 <Button
9999 size="sm"
100100 variant={props.triggerVariant ?? "ghost"}
101 data-chat-provider-model-picker="true"
101102 className={cn(
102103 "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0",
103104 props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3",
......@@ -109,8 +110,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
109110 >
110111 <span
111112 className={cn(
112 "flex min-w-0 w-full items-center gap-2 overflow-hidden",
113 props.compact ? "max-w-36" : undefined,
113 "flex min-w-0 w-full box-border items-center gap-2 overflow-hidden",
114 props.compact ? "max-w-36 sm:pl-1" : undefined,
114115 )}
115116 >
116117 <ProviderIcon
apps/web/src/components/composerFooterLayout.test.ts+119
......@@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest";
33import {
44 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
55 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
6 COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX,
7 measureComposerFooterOverflowPx,
8 resolveComposerFooterContentWidth,
9 shouldForceCompactComposerFooterForFit,
10 shouldUseCompactComposerPrimaryActions,
611 shouldUseCompactComposerFooter,
712} from "./composerFooterLayout";
813
......@@ -33,3 +38,117 @@ describe("shouldUseCompactComposerFooter", () => {
3338 ).toBe(false);
3439 });
3540});
41
42describe("shouldUseCompactComposerPrimaryActions", () => {
43 it("matches the wide footer breakpoint", () => {
44 expect(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX).toBe(
45 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
46 );
47 expect(
48 shouldUseCompactComposerPrimaryActions(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX - 1, {
49 hasWideActions: true,
50 }),
51 ).toBe(true);
52 expect(
53 shouldUseCompactComposerPrimaryActions(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, {
54 hasWideActions: true,
55 }),
56 ).toBe(false);
57 });
58});
59
60describe("measureComposerFooterOverflowPx", () => {
61 it("returns the overflow amount when content exceeds the footer width", () => {
62 expect(
63 measureComposerFooterOverflowPx({
64 footerContentWidth: 500,
65 leadingContentWidth: 340,
66 actionsWidth: 180,
67 }),
68 ).toBe(28);
69 });
70
71 it("returns zero when content fits", () => {
72 expect(
73 measureComposerFooterOverflowPx({
74 footerContentWidth: 500,
75 leadingContentWidth: 320,
76 actionsWidth: 160,
77 }),
78 ).toBe(0);
79 });
80});
81
82describe("shouldForceCompactComposerFooterForFit", () => {
83 it("stays expanded when content widths fit within the footer", () => {
84 expect(
85 shouldForceCompactComposerFooterForFit({
86 footerContentWidth: 500,
87 leadingContentWidth: 320,
88 actionsWidth: 160,
89 }),
90 ).toBe(false);
91 });
92
93 it("stays expanded when minor overflow can be recovered by compacting primary actions", () => {
94 expect(
95 shouldForceCompactComposerFooterForFit({
96 footerContentWidth: 500,
97 leadingContentWidth: 340,
98 actionsWidth: 180,
99 }),
100 ).toBe(false);
101 });
102
103 it("forces footer compact mode when action compaction would not recover enough space", () => {
104 expect(
105 shouldForceCompactComposerFooterForFit({
106 footerContentWidth: 500,
107 leadingContentWidth: 420,
108 actionsWidth: 220,
109 }),
110 ).toBe(true);
111 });
112
113 it("ignores incomplete measurements", () => {
114 expect(
115 shouldForceCompactComposerFooterForFit({
116 footerContentWidth: null,
117 leadingContentWidth: 340,
118 actionsWidth: 180,
119 }),
120 ).toBe(false);
121 });
122});
123
124describe("resolveComposerFooterContentWidth", () => {
125 it("subtracts horizontal padding from the measured footer width", () => {
126 expect(
127 resolveComposerFooterContentWidth({
128 footerWidth: 500,
129 paddingLeft: 10,
130 paddingRight: 10,
131 }),
132 ).toBe(480);
133 });
134
135 it("clamps negative widths to zero", () => {
136 expect(
137 resolveComposerFooterContentWidth({
138 footerWidth: 10,
139 paddingLeft: 8,
140 paddingRight: 8,
141 }),
142 ).toBe(0);
143 });
144
145 it("returns null when measurements are incomplete", () => {
146 expect(
147 resolveComposerFooterContentWidth({
148 footerWidth: null,
149 paddingLeft: 8,
150 paddingRight: 8,
151 }),
152 ).toBeNull();
153 });
154});
apps/web/src/components/composerFooterLayout.ts+58-1
......@@ -1,5 +1,9 @@
11export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
2export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 720;
2export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;
3export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX =
4 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
5const COMPOSER_FOOTER_CONTENT_GAP_PX = 8;
6const COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX = 120;
37
48export function shouldUseCompactComposerFooter(
59 width: number | null,
......@@ -10,3 +14,56 @@ export function shouldUseCompactComposerFooter(
1014 : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX;
1115 return width !== null && width < breakpoint;
1216}
17
18export function shouldUseCompactComposerPrimaryActions(
19 width: number | null,
20 options?: { hasWideActions?: boolean },
21): boolean {
22 if (!options?.hasWideActions) {
23 return false;
24 }
25 return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX;
26}
27
28export function measureComposerFooterOverflowPx(input: {
29 footerContentWidth: number | null;
30 leadingContentWidth: number | null;
31 actionsWidth: number | null;
32}): number | null {
33 const footerContentWidth = input.footerContentWidth;
34 const leadingContentWidth = input.leadingContentWidth;
35 const actionsWidth = input.actionsWidth;
36 if (footerContentWidth === null || leadingContentWidth === null || actionsWidth === null) {
37 return null;
38 }
39 return Math.max(
40 0,
41 leadingContentWidth + actionsWidth + COMPOSER_FOOTER_CONTENT_GAP_PX - footerContentWidth,
42 );
43}
44
45export function shouldForceCompactComposerFooterForFit(input: {
46 footerContentWidth: number | null;
47 leadingContentWidth: number | null;
48 actionsWidth: number | null;
49}): boolean {
50 const overflowPx = measureComposerFooterOverflowPx(input);
51 if (overflowPx === null) {
52 return false;
53 }
54 return overflowPx > COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX;
55}
56
57export function resolveComposerFooterContentWidth(input: {
58 footerWidth: number | null;
59 paddingLeft: number | null;
60 paddingRight: number | null;
61}): number | null {
62 const footerWidth = input.footerWidth;
63 const paddingLeft = input.paddingLeft;
64 const paddingRight = input.paddingRight;
65 if (footerWidth === null || paddingLeft === null || paddingRight === null) {
66 return null;
67 }
68 return Math.max(0, footerWidth - paddingLeft - paddingRight);
69}