diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4e18092463..03ce56eeb1 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, @@ -16,7 +17,7 @@ import { import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; -import { page } from "vitest/browser"; +import { page, userEvent } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -50,6 +51,7 @@ interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; welcome: WsWelcomePayload; + projectSearchEntries: ProjectEntry[]; } let fixture: TestFixture; @@ -156,6 +158,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; @@ -278,6 +293,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { bootstrapProjectId: PROJECT_ID, bootstrapThreadId: THREAD_ID, }, + projectSearchEntries: [], }; } @@ -443,7 +459,7 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { } if (tag === WS_METHODS.projectsSearchEntries) { return { - entries: [], + entries: fixture.projectSearchEntries, truncated: false, }; } @@ -579,6 +595,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"]'), @@ -1262,6 +1292,86 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("scrolls the composer @ menu to keep the keyboard-highlighted file in view", async () => { + const scrollIntoViewSpy = vi.spyOn(HTMLElement.prototype, "scrollIntoView"); + 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 initialActiveItem = await waitForActiveComposerCommandItem(); + expect(initialActiveItem.dataset.path).toBe(projectEntries[0]?.path); + + for (let index = 0; index < 12; index += 1) { + await userEvent.keyboard("{ArrowDown}"); + 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(scrollIntoViewSpy).toHaveBeenCalled(); + }, + { timeout: 8_000, interval: 16 }, + ); + + await userEvent.keyboard("{Enter}"); + + await vi.waitFor( + () => { + expect(activePath).toBeTruthy(); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toContain( + `@${activePath} `, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + scrollIntoViewSpy.mockRestore(); + await mounted.cleanup(); + } + }); + it("keeps removed 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/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 818c3c20f8..35bb3bb659 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,5 +1,5 @@ import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { memo } from "react"; +import { memo, useEffect, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -41,23 +41,34 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { + 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.onHighlightedItemChange( - typeof highlightedValue === "string" ? highlightedValue : null, - ); - }} - > +
{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} + onHighlightedItemChange={props.onHighlightedItemChange} onSelect={props.onSelect} /> ))} @@ -78,17 +89,27 @@ 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; + onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { return ( { + if (!props.isActive) { + props.onHighlightedItemChange(props.item.id); + } + }} onMouseDown={(event) => { event.preventDefault(); }}