History

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

shivam committed 7 months ago

Commit 0bc94bc1 parent 19eb9e76 files · +831 −173Browse files

Unified · Split

6 files
  1. apps/web/src/components/ChatView.browser.tsx +315 −1
  2. apps/web/src/components/ChatView.tsx +119 −169
  3. apps/web/src/components/chat/ComposerPrimaryActions.tsx +217 −0
  4. apps/web/src/components/chat/ProviderModelPicker.tsx +3 −2
  5. apps/web/src/components/composerFooterLayout.test.ts +119 −0
  6. apps/web/src/components/composerFooterLayout.ts +58 −1
apps/web/src/components/ChatView.browser.tsx+315−1
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
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 = { · 62 unmodified lines
7980 textTolerancePx: 44,
8081 attachmentTolerancePx: 56,
8182 };
83+const WIDE_FOOTER_VIEWPORT: ViewportSpec = {
84+ name: "wide-footer",
85+ width: 1_400,
86+ height: 1_100,
87+ textTolerancePx: 44,
88+ attachmentTolerancePx: 56,
89+};
90+const COMPACT_FOOTER_VIEWPORT: ViewportSpec = {
91+ name: "compact-footer",
92+ width: 430,
93+ height: 932,
94+ textTolerancePx: 56,
95+ attachmentTolerancePx: 56,
96+};
8297 const TEXT_VIEWPORT_MATRIX = [
8398 DEFAULT_VIEWPORT,
8499 { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 },
@@ -102,6 +117,7 @@ interface MountedChatView { · 17 unmodified lines
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 { · 405 unmodified lines
513529 };
514530 }
515531
532+function 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+
597+function 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+
516640 function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
517641 const customResult = customWsRpcResolver?.(body);
518642 if (customResult !== undefined) {
@@ -694,6 +818,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> { · 175 unmodified lines
694818 );
695819 }
696820
821+async 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+
697828 async 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> { · 1 unmodified line
701832 );
702833 }
703834
835+function findComposerProviderModelPicker(): HTMLButtonElement | null {
836+ return document.querySelector<HTMLButtonElement>('[data-chat-provider-model-picker="true"]');
837+}
838+
839+function 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+
845+async function waitForButtonByText(text: string): Promise<HTMLButtonElement> {
846+ return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`);
847+}
848+
849+function 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+
855+async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> {
856+ return waitForElement(
857+ () => findButtonContainingText(text),
858+ `Unable to find button containing "${text}".`,
859+ );
860+}
861+
862+async 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+
704891 async function waitForInteractionModeButton(
705892 expectedLabel: "Chat" | "Plan",
706893 ): Promise<HTMLButtonElement> {
@@ -864,7 +1051,8 @@ async function mountChatView(options: { · 157 unmodified lines
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: { · 26 unmodified lines
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)", () => { · 1438 unmodified lines
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 { · 105 unmodified lines
106106 } from "lucide-react";
107107 import { Button } from "./ui/button";
108108 import { Separator } from "./ui/separator";
109−import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu";
110109 import { cn, randomUUID } from "~/lib/utils";
111110 import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
112111 import { toastManager } from "./ui/toast";
@@ -148,7 +147,12 @@ import { · 35 unmodified lines
148147 type TerminalContextSelection,
149148 } from "../lib/terminalContext";
150149 import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow";
151−import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
150+import {
151+ resolveComposerFooterContentWidth,
152+ shouldForceCompactComposerFooterForFit,
153+ shouldUseCompactComposerPrimaryActions,
154+ shouldUseCompactComposerFooter,
155+} from "./composerFooterLayout";
152156 import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
153157 import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
154158 import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
@@ -160,6 +164,7 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider · 5 unmodified lines
160164 import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
161165 import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
162166 import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
167+import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions";
163168 import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
164169 import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
165170 import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
@@ -499,6 +504,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 333 unmodified lines
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) { · 36 unmodified lines
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) { · 383 unmodified lines
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) { · 1102 unmodified lines
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) { · 8 unmodified lines
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) { · 1712 unmodified lines
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) { · 176 unmodified lines
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) { · 123 unmodified lines
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) { · 3 unmodified lines
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+217−0
@@ -0,0 +1,217 @@
1+import { memo } from "react";
2+import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3+import { cn } from "~/lib/utils";
4+import { Button } from "../ui/button";
5+import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
6+
7+interface PendingActionState {
8+ questionIndex: number;
9+ isLastQuestion: boolean;
10+ canAdvance: boolean;
11+ isResponding: boolean;
12+ isComplete: boolean;
13+}
14+
15+interface 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+
30+const 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+
44+export 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: { · 97 unmodified lines
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: { · 5 unmodified lines
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−0
@@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines
33 import {
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", () => { · 24 unmodified lines
3338 ).toBe(false);
3439 });
3540 });
41+
42+describe("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+
60+describe("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+
82+describe("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+
124+describe("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 @@
11 export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
2−export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 720;
2+export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;
3+export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX =
4+ COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
5+const COMPOSER_FOOTER_CONTENT_GAP_PX = 8;
6+const COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX = 120;
37
48 export function shouldUseCompactComposerFooter(
59 width: number | null,
@@ -10,3 +14,56 @@ export function shouldUseCompactComposerFooter( · 4 unmodified lines
1014 : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX;
1115 return width !== null && width < breakpoint;
1216 }
17+
18+export 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+
28+export 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+
45+export 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+
57+export 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+}