Skip to content
Open
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
132 changes: 131 additions & 1 deletion 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 Down Expand Up @@ -49,6 +50,7 @@ interface TestFixture {
snapshot: OrchestrationReadModel;
serverConfig: ServerConfig;
welcome: WsWelcomePayload;
projectSearchEntries: ProjectEntry[];
}

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

Expand Down Expand Up @@ -420,7 +436,7 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
}
if (tag === WS_METHODS.projectsSearchEntries) {
return {
entries: [],
entries: fixture.projectSearchEntries,
truncated: false,
};
}
Expand Down Expand Up @@ -556,6 +572,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 @@ -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<HTMLElement>(
'[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";
Expand Down
33 changes: 15 additions & 18 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const composerMenuOpenRef = useRef(false);
const composerMenuItemsRef = useRef<ComposerCommandItem[]>([]);
const activeComposerMenuItemRef = useRef<ComposerCommandItem | null>(null);
const composerCommandInputRef = useRef<HTMLInputElement>(null);
const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef<Record<string, number>>({});
const sendInFlightRef = useRef(false);
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -3629,6 +3625,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeItemId={activeComposerMenuItem?.id ?? null}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
commandInputRef={composerCommandInputRef}
/>
</div>
)}
Expand Down
35 changes: 28 additions & 7 deletions apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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<HTMLInputElement | null>;
}) {
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"
Expand All @@ -51,11 +61,21 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
}}
>
<div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
<div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">
<CommandInput autoFocus={false} ref={props.commandInputRef} />
</div>
<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}
onSelect={props.onSelect}
Expand All @@ -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 (
<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",
props.isActive && "bg-accent text-accent-foreground",
)}
className="cursor-pointer scroll-my-2 select-none gap-2"
onMouseDown={(event) => {
event.preventDefault();
}}
Expand Down