Skip to content

Commit 5fa09fa

Browse files
authored
[codex] fix composer footer compact layout (#1894)
1 parent 12c3af7 commit 5fa09fa

File tree

5 files changed

+92
-183
lines changed

5 files changed

+92
-183
lines changed

apps/web/src/components/ChatView.browser.tsx

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -717,17 +717,30 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel {
717717
};
718718
}
719719

720-
function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel {
720+
function 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

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ import {
5555
removeInlineTerminalContextPlaceholder,
5656
} from "../../lib/terminalContext";
5757
import {
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,23 +1776,14 @@ 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(
18021782
"flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3",
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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 0 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
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

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620;
22
export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780;
33
export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX =
44
COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX;
5-
const COMPOSER_FOOTER_CONTENT_GAP_PX = 8;
6-
const COMPOSER_PRIMARY_ACTIONS_COMPACT_RECOVERY_PX = 120;
75

86
export 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-
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-
}

0 commit comments

Comments
 (0)