diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20816d6935..e6dcc4f637 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -11,6 +11,7 @@ import { type ProjectId, type ServerConfig, type ThreadId, + type TurnId, type WsWelcomePayload, WS_CHANNELS, WS_METHODS, @@ -79,6 +80,20 @@ const DEFAULT_VIEWPORT: ViewportSpec = { textTolerancePx: 44, attachmentTolerancePx: 56, }; +const WIDE_FOOTER_VIEWPORT: ViewportSpec = { + name: "wide-footer", + width: 1_400, + height: 1_100, + textTolerancePx: 44, + attachmentTolerancePx: 56, +}; +const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { + name: "compact-footer", + width: 430, + height: 932, + textTolerancePx: 56, + attachmentTolerancePx: 56, +}; const TEXT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, @@ -102,6 +117,7 @@ interface MountedChatView { cleanup: () => Promise; measureUserRow: (targetMessageId: MessageId) => Promise; setViewport: (viewport: ViewportSpec) => Promise; + setContainerSize: (viewport: Pick) => Promise; router: ReturnType; } @@ -513,6 +529,114 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithPendingUserInput(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-pending-input-target" as MessageId, + targetText: "question thread", + }); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + interactionMode: "plan", + activities: [ + { + id: EventId.makeUnsafe("activity-user-input-requested"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "req-browser-user-input", + questions: [ + { + id: "scope", + header: "Scope", + question: "What should this change cover?", + options: [ + { + label: "Tight", + description: "Touch only the footer layout logic.", + }, + { + label: "Broad", + description: "Also adjust the related composer controls.", + }, + ], + }, + { + id: "risk", + header: "Risk", + question: "How aggressive should the imaginary plan be?", + options: [ + { + label: "Conservative", + description: "Favor reliability and low-risk changes.", + }, + { + label: "Balanced", + description: "Mix quick wins with one structural improvement.", + }, + ], + }, + ], + }, + turnId: null, + sequence: 1, + createdAt: isoAt(1_000), + }, + ], + updatedAt: isoAt(1_000), + }) + : thread, + ), + }; +} + +function createSnapshotWithPlanFollowUpPrompt(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-plan-follow-up-target" as MessageId, + targetText: "plan follow-up thread", + }); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + interactionMode: "plan", + latestTurn: { + turnId: "turn-plan-follow-up" as TurnId, + state: "completed", + requestedAt: isoAt(1_000), + startedAt: isoAt(1_001), + completedAt: isoAt(1_010), + assistantMessageId: null, + }, + proposedPlans: [ + { + id: "plan-follow-up-browser-test", + turnId: "turn-plan-follow-up" as TurnId, + planMarkdown: "# Follow-up plan\n\n- Keep the composer footer stable on resize.", + implementedAt: null, + implementationThreadId: null, + createdAt: isoAt(1_002), + updatedAt: isoAt(1_003), + }, + ], + session: { + ...thread.session, + status: "ready", + updatedAt: isoAt(1_010), + }, + updatedAt: isoAt(1_010), + }) + : thread, + ), + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { @@ -694,6 +818,13 @@ async function waitForComposerEditor(): Promise { ); } +async function waitForComposerMenuItem(itemId: string): Promise { + return waitForElement( + () => document.querySelector(`[data-composer-item-id="${itemId}"]`), + `Unable to find composer menu item "${itemId}".`, + ); +} + async function waitForSendButton(): Promise { return waitForElement( () => document.querySelector('button[aria-label="Send message"]'), @@ -701,6 +832,62 @@ async function waitForSendButton(): Promise { ); } +function findComposerProviderModelPicker(): HTMLButtonElement | null { + return document.querySelector('[data-chat-provider-model-picker="true"]'); +} + +function findButtonByText(text: string): HTMLButtonElement | null { + return (Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === text, + ) ?? null) as HTMLButtonElement | null; +} + +async function waitForButtonByText(text: string): Promise { + return waitForElement(() => findButtonByText(text), `Unable to find "${text}" button.`); +} + +function findButtonContainingText(text: string): HTMLButtonElement | null { + return (Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes(text), + ) ?? null) as HTMLButtonElement | null; +} + +async function waitForButtonContainingText(text: string): Promise { + return waitForElement( + () => findButtonContainingText(text), + `Unable to find button containing "${text}".`, + ); +} + +async function expectComposerActionsContained(): Promise { + const footer = await waitForElement( + () => document.querySelector('[data-chat-composer-footer="true"]'), + "Unable to find composer footer.", + ); + const actions = await waitForElement( + () => document.querySelector('[data-chat-composer-actions="right"]'), + "Unable to find composer actions container.", + ); + + await vi.waitFor( + () => { + const footerRect = footer.getBoundingClientRect(); + const actionButtons = Array.from(actions.querySelectorAll("button")); + expect(actionButtons.length).toBeGreaterThanOrEqual(1); + + const buttonRects = actionButtons.map((button) => button.getBoundingClientRect()); + const firstTop = buttonRects[0]?.top ?? 0; + + for (const rect of buttonRects) { + expect(rect.right).toBeLessThanOrEqual(footerRect.right + 0.5); + expect(rect.bottom).toBeLessThanOrEqual(footerRect.bottom + 0.5); + expect(Math.abs(rect.top - firstTop)).toBeLessThanOrEqual(1.5); + } + }, + { timeout: 8_000, interval: 16 }, + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -864,7 +1051,8 @@ async function mountChatView(options: { const host = document.createElement("div"); host.style.position = "fixed"; - host.style.inset = "0"; + host.style.top = "0"; + host.style.left = "0"; host.style.width = "100vw"; host.style.height = "100vh"; host.style.display = "grid"; @@ -897,6 +1085,11 @@ async function mountChatView(options: { await setViewport(viewport); await waitForProductionStyles(); }, + setContainerSize: async (viewport) => { + host.style.width = `${viewport.width}px`; + host.style.height = `${viewport.height}px`; + await waitForLayout(); + }, router, }; } @@ -2341,4 +2534,125 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("keeps pending-question footer actions inside the composer after a real resize", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPendingUserInput(), + }); + + try { + const firstOption = await waitForButtonContainingText("Tight"); + firstOption.click(); + + await waitForButtonByText("Previous"); + await waitForButtonByText("Submit answers"); + + await mounted.setContainerSize(COMPACT_FOOTER_VIEWPORT); + await expectComposerActionsContained(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPlanFollowUpPrompt(), + }); + + try { + const footer = await waitForElement( + () => document.querySelector('[data-chat-composer-footer="true"]'), + "Unable to find composer footer.", + ); + const initialModelPicker = await waitForElement( + findComposerProviderModelPicker, + "Unable to find provider model picker.", + ); + const initialModelPickerOffset = + initialModelPicker.getBoundingClientRect().left - footer.getBoundingClientRect().left; + + await waitForButtonByText("Implement"); + await waitForElement( + () => + document.querySelector('button[aria-label="Implementation actions"]'), + "Unable to find implementation actions trigger.", + ); + + await mounted.setContainerSize({ + width: 440, + height: WIDE_FOOTER_VIEWPORT.height, + }); + await expectComposerActionsContained(); + + const implementButton = await waitForButtonByText("Implement"); + const implementActionsButton = await waitForElement( + () => + document.querySelector('button[aria-label="Implementation actions"]'), + "Unable to find implementation actions trigger.", + ); + + await vi.waitFor( + () => { + const implementRect = implementButton.getBoundingClientRect(); + const implementActionsRect = implementActionsButton.getBoundingClientRect(); + const compactModelPicker = findComposerProviderModelPicker(); + expect(compactModelPicker).toBeTruthy(); + + const compactModelPickerOffset = + compactModelPicker!.getBoundingClientRect().left - footer.getBoundingClientRect().left; + + expect(Math.abs(implementRect.right - implementActionsRect.left)).toBeLessThanOrEqual(1); + expect(Math.abs(implementRect.top - implementActionsRect.top)).toBeLessThanOrEqual(1); + expect(Math.abs(compactModelPickerOffset - initialModelPickerOffset)).toBeLessThanOrEqual( + 1, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the slash-command menu visible above the composer", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-menu-target" as MessageId, + targetText: "command menu thread", + }), + }); + + try { + await waitForComposerEditor(); + await page.getByTestId("composer-editor").fill("/"); + + const menuItem = await waitForComposerMenuItem("slash:model"); + const composerForm = await waitForElement( + () => document.querySelector('[data-chat-composer-form="true"]'), + "Unable to find composer form.", + ); + + await vi.waitFor( + () => { + const menuRect = menuItem.getBoundingClientRect(); + const composerRect = composerForm.getBoundingClientRect(); + const hitTarget = document.elementFromPoint( + menuRect.left + menuRect.width / 2, + menuRect.top + menuRect.height / 2, + ); + + expect(menuRect.width).toBeGreaterThan(0); + expect(menuRect.height).toBeGreaterThan(0); + expect(menuRect.bottom).toBeLessThanOrEqual(composerRect.bottom); + expect(hitTarget instanceof Element && menuItem.contains(hitTarget)).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7562f845e2..d4f304f837 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -106,7 +106,6 @@ import { } from "lucide-react"; import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; @@ -148,7 +147,12 @@ import { type TerminalContextSelection, } from "../lib/terminalContext"; import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; -import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; +import { + resolveComposerFooterContentWidth, + shouldForceCompactComposerFooterForFit, + shouldUseCompactComposerPrimaryActions, + shouldUseCompactComposerFooter, +} from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -160,6 +164,7 @@ import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/Provider import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; +import { ComposerPrimaryActions } from "./chat/ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; @@ -499,6 +504,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); + const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // 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) { const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); + const composerFooterRef = useRef(null); + const composerFooterLeadingRef = useRef(null); + const composerFooterActionsRef = useRef(null); const composerImagesRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); @@ -930,6 +939,28 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const composerFooterActionLayoutKey = useMemo(() => { + if (activePendingProgress) { + return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; + } + if (phase === "running") { + return "running"; + } + if (showPlanFollowUpPrompt) { + return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; + } + return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; + }, [ + activePendingIsResponding, + activePendingProgress, + composerSendState.hasSendableContent, + isConnecting, + isPreparingWorktree, + isSendBusy, + phase, + prompt, + showPlanFollowUpPrompt, + ]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -2038,23 +2069,56 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerForm = composerFormRef.current; if (!composerForm) return; const measureComposerFormWidth = () => composerForm.clientWidth; + const measureFooterCompactness = () => { + const composerFormWidth = measureComposerFormWidth(); + const heuristicFooterCompact = shouldUseCompactComposerFooter(composerFormWidth, { + hasWideActions: composerFooterHasWideActions, + }); + const footer = composerFooterRef.current; + const footerStyle = footer ? window.getComputedStyle(footer) : null; + const footerContentWidth = resolveComposerFooterContentWidth({ + footerWidth: footer?.clientWidth ?? null, + paddingLeft: footerStyle ? Number.parseFloat(footerStyle.paddingLeft) : null, + paddingRight: footerStyle ? Number.parseFloat(footerStyle.paddingRight) : null, + }); + const fitInput = { + footerContentWidth, + leadingContentWidth: composerFooterLeadingRef.current?.scrollWidth ?? null, + actionsWidth: composerFooterActionsRef.current?.scrollWidth ?? null, + }; + const nextFooterCompact = + heuristicFooterCompact || shouldForceCompactComposerFooterForFit(fitInput); + const nextPrimaryActionsCompact = + nextFooterCompact && + shouldUseCompactComposerPrimaryActions(composerFormWidth, { + hasWideActions: composerFooterHasWideActions, + }); + + return { + primaryActionsCompact: nextPrimaryActionsCompact, + footerCompact: nextFooterCompact, + }; + }; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - setIsComposerFooterCompact( - shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }), - ); + const initialCompactness = measureFooterCompactness(); + setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); + setIsComposerFooterCompact(initialCompactness.footerCompact); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; - const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }); - setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); + const nextCompactness = measureFooterCompactness(); + setIsComposerPrimaryActionsCompact((previous) => + previous === nextCompactness.primaryActionsCompact + ? previous + : nextCompactness.primaryActionsCompact, + ); + setIsComposerFooterCompact((previous) => + previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, + ); const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; @@ -2069,7 +2133,12 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { observer.disconnect(); }; - }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); + }, [ + activeThread?.id, + composerFooterActionLayoutKey, + composerFooterHasWideActions, + scheduleStickToBottom, + ]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -3788,7 +3857,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
) : (
@@ -4113,8 +4183,12 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Right side: send / stop button */}
{activeContextWindow ? ( @@ -4124,156 +4198,32 @@ export default function ChatView({ threadId }: ChatViewProps) { Preparing worktree... ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} - -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( - - ) : ( -
- - - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in a new thread - - - -
- ) - ) : ( - - ) - ) : null} + 0} + isSendBusy={isSendBusy} + isConnecting={isConnecting} + isPreparingWorktree={isPreparingWorktree} + hasSendableContent={composerSendState.hasSendableContent} + onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} + onInterrupt={() => void onInterrupt()} + onImplementPlanInNewThread={() => void onImplementPlanInNewThread()} + />
)} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx new file mode 100644 index 0000000000..09b343c900 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -0,0 +1,217 @@ +import { memo } from "react"; +import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; + +interface PendingActionState { + questionIndex: number; + isLastQuestion: boolean; + canAdvance: boolean; + isResponding: boolean; + isComplete: boolean; +} + +interface ComposerPrimaryActionsProps { + compact: boolean; + pendingAction: PendingActionState | null; + isRunning: boolean; + showPlanFollowUpPrompt: boolean; + promptHasText: boolean; + isSendBusy: boolean; + isConnecting: boolean; + isPreparingWorktree: boolean; + hasSendableContent: boolean; + onPreviousPendingQuestion: () => void; + onInterrupt: () => void; + onImplementPlanInNewThread: () => void; +} + +const formatPendingPrimaryActionLabel = (input: { + compact: boolean; + isLastQuestion: boolean; + isResponding: boolean; +}) => { + if (input.isResponding) { + return "Submitting..."; + } + if (input.compact) { + return input.isLastQuestion ? "Submit" : "Next"; + } + return input.isLastQuestion ? "Submit answers" : "Next question"; +}; + +export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ + compact, + pendingAction, + isRunning, + showPlanFollowUpPrompt, + promptHasText, + isSendBusy, + isConnecting, + isPreparingWorktree, + hasSendableContent, + onPreviousPendingQuestion, + onInterrupt, + onImplementPlanInNewThread, +}: ComposerPrimaryActionsProps) { + if (pendingAction) { + return ( +
+ {pendingAction.questionIndex > 0 ? ( + compact ? ( + + ) : ( + + ) + ) : null} + +
+ ); + } + + if (isRunning) { + return ( + + ); + } + + if (showPlanFollowUpPrompt) { + if (promptHasText) { + return ( + + ); + } + + return ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in a new thread + + + +
+ ); + } + + return ( + + ); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 565a9d399d..01fa37516e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -98,6 +98,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {