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
156 changes: 156 additions & 0 deletions apps/vscode/test/webview/questions-history.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { QuestionsHistoryMenu } from '@/components/QuestionsHistoryMenu';
import type { ChatMessage } from '@/stores/chat.store';
import {
collapseWhitespace,
collectQuestionItems,
filterQuestions,
findCurrentQuestionIndex,
useQuestionsHistory,
type QuestionItem,
} from '@/components/inputarea/hooks/useQuestionsHistory';

const items: QuestionItem[] = [
{ id: 'a', text: 'fix the login bug' },
{ id: 'b', text: 'add unit tests' },
{ id: 'c', text: 'Fix the build' },
];

describe('collapseWhitespace', () => {
it('collapses newlines and repeated spaces', () => {
expect(collapseWhitespace(' hello\n\nworld\t ! ')).toBe('hello world !');
});
});

const msg = (m: Partial<ChatMessage> & Pick<ChatMessage, 'id' | 'role'>): ChatMessage => ({ content: '', timestamp: 0, ...m });

describe('collectQuestionItems', () => {
it('collects user messages and steer inputs in conversation order', () => {
const messages = [
msg({ id: 'u1', role: 'user', content: 'first question' }),
msg({ id: 'a1', role: 'assistant', steps: [{ n: 1, items: [{ type: 'steer', content: 'mid-turn\nfollow up' }] }] }),
msg({ id: 'u2', role: 'user', content: ' second question ' }),
];
expect(collectQuestionItems(messages)).toEqual([
{ id: 'u1', text: 'first question' },
{ id: 'a1-steer-1-0', text: 'mid-turn follow up' },
{ id: 'u2', text: 'second question' },
]);
});

it('skips empty messages and non-steer step items', () => {
const messages = [
msg({ id: 'u1', role: 'user', content: ' ' }),
msg({ id: 'a1', role: 'assistant', steps: [{ n: 1, items: [{ type: 'text', content: 'answer' }] }] }),
];
expect(collectQuestionItems(messages)).toEqual([]);
});
});

describe('filterQuestions', () => {
it('returns all items for an empty query', () => {
expect(filterQuestions(items, '', new Set())).toHaveLength(3);
});

it('matches case-insensitive substrings', () => {
const result = filterQuestions(items, 'fix', new Set());
expect(result.map((it) => it.id)).toEqual(['a', 'c']);
});

it('excludes hidden ids', () => {
const result = filterQuestions(items, '', new Set(['b']));
expect(result.map((it) => it.id)).toEqual(['a', 'c']);
});
});

describe('findCurrentQuestionIndex', () => {
it('returns -1 for no items', () => {
expect(findCurrentQuestionIndex([])).toBe(-1);
});

it('falls back to the last item when no element is found', () => {
expect(findCurrentQuestionIndex(items)).toBe(2);
});
});

describe('useQuestionsHistory', () => {
it('filters visible items by query and resets the active index', async () => {
const { result } = renderHook(() => useQuestionsHistory(items));
await act(() => result.current.moveActive(1));
expect(result.current.activeIndex).toBe(0);
await act(() => result.current.setQuery('tests'));
expect(result.current.visibleItems.map((it) => it.id)).toEqual(['b']);
expect(result.current.activeIndex).toBe(-1);
});

it('wraps around when moving past either end', async () => {
const { result } = renderHook(() => useQuestionsHistory(items));
await act(() => result.current.moveActive(1));
expect(result.current.activeIndex).toBe(0);
await act(() => result.current.moveActive(-1));
expect(result.current.activeIndex).toBe(2);
await act(() => result.current.moveActive(1));
expect(result.current.activeIndex).toBe(0);
});

it('hides an item for the rest of the session', async () => {
const { result } = renderHook(() => useQuestionsHistory(items));
await act(() => result.current.hide('a'));
expect(result.current.visibleItems.map((it) => it.id)).toEqual(['b', 'c']);
expect(result.current.activeIndex).toBe(-1);
});
});

describe('QuestionsHistoryMenu', () => {
function renderMenu(overrides: Partial<Parameters<typeof QuestionsHistoryMenu>[0]> = {}) {
const props = {
items,
totalCount: items.length,
query: '',
activeIndex: -1,
onQueryChange: vi.fn(),
onSelect: vi.fn(),
onHide: vi.fn(),
onHover: vi.fn(),
onMoveActive: vi.fn(),
onClose: vi.fn(),
...overrides,
};
render(<QuestionsHistoryMenu {...props} />);
return props;
}

it('shows an empty-state hint when there are no questions', () => {
renderMenu({ items: [], totalCount: 0 });
expect(screen.getByText('No questions yet in this session')).toBeTruthy();
});

it('shows a no-match hint when filtering excludes everything', () => {
renderMenu({ items: [], totalCount: 3 });
expect(screen.getByText('No matches')).toBeTruthy();
});

it('selects the single matching item on Enter without an active selection', async () => {
const props = renderMenu({ items: [items[1]] });
await userEvent.type(screen.getByRole('textbox'), '{Enter}');
expect(props.onSelect).toHaveBeenCalledWith(items[1]);
});

it('delegates arrow keys and Escape', async () => {
const props = renderMenu();
const box = screen.getByRole('textbox');
await userEvent.type(box, '{ArrowDown}{ArrowUp}{Escape}');
expect(props.onMoveActive).toHaveBeenCalledWith(1);
expect(props.onMoveActive).toHaveBeenCalledWith(-1);
expect(props.onClose).toHaveBeenCalled();
});

it('hides an item via its hide button without selecting it', async () => {
const props = renderMenu();
await userEvent.click(screen.getAllByTitle('Hide for this session')[0]);
expect(props.onHide).toHaveBeenCalledWith('a');
expect(props.onSelect).not.toHaveBeenCalled();
});
});
19 changes: 10 additions & 9 deletions apps/vscode/webview-ui/src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { toast } from "@/components/ui/sonner";
import { useChatStore } from "@/stores";
import { bridge } from "@/services";
import type { ChatMessage as ChatMessageType, UIStep, UIStepItem } from "@/stores/chat.store";
import { steerAnchorId } from "./inputarea/hooks/useQuestionsHistory";
import type { ContentPart } from "shared/legacy-sdk";

interface ChatMessageProps {
Expand All @@ -35,18 +36,18 @@ function ThinkingIndicator() {
);
}

function SteerBubble({ content }: { content: string | ContentPart[] }) {
function SteerBubble({ content, anchorId }: { content: string | ContentPart[]; anchorId?: string }) {
const text = typeof content === "string" ? content : Content.getText(content);
return (
<div className="flex justify-end my-1">
<div className="flex justify-end my-1 scroll-mt-16" data-message-id={anchorId}>
<div className="max-w-[85%] px-3 py-1 rounded-2xl rounded-br-md bg-zinc-100 dark:bg-zinc-800 text-foreground">
<p className="text-xs leading-relaxed">{text}</p>
</div>
</div>
);
}

function StepItemRenderer({ item }: { item: UIStepItem }) {
function StepItemRenderer({ item, anchorId }: { item: UIStepItem; anchorId?: string }) {
switch (item.type) {
case "thinking":
return <ThinkingBlock content={item.content} finished={item.finished} />;
Expand All @@ -57,13 +58,13 @@ function StepItemRenderer({ item }: { item: UIStepItem }) {
case "compaction":
return <CompactionCard />;
case "steer":
return <SteerBubble content={item.content} />;
return <SteerBubble content={item.content} anchorId={anchorId} />;
default:
return null;
}
}

function StepContent({ step, showConnector }: { step: UIStep; showConnector?: boolean }) {
function StepContent({ step, showConnector, messageId }: { step: UIStep; showConnector?: boolean; messageId?: string }) {
const hasItems = step.items.length > 0;
const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction");
const showIndicator = hasToolOrThinking;
Expand Down Expand Up @@ -95,7 +96,7 @@ function StepContent({ step, showConnector }: { step: UIStep; showConnector?: bo
)}
<div className="flex-1 min-w-0 space-y-2">
{step.items.map((item, idx) => (
<StepItemRenderer key={`${step.n}-${idx}`} item={item} />
<StepItemRenderer key={`${step.n}-${idx}`} item={item} anchorId={item.type === "steer" && messageId ? steerAnchorId(messageId, step.n, idx) : undefined} />
))}
</div>
</div>
Expand Down Expand Up @@ -217,7 +218,7 @@ function UserMessage({ message }: { message: ChatMessageType }) {
const videos = Content.getVideos(message.content);

return (
<div className="px-3 pt-3 pb-1 flex justify-end">
<div className="px-3 pt-3 pb-1 flex justify-end scroll-mt-16" data-message-id={message.id}>
<div className={cn("max-w-[85%] px-3.5 py-1.5 rounded-2xl rounded-br-md", "bg-zinc-100 dark:bg-zinc-800", "text-foreground")}>
{displayContent && (
// FIX: removed whitespace-pre-wrap — it conflicted with ReactMarkdown's
Expand Down Expand Up @@ -249,7 +250,7 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe
if (!hasSteps) {
return typeof message.content === "string" ? message.content : "";
}
const lastStep = steps[steps.length - 1];
const lastStep = steps.at(-1);
const textItems = lastStep.items.filter((item) => item.type === "text");
if (textItems.length > 0) {
return textItems.map((item) => (item as { type: "text"; content: string }).content).join("\n");
Expand Down Expand Up @@ -285,7 +286,7 @@ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMe
const hasIndicator = stepHasIndicator[globalIndex];
const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean);
const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall;
return <StepContent key={step.n} step={step} showConnector={showConnector} />;
return <StepContent key={step.n} step={step} showConnector={showConnector} messageId={message.id} />;
});

if (group.planMode) {
Expand Down
106 changes: 106 additions & 0 deletions apps/vscode/webview-ui/src/components/QuestionsHistoryMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useEffect, useRef } from "react";
import { IconX } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import type { QuestionItem } from "./inputarea/hooks/useQuestionsHistory";

interface QuestionsHistoryMenuProps {
items: QuestionItem[];
totalCount: number;
query: string;
activeIndex: number;
onQueryChange: (query: string) => void;
onSelect: (item: QuestionItem) => void;
onHide: (id: string) => void;
onHover: (index: number) => void;
onMoveActive: (delta: number) => void;
onClose: () => void;
}

export function QuestionsHistoryMenu({ items, totalCount, query, activeIndex, onQueryChange, onSelect, onHide, onHover, onMoveActive, onClose }: QuestionsHistoryMenuProps) {
const selectedRef = useRef<HTMLButtonElement>(null);
const hoverSelectionRef = useRef<number | null>(null);

useEffect(() => {
if (hoverSelectionRef.current === activeIndex) {
hoverSelectionRef.current = null;
return;
}
hoverSelectionRef.current = null;
selectedRef.current?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);

const handleHover = (index: number) => {
hoverSelectionRef.current = index;
onHover(index);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
onMoveActive(1);
break;
case "ArrowUp":
e.preventDefault();
onMoveActive(-1);
break;
case "Enter": {
e.preventDefault();
const target = items[activeIndex] ?? (items.length === 1 ? items[0] : undefined);
if (target) {
onSelect(target);
}
break;
}
case "Escape":
e.preventDefault();
onClose();
break;
}
};

return (
<div className="rounded-md border bg-popover shadow-md overflow-hidden flex flex-col max-h-[40vh]">
<input
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Filter questions… (↑↓ select, Enter jump, Esc close)"
className="px-2 py-1.5 text-xs bg-transparent outline-none border-b placeholder:text-muted-foreground"
/>
<div className="overflow-y-auto min-h-0">
{items.length === 0 ? (
<div className="p-3 text-xs text-muted-foreground text-center">
{totalCount === 0 ? "No questions yet in this session" : "No matches"}
</div>
) : (
items.map((item, idx) => (
<button
key={item.id}
ref={idx === activeIndex ? selectedRef : null}
onClick={() => onSelect(item)}
onMouseMove={() => handleHover(idx)}
className={cn("w-full px-2 py-1.5 text-left flex items-center justify-between gap-2 group", idx === activeIndex ? "bg-accent" : "hover:bg-accent/50")}
>
<span className="text-xs truncate" title={item.text}>
{item.text}
</span>
<span
role="button"
title="Hide for this session"
onClick={(e) => {
e.stopPropagation();
onHide(item.id);
}}
className="shrink-0 invisible group-hover:visible rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<IconX className="size-3" />
</span>
</button>
))
)}
</div>
</div>
);
}
Loading