Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 112 additions & 2 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ORCHESTRATION_WS_METHODS,
type MessageId,
type OrchestrationReadModel,
type ProjectEntry,
type ProjectId,
type ServerConfig,
type ThreadId,
Expand All @@ -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";

Expand Down Expand Up @@ -50,6 +51,7 @@ interface TestFixture {
snapshot: OrchestrationReadModel;
serverConfig: ServerConfig;
welcome: WsWelcomePayload;
projectSearchEntries: ProjectEntry[];
}

let fixture: TestFixture;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -278,6 +293,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
bootstrapProjectId: PROJECT_ID,
bootstrapThreadId: THREAD_ID,
},
projectSearchEntries: [],
};
}

Expand Down Expand Up @@ -443,7 +459,7 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
}
if (tag === WS_METHODS.projectsSearchEntries) {
return {
entries: [],
entries: fixture.projectSearchEntries,
truncated: false,
};
}
Expand Down Expand Up @@ -579,6 +595,20 @@ async function waitForComposerEditor(): Promise<HTMLElement> {
);
}

async function waitForComposerCommandList(): Promise<HTMLElement> {
return waitForElement(
() => document.querySelector<HTMLElement>('[data-slot="command-list"]'),
"Unable to find composer command list.",
);
}

async function waitForActiveComposerCommandItem(): Promise<HTMLElement> {
return waitForElement(
() => document.querySelector<HTMLElement>('[data-slot="command-item"][data-active]'),
"Unable to find active composer command item.",
);
}

async function waitForSendButton(): Promise<HTMLButtonElement> {
return waitForElement(
() => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'),
Expand Down Expand Up @@ -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";
Expand Down
41 changes: 31 additions & 10 deletions apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, HTMLDivElement>());

useEffect(() => {
if (!props.activeItemId) {
return;
}
const activeItem = itemRefs.current.get(props.activeItemId);
activeItem?.scrollIntoView({ block: "nearest" });
}, [props.activeItemId, props.items]);

return (
<Command
mode="none"
onItemHighlighted={(highlightedValue) => {
props.onHighlightedItemChange(
typeof highlightedValue === "string" ? highlightedValue : null,
);
}}
>
<Command mode="none" autoHighlight={false} keepHighlight={false} highlightItemOnHover={false}>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
<CommandList className="max-h-64">
{props.items.map((item) => (
<ComposerCommandMenuItem
key={item.id}
item={item}
itemRef={(element) => {
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}
/>
))}
Expand All @@ -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 (
<CommandItem
ref={props.itemRef}
data-active={props.isActive ? "" : undefined}
data-path={props.item.type === "path" ? props.item.path : undefined}
value={props.item.id}
className={cn(
"cursor-pointer select-none gap-2",
"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();
}}
Expand Down