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 {...@@ -717,17 +717,30 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
717 };717 };
718}718}
719719
720function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {720function createSnapshotWithPlanFollowUpPrompt(options?: {
721 modelSelection?: { provider: "codex"; model: string };
722 planMarkdown?: string;
723}): OrchestrationReadModel {
721 const snapshot = createSnapshotForTargetUser({724 const snapshot = createSnapshotForTargetUser({
722 targetMessageId: "msg-user-plan-follow-up-target" as MessageId,725 targetMessageId: "msg-user-plan-follow-up-target" as MessageId,
723 targetText: "plan follow-up thread",726 targetText: "plan follow-up thread",
724 });727 });
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
726 return {735 return {
727 ...snapshot,736 ...snapshot,
737 projects: snapshot.projects.map((project) =>
738 project.id === PROJECT_ID ? { ...project, defaultModelSelection: modelSelection } : project,
739 ),
728 threads: snapshot.threads.map((thread) =>740 threads: snapshot.threads.map((thread) =>
729 thread.id === THREAD_ID741 thread.id === THREAD_ID
730 ? Object.assign({}, thread, {742 ? Object.assign({}, thread, {
743 modelSelection,
731 interactionMode: "plan",744 interactionMode: "plan",
732 latestTurn: {745 latestTurn: {
733 turnId: "turn-plan-follow-up" as TurnId,746 turnId: "turn-plan-follow-up" as TurnId,
...@@ -741,7 +754,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {...@@ -741,7 +754,7 @@ function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
741 {754 {
742 id: "plan-follow-up-browser-test",755 id: "plan-follow-up-browser-test",
743 turnId: "turn-plan-follow-up" as TurnId,756 turnId: "turn-plan-follow-up" as TurnId,
744 planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.",757 planMarkdown,
745 implementedAt: null,758 implementedAt: null,
746 implementationThreadId: null,759 implementationThreadId: null,
747 createdAt: isoAt(1_002),760 createdAt: isoAt(1_002),
...@@ -3720,8 +3733,9 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3720,8 +3733,9 @@ describe("ChatView timeline estimator parity (full app)", () => {
3720 );3733 );
3721 const initialModelPickerOffset =3734 const initialModelPickerOffset =
3722 initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left;3735 initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left;
3736 const initialImplementButton = await waitForButtonByText("Implement");
3737 const initialImplementWidth = initialImplementButton.getBoundingClientRect().width;
37233738
3724 await waitForButtonByText("Implement");
3725 await waitForElement(3739 await waitForElement(
3726 () =>3740 () =>
3727 document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'),3741 document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'),
...@@ -3753,6 +3767,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3753,6 +3767,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
37533767
3754 expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1);3768 expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1);
3755 expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1);3769 expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1);
3770 expect(Math.abs(implementRect.width - initialImplementWidth)).toBeLessThanOrEqual(1);
3756 expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual(3771 expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual(
3757 1,3772 1,
3758 );3773 );
...@@ -3764,6 +3779,73 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -3764,6 +3779,73 @@ describe("ChatView timeline estimator parity (full app)", () => {
3764 }3779 }
3765 });3780 });
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
3767 it("keeps the slash-command menu visible above the composer", async () => {3849 it("keeps the slash-command menu visible above the composer", async () => {
3768 const mounted = await mountChatView({3850 const mounted = await mountChatView({
3769 viewport: DEFAULT_VIEWPORT,3851 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/chat/ChatComposer.tsx+6-35
...@@ -55,8 +55,6 @@ import {...@@ -55,8 +55,6 @@ import {
55 removeInlineTerminalContextPlaceholder,55 removeInlineTerminalContextPlaceholder,
56} from "../../lib/terminalContext";56} from "../../lib/terminalContext";
57import {57import {
58 resolveComposerFooterContentWidth,
59 shouldForceCompactComposerFooterForFit,
60 shouldUseCompactComposerPrimaryActions,58 shouldUseCompactComposerPrimaryActions,
61 shouldUseCompactComposerFooter,59 shouldUseCompactComposerFooter,
62} from "../composerFooterLayout";60} from "../composerFooterLayout";
...@@ -646,9 +644,6 @@ export const ChatComposer = memo(...@@ -646,9 +644,6 @@ export const ChatComposer = memo(
646 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);644 const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
647 const composerFormRef = useRef<HTMLFormElement>(null);645 const composerFormRef = useRef<HTMLFormElement>(null);
648 const composerFormHeightRef = useRef(0);646 const composerFormHeightRef = useRef(0);
649 const composerFooterRef = useRef<HTMLDivElement>(null);
650 const composerFooterLeadingRef = useRef<HTMLDivElement>(null);
651 const composerFooterActionsRef = useRef<HTMLDivElement>(null);
652 const composerSelectLockRef = useRef(false);647 const composerSelectLockRef = useRef(false);
653 const composerMenuOpenRef = useRef(false);648 const composerMenuOpenRef = useRef(false);
654 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);649 const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
...@@ -1017,31 +1012,17 @@ export const ChatComposer = memo(...@@ -1017,31 +1012,17 @@ export const ChatComposer = memo(
1017 const measureComposerFormWidth = () => composerForm.clientWidth;1012 const measureComposerFormWidth = () => composerForm.clientWidth;
1018 const measureFooterCompactness = () => {1013 const measureFooterCompactness = () => {
1019 const composerFormWidth = measureComposerFormWidth();1014 const composerFormWidth = measureComposerFormWidth();
1020 const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, {1015 const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, {
1021 hasWideActions: composerFooterHasWideActions,1016 hasWideActions: composerFooterHasWideActions,
1022 });1017 });
1023 const footer = composerFooterRef.current;1018 const primaryActionsCompact =
1024 const footerStyle = footer ? window.getComputedStyle(footer) : null;1019 footerCompact &&
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 &&
1039 shouldUseCompactComposerPrimaryActions(composerFormWidth, {1020 shouldUseCompactComposerPrimaryActions(composerFormWidth, {
1040 hasWideActions: composerFooterHasWideActions,1021 hasWideActions: composerFooterHasWideActions,
1041 });1022 });
1042 return {1023 return {
1043 primaryActionsCompact: nextPrimaryActionsCompact,1024 primaryActionsCompact,
1044 footerCompact: nextFooterCompact,1025 footerCompact,
1045 };1026 };
1046 };1027 };
10471028
...@@ -1795,7 +1776,6 @@ export const ChatComposer = memo(...@@ -1795,7 +1776,6 @@ export const ChatComposer = memo(
1795 </div>1776 </div>
1796 ) : (1777 ) : (
1797 <div1778 <div
1798 ref={composerFooterRef}
1799 data-chat-composer-footer="true"1779 data-chat-composer-footer="true"
1800 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}1780 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
1801 className={cn(1781 className={cn(
...@@ -1803,15 +1783,7 @@ export const ChatComposer = memo(...@@ -1803,15 +1783,7 @@ export const ChatComposer = memo(
1803 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",1783 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
1804 )}1784 )}
1805 >1785 >
1806 <div1786 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
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 >
1815 <ProviderModelPicker1787 <ProviderModelPicker
1816 compact={isComposerFooterCompact}1788 compact={isComposerFooterCompact}
1817 provider={selectedProvider}1789 provider={selectedProvider}
...@@ -1865,7 +1837,6 @@ export const ChatComposer = memo(...@@ -1865,7 +1837,6 @@ export const ChatComposer = memo(
18651837
1866 {/* Right side: send / stop button */}1838 {/* Right side: send / stop button */}
1867 <div1839 <div
1868 ref={composerFooterActionsRef}
1869 data-chat-composer-actions="right"1840 data-chat-composer-actions="right"
1870 data-chat-composer-primary-actions-compact={1841 data-chat-composer-primary-actions-compact={
1871 isComposerPrimaryActionsCompact ? "true" : "false"1842 isComposerPrimaryActionsCompact ? "true" : "false"
apps/web/src/components/chat/ComposerPrimaryActions.tsx+1-1
...@@ -140,7 +140,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -140,7 +140,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
140 <Button140 <Button
141 type="submit"141 type="submit"
142 size="sm"142 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"
144 disabled={isSendBusy || isConnecting}144 disabled={isSendBusy || isConnecting}
145 >145 >
146 {isConnecting || isSendBusy ? "Sending..." : "Implement"}146 {isConnecting || isSendBusy ? "Sending..." : "Implement"}
apps/web/src/components/composerFooterLayout.test.ts-99
...@@ -4,9 +4,6 @@ import {...@@ -4,9 +4,6 @@ import {
4 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,4 COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX,
5 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,5 COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX,
6 COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX,6 COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX,
7 measureComposerFooterOverflowPx,
8 resolveComposerFooterContentWidth,
9 shouldForceCompactComposerFooterForFit,
10 shouldUseCompactComposerPrimaryActions,7 shouldUseCompactComposerPrimaryActions,
11 shouldUseCompactComposerFooter,8 shouldUseCompactComposerFooter,
12} from "./composerFooterLayout";9} from "./composerFooterLayout";
...@@ -56,99 +53,3 @@ describe("shouldUseCompactComposerPrimaryActions", () => {...@@ -56,99 +53,3 @@ describe("shouldUseCompactComposerPrimaryActions", () => {
56 ).toBe(false);53 ).toBe(false);
57 });54 });
58});55});
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;...@@ -2,8 +2,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
2export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;2export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;
3export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX =3export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX =
4 COMPOSER_FOOTER_WIDE_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;
75
8export function shouldUseCompactComposerFooter(6export function shouldUseCompactComposerFooter(
9 width: number | null,7 width: number | null,
...@@ -24,46 +22,3 @@ export function shouldUseCompactComposerPrimaryActions(...@@ -24,46 +22,3 @@ export function shouldUseCompactComposerPrimaryActions(
24 }22 }
25 return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX;23 return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX;
26}24}
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}