| author | |
| committer | |
| log | 0bc94bc24ec221e2ea7c370c9bfb2b06af9ae573 |
| tree | ff1144c59584720902f29b44a00577eee9c58199 |
| parent | 19eb9e7227b2daad4e77fc2ae91814c1dae0875d |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 831 insertions(+), 173 deletions(-)
apps/web/src/components/ChatView.browser.tsx+315-1| ... | ... | @@ -11,6 +11,7 @@ import { |
| 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 = { |
| 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 { |
| 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 { |
| 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> { |
| 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> { |
| 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: { |
| 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: { |
| 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)", () => { |
| 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 | }); |
apps/web/src/components/ChatView.tsx+119-169| ... | ... | @@ -106,7 +106,6 @@ import { |
| 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 { |
| 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 |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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 | )} |
apps/web/src/components/chat/ComposerPrimaryActions.tsx created+217| ... | ... | @@ -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 | }); |
apps/web/src/components/chat/ProviderModelPicker.tsx+3-2| ... | ... | @@ -98,6 +98,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 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: { |
| 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 |
apps/web/src/components/composerFooterLayout.test.ts+119| ... | ... | @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; |
| 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", () => { |
| 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 | }); |
apps/web/src/components/composerFooterLayout.ts+58-1| ... | ... | @@ -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( |
| 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 | } |