Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 85 additions & 3 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -3720,8 +3733,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<HTMLButtonElement>('button[aria-label="Implementation actions"]'),
Expand Down Expand Up @@ -3753,6 +3767,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,
);
Expand All @@ -3764,6 +3779,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<HTMLElement>('[data-chat-composer-footer="true"]');
const actions = document.querySelector<HTMLElement>(
'[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<HTMLElement>('[data-chat-composer-footer="true"]');
const actions = document.querySelector<HTMLElement>(
'[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,
Expand Down
41 changes: 6 additions & 35 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ import {
removeInlineTerminalContextPlaceholder,
} from "../../lib/terminalContext";
import {
resolveComposerFooterContentWidth,
shouldForceCompactComposerFooterForFit,
shouldUseCompactComposerPrimaryActions,
shouldUseCompactComposerFooter,
} from "../composerFooterLayout";
Expand Down Expand Up @@ -646,9 +644,6 @@ export const ChatComposer = memo(
const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
const composerFormRef = useRef<HTMLFormElement>(null);
const composerFormHeightRef = useRef(0);
const composerFooterRef = useRef<HTMLDivElement>(null);
const composerFooterLeadingRef = useRef<HTMLDivElement>(null);
const composerFooterActionsRef = useRef<HTMLDivElement>(null);
const composerSelectLockRef = useRef(false);
const composerMenuOpenRef = useRef(false);
const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
Expand Down Expand Up @@ -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,
};
};

Expand Down Expand Up @@ -1795,23 +1776,14 @@ export const ChatComposer = memo(
</div>
) : (
<div
ref={composerFooterRef}
data-chat-composer-footer="true"
data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
className={cn(
"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",
isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
)}
>
<div
ref={composerFooterLeadingRef}
className={cn(
"flex min-w-0 flex-1 items-center",
isComposerFooterCompact
? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ProviderModelPicker
compact={isComposerFooterCompact}
provider={selectedProvider}
Expand Down Expand Up @@ -1865,7 +1837,6 @@ export const ChatComposer = memo(

{/* Right side: send / stop button */}
<div
ref={composerFooterActionsRef}
data-chat-composer-actions="right"
data-chat-composer-primary-actions-compact={
isComposerPrimaryActionsCompact ? "true" : "false"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
<Button
type="submit"
size="sm"
className={cn("h-9 rounded-l-full rounded-r-none sm:h-8", compact ? "px-3" : "px-4")}
className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8"
disabled={isSendBusy || isConnecting}
>
{isConnecting || isSendBusy ? "Sending..." : "Implement"}
Expand Down
99 changes: 0 additions & 99 deletions apps/web/src/components/composerFooterLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});
});
45 changes: 0 additions & 45 deletions apps/web/src/components/composerFooterLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Loading