| 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,6 +11,7 @@ import { |
| 11 | type ProjectId, | 11 | type ProjectId, |
| 12 | type ServerConfig, | 12 | type ServerConfig, |
| 13 | type ThreadId, | 13 | type ThreadId, |
| 14 | type TurnId, | ||
| 14 | type WsWelcomePayload, | 15 | type WsWelcomePayload, |
| 15 | WS_CHANNELS, | 16 | WS_CHANNELS, |
| 16 | WS_METHODS, | 17 | WS_METHODS, |
| ... | @@ -79,6 +80,20 @@ const DEFAULT_VIEWPORT: ViewportSpec = { | ... | @@ -79,6 +80,20 @@ const DEFAULT_VIEWPORT: ViewportSpec = { |
| 79 | textTolerancePx: 44, | 80 | textTolerancePx: 44, |
| 80 | attachmentTolerancePx: 56, | 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 | const TEXT_VIEWPORT_MATRIX = [ | 97 | const TEXT_VIEWPORT_MATRIX = [ |
| 83 | DEFAULT_VIEWPORT, | 98 | DEFAULT_VIEWPORT, |
| 84 | { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, | 99 | { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, |
| ... | @@ -102,6 +117,7 @@ interface MountedChatView { | ... | @@ -102,6 +117,7 @@ interface MountedChatView { |
| 102 | cleanup: () => Promise<void>; | 117 | cleanup: () => Promise<void>; |
| 103 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; | 118 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; |
| 104 | setViewport: (viewport: ViewportSpec) => Promise<void>; | 119 | setViewport: (viewport: ViewportSpec) => Promise<void>; |
| 120 | setContainerSize: (viewport: Pick<ViewportSpec, "width" | "height">) => Promise<void>; | ||
| 105 | router: ReturnType<typeof getRouter>; | 121 | router: ReturnType<typeof getRouter>; |
| 106 | } | 122 | } |
| 107 | 123 | ||
| ... | @@ -513,6 +529,114 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { | ... | @@ -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 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { | 640 | function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { |
| 517 | const customResult = customWsRpcResolver?.(body); | 641 | const customResult = customWsRpcResolver?.(body); |
| 518 | if (customResult !== undefined) { | 642 | if (customResult !== undefined) { |
| ... | @@ -694,6 +818,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> { | ... | @@ -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 | async function waitForSendButton(): Promise<HTMLButtonElement> { | 828 | async function waitForSendButton(): Promise<HTMLButtonElement> { |
| 698 | return waitForElement( | 829 | return waitForElement( |
| 699 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'), | 830 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'), |
| ... | @@ -701,6 +832,62 @@ async function waitForSendButton(): Promise<HTMLButtonElement> { | ... | @@ -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 | async function waitForInteractionModeButton( | 891 | async function waitForInteractionModeButton( |
| 705 | expectedLabel: "Chat" | "Plan", | 892 | expectedLabel: "Chat" | "Plan", |
| 706 | ): Promise<HTMLButtonElement> { | 893 | ): Promise<HTMLButtonElement> { |
| ... | @@ -864,7 +1051,8 @@ async function mountChatView(options: { | ... | @@ -864,7 +1051,8 @@ async function mountChatView(options: { |
| 864 | 1051 | ||
| 865 | const host = document.createElement("div"); | 1052 | const host = document.createElement("div"); |
| 866 | host.style.position = "fixed"; | 1053 | host.style.position = "fixed"; |
| 867 | host.style.inset = "0"; | 1054 | host.style.top = "0"; |
| 1055 | host.style.left = "0"; | ||
| 868 | host.style.width = "100vw"; | 1056 | host.style.width = "100vw"; |
| 869 | host.style.height = "100vh"; | 1057 | host.style.height = "100vh"; |
| 870 | host.style.display = "grid"; | 1058 | host.style.display = "grid"; |
| ... | @@ -897,6 +1085,11 @@ async function mountChatView(options: { | ... | @@ -897,6 +1085,11 @@ async function mountChatView(options: { |
| 897 | await setViewport(viewport); | 1085 | await setViewport(viewport); |
| 898 | await waitForProductionStyles(); | 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 | router, | 1093 | router, |
| 901 | }; | 1094 | }; |
| 902 | } | 1095 | } |
| ... | @@ -2341,4 +2534,125 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2341,4 +2534,125 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2341 | await mounted.cleanup(); | 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,7 +106,6 @@ import { |
| 106 | } from "lucide-react"; | 106 | } from "lucide-react"; |
| 107 | import { Button } from "./ui/button"; | 107 | import { Button } from "./ui/button"; |
| 108 | import { Separator } from "./ui/separator"; | 108 | import { Separator } from "./ui/separator"; |
| 109 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; | ||
| 110 | import { cn, randomUUID } from "~/lib/utils"; | 109 | import { cn, randomUUID } from "~/lib/utils"; |
| 111 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 110 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 112 | import { toastManager } from "./ui/toast"; | 111 | import { toastManager } from "./ui/toast"; |
| ... | @@ -148,7 +147,12 @@ import { | ... | @@ -148,7 +147,12 @@ import { |
| 148 | type TerminalContextSelection, | 147 | type TerminalContextSelection, |
| 149 | } from "../lib/terminalContext"; | 148 | } from "../lib/terminalContext"; |
| 150 | import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; | 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 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 156 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 153 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; | 157 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| 154 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; | 158 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| ... | @@ -160,6 +164,7 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider | ... | @@ -160,6 +164,7 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider |
| 160 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; | 164 | import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; |
| 161 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; | 165 | import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; |
| 162 | import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; | 166 | import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; |
| 167 | import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; | ||
| 163 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; | 168 | import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; |
| 164 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; | 169 | import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; |
| 165 | import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; | 170 | import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; |
| ... | @@ -499,6 +504,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -499,6 +504,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 499 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 504 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| 500 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); | 505 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); |
| 501 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); | 506 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); |
| 507 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); | ||
| 502 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. | 508 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| 503 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); | 509 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); |
| 504 | // When set, the thread-change reset effect will open the sidebar instead of closing it. | 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,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 541 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); | 547 | const composerEditorRef = useRef<ComposerPromptEditorHandle>(null); |
| 542 | const composerFormRef = useRef<HTMLFormElement>(null); | 548 | const composerFormRef = useRef<HTMLFormElement>(null); |
| 543 | const composerFormHeightRef = useRef(0); | 549 | const composerFormHeightRef = useRef(0); |
| 550 | const composerFooterRef = useRef<HTMLDivElement>(null); | ||
| 551 | const composerFooterLeadingRef = useRef<HTMLDivElement>(null); | ||
| 552 | const composerFooterActionsRef = useRef<HTMLDivElement>(null); | ||
| 544 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); | 553 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 545 | const composerSelectLockRef = useRef(false); | 554 | const composerSelectLockRef = useRef(false); |
| 546 | const composerMenuOpenRef = useRef(false); | 555 | const composerMenuOpenRef = useRef(false); |
| ... | @@ -930,6 +939,28 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -930,6 +939,28 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 930 | pendingUserInputs.length > 0 || | 939 | pendingUserInputs.length > 0 || |
| 931 | (showPlanFollowUpPrompt && activeProposedPlan !== null); | 940 | (showPlanFollowUpPrompt && activeProposedPlan !== null); |
| 932 | const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; | 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 | const lastSyncedPendingInputRef = useRef<{ | 964 | const lastSyncedPendingInputRef = useRef<{ |
| 934 | requestId: string | null; | 965 | requestId: string | null; |
| 935 | questionId: string | null; | 966 | questionId: string | null; |
| ... | @@ -2038,23 +2069,56 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2038,23 +2069,56 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2038 | const composerForm = composerFormRef.current; | 2069 | const composerForm = composerFormRef.current; |
| 2039 | if (!composerForm) return; | 2070 | if (!composerForm) return; |
| 2040 | const measureComposerFormWidth = () => composerForm.clientWidth; | 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 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; | 2103 | composerFormHeightRef.current = composerForm.getBoundingClientRect().height; |
| 2043 | setIsComposerFooterCompact( | 2104 | const initialCompactness = measureFooterCompactness(); |
| 2044 | shouldUseCompactComposerFooter(measureComposerFormWidth(), { | 2105 | setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); |
| 2045 | hasWideActions: composerFooterHasWideActions, | 2106 | setIsComposerFooterCompact(initialCompactness.footerCompact); |
| 2046 | }), | ||
| 2047 | ); | ||
| 2048 | if (typeof ResizeObserver === "undefined") return; | 2107 | if (typeof ResizeObserver === "undefined") return; |
| 2049 | 2108 | ||
| 2050 | const observer = new ResizeObserver((entries) => { | 2109 | const observer = new ResizeObserver((entries) => { |
| 2051 | const [entry] = entries; | 2110 | const [entry] = entries; |
| 2052 | if (!entry) return; | 2111 | if (!entry) return; |
| 2053 | 2112 | ||
| 2054 | const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { | 2113 | const nextCompactness = measureFooterCompactness(); |
| 2055 | hasWideActions: composerFooterHasWideActions, | 2114 | setIsComposerPrimaryActionsCompact((previous) => |
| 2056 | }); | 2115 | previous === nextCompactness.primaryActionsCompact |
| 2057 | setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); | 2116 | ? previous |
| 2117 | : nextCompactness.primaryActionsCompact, | ||
| 2118 | ); | ||
| 2119 | setIsComposerFooterCompact((previous) => | ||
| 2120 | previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, | ||
| 2121 | ); | ||
| 2058 | 2122 | ||
| 2059 | const nextHeight = entry.contentRect.height; | 2123 | const nextHeight = entry.contentRect.height; |
| 2060 | const previousHeight = composerFormHeightRef.current; | 2124 | const previousHeight = composerFormHeightRef.current; |
| ... | @@ -2069,7 +2133,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2069,7 +2133,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2069 | return () => { | 2133 | return () => { |
| 2070 | observer.disconnect(); | 2134 | observer.disconnect(); |
| 2071 | }; | 2135 | }; |
| 2072 | }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); | 2136 | }, [ |
| 2137 | activeThread?.id, | ||
| 2138 | composerFooterActionLayoutKey, | ||
| 2139 | composerFooterHasWideActions, | ||
| 2140 | scheduleStickToBottom, | ||
| 2141 | ]); | ||
| 2073 | useEffect(() => { | 2142 | useEffect(() => { |
| 2074 | if (!shouldAutoScrollRef.current) return; | 2143 | if (!shouldAutoScrollRef.current) return; |
| 2075 | scheduleStickToBottom(); | 2144 | scheduleStickToBottom(); |
| ... | @@ -3788,7 +3857,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3788,7 +3857,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3788 | <form | 3857 | <form |
| 3789 | ref={composerFormRef} | 3858 | ref={composerFormRef} |
| 3790 | onSubmit={onSend} | 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 | data-chat-composer-form="true" | 3861 | data-chat-composer-form="true" |
| 3793 | > | 3862 | > |
| 3794 | <div | 3863 | <div |
| ... | @@ -3971,19 +4040,20 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3971,19 +4040,20 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3971 | </div> | 4040 | </div> |
| 3972 | ) : ( | 4041 | ) : ( |
| 3973 | <div | 4042 | <div |
| 4043 | ref={composerFooterRef} | ||
| 3974 | data-chat-composer-footer="true" | 4044 | data-chat-composer-footer="true" |
| 4045 | data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} | ||
| 3975 | className={cn( | 4046 | className={cn( |
| 3976 | "flex items-center justify-between px-2.5 pb-2.5 sm:px-3 sm:pb-3", | 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", |
| 3977 | isComposerFooterCompact | 4048 | isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", |
| 3978 | ? "gap-1.5" | ||
| 3979 | : "flex-wrap gap-2 sm:flex-nowrap sm:gap-0", | ||
| 3980 | )} | 4049 | )} |
| 3981 | > | 4050 | > |
| 3982 | <div | 4051 | <div |
| 4052 | ref={composerFooterLeadingRef} | ||
| 3983 | className={cn( | 4053 | className={cn( |
| 3984 | "flex min-w-0 flex-1 items-center", | 4054 | "flex min-w-0 flex-1 items-center", |
| 3985 | isComposerFooterCompact | 4055 | isComposerFooterCompact |
| 3986 | ? "gap-1 overflow-hidden" | 4056 | ? "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" |
| 3987 | : "gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", | 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,8 +4183,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4113 | 4183 | ||
| 4114 | {/* Right side: send / stop button */} | 4184 | {/* Right side: send / stop button */} |
| 4115 | <div | 4185 | <div |
| 4186 | ref={composerFooterActionsRef} | ||
| 4116 | data-chat-composer-actions="right" | 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 | {activeContextWindow ? ( | 4193 | {activeContextWindow ? ( |
| 4120 | <ContextWindowMeter usage={activeContextWindow} /> | 4194 | <ContextWindowMeter usage={activeContextWindow} /> |
| ... | @@ -4124,156 +4198,32 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -4124,156 +4198,32 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4124 | Preparing worktree... | 4198 | Preparing worktree... |
| 4125 | </span> | 4199 | </span> |
| 4126 | ) : null} | 4200 | ) : null} |
| 4127 | {activePendingProgress ? ( | 4201 | <ComposerPrimaryActions |
| 4128 | <div className="flex items-center gap-2"> | 4202 | compact={isComposerPrimaryActionsCompact} |
| 4129 | {activePendingProgress.questionIndex > 0 ? ( | 4203 | pendingAction={ |
| 4130 | <Button | 4204 | activePendingProgress |
| 4131 | size="sm" | 4205 | ? { |
| 4132 | variant="outline" | 4206 | questionIndex: activePendingProgress.questionIndex, |
| 4133 | className="rounded-full" | 4207 | isLastQuestion: activePendingProgress.isLastQuestion, |
| 4134 | onClick={onPreviousActivePendingUserInputQuestion} | 4208 | canAdvance: activePendingProgress.canAdvance, |
| 4135 | disabled={activePendingIsResponding} | 4209 | isResponding: activePendingIsResponding, |
| 4136 | > | 4210 | isComplete: Boolean(activePendingResolvedAnswers), |
| 4137 | Previous | 4211 | } |
| 4138 | </Button> | 4212 | : null |
| 4139 | ) : null} | 4213 | } |
| 4140 | <Button | 4214 | isRunning={phase === "running"} |
| 4141 | type="submit" | 4215 | showPlanFollowUpPrompt={ |
| 4142 | size="sm" | 4216 | pendingUserInputs.length === 0 && showPlanFollowUpPrompt |
| 4143 | className="rounded-full px-4" | 4217 | } |
| 4144 | disabled={ | 4218 | promptHasText={prompt.trim().length > 0} |
| 4145 | activePendingIsResponding || | 4219 | isSendBusy={isSendBusy} |
| 4146 | (activePendingProgress.isLastQuestion | 4220 | isConnecting={isConnecting} |
| 4147 | ? !activePendingResolvedAnswers | 4221 | isPreparingWorktree={isPreparingWorktree} |
| 4148 | : !activePendingProgress.canAdvance) | 4222 | hasSendableContent={composerSendState.hasSendableContent} |
| 4149 | } | 4223 | onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} |
| 4150 | > | 4224 | onInterrupt={() => void onInterrupt()} |
| 4151 | {activePendingIsResponding | 4225 | onImplementPlanInNewThread={() => void onImplementPlanInNewThread()} |
| 4152 | ? "Submitting..." | 4226 | /> |
| 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} | ||
| 4277 | </div> | 4227 | </div> |
| 4278 | </div> | 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,6 +98,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 98 | <Button | 98 | <Button |
| 99 | size="sm" | 99 | size="sm" |
| 100 | variant={props.triggerVariant ?? "ghost"} | 100 | variant={props.triggerVariant ?? "ghost"} |
| 101 | data-chat-provider-model-picker="true" | ||
| 101 | className={cn( | 102 | className={cn( |
| 102 | "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0", | 103 | "min-w-0 justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 [&_svg]:mx-0", |
| 103 | props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56 sm:px-3", | 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,8 +110,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 109 | > | 110 | > |
| 110 | <span | 111 | <span |
| 111 | className={cn( | 112 | className={cn( |
| 112 | "flex min-w-0 w-full items-center gap-2 overflow-hidden", | 113 | "flex min-w-0 w-full box-border items-center gap-2 overflow-hidden", |
| 113 | props.compact ? "max-w-36" : undefined, | 114 | props.compact ? "max-w-36 sm:pl-1" : undefined, |
| 114 | )} | 115 | )} |
| 115 | > | 116 | > |
| 116 | <ProviderIcon | 117 | <ProviderIcon |
apps/web/src/components/composerFooterLayout.test.ts+119| ... | @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; | ... | @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, | 4 | COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX, |
| 5 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, | 5 | COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, |
| 6 | COMPOSER_PRIMARY_ACTIONS_COMPACT_BREAKPOINT_PX, | ||
| 7 | measureComposerFooterOverflowPx, | ||
| 8 | resolveComposerFooterContentWidth, | ||
| 9 | shouldForceCompactComposerFooterForFit, | ||
| 10 | shouldUseCompactComposerPrimaryActions, | ||
| 6 | shouldUseCompactComposerFooter, | 11 | shouldUseCompactComposerFooter, |
| 7 | } from "./composerFooterLayout"; | 12 | } from "./composerFooterLayout"; |
| 8 | 13 | ||
| ... | @@ -33,3 +38,117 @@ describe("shouldUseCompactComposerFooter", () => { | ... | @@ -33,3 +38,117 @@ describe("shouldUseCompactComposerFooter", () => { |
| 33 | ).toBe(false); | 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,5 +1,9 @@ |
| 1 | export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; | 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 | export function shouldUseCompactComposerFooter( | 8 | export function shouldUseCompactComposerFooter( |
| 5 | width: number | null, | 9 | width: number | null, |
| ... | @@ -10,3 +14,56 @@ export function shouldUseCompactComposerFooter( | ... | @@ -10,3 +14,56 @@ export function shouldUseCompactComposerFooter( |
| 10 | : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX; | 14 | : COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX; |
| 11 | return width !== null && width < breakpoint; | 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 | } |