Unified · Split
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | type ProjectId, |
| 12 | 12 | type ServerConfig, |
| 13 | 13 | type ThreadId, |
| 14 | + type TurnId, | |
| 14 | 15 | type WsWelcomePayload, |
| 15 | 16 | WS_CHANNELS, |
| 16 | 17 | WS_METHODS, |
| @@ -79,6 +80,20 @@ const DEFAULT_VIEWPORT: ViewportSpec = { · 62 unmodified lines | ||
| 79 | 80 | textTolerancePx: 44, |
| 80 | 81 | attachmentTolerancePx: 56, |
| 81 | 82 | }; |
| 83 | +const WIDE_FOOTER_VIEWPORT: ViewportSpec = { | |
| 84 | + name: "wide-footer", | |
| 85 | + width: 1_400, | |
| 86 | + height: 1_100, | |
| 87 | + textTolerancePx: 44, | |
| 88 | + attachmentTolerancePx: 56, | |
| 89 | +}; | |
| 90 | +const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { | |
| 91 | + name: "compact-footer", | |
| 92 | + width: 430, | |
| 93 | + height: 932, | |
| 94 | + textTolerancePx: 56, | |
| 95 | + attachmentTolerancePx: 56, | |
| 96 | +}; | |
| 82 | 97 | const TEXT_VIEWPORT_MATRIX = [ |
| 83 | 98 | DEFAULT_VIEWPORT, |
| 84 | 99 | { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, |
| @@ -102,6 +117,7 @@ interface MountedChatView { · 17 unmodified lines | ||
| 102 | 117 | cleanup: () => Promise<void>; |
| 103 | 118 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; |
| 104 | 119 | setViewport: (viewport: ViewportSpec) => Promise<void>; |
| 120 | + setContainerSize: (viewport: Pick<ViewportSpec, "width" | "height">) => Promise<void>; | |
| 105 | 121 | router: ReturnType<typeof getRouter>; |
| 106 | 122 | } |
| 107 | 123 | |
| @@ -513,6 +529,114 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { · 405 unmodified lines | ||
| 513 | 529 | }; |
| 514 | 530 | } |
| 515 | 531 | |
| 532 | +function createSnapshotWithPendingUserInput(): OrchestrationReadModel { | |
| 533 | + const snapshot = createSnapshotForTargetUser({ | |
| 534 | + targetMessageId: "msg-user-pending-input-target" as MessageId, | |
| 535 | + targetText: "question thread", | |
| 536 | + }); | |
| 537 | + | |
| 538 | + return { | |
| 539 | + ...snapshot, | |
| 540 | + threads: snapshot.threads.map((thread) => | |
| 541 | + thread.id === THREAD_ID | |
| 542 | + ? Object.assign({}, thread, { | |
| 543 | + interactionMode: "plan", | |
| 544 | + activities: [ | |
| 545 | + { | |
| 546 | + id: EventId.makeUnsafe("activity-user-input-requested"), | |
| 547 | + tone: "info", | |
| 548 | + kind: "user-input.requested", | |
| 549 | + summary: "User input requested", | |
| 550 | + payload: { | |
| 551 | + requestId: "req-browser-user-input", | |
| 552 | + questions: [ | |
| 553 | + { | |
| 554 | + id: "scope", | |
| 555 | + header: "Scope", | |
| 556 | + question: "What should this change cover?", | |
| 557 | + options: [ | |
| 558 | + { | |
| 559 | + label: "Tight", | |
| 560 | + description: "Touch only the footer layout logic.", | |
| 561 | + }, | |
| 562 | + { | |
| 563 | + label: "Broad", | |
| 564 | + description: "Also adjust the related composer controls.", | |
| 565 | + }, | |
| 566 | + ], | |
| 567 | + }, | |
| 568 | + { | |
| 569 | + id: "risk", | |
| 570 | + header: "Risk", | |
| 571 | + question: "How aggressive should the imaginary plan be?", | |
| 572 | + options: [ | |
| 573 | + { | |
| 574 | + label: "Conservative", | |
| 575 | + description: "Favor reliability and low-risk changes.", | |
| 576 | + }, | |
| 577 | + { | |
| 578 | + label: "Balanced", | |
| 579 | + description: "Mix quick wins with one structural improvement.", | |
| 580 | + }, | |
| 581 | + ], | |
| 582 | + }, | |
| 583 | + ], | |
| 584 | + }, | |
| 585 | + turnId: null, | |
| 586 | + sequence: 1, | |
| 587 | + createdAt: isoAt(1_000), | |
| 588 | + }, | |
| 589 | + ], | |
| 590 | + updatedAt: isoAt(1_000), | |
| 591 | + }) | |
| 592 | + : thread, | |
| 593 | + ), | |
| 594 | + }; | |
| 595 | +} | |
| 596 | + | |
| 597 | +function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { | |
| 598 | + const snapshot = createSnapshotForTargetUser({ | |
| 599 | + targetMessageId: "msg-user-plan-follow-up-target" as MessageId, | |
| 600 | + targetText: "plan follow-up thread", | |
| 601 | + }); | |
| 602 | + | |
| 603 | + return { | |
| 604 | + ...snapshot, | |
| 605 | + threads: snapshot.threads.map((thread) => | |
| 606 | + thread.id === THREAD_ID | |
| 607 | + ? Object.assign({}, thread, { | |
| 608 | + interactionMode: "plan", | |
| 609 | + latestTurn: { | |
| 610 | + turnId: "turn-plan-follow-up" as TurnId, | |
| 611 | + state: "completed", | |
| 612 | + requestedAt: isoAt(1_000), | |
| 613 | + startedAt: isoAt(1_001), | |
| 614 | + completedAt: isoAt(1_010), | |
| 615 | + assistantMessageId: null, | |
| 616 | + }, | |
| 617 | + proposedPlans: [ | |
| 618 | + { | |
| 619 | + id: "plan-follow-up-browser-test", | |
| 620 | + turnId: "turn-plan-follow-up" as TurnId, | |
| 621 | + planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.", | |
| 622 | + implementedAt: null, | |
| 623 | + implementationThreadId: null, | |
| 624 | + createdAt: isoAt(1_002), | |
| 625 | + updatedAt: isoAt(1_003), | |
| 626 | + }, | |
| 627 | + ], | |
| 628 | + session: { | |
| 629 | + ...thread.session, | |
| 630 | + status: "ready", | |
| 631 | + updatedAt: isoAt(1_010), | |
| 632 | + }, | |
| 633 | + updatedAt: isoAt(1_010), | |
| 634 | + }) | |
| 635 | + : thread, | |
| 636 | + ), | |
| 637 | + }; | |
| 638 | +} | |
| 639 | + | |
| 516 | 640 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
| 517 | 641 | const customResult = customWsRpcResolver?.(body); |
| 518 | 642 | if (customResult !== undefined) { |
| @@ -694,6 +818,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> { · 175 unmodified lines | ||
| 694 | 818 | ); |
| 695 | 819 | } |
| 696 | 820 | |
| 821 | +async function waitForComposerMenuItem(itemId: string): Promise<HTMLElement> { | |
| 822 | + return waitForElement( | |
| 823 | + () => document.querySelector<HTMLElement>(`[data-composer-item-id="${itemId}"]`), | |
| 824 | + `Unable to find composer menu item "${itemId}".`, | |
| 825 | + ); | |
| 826 | +} | |
| 827 | + | |
| 697 | 828 | async function waitForSendButton(): Promise<HTMLButtonElement> { |
| 698 | 829 | return waitForElement( |
| 699 | 830 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'), |
| @@ -701,6 +832,62 @@ async function waitForSendButton(): Promise<HTMLButtonElement> { · 1 unmodified line | ||
| 701 | 832 | ); |
| 702 | 833 | } |
| 703 | 834 | |
| 835 | +function findComposerProviderModelPicker(): HTMLButtonElement | null { | |
| 836 | + return document.querySelector<HTMLButtonElement>('[data-chat-provider-model-picker="true"]'); | |
| 837 | +} | |
| 838 | + | |
| 839 | +function findButtonByText(text: string): HTMLButtonElement | null { | |
| 840 | + return (Array.from(document.querySelectorAll("button")).find( | |
| 841 | + (button) => button.textContent?.trim() === text, | |
| 842 | + ) ?? null) as HTMLButtonElement | null; | |
| 843 | +} | |
| 844 | + | |
| 845 | +async function waitForButtonByText(text: string): Promise<HTMLButtonElement> { | |
| 846 | + return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`); | |
| 847 | +} | |
| 848 | + | |
| 849 | +function findButtonContainingText(text: string): HTMLButtonElement | null { | |
| 850 | + return (Array.from(document.querySelectorAll("button")).find((button) => | |
| 851 | + button.textContent?.includes(text), | |
| 852 | + ) ?? null) as HTMLButtonElement | null; | |
| 853 | +} | |
| 854 | + | |
| 855 | +async function waitForButtonContainingText(text: string): Promise<HTMLButtonElement> { | |
| 856 | + return waitForElement( | |
| 857 | + () => findButtonContainingText(text), | |
| 858 | + `Unable to find button containing "${text}".`, | |
| 859 | + ); | |
| 860 | +} | |
| 861 | + | |
| 862 | +async function expectComposerActionsContained(): Promise<void> { | |
| 863 | + const footer = await waitForElement( | |
| 864 | + () => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'), | |
| 865 | + "Unable to find composer footer.", | |
| 866 | + ); | |
| 867 | + const actions = await waitForElement( | |
| 868 | + () => document.querySelector<HTMLElement>('[data-chat-composer-actions="right"]'), | |
| 869 | + "Unable to find composer actions container.", | |
| 870 | + ); | |
| 871 | + | |
| 872 | + await vi.waitFor( | |
| 873 | + () => { | |
| 874 | + const footerRect = footer.getBoundingClientRect(); | |
| 875 | + const actionButtons = Array.from(actions.querySelectorAll<HTMLButtonElement>("button")); | |
| 876 | + expect(actionButtons.length).toBeGreaterThanOrEqual(1); | |
| 877 | + | |
| 878 | + const buttonRects = actionButtons.map((button) => button.getBoundingClientRect()); | |
| 879 | + const firstTop = buttonRects[0]?.top ?? 0; | |
| 880 | + | |
| 881 | + for (const rect of buttonRects) { | |
| 882 | + expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5); | |
| 883 | + expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5); | |
| 884 | + expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5); | |
| 885 | + } | |
| 886 | + }, | |
| 887 | + { timeout: 8_000, interval: 16 }, | |
| 888 | + ); | |
| 889 | +} | |
| 890 | + | |
| 704 | 891 | async function waitForInteractionModeButton( |
| 705 | 892 | expectedLabel: "Chat" | "Plan", |
| 706 | 893 | ): Promise<HTMLButtonElement> { |
| @@ -864,7 +1051,8 @@ async function mountChatView(options: { · 157 unmodified lines | ||
| 864 | 1051 | |
| 865 | 1052 | const host = document.createElement("div"); |
| 866 | 1053 | host.style.position = "fixed"; |
| 867 | − host.style.inset = "0"; | |
| 1054 | + host.style.top = "0"; | |
| 1055 | + host.style.left = "0"; | |
| 868 | 1056 | host.style.width = "100vw"; |
| 869 | 1057 | host.style.height = "100vh"; |
| 870 | 1058 | host.style.display = "grid"; |
| @@ -897,6 +1085,11 @@ async function mountChatView(options: { · 26 unmodified lines | ||
| 897 | 1085 | await setViewport(viewport); |
| 898 | 1086 | await waitForProductionStyles(); |
| 899 | 1087 | }, |
| 1088 | + setContainerSize: async (viewport) => { | |
| 1089 | + host.style.width = `${viewport.width}px`; | |
| 1090 | + host.style.height = `${viewport.height}px`; | |
| 1091 | + await waitForLayout(); | |
| 1092 | + }, | |
| 900 | 1093 | router, |
| 901 | 1094 | }; |
| 902 | 1095 | } |
| @@ -2341,4 +2534,125 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1438 unmodified lines | ||
| 2341 | 2534 | await mounted.cleanup(); |
| 2342 | 2535 | } |
| 2343 | 2536 | }); |
| 2537 | + | |
| 2538 | + it("keeps pending-question footer actions inside the composer after a real resize", async () => { | |
| 2539 | + const mounted = await mountChatView({ | |
| 2540 | + viewport: WIDE_FOOTER_VIEWPORT, | |
| 2541 | + snapshot: createSnapshotWithPendingUserInput(), | |
| 2542 | + }); | |
| 2543 | + | |
| 2544 | + try { | |
| 2545 | + const firstOption = await waitForButtonContainingText("Tight"); | |
| 2546 | + firstOption.click(); | |
| 2547 | + | |
| 2548 | + await waitForButtonByText("Previous"); | |
| 2549 | + await waitForButtonByText("Submit answers"); | |
| 2550 | + | |
| 2551 | + await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT); | |
| 2552 | + await expectComposerActionsContained(); | |
| 2553 | + } finally { | |
| 2554 | + await mounted.cleanup(); | |
| 2555 | + } | |
| 2556 | + }); | |
| 2557 | + | |
| 2558 | + it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { | |
| 2559 | + const mounted = await mountChatView({ | |
| 2560 | + viewport: WIDE_FOOTER_VIEWPORT, | |
| 2561 | + snapshot: createSnapshotWithPlanFollowUpPrompt(), | |
| 2562 | + }); | |
| 2563 | + | |
| 2564 | + try { | |
| 2565 | + const footer = await waitForElement( | |
| 2566 | + () => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'), | |
| 2567 | + "Unable to find composer footer.", | |
| 2568 | + ); | |
| 2569 | + const initialModelPicker = await waitForElement( | |
| 2570 | + findComposerProviderModelPicker, | |
| 2571 | + "Unable to find provider model picker.", | |
| 2572 | + ); | |
| 2573 | + const initialModelPickerOffset = | |
| 2574 | + initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; | |
| 2575 | + | |
| 2576 | + await waitForButtonByText("Implement"); | |
| 2577 | + await waitForElement( | |
| 2578 | + () => | |
| 2579 | + document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'), | |
| 2580 | + "Unable to find implementation actions trigger.", | |
| 2581 | + ); | |
| 2582 | + | |
| 2583 | + await mounted.setContainerSize({ | |
| 2584 | + width: 440, | |
| 2585 | + height: WIDE_FOOTER_VIEWPORT.height, | |
| 2586 | + }); | |
| 2587 | + await expectComposerActionsContained(); | |
| 2588 | + | |
| 2589 | + const implementButton = await waitForButtonByText("Implement"); | |
| 2590 | + const implementActionsButton = await waitForElement( | |
| 2591 | + () => | |
| 2592 | + document.querySelector<HTMLButtonElement>('button[aria-label="Implementation actions"]'), | |
| 2593 | + "Unable to find implementation actions trigger.", | |
| 2594 | + ); | |
| 2595 | + | |
| 2596 | + await vi.waitFor( | |
| 2597 | + () => { | |
| 2598 | + const implementRect = implementButton.getBoundingClientRect(); | |
| 2599 | + const implementActionsRect = implementActionsButton.getBoundingClientRect(); | |
| 2600 | + const compactModelPicker = findComposerProviderModelPicker(); | |
| 2601 | + expect(compactModelPicker).toBeTruthy(); | |
| 2602 | + | |
| 2603 | + const compactModelPickerOffset = | |
| 2604 | + compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left; | |
| 2605 | + | |
| 2606 | + expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); | |
| 2607 | + expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); | |
| 2608 | + expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( | |
| 2609 | + 1, | |
| 2610 | + ); | |
| 2611 | + }, | |
| 2612 | + { timeout: 8_000, interval: 16 }, | |
| 2613 | + ); | |
| 2614 | + } finally { | |
| 2615 | + await mounted.cleanup(); | |
| 2616 | + } | |
| 2617 | + }); | |
| 2618 | + | |
| 2619 | + it("keeps the slash-command menu visible above the composer", async () => { | |
| 2620 | + const mounted = await mountChatView({ | |
| 2621 | + viewport: DEFAULT_VIEWPORT, | |
| 2622 | + snapshot: createSnapshotForTargetUser({ | |
| 2623 | + targetMessageId: "msg-user-command-menu-target" as MessageId, | |
| 2624 | + targetText: "command menu thread", | |
| 2625 | + }), | |
| 2626 | + }); | |
| 2627 | + | |
| 2628 | + try { | |
| 2629 | + await waitForComposerEditor(); | |
| 2630 | + await page.getByTestId("composer-editor").fill("/"); | |
| 2631 | + | |
| 2632 | + const menuItem = await waitForComposerMenuItem("slash:model"); | |
| 2633 | + const composerForm = await waitForElement( | |
| 2634 | + () => document.querySelector<HTMLElement>('[data-chat-composer-form="true"]'), | |
| 2635 | + "Unable to find composer form.", | |
| 2636 | + ); | |
| 2637 | + | |
| 2638 | + await vi.waitFor( | |
| 2639 | + () => { | |
| 2640 | + const menuRect = menuItem.getBoundingClientRect(); | |
| 2641 | + const composerRect = composerForm.getBoundingClientRect(); | |
| 2642 | + const hitTarget = document.elementFromPoint( | |
| 2643 | + menuRect.left + menuRect.width / 2, | |
| 2644 | + menuRect.top + menuRect.height / 2, | |
| 2645 | + ); | |
| 2646 | + | |
| 2647 | + expect(menuRect.width).toBeGreaterThan(0); | |
| 2648 | + expect(menuRect.height).toBeGreaterThan(0); | |
| 2649 | + expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); | |
| 2650 | + expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); | |
| 2651 | + }, | |
| 2652 | + { timeout: 8_000, interval: 16 }, | |
| 2653 | + ); | |
| 2654 | + } finally { | |
| 2655 | + await mounted.cleanup(); | |
| 2656 | + } | |
| 2657 | + }); | |
| 2344 | 2658 | }); |
| @@ -106,7 +106,6 @@ import { · 105 unmodified lines | ||
| 106 | 106 | } from "lucide-react"; |
| 107 | 107 | import { Button } from "./ui/button"; |
| 108 | 108 | import { Separator } from "./ui/separator"; |
| 109 | −import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | |
| 110 | 109 | import { cn, randomUUID } from "~/lib/utils"; |
| 111 | 110 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 112 | 111 | import { toastManager } from "./ui/toast"; |
| @@ -148,7 +147,12 @@ import { · 35 unmodified lines | ||
| 148 | 147 | type TerminalContextSelection, |
| 149 | 148 | } from "../lib/terminalContext"; |
| 150 | 149 | import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; |
| 151 | −import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; | |
| 150 | +import { | |
| 151 | + resolveComposerFooterContentWidth, | |
| 152 | + shouldForceCompactComposerFooterForFit, | |
| 153 | + shouldUseCompactComposerPrimaryActions, | |
| 154 | + shouldUseCompactComposerFooter, | |
| 155 | +} from "./composerFooterLayout"; | |
| 152 | 156 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 153 | 157 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 154 | 158 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| @@ -160,6 +164,7 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider · 5 unmodified lines | ||
| 160 | 164 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; |
| 161 | 165 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; |
| 162 | 166 | import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; |
| 167 | +import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; | |
| 163 | 168 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; |
| 164 | 169 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; |
| 165 | 170 | import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; |
| @@ -499,6 +504,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 333 unmodified lines | ||
| 499 | 504 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 500 | 505 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); |
| 501 | 506 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); |
| 507 | + const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); | |
| 502 | 508 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| 503 | 509 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); |
| 504 | 510 | // When set, the thread-change reset effect will open the sidebar instead of closing it. |
| @@ -541,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) { · 36 unmodified lines | ||
| 541 | 547 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 542 | 548 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 543 | 549 | const composerFormHeightRef = useRef(0); |
| 550 | + const composerFooterRef = useRef<HTMLDivElement>(null); | |
| 551 | + const composerFooterLeadingRef = useRef<HTMLDivElement>(null); | |
| 552 | + const composerFooterActionsRef = useRef<HTMLDivElement>(null); | |
| 544 | 553 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 545 | 554 | const composerSelectLockRef = useRef(false); |
| 546 | 555 | const composerMenuOpenRef = useRef(false); |
| @@ -930,6 +939,28 @@ export default function ChatView({ threadId }: ChatViewProps) { · 383 unmodified lines | ||
| 930 | 939 | pendingUserInputs.length > 0 || |
| 931 | 940 | (showPlanFollowUpPrompt && activeProposedPlan !== null); |
| 932 | 941 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; |
| 942 | + const composerFooterActionLayoutKey = useMemo(() => { | |
| 943 | + if (activePendingProgress) { | |
| 944 | + return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; | |
| 945 | + } | |
| 946 | + if (phase === "running") { | |
| 947 | + return "running"; | |
| 948 | + } | |
| 949 | + if (showPlanFollowUpPrompt) { | |
| 950 | + return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; | |
| 951 | + } | |
| 952 | + return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; | |
| 953 | + }, [ | |
| 954 | + activePendingIsResponding, | |
| 955 | + activePendingProgress, | |
| 956 | + composerSendState.hasSendableContent, | |
| 957 | + isConnecting, | |
| 958 | + isPreparingWorktree, | |
| 959 | + isSendBusy, | |
| 960 | + phase, | |
| 961 | + prompt, | |
| 962 | + showPlanFollowUpPrompt, | |
| 963 | + ]); | |
| 933 | 964 | const lastSyncedPendingInputRef = useRef<{ |
| 934 | 965 | requestId: string | null; |
| 935 | 966 | questionId: string | null; |
| @@ -2038,23 +2069,56 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1102 unmodified lines | ||
| 2038 | 2069 | const composerForm = composerFormRef.current; |
| 2039 | 2070 | if (!composerForm) return; |
| 2040 | 2071 | const measureComposerFormWidth = () => composerForm.clientWidth; |
| 2072 | + const measureFooterCompactness = () => { | |
| 2073 | + const composerFormWidth = measureComposerFormWidth(); | |
| 2074 | + const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { | |
| 2075 | + hasWideActions: composerFooterHasWideActions, | |
| 2076 | + }); | |
| 2077 | + const footer = composerFooterRef.current; | |
| 2078 | + const footerStyle = footer ? window.getComputedStyle(footer) : null; | |
| 2079 | + const footerContentWidth = resolveComposerFooterContentWidth({ | |
| 2080 | + footerWidth: footer?.clientWidth ?? null, | |
| 2081 | + paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, | |
| 2082 | + paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, | |
| 2083 | + }); | |
| 2084 | + const fitInput = { | |
| 2085 | + footerContentWidth, | |
| 2086 | + leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, | |
| 2087 | + actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, | |
| 2088 | + }; | |
| 2089 | + const nextFooterCompact = | |
| 2090 | + heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); | |
| 2091 | + const nextPrimaryActionsCompact = | |
| 2092 | + nextFooterCompact && | |
| 2093 | + shouldUseCompactComposerPrimaryActions(composerFormWidth, { | |
| 2094 | + hasWideActions: composerFooterHasWideActions, | |
| 2095 | + }); | |
| 2096 | + | |
| 2097 | + return { | |
| 2098 | + primaryActionsCompact: nextPrimaryActionsCompact, | |
| 2099 | + footerCompact: nextFooterCompact, | |
| 2100 | + }; | |
| 2101 | + }; | |
| 2041 | 2102 | |
| 2042 | 2103 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; |
| 2043 | − setIsComposerFooterCompact( | |
| 2044 | − shouldUseCompactComposerFooter(measureComposerFormWidth(), { | |
| 2045 | − hasWideActions: composerFooterHasWideActions, | |
| 2046 | − }), | |
| 2047 | − ); | |
| 2104 | + const initialCompactness = measureFooterCompactness(); | |
| 2105 | + setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); | |
| 2106 | + setIsComposerFooterCompact(initialCompactness.footerCompact); | |
| 2048 | 2107 | if (typeof ResizeObserver === "undefined") return; |
| 2049 | 2108 | |
| 2050 | 2109 | const observer = new ResizeObserver((entries) => { |
| 2051 | 2110 | const [entry] = entries; |
| 2052 | 2111 | if (!entry) return; |
| 2053 | 2112 | |
| 2054 | − const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { | |
| 2055 | − hasWideActions: composerFooterHasWideActions, | |
| 2056 | − }); | |
| 2057 | − setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); | |
| 2113 | + const nextCompactness = measureFooterCompactness(); | |
| 2114 | + setIsComposerPrimaryActionsCompact((previous) => | |
| 2115 | + previous === nextCompactness.primaryActionsCompact | |
| 2116 | + ? previous | |
| 2117 | + : nextCompactness.primaryActionsCompact, | |
| 2118 | + ); | |
| 2119 | + setIsComposerFooterCompact((previous) => | |
| 2120 | + previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | |
| 2121 | + ); | |
| 2058 | 2122 | |
| 2059 | 2123 | const nextHeight = entry.contentRect.height; |
| 2060 | 2124 | const previousHeight = composerFormHeightRef.current; |
| @@ -2069,7 +2133,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 8 unmodified lines | ||
| 2069 | 2133 | return () => { |
| 2070 | 2134 | observer.disconnect(); |
| 2071 | 2135 | }; |
| 2072 | − }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); | |
| 2136 | + }, [ | |
| 2137 | + activeThread?.id, | |
| 2138 | + composerFooterActionLayoutKey, | |
| 2139 | + composerFooterHasWideActions, | |
| 2140 | + scheduleStickToBottom, | |
| 2141 | + ]); | |
| 2073 | 2142 | useEffect(() => { |
| 2074 | 2143 | if (!shouldAutoScrollRef.current) return; |
| 2075 | 2144 | scheduleStickToBottom(); |
| @@ -3788,7 +3857,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1712 unmodified lines | ||
| 3788 | 3857 | <form |
| 3789 | 3858 | ref={composerFormRef} |
| 3790 | 3859 | onSubmit={onSend} |
| 3791 | − className="mx-auto w-full min-w-0 max-w-3xl" | |
| 3860 | + className="mx-auto w-full min-w-0 max-w-[52rem]" | |
| 3792 | 3861 | data-chat-composer-form="true" |
| 3793 | 3862 | > |
| 3794 | 3863 | <div |
| @@ -3971,19 +4040,20 @@ export default function ChatView({ threadId }: ChatViewProps) { · 176 unmodified lines | ||
| 3971 | 4040 | </div> |
| 3972 | 4041 | ) : ( |
| 3973 | 4042 | <div |
| 4043 | + ref={composerFooterRef} | |
| 3974 | 4044 | data-chat-composer-footer="true" |
| 4045 | + data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} | |
| 3975 | 4046 | className={cn( |
| 3976 | − "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3", | |
| 3977 | − isComposerFooterCompact | |
| 3978 | − ? "gap-1.5" | |
| 3979 | − : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0", | |
| 4047 | + "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-hidden px-2.5 pb-2.5 sm:px-3 sm:pb-3", | |
| 4048 | + isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", | |
| 3980 | 4049 | )} |
| 3981 | 4050 | > |
| 3982 | 4051 | <div |
| 4052 | + ref={composerFooterLeadingRef} | |
| 3983 | 4053 | className={cn( |
| 3984 | 4054 | "flex min-w-0 flex-1 items-center", |
| 3985 | 4055 | isComposerFooterCompact |
| 3986 | − ? "gap-1 overflow-hidden" | |
| 4056 | + ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" | |
| 3987 | 4057 | : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", |
| 3988 | 4058 | )} |
| 3989 | 4059 | > |
| @@ -4113,8 +4183,12 @@ export default function ChatView({ threadId }: ChatViewProps) { · 123 unmodified lines | ||
| 4113 | 4183 | |
| 4114 | 4184 | {/* Right side: send / stop button */} |
| 4115 | 4185 | <div |
| 4186 | + ref={composerFooterActionsRef} | |
| 4116 | 4187 | data-chat-composer-actions="right" |
| 4117 | − className="flex shrink-0 items-center gap-2" | |
| 4188 | + data-chat-composer-primary-actions-compact={ | |
| 4189 | + isComposerPrimaryActionsCompact ? "true" : "false" | |
| 4190 | + } | |
| 4191 | + className="flex shrink-0 flex-nowrap items-center justify-end gap-2" | |
| 4118 | 4192 | > |
| 4119 | 4193 | {activeContextWindow ? ( |
| 4120 | 4194 | <ContextWindowMeter usage={activeContextWindow} /> |
| @@ -4124,156 +4198,32 @@ export default function ChatView({ threadId }: ChatViewProps) { · 3 unmodified lines | ||
| 4124 | 4198 | Preparing worktree... |
| 4125 | 4199 | </span> |
| 4126 | 4200 | ) : null} |
| 4127 | − {activePendingProgress ? ( | |
| 4128 | − <div className="flex items-center gap-2"> | |
| 4129 | − {activePendingProgress.questionIndex > 0 ? ( | |
| 4130 | − <Button | |
| 4131 | − size="sm" | |
| 4132 | − variant="outline" | |
| 4133 | − className="rounded-full" | |
| 4134 | − onClick={onPreviousActivePendingUserInputQuestion} | |
| 4135 | − disabled={activePendingIsResponding} | |
| 4136 | − > | |
| 4137 | − Previous | |
| 4138 | − </Button> | |
| 4139 | − ) : null} | |
| 4140 | − <Button | |
| 4141 | − type="submit" | |
| 4142 | − size="sm" | |
| 4143 | − className="rounded-full px-4" | |
| 4144 | − disabled={ | |
| 4145 | − activePendingIsResponding || | |
| 4146 | − (activePendingProgress.isLastQuestion | |
| 4147 | − ? !activePendingResolvedAnswers | |
| 4148 | − : !activePendingProgress.canAdvance) | |
| 4149 | − } | |
| 4150 | − > | |
| 4151 | − {activePendingIsResponding | |
| 4152 | − ? "Submitting..." | |
| 4153 | − : activePendingProgress.isLastQuestion | |
| 4154 | − ? "Submit answers" | |
| 4155 | − : "Next question"} | |
| 4156 | − </Button> | |
| 4157 | − </div> | |
| 4158 | − ) : phase === "running" ? ( | |
| 4159 | − <button | |
| 4160 | − type="button" | |
| 4161 | − className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8" | |
| 4162 | − onClick={() => void onInterrupt()} | |
| 4163 | − aria-label="Stop generation" | |
| 4164 | − > | |
| 4165 | − <svg | |
| 4166 | − width="12" | |
| 4167 | − height="12" | |
| 4168 | − viewBox="0 0 12 12" | |
| 4169 | − fill="currentColor" | |
| 4170 | − aria-hidden="true" | |
| 4171 | − > | |
| 4172 | − <rect x="2" y="2" width="8" height="8" rx="1.5" /> | |
| 4173 | − </svg> | |
| 4174 | − </button> | |
| 4175 | − ) : pendingUserInputs.length === 0 ? ( | |
| 4176 | − showPlanFollowUpPrompt ? ( | |
| 4177 | − prompt.trim().length > 0 ? ( | |
| 4178 | − <Button | |
| 4179 | − type="submit" | |
| 4180 | − size="sm" | |
| 4181 | − className="h-9 rounded-full px-4 sm:h-8" | |
| 4182 | − disabled={isSendBusy || isConnecting} | |
| 4183 | − > | |
| 4184 | − {isConnecting || isSendBusy ? "Sending..." : "Refine"} | |
| 4185 | − </Button> | |
| 4186 | − ) : ( | |
| 4187 | − <div className="flex items-center"> | |
| 4188 | − <Button | |
| 4189 | − type="submit" | |
| 4190 | − size="sm" | |
| 4191 | − className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" | |
| 4192 | − disabled={isSendBusy || isConnecting} | |
| 4193 | − > | |
| 4194 | − {isConnecting || isSendBusy ? "Sending..." : "Implement"} | |
| 4195 | − </Button> | |
| 4196 | − <Menu> | |
| 4197 | − <MenuTrigger | |
| 4198 | − render={ | |
| 4199 | − <Button | |
| 4200 | − size="sm" | |
| 4201 | − variant="default" | |
| 4202 | − className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" | |
| 4203 | − aria-label="Implementation actions" | |
| 4204 | − disabled={isSendBusy || isConnecting} | |
| 4205 | − /> | |
| 4206 | − } | |
| 4207 | − > | |
| 4208 | − <ChevronDownIcon className="size-3.5" /> | |
| 4209 | − </MenuTrigger> | |
| 4210 | − <MenuPopup align="end" side="top"> | |
| 4211 | − <MenuItem | |
| 4212 | − disabled={isSendBusy || isConnecting} | |
| 4213 | − onClick={() => void onImplementPlanInNewThread()} | |
| 4214 | − > | |
| 4215 | − Implement in a new thread | |
| 4216 | − </MenuItem> | |
| 4217 | − </MenuPopup> | |
| 4218 | − </Menu> | |
| 4219 | − </div> | |
| 4220 | − ) | |
| 4221 | − ) : ( | |
| 4222 | − <button | |
| 4223 | − type="submit" | |
| 4224 | − className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" | |
| 4225 | − disabled={ | |
| 4226 | − isSendBusy || isConnecting || !composerSendState.hasSendableContent | |
| 4227 | − } | |
| 4228 | − aria-label={ | |
| 4229 | − isConnecting | |
| 4230 | − ? "Connecting" | |
| 4231 | − : isPreparingWorktree | |
| 4232 | − ? "Preparing worktree" | |
| 4233 | − : isSendBusy | |
| 4234 | − ? "Sending" | |
| 4235 | − : "Send message" | |
| 4236 | − } | |
| 4237 | − > | |
| 4238 | − {isConnecting || isSendBusy ? ( | |
| 4239 | − <svg | |
| 4240 | − width="14" | |
| 4241 | − height="14" | |
| 4242 | − viewBox="0 0 14 14" | |
| 4243 | − fill="none" | |
| 4244 | − className="animate-spin" | |
| 4245 | − aria-hidden="true" | |
| 4246 | − > | |
| 4247 | − <circle | |
| 4248 | − cx="7" | |
| 4249 | − cy="7" | |
| 4250 | − r="5.5" | |
| 4251 | − stroke="currentColor" | |
| 4252 | − strokeWidth="1.5" | |
| 4253 | − strokeLinecap="round" | |
| 4254 | − strokeDasharray="20 12" | |
| 4255 | − /> | |
| 4256 | − </svg> | |
| 4257 | − ) : ( | |
| 4258 | − <svg | |
| 4259 | − width="14" | |
| 4260 | − height="14" | |
| 4261 | − viewBox="0 0 14 14" | |
| 4262 | − fill="none" | |
| 4263 | − aria-hidden="true" | |
| 4264 | − > | |
| 4265 | − <path | |
| 4266 | − d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" | |
| 4267 | − stroke="currentColor" | |
| 4268 | − strokeWidth="1.8" | |
| 4269 | − strokeLinecap="round" | |
| 4270 | − strokeLinejoin="round" | |
| 4271 | − /> | |
| 4272 | − </svg> | |
| 4273 | − )} | |
| 4274 | − </button> | |
| 4275 | − ) | |
| 4276 | − ) : null} | |
| 4201 | + <ComposerPrimaryActions | |
| 4202 | + compact={isComposerPrimaryActionsCompact} | |
| 4203 | + pendingAction={ | |
| 4204 | + activePendingProgress | |
| 4205 | + ? { | |
| 4206 | + questionIndex: activePendingProgress.questionIndex, | |
| 4207 | + isLastQuestion: activePendingProgress.isLastQuestion, | |
| 4208 | + canAdvance: activePendingProgress.canAdvance, | |
| 4209 | + isResponding: activePendingIsResponding, | |
| 4210 | + isComplete: Boolean(activePendingResolvedAnswers), | |
| 4211 | + } | |
| 4212 | + : null | |
| 4213 | + } | |
| 4214 | + isRunning={phase === "running"} | |
| 4215 | + showPlanFollowUpPrompt={ | |
| 4216 | + pendingUserInputs.length === 0 && showPlanFollowUpPrompt | |
| 4217 | + } | |
| 4218 | + promptHasText={prompt.trim().length > 0} | |
| 4219 | + isSendBusy={isSendBusy} | |
| 4220 | + isConnecting={isConnecting} | |
| 4221 | + isPreparingWorktree={isPreparingWorktree} | |
| 4222 | + hasSendableContent={composerSendState.hasSendableContent} | |
| 4223 | + onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} | |
| 4224 | + onInterrupt={() => void onInterrupt()} | |
| 4225 | + onImplementPlanInNewThread={() => void onImplementPlanInNewThread()} | |
| 4226 | + /> | |
| 4277 | 4227 | </div> |
| 4278 | 4228 | </div> |
| 4279 | 4229 | )} |
| @@ -0,0 +1,217 @@ | ||
| 1 | +import { memo } from "react"; | |
| 2 | +import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; | |
| 3 | +import { cn } from "~/lib/utils"; | |
| 4 | +import { Button } from "../ui/button"; | |
| 5 | +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | |
| 6 | + | |
| 7 | +interface PendingActionState { | |
| 8 | + questionIndex: number; | |
| 9 | + isLastQuestion: boolean; | |
| 10 | + canAdvance: boolean; | |
| 11 | + isResponding: boolean; | |
| 12 | + isComplete: boolean; | |
| 13 | +} | |
| 14 | + | |
| 15 | +interface ComposerPrimaryActionsProps { | |
| 16 | + compact: boolean; | |
| 17 | + pendingAction: PendingActionState | null; | |
| 18 | + isRunning: boolean; | |
| 19 | + showPlanFollowUpPrompt: boolean; | |
| 20 | + promptHasText: boolean; | |
| 21 | + isSendBusy: boolean; | |
| 22 | + isConnecting: boolean; | |
| 23 | + isPreparingWorktree: boolean; | |
| 24 | + hasSendableContent: boolean; | |
| 25 | + onPreviousPendingQuestion: () => void; | |
| 26 | + onInterrupt: () => void; | |
| 27 | + onImplementPlanInNewThread: () => void; | |
| 28 | +} | |
| 29 | + | |
| 30 | +const formatPendingPrimaryActionLabel = (input: { | |
| 31 | + compact: boolean; | |
| 32 | + isLastQuestion: boolean; | |
| 33 | + isResponding: boolean; | |
| 34 | +}) => { | |
| 35 | + if (input.isResponding) { | |
| 36 | + return "Submitting..."; | |
| 37 | + } | |
| 38 | + if (input.compact) { | |
| 39 | + return input.isLastQuestion ? "Submit" : "Next"; | |
| 40 | + } | |
| 41 | + return input.isLastQuestion ? "Submit answers" : "Next question"; | |
| 42 | +}; | |
| 43 | + | |
| 44 | +export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ | |
| 45 | + compact, | |
| 46 | + pendingAction, | |
| 47 | + isRunning, | |
| 48 | + showPlanFollowUpPrompt, | |
| 49 | + promptHasText, | |
| 50 | + isSendBusy, | |
| 51 | + isConnecting, | |
| 52 | + isPreparingWorktree, | |
| 53 | + hasSendableContent, | |
| 54 | + onPreviousPendingQuestion, | |
| 55 | + onInterrupt, | |
| 56 | + onImplementPlanInNewThread, | |
| 57 | +}: ComposerPrimaryActionsProps) { | |
| 58 | + if (pendingAction) { | |
| 59 | + return ( | |
| 60 | + <div className={cn("flex items-center justify-end", compact ? "gap-1.5" : "gap-2")}> | |
| 61 | + {pendingAction.questionIndex > 0 ? ( | |
| 62 | + compact ? ( | |
| 63 | + <Button | |
| 64 | + size="icon-sm" | |
| 65 | + variant="outline" | |
| 66 | + className="rounded-full" | |
| 67 | + onClick={onPreviousPendingQuestion} | |
| 68 | + disabled={pendingAction.isResponding} | |
| 69 | + aria-label="Previous question" | |
| 70 | + > | |
| 71 | + <ChevronLeftIcon className="size-3.5" /> | |
| 72 | + </Button> | |
| 73 | + ) : ( | |
| 74 | + <Button | |
| 75 | + size="sm" | |
| 76 | + variant="outline" | |
| 77 | + className="rounded-full" | |
| 78 | + onClick={onPreviousPendingQuestion} | |
| 79 | + disabled={pendingAction.isResponding} | |
| 80 | + > | |
| 81 | + Previous | |
| 82 | + </Button> | |
| 83 | + ) | |
| 84 | + ) : null} | |
| 85 | + <Button | |
| 86 | + type="submit" | |
| 87 | + size="sm" | |
| 88 | + className={cn("rounded-full", compact ? "px-3" : "px-4")} | |
| 89 | + disabled={ | |
| 90 | + pendingAction.isResponding || | |
| 91 | + (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) | |
| 92 | + } | |
| 93 | + > | |
| 94 | + {formatPendingPrimaryActionLabel({ | |
| 95 | + compact, | |
| 96 | + isLastQuestion: pendingAction.isLastQuestion, | |
| 97 | + isResponding: pendingAction.isResponding, | |
| 98 | + })} | |
| 99 | + </Button> | |
| 100 | + </div> | |
| 101 | + ); | |
| 102 | + } | |
| 103 | + | |
| 104 | + if (isRunning) { | |
| 105 | + return ( | |
| 106 | + <button | |
| 107 | + type="button" | |
| 108 | + className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105 sm:h-8 sm:w-8" | |
| 109 | + onClick={onInterrupt} | |
| 110 | + aria-label="Stop generation" | |
| 111 | + > | |
| 112 | + <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"> | |
| 113 | + <rect x="2" y="2" width="8" height="8" rx="1.5" /> | |
| 114 | + </svg> | |
| 115 | + </button> | |
| 116 | + ); | |
| 117 | + } | |
| 118 | + | |
| 119 | + if (showPlanFollowUpPrompt) { | |
| 120 | + if (promptHasText) { | |
| 121 | + return ( | |
| 122 | + <Button | |
| 123 | + type="submit" | |
| 124 | + size="sm" | |
| 125 | + className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} | |
| 126 | + disabled={isSendBusy || isConnecting} | |
| 127 | + > | |
| 128 | + {isConnecting || isSendBusy ? "Sending..." : "Refine"} | |
| 129 | + </Button> | |
| 130 | + ); | |
| 131 | + } | |
| 132 | + | |
| 133 | + return ( | |
| 134 | + <div data-chat-composer-implement-actions="true" className="flex items-center justify-end"> | |
| 135 | + <Button | |
| 136 | + type="submit" | |
| 137 | + size="sm" | |
| 138 | + className={cn("h-9 rounded-l-full rounded-r-none sm:h-8", compact ? "px-3" : "px-4")} | |
| 139 | + disabled={isSendBusy || isConnecting} | |
| 140 | + > | |
| 141 | + {isConnecting || isSendBusy ? "Sending..." : "Implement"} | |
| 142 | + </Button> | |
| 143 | + <Menu> | |
| 144 | + <MenuTrigger | |
| 145 | + render={ | |
| 146 | + <Button | |
| 147 | + size="sm" | |
| 148 | + variant="default" | |
| 149 | + className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" | |
| 150 | + aria-label="Implementation actions" | |
| 151 | + disabled={isSendBusy || isConnecting} | |
| 152 | + /> | |
| 153 | + } | |
| 154 | + > | |
| 155 | + <ChevronDownIcon className="size-3.5" /> | |
| 156 | + </MenuTrigger> | |
| 157 | + <MenuPopup align="end" side="top"> | |
| 158 | + <MenuItem | |
| 159 | + disabled={isSendBusy || isConnecting} | |
| 160 | + onClick={() => void onImplementPlanInNewThread()} | |
| 161 | + > | |
| 162 | + Implement in a new thread | |
| 163 | + </MenuItem> | |
| 164 | + </MenuPopup> | |
| 165 | + </Menu> | |
| 166 | + </div> | |
| 167 | + ); | |
| 168 | + } | |
| 169 | + | |
| 170 | + return ( | |
| 171 | + <button | |
| 172 | + type="submit" | |
| 173 | + className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" | |
| 174 | + disabled={isSendBusy || isConnecting || !hasSendableContent} | |
| 175 | + aria-label={ | |
| 176 | + isConnecting | |
| 177 | + ? "Connecting" | |
| 178 | + : isPreparingWorktree | |
| 179 | + ? "Preparing worktree" | |
| 180 | + : isSendBusy | |
| 181 | + ? "Sending" | |
| 182 | + : "Send message" | |
| 183 | + } | |
| 184 | + > | |
| 185 | + {isConnecting || isSendBusy ? ( | |
| 186 | + <svg | |
| 187 | + width="14" | |
| 188 | + height="14" | |
| 189 | + viewBox="0 0 14 14" | |
| 190 | + fill="none" | |
| 191 | + className="animate-spin" | |
| 192 | + aria-hidden="true" | |
| 193 | + > | |
| 194 | + <circle | |
| 195 | + cx="7" | |
| 196 | + cy="7" | |
| 197 | + r="5.5" | |
| 198 | + stroke="currentColor" | |
| 199 | + strokeWidth="1.5" | |
| 200 | + strokeLinecap="round" | |
| 201 | + strokeDasharray="20 12" | |
| 202 | + /> | |
| 203 | + </svg> | |
| 204 | + ) : ( | |
| 205 | + <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> | |
| 206 | + <path | |
| 207 | + d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" | |
| 208 | + stroke="currentColor" | |
| 209 | + strokeWidth="1.8" | |
| 210 | + strokeLinecap="round" | |
| 211 | + strokeLinejoin="round" | |
| 212 | + /> | |
| 213 | + </svg> | |
| 214 | + )} | |
| 215 | + </button> | |
| 216 | + ); | |
| 217 | +}); | |
| @@ -98,6 +98,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 97 unmodified lines | ||
| 98 | 98 | <Button |
| 99 | 99 | size="sm" |
| 100 | 100 | variant={props.triggerVariant ?? "ghost"} |
| 101 | + data-chat-provider-model-picker="true" | |
| 101 | 102 | className={cn( |
| 102 | 103 | "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0", |
| 103 | 104 | props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3", |
| @@ -109,8 +110,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { · 5 unmodified lines | ||
| 109 | 110 | > |
| 110 | 111 | <span |
| 111 | 112 | className={cn( |
| 112 | − "flex min-w-0 w-full items-center gap-2 overflow-hidden", | |
| 113 | − props.compact ? "max-w-36" : undefined, | |
| 113 | + "flex min-w-0 w-full box-border items-center gap-2 overflow-hidden", | |
| 114 | + props.compact ? "max-w-36 sm:pl-1" : undefined, | |
| 114 | 115 | )} |
| 115 | 116 | > |
| 116 | 117 | <ProviderIcon |
| @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 5 | 5 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 6 | + COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, | |
| 7 | + measureComposerFooterOverflowPx, | |
| 8 | + resolveComposerFooterContentWidth, | |
| 9 | + shouldForceCompactComposerFooterForFit, | |
| 10 | + shouldUseCompactComposerPrimaryActions, | |
| 6 | 11 | shouldUseCompactComposerFooter, |
| 7 | 12 | } from "./composerFooterLayout"; |
| 8 | 13 | |
| @@ -33,3 +38,117 @@ describe("shouldUseCompactComposerFooter", () => { · 24 unmodified lines | ||
| 33 | 38 | ).toBe(false); |
| 34 | 39 | }); |
| 35 | 40 | }); |
| 41 | + | |
| 42 | +describe("shouldUseCompactComposerPrimaryActions", () => { | |
| 43 | + it("matches the wide footer breakpoint", () => { | |
| 44 | + expect(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX).toBe( | |
| 45 | + COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, | |
| 46 | + ); | |
| 47 | + expect( | |
| 48 | + shouldUseCompactComposerPrimaryActions(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX - 1, { | |
| 49 | + hasWideActions: true, | |
| 50 | + }), | |
| 51 | + ).toBe(true); | |
| 52 | + expect( | |
| 53 | + shouldUseCompactComposerPrimaryActions(COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, { | |
| 54 | + hasWideActions: true, | |
| 55 | + }), | |
| 56 | + ).toBe(false); | |
| 57 | + }); | |
| 58 | +}); | |
| 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 | +}); | |
| @@ -1,5 +1,9 @@ | ||
| 1 | 1 | export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; |
| 2 | −export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 720; | |
| 2 | +export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; | |
| 3 | +export const COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX = | |
| 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; | |
| 3 | 7 | |
| 4 | 8 | export function shouldUseCompactComposerFooter( |
| 5 | 9 | width: number | null, |
| @@ -10,3 +14,56 @@ export function shouldUseCompactComposerFooter( · 4 unmodified lines | ||
| 10 | 14 | : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX; |
| 11 | 15 | return width !== null && width < breakpoint; |
| 12 | 16 | } |
| 17 | + | |
| 18 | +export function shouldUseCompactComposerPrimaryActions( | |
| 19 | + width: number | null, | |
| 20 | + options?: { hasWideActions?: boolean }, | |
| 21 | +): boolean { | |
| 22 | + if (!options?.hasWideActions) { | |
| 23 | + return false; | |
| 24 | + } | |
| 25 | + return width !== null && width < COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX; | |
| 26 | +} | |
| 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 | +} | |