From 43f22ce752a8d84f4a9d2fe20e5a597fb2c0c5ac Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:44:29 +0530 Subject: [PATCH] fix composer footer compact layout --- apps/web/src/components/ChatView.browser.tsx | 88 ++++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 41 ++------ .../chat/ComposerPrimaryActions.tsx | 2 +- .../components/composerFooterLayout.test.ts | 99 ------------------- .../src/components/composerFooterLayout.ts | 45 --------- 5 files changed, 92 insertions(+), 183 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 2261684dd9..cd836a242f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -717,17 +717,30 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { }; } -function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { +function createSnapshotWithPlanFollowUpPrompt(options?: { + modelSelection?: { provider: "codex"; model: string }; + planMarkdown?: string; +}): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-follow-up-target" as MessageId, targetText: "plan follow-up thread", }); + const modelSelection = options?.modelSelection ?? { + provider: "codex" as const, + model: "gpt-5", + }; + const planMarkdown = + options?.planMarkdown ?? "# Follow-up plan\n\n- Keep the composer footer stable on resize."; return { ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID ? { ...project, defaultModelSelection: modelSelection } : project, + ), threads: snapshot.threads.map((thread) => thread.id === THREAD_ID ? Object.assign({}, thread, { + modelSelection, interactionMode: "plan", latestTurn: { turnId: "turn-plan-follow-up" as TurnId, @@ -741,7 +754,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { { id: "plan-follow-up-browser-test", turnId: "turn-plan-follow-up" as TurnId, - planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.", + planMarkdown, implementedAt: null, implementationThreadId: null, createdAt: isoAt(1_002), @@ -3606,8 +3619,9 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const initialModelPickerOffset = initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; + const initialImplementButton = await waitForButtonByText("Implement"); + const initialImplementWidth = initialImplementButton.getBoundingClientRect().width; - await waitForButtonByText("Implement"); await waitForElement( () => document.querySelector('button[aria-label="Implementation actions"]'), @@ -3639,6 +3653,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); + expect(Math.abs(implementRect.width - initialImplementWidth)).toBeLessThanOrEqual(1); expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( 1, ); @@ -3650,6 +3665,73 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the wide desktop follow-up layout expanded when the footer still fits", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPlanFollowUpPrompt({ + modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + planMarkdown: + "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", + }), + }); + + try { + await waitForButtonByText("Implement"); + + await vi.waitFor( + () => { + const footer = document.querySelector('[data-chat-composer-footer="true"]'); + const actions = document.querySelector( + '[data-chat-composer-actions="right"]', + ); + + expect(footer?.dataset.chatComposerFooterCompact).toBe("false"); + expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("false"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("compacts the footer when a wide desktop follow-up layout starts overflowing", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPlanFollowUpPrompt({ + modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" }, + planMarkdown: + "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative", + }), + }); + + try { + await waitForButtonByText("Implement"); + + await mounted.setContainerSize({ + width: 804, + height: WIDE_FOOTER_VIEWPORT.height, + }); + + await expectComposerActionsContained(); + + await vi.waitFor( + () => { + const footer = document.querySelector('[data-chat-composer-footer="true"]'); + const actions = document.querySelector( + '[data-chat-composer-actions="right"]', + ); + + expect(footer?.dataset.chatComposerFooterCompact).toBe("true"); + expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("true"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the slash-command menu visible above the composer", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 26413937d6..6aff3933db 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -55,8 +55,6 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { - resolveComposerFooterContentWidth, - shouldForceCompactComposerFooterForFit, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, } from "../composerFooterLayout"; @@ -646,9 +644,6 @@ export const ChatComposer = memo( 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 composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -1017,31 +1012,17 @@ export const ChatComposer = memo( const measureComposerFormWidth = () => composerForm.clientWidth; const measureFooterCompactness = () => { const composerFormWidth = measureComposerFormWidth(); - const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { + const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { hasWideActions: composerFooterHasWideActions, }); - const footer = composerFooterRef.current; - const footerStyle = footer ? window.getComputedStyle(footer) : null; - const footerContentWidth = resolveComposerFooterContentWidth({ - footerWidth: footer?.clientWidth ?? null, - paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, - paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, - }); - const fitInput = { - footerContentWidth, - leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, - actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, - }; - const nextFooterCompact = - heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); - const nextPrimaryActionsCompact = - nextFooterCompact && + const primaryActionsCompact = + footerCompact && shouldUseCompactComposerPrimaryActions(composerFormWidth, { hasWideActions: composerFooterHasWideActions, }); return { - primaryActionsCompact: nextPrimaryActionsCompact, - footerCompact: nextFooterCompact, + primaryActionsCompact, + footerCompact, }; }; @@ -1795,7 +1776,6 @@ export const ChatComposer = memo( ) : (
-
+
{isConnecting || isSendBusy ? "Sending..." : "Implement"} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index d269fafbbf..0a019f6f33 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -4,9 +4,6 @@ import { COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, - measureComposerFooterOverflowPx, - resolveComposerFooterContentWidth, - shouldForceCompactComposerFooterForFit, shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, } from "./composerFooterLayout"; @@ -56,99 +53,3 @@ describe("shouldUseCompactComposerPrimaryActions", () => { ).toBe(false); }); }); - -describe("measureComposerFooterOverflowPx", () => { - it("returns the overflow amount when content exceeds the footer width", () => { - expect( - measureComposerFooterOverflowPx({ - footerContentWidth: 500, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(28); - }); - - it("returns zero when content fits", () => { - expect( - measureComposerFooterOverflowPx({ - footerContentWidth: 500, - leadingContentWidth: 320, - actionsWidth: 160, - }), - ).toBe(0); - }); -}); - -describe("shouldForceCompactComposerFooterForFit", () => { - it("stays expanded when content widths fit within the footer", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 320, - actionsWidth: 160, - }), - ).toBe(false); - }); - - it("stays expanded when minor overflow can be recovered by compacting primary actions", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(false); - }); - - it("forces footer compact mode when action compaction would not recover enough space", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: 500, - leadingContentWidth: 420, - actionsWidth: 220, - }), - ).toBe(true); - }); - - it("ignores incomplete measurements", () => { - expect( - shouldForceCompactComposerFooterForFit({ - footerContentWidth: null, - leadingContentWidth: 340, - actionsWidth: 180, - }), - ).toBe(false); - }); -}); - -describe("resolveComposerFooterContentWidth", () => { - it("subtracts horizontal padding from the measured footer width", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: 500, - paddingLeft: 10, - paddingRight: 10, - }), - ).toBe(480); - }); - - it("clamps negative widths to zero", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: 10, - paddingLeft: 8, - paddingRight: 8, - }), - ).toBe(0); - }); - - it("returns null when measurements are incomplete", () => { - expect( - resolveComposerFooterContentWidth({ - footerWidth: null, - paddingLeft: 8, - paddingRight: 8, - }), - ).toBeNull(); - }); -}); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index b4a7fe3d60..ae5fd56669 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -2,8 +2,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX = COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX; -const COMPOSER_FOOTER_CONTENT_GAP_PX = 8; -const COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX = 120; export function shouldUseCompactComposerFooter( width: number | null, @@ -24,46 +22,3 @@ export function shouldUseCompactComposerPrimaryActions( } return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX; } - -export function measureComposerFooterOverflowPx(input: { - footerContentWidth: number | null; - leadingContentWidth: number | null; - actionsWidth: number | null; -}): number | null { - const footerContentWidth = input.footerContentWidth; - const leadingContentWidth = input.leadingContentWidth; - const actionsWidth = input.actionsWidth; - if (footerContentWidth === null || leadingContentWidth === null || actionsWidth === null) { - return null; - } - return Math.max( - 0, - leadingContentWidth + actionsWidth + COMPOSER_FOOTER_CONTENT_GAP_PX - footerContentWidth, - ); -} - -export function shouldForceCompactComposerFooterForFit(input: { - footerContentWidth: number | null; - leadingContentWidth: number | null; - actionsWidth: number | null; -}): boolean { - const overflowPx = measureComposerFooterOverflowPx(input); - if (overflowPx === null) { - return false; - } - return overflowPx > COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX; -} - -export function resolveComposerFooterContentWidth(input: { - footerWidth: number | null; - paddingLeft: number | null; - paddingRight: number | null; -}): number | null { - const footerWidth = input.footerWidth; - const paddingLeft = input.paddingLeft; - const paddingRight = input.paddingRight; - if (footerWidth === null || paddingLeft === null || paddingRight === null) { - return null; - } - return Math.max(0, footerWidth - paddingLeft - paddingRight); -}