From 7ed9e844e43af56887301a5dcd0f969dd6745406 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Sat, 21 Mar 2026 21:39:09 +0000 Subject: [PATCH 1/2] fix(web): restore composer mention menu highlight ownership and scroll sync --- apps/web/src/components/ChatView.browser.tsx | 132 +++++++++++++++++- apps/web/src/components/ChatView.tsx | 33 ++--- .../components/chat/ComposerCommandMenu.tsx | 35 ++++- 3 files changed, 174 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747df..044951e3e60 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 e628f6ea6a9..203bf474aca 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 818c3c20f8b..1d63c3e26e2 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(); }} From 9c7c97b8e29ff2b220d14b44a8b69bd0c6497836 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Wed, 25 Mar 2026 21:15:40 +0000 Subject: [PATCH 2/2] fix(web): fix composer mention menu highlight and scroll --- apps/web/src/components/ChatView.browser.tsx | 38 +++++-------------- apps/web/src/components/ChatView.tsx | 33 ++++++++-------- .../components/chat/ComposerCommandMenu.tsx | 30 +++++++-------- 3 files changed, 42 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bdad32a9506..03ce56eeb10 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -17,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"; @@ -1293,6 +1293,7 @@ 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", @@ -1334,23 +1335,12 @@ describe("ChatView timeline estimator parity (full app)", () => { }, { 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; + const initialActiveItem = await waitForActiveComposerCommandItem(); + expect(initialActiveItem.dataset.path).toBe(projectEntries[0]?.path); + for (let index = 0; index < 12; index += 1) { - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "ArrowDown", - bubbles: true, - cancelable: true, - }), - ); + await userEvent.keyboard("{ArrowDown}"); await nextFrame(); } @@ -1360,23 +1350,12 @@ describe("ChatView timeline estimator parity (full app)", () => { 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); + expect(scrollIntoViewSpy).toHaveBeenCalled(); }, { timeout: 8_000, interval: 16 }, ); - composerEditor.dispatchEvent( - new KeyboardEvent("keydown", { - key: "Enter", - bubbles: true, - cancelable: true, - }), - ); + await userEvent.keyboard("{Enter}"); await vi.waitFor( () => { @@ -1388,6 +1367,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); } finally { + scrollIntoViewSpy.mockRestore(); await mounted.cleanup(); } }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79f1267d70c..ace74a5cc80 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -388,7 +388,6 @@ 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); @@ -3319,19 +3318,24 @@ export default function ChatView({ threadId }: ChatViewProps) { const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { setComposerHighlightedItemId(itemId); }, []); - const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => { - const commandInput = composerCommandInputRef.current; - if (!commandInput) { - return; - } - commandInput.dispatchEvent( - new KeyboardEvent("keydown", { - key, - bubbles: true, - cancelable: true, - }), - ); - }, []); + 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 isComposerMenuLoading = composerTriggerKind === "path" && ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || @@ -3649,7 +3653,6 @@ 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 1d63c3e26e2..35bb3bb659c 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,9 +1,10 @@ import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { memo, useEffect, useRef, type RefObject } 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"; import { Badge } from "../ui/badge"; -import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; +import { Command, CommandItem, CommandList } from "../ui/command"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = @@ -39,7 +40,6 @@ 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()); @@ -52,18 +52,8 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { }, [props.activeItemId, props.items]); return ( - { - props.onHighlightedItemChange( - typeof highlightedValue === "string" ? highlightedValue : null, - ); - }} - > +
-
- -
{props.items.map((item) => ( ))} @@ -101,6 +92,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { itemRef: (element: HTMLDivElement | null) => void; resolvedTheme: "light" | "dark"; isActive: boolean; + onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; }) { return ( @@ -109,7 +101,15 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { data-active={props.isActive ? "" : undefined} data-path={props.item.type === "path" ? props.item.path : undefined} value={props.item.id} - className="cursor-pointer scroll-my-2 select-none gap-2" + className={cn( + "cursor-pointer select-none gap-2 scroll-my-2", + props.isActive && "bg-accent text-accent-foreground", + )} + onMouseMove={() => { + if (!props.isActive) { + props.onHighlightedItemChange(props.item.id); + } + }} onMouseDown={(event) => { event.preventDefault(); }}