authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-04-11 04:39:27+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 16:09:27-07:00
log5fa09fa2d9886a535b1629ce58a9ae88fc912f0c
tree2089b391367c82feeaac162c7c0cd442b890d79f
parent12c3af7821b487878fad67a10b6e7ea980efb214
signature Signed by PGP key B5690EEEBB952194

[codex] fix composer footer compact layout (#1894)


5 files changed, 92 insertions(+), 183 deletions(-)

apps/web/src/components/ChatView.browser.tsx+85-3
......@@ -717,17 +717,30 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
717717 };
718718}
719719
720function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
720function createSnapshotWithPlanFollowUpPrompt(options?: {
721 modelSelection?: { provider: "codex"; model: string };
722 planMarkdown?: string;
723}): OrchestrationReadModel {
721724 const snapshot = createSnapshotForTargetUser({
722725 targetMessageId: "msg-user-plan-follow-up-target" as MessageId,
723726 targetText: "plan follow-up thread",
724727 });
728 const modelSelection = options?.modelSelection ?? {
729 provider: "codex" as const,
730 model: "gpt-5",
731 };
732 const planMarkdown =
733 options?.planMarkdown ?? "# Follow-up plan\n\n- Keep the composer footer stable on resize.";
725734
726735 return {
727736 ...snapshot,
737 projects: snapshot.projects.map((project) =>
738 project.id === PROJECT_ID ? { ...project, defaultModelSelection: modelSelection } : project,
739 ),
728740 threads: snapshot.threads.map((thread) =>
729741 thread.id === THREAD_ID
730742 ? Object.assign({}, thread, {
743 modelSelection,
731744 interactionMode: "plan",
732745 latestTurn: {
733746 turnId: "turn-plan-follow-up" as TurnId,
......@@ -741,7 +754,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
741754 {
742755 id: "plan-follow-up-browser-test",
743756 turnId: "turn-plan-follow-up" as TurnId,
744 planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.",
757 planMarkdown,
745758 implementedAt: null,
746759 implementationThreadId: null,
747760 createdAt: isoAt(1_002),
......@@ -3720,8 +3733,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
37203733 );
37213734 const initialModelPickerOffset =
37223735 initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left;
3736 const initialImplementButton = await waitForButtonByText("Implement");
3737 const initialImplementWidth = initialImplementButton.getBoundingClientRect().width;
37233738
3724 await waitForButtonByText("Implement");
37253739 await waitForElement(
37263740 () =>
37273741 document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'),
......@@ -3753,6 +3767,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
37533767
37543768 expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1);
37553769 expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1);
3770 expect(Math.abs(implementRect.width - initialImplementWidth)).toBeLessThanOrEqual(1);
37563771 expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual(
37573772 1,
37583773 );
......@@ -3764,6 +3779,73 @@ describe("ChatView timeline estimator parity (full app)", () => {
37643779 }
37653780 });
37663781
3782 it("keeps the wide desktop follow-up layout expanded when the footer still fits", async () => {
3783 const mounted = await mountChatView({
3784 viewport: WIDE_FOOTER_VIEWPORT,
3785 snapshot: createSnapshotWithPlanFollowUpPrompt({
3786 modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" },
3787 planMarkdown:
3788 "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative",
3789 }),
3790 });
3791
3792 try {
3793 await waitForButtonByText("Implement");
3794
3795 await vi.waitFor(
3796 () => {
3797 const footer = document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]');
3798 const actions = document.querySelector<HTMLElement>(
3799 '[data-chat-composer-actions="right"]',
3800 );
3801
3802 expect(footer?.dataset.chatComposerFooterCompact).toBe("false");
3803 expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("false");
3804 },
3805 { timeout: 8_000, interval: 16 },
3806 );
3807 } finally {
3808 await mounted.cleanup();
3809 }
3810 });
3811
3812 it("compacts the footer when a wide desktop follow-up layout starts overflowing", async () => {
3813 const mounted = await mountChatView({
3814 viewport: WIDE_FOOTER_VIEWPORT,
3815 snapshot: createSnapshotWithPlanFollowUpPrompt({
3816 modelSelection: { provider: "codex", model: "gpt-5.3-codex-spark" },
3817 planMarkdown:
3818 "# Imaginary Long-Range Plan: T3 Code Adaptive Orchestration and Safe-Delay Execution Initiative",
3819 }),
3820 });
3821
3822 try {
3823 await waitForButtonByText("Implement");
3824
3825 await mounted.setContainerSize({
3826 width: 804,
3827 height: WIDE_FOOTER_VIEWPORT.height,
3828 });
3829
3830 await expectComposerActionsContained();
3831
3832 await vi.waitFor(
3833 () => {
3834 const footer = document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]');
3835 const actions = document.querySelector<HTMLElement>(
3836 '[data-chat-composer-actions="right"]',
3837 );
3838
3839 expect(footer?.dataset.chatComposerFooterCompact).toBe("true");
3840 expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("true");
3841 },
3842 { timeout: 8_000, interval: 16 },
3843 );
3844 } finally {
3845 await mounted.cleanup();
3846 }
3847 });
3848
37673849 it("keeps the slash-command menu visible above the composer", async () => {
37683850 const mounted = await mountChatView({
37693851 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/chat/ChatComposer.tsx+6-35
......@@ -55,8 +55,6 @@ import {
5555 removeInlineTerminalContextPlaceholder,
5656} from "../../lib/terminalContext";
5757import {
58 resolveComposerFooterContentWidth,
59 shouldForceCompactComposerFooterForFit,
6058 shouldUseCompactComposerPrimaryActions,
6159 shouldUseCompactComposerFooter,
6260} from "../composerFooterLayout";
......@@ -646,9 +644,6 @@ export const ChatComposer = memo(
646644 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
647645 const composerFormRef = useRef<HTMLFormElement>(null);
648646 const composerFormHeightRef = useRef(0);
649 const composerFooterRef = useRef<HTMLDivElement>(null);
650 const composerFooterLeadingRef = useRef<HTMLDivElement>(null);
651 const composerFooterActionsRef = useRef<HTMLDivElement>(null);
652647 const composerSelectLockRef = useRef(false);
653648 const composerMenuOpenRef = useRef(false);
654649 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
......@@ -1017,31 +1012,17 @@ export const ChatComposer = memo(
10171012 const measureComposerFormWidth = () => composerForm.clientWidth;
10181013 const measureFooterCompactness = () => {
10191014 const composerFormWidth = measureComposerFormWidth();
1020 const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, {
1015 const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, {
10211016 hasWideActions: composerFooterHasWideActions,
10221017 });
1023 const footer = composerFooterRef.current;
1024 const footerStyle = footer ? window.getComputedStyle(footer) : null;
1025 const footerContentWidth = resolveComposerFooterContentWidth({
1026 footerWidth: footer?.clientWidth ?? null,
1027 paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null,
1028 paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null,
1029 });
1030 const fitInput = {
1031 footerContentWidth,
1032 leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null,
1033 actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null,
1034 };
1035 const nextFooterCompact =
1036 heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput);
1037 const nextPrimaryActionsCompact =
1038 nextFooterCompact &&
1018 const primaryActionsCompact =
1019 footerCompact &&
10391020 shouldUseCompactComposerPrimaryActions(composerFormWidth, {
10401021 hasWideActions: composerFooterHasWideActions,
10411022 });
10421023 return {
1043 primaryActionsCompact: nextPrimaryActionsCompact,
1044 footerCompact: nextFooterCompact,
1024 primaryActionsCompact,
1025 footerCompact,
10451026 };
10461027 };
10471028
......@@ -1795,7 +1776,6 @@ export const ChatComposer = memo(
17951776 </div>
17961777 ) : (
17971778 <div
1798 ref={composerFooterRef}
17991779 data-chat-composer-footer="true"
18001780 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
18011781 className={cn(
......@@ -1803,15 +1783,7 @@ export const ChatComposer = memo(
18031783 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
18041784 )}
18051785 >
1806 <div
1807 ref={composerFooterLeadingRef}
1808 className={cn(
1809 "flex min-w-0 flex-1 items-center",
1810 isComposerFooterCompact
1811 ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
1812 : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
1813 )}
1814 >
1786 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
18151787 <ProviderModelPicker
18161788 compact={isComposerFooterCompact}
18171789 provider={selectedProvider}
......@@ -1865,7 +1837,6 @@ export const ChatComposer = memo(
18651837
18661838 {/* Right side: send / stop button */}
18671839 <div
1868 ref={composerFooterActionsRef}
18691840 data-chat-composer-actions="right"
18701841 data-chat-composer-primary-actions-compact={
18711842 isComposerPrimaryActionsCompact ? "true" : "false"
apps/web/src/components/chat/ComposerPrimaryActions.tsx+1-1
......@@ -140,7 +140,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
140140 <Button
141141 type="submit"
142142 size="sm"
143 className={cn("h-9 rounded-l-full rounded-r-none sm:h-8", compact ? "px-3" : "px-4")}
143 className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
144144 disabled={isSendBusy || isConnecting}
145145 >
146146 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
apps/web/src/components/composerFooterLayout.test.ts-99
......@@ -4,9 +4,6 @@ import {
44 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
55 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
66 COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX,
7 measureComposerFooterOverflowPx,
8 resolveComposerFooterContentWidth,
9 shouldForceCompactComposerFooterForFit,
107 shouldUseCompactComposerPrimaryActions,
118 shouldUseCompactComposerFooter,
129} from "./composerFooterLayout";
......@@ -56,99 +53,3 @@ describe("shouldUseCompactComposerPrimaryActions", () => {
5653 ).toBe(false);
5754 });
5855});
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-45
......@@ -2,8 +2,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
22export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;
33export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX =
44 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
5const COMPOSER_FOOTER_CONTENT_GAP_PX = 8;
6const COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX = 120;
75
86export function shouldUseCompactComposerFooter(
97 width: number | null,
......@@ -24,46 +22,3 @@ export function shouldUseCompactComposerPrimaryActions(
2422 }
2523 return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX;
2624}
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}