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