diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747..044951e3e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -5,6 +5,7 @@ import { ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, + type ProjectEntry, type ProjectId, type ServerConfig, type ThreadId, @@ -49,6 +50,7 @@ interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; welcome: WsWelcomePayload; + projectSearchEntries: ProjectEntry[]; } let fixture: TestFixture; @@ -154,6 +156,19 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe }; } +function createProjectEntries(paths: string[]): ProjectEntry[] { + return paths.map((path) => { + const normalizedPath = path.split("/"); + const label = normalizedPath.at(-1) ?? path; + const parentSegments = normalizedPath.slice(0, -1); + return { + path, + kind: label.includes(".") ? "file" : "directory", + ...(parentSegments.length > 0 ? { parentPath: parentSegments.join("/") } : {}), + }; + }); +} + function createTerminalContext(input: { id: string; terminalLabel: string; @@ -270,6 +285,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { bootstrapProjectId: PROJECT_ID, bootstrapThreadId: THREAD_ID, }, + projectSearchEntries: [], }; } @@ -420,7 +436,7 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { } if (tag === WS_METHODS.projectsSearchEntries) { return { - entries: [], + entries: fixture.projectSearchEntries, truncated: false, }; } @@ -556,6 +572,20 @@ async function waitForComposerEditor(): Promise { ); } +async function waitForComposerCommandList(): Promise { + return waitForElement( + () => document.querySelector('[data-slot="command-list"]'), + "Unable to find composer command list.", + ); +} + +async function waitForActiveComposerCommandItem(): Promise { + return waitForElement( + () => document.querySelector('[data-slot="command-item"][data-active]'), + "Unable to find active composer command item.", + ); +} + async function waitForSendButton(): Promise { return waitForElement( () => document.querySelector('button[aria-label="Send message"]'), @@ -1045,6 +1075,106 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("scrolls the composer @ menu to keep the keyboard-highlighted file in view", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "@c"); + const projectEntries = createProjectEntries([ + "apps/web/src/components/ChatView.tsx", + "apps/web/src/components/ComposerPromptEditor.tsx", + "apps/web/src/components/chat/ComposerCommandMenu.tsx", + "apps/web/src/components/chat/VscodeEntryIcon.tsx", + "apps/web/src/components/chat/ProviderModelPicker.tsx", + "apps/web/src/components/chat/MessagesTimeline.tsx", + "apps/web/src/components/chat/ChangedFilesTree.tsx", + "apps/web/src/components/chat/ExpandedImagePreview.tsx", + "apps/web/src/components/DiffPanel.tsx", + "apps/web/src/components/Sidebar.tsx", + "apps/web/src/components/ThreadTerminalDrawer.tsx", + "apps/web/src/components/chat/OpenInPicker.tsx", + "apps/web/src/components/chat/CompactComposerControlsMenu.tsx", + "apps/web/src/components/chat/viewer.tsx", + ]); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-target-composer-scroll" as MessageId, + targetText: "composer scroll target", + }), + configureFixture: (nextFixture) => { + nextFixture.projectSearchEntries = projectEntries; + }, + }); + + try { + const composerEditor = await waitForComposerEditor(); + const commandList = await waitForComposerCommandList(); + let activePath: string | null = null; + + composerEditor.focus(); + await vi.waitFor( + () => { + expect(commandList.childElementCount).toBeGreaterThan(8); + }, + { timeout: 8_000, interval: 16 }, + ); + const commandScrollViewport = commandList.closest( + '[data-slot="scroll-area-viewport"]', + ); + expect( + commandScrollViewport, + "Unable to find composer command scroll viewport.", + ).toBeTruthy(); + + const initialScrollTop = commandScrollViewport!.scrollTop; + for (let index = 0; index < 12; index += 1) { + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }), + ); + await nextFrame(); + } + + await vi.waitFor( + async () => { + const activeItem = await waitForActiveComposerCommandItem(); + activePath = activeItem.dataset.path ?? null; + expect(activePath).toBeTruthy(); + expect(activePath).not.toBe(projectEntries[0]?.path); + expect(commandScrollViewport!.scrollTop).toBeGreaterThan(initialScrollTop); + + const viewportRect = commandScrollViewport!.getBoundingClientRect(); + const itemRect = activeItem.getBoundingClientRect(); + expect(itemRect.bottom).toBeLessThanOrEqual(viewportRect.bottom); + expect(itemRect.top).toBeGreaterThanOrEqual(viewportRect.top); + }, + { timeout: 8_000, interval: 16 }, + ); + + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(activePath).toBeTruthy(); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toContain( + `@${activePath} `, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps backspaced terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6..203bf474a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -381,6 +381,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); + const composerCommandInputRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); @@ -3298,24 +3299,19 @@ export default function ChatView({ threadId }: ChatViewProps) { const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { setComposerHighlightedItemId(itemId); }, []); - const nudgeComposerMenuHighlight = useCallback( - (key: "ArrowDown" | "ArrowUp") => { - if (composerMenuItems.length === 0) { - return; - } - const highlightedIndex = composerMenuItems.findIndex( - (item) => item.id === composerHighlightedItemId, - ); - const normalizedIndex = - highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; - const offset = key === "ArrowDown" ? 1 : -1; - const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; - const nextItem = composerMenuItems[nextIndex]; - setComposerHighlightedItemId(nextItem?.id ?? null); - }, - [composerHighlightedItemId, composerMenuItems], - ); + const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => { + const commandInput = composerCommandInputRef.current; + if (!commandInput) { + return; + } + commandInput.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }), + ); + }, []); const isComposerMenuLoading = composerTriggerKind === "path" && ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || @@ -3629,6 +3625,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlighted} onSelect={onSelectComposerItem} + commandInputRef={composerCommandInputRef} /> )} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 818c3c20f..1d63c3e26 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,10 +1,9 @@ import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { memo } from "react"; +import { memo, useEffect, useRef, type RefObject } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; -import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; -import { Command, CommandItem, CommandList } from "../ui/command"; +import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = @@ -40,7 +39,18 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; + commandInputRef: RefObject; }) { + const itemRefs = useRef(new Map()); + + useEffect(() => { + if (!props.activeItemId) { + return; + } + const activeItem = itemRefs.current.get(props.activeItemId); + activeItem?.scrollIntoView({ block: "nearest" }); + }, [props.activeItemId, props.items]); + return (
+
+ +
{props.items.map((item) => ( { + if (element) { + itemRefs.current.set(item.id, element); + return; + } + itemRefs.current.delete(item.id); + }} resolvedTheme={props.resolvedTheme} isActive={props.activeItemId === item.id} onSelect={props.onSelect} @@ -78,17 +98,18 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; + itemRef: (element: HTMLDivElement | null) => void; resolvedTheme: "light" | "dark"; isActive: boolean; onSelect: (item: ComposerCommandItem) => void; }) { return ( { event.preventDefault(); }}