feat(chat): add toolbar question directory (#1032)
This commit is contained in:
@@ -194,5 +194,10 @@
|
||||
"withinTwoWeeks": "Within 2 Weeks",
|
||||
"withinMonth": "Within 1 Month",
|
||||
"older": "Older than 1 Month"
|
||||
},
|
||||
"questionDirectory": {
|
||||
"title": "Question directory",
|
||||
"fallback": "Question {{number}}",
|
||||
"moreHint": "{{count}} more questions not shown"
|
||||
}
|
||||
}
|
||||
@@ -194,5 +194,10 @@
|
||||
"withinTwoWeeks": "2週間以内",
|
||||
"withinMonth": "1か月以内",
|
||||
"older": "1か月より前"
|
||||
},
|
||||
"questionDirectory": {
|
||||
"title": "質問目次",
|
||||
"fallback": "質問 {{number}}",
|
||||
"moreHint": "さらに {{count}} 件の質問は表示されていません"
|
||||
}
|
||||
}
|
||||
@@ -194,5 +194,10 @@
|
||||
"withinTwoWeeks": "В течение 2 недель",
|
||||
"withinMonth": "В течение месяца",
|
||||
"older": "Старее месяца"
|
||||
},
|
||||
"questionDirectory": {
|
||||
"title": "Список вопросов",
|
||||
"fallback": "Вопрос {{number}}",
|
||||
"moreHint": "Ещё {{count}} вопросов не показано"
|
||||
}
|
||||
}
|
||||
@@ -194,5 +194,10 @@
|
||||
"withinTwoWeeks": "两周内",
|
||||
"withinMonth": "一个月内",
|
||||
"older": "一个月之前"
|
||||
},
|
||||
"questionDirectory": {
|
||||
"title": "问题目录",
|
||||
"fallback": "问题 {{number}}",
|
||||
"moreHint": "还有 {{count}} 个问题未显示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* entry point. Rendered in the Header when on the Chat page.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { RefreshCw, Bot, FolderTree } from 'lucide-react';
|
||||
import { RefreshCw, Bot, FolderTree, ListTree } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
@@ -14,7 +14,17 @@ import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WORKSPACE_BROWSER_ENABLED } from '@/components/file-preview/workspace-browser-config';
|
||||
|
||||
export function ChatToolbar() {
|
||||
type ChatToolbarProps = {
|
||||
questionDirectoryOpen?: boolean;
|
||||
questionDirectoryCount?: number;
|
||||
onToggleQuestionDirectory?: () => void;
|
||||
};
|
||||
|
||||
export function ChatToolbar({
|
||||
questionDirectoryOpen = false,
|
||||
questionDirectoryCount = 0,
|
||||
onToggleQuestionDirectory,
|
||||
}: ChatToolbarProps = {}) {
|
||||
const refresh = useChatStore((s) => s.refresh);
|
||||
const loading = useChatStore((s) => s.loading);
|
||||
const currentAgentId = useChatStore((s) => s.currentAgentId);
|
||||
@@ -31,6 +41,7 @@ export function ChatToolbar() {
|
||||
const currentAgentName = currentAgent?.name ?? currentAgentId;
|
||||
|
||||
const browserActive = WORKSPACE_BROWSER_ENABLED && panelOpen && panelTab === 'browser';
|
||||
const questionDirectoryAvailable = questionDirectoryCount > 1 && !!onToggleQuestionDirectory;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -57,6 +68,24 @@ export function ChatToolbar() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-testid="chat-question-directory-toggle"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-8 w-8', questionDirectoryOpen && 'bg-foreground/10 text-foreground')}
|
||||
onClick={onToggleQuestionDirectory}
|
||||
disabled={!questionDirectoryAvailable}
|
||||
aria-label={t('questionDirectory.title')}
|
||||
>
|
||||
<ListTree className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('questionDirectory.title')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Refresh */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -71,12 +71,36 @@ type UserRunCard = {
|
||||
suppressThinking: boolean;
|
||||
};
|
||||
|
||||
type QuestionDirectoryItem = {
|
||||
index: number;
|
||||
ordinal: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const QUESTION_DIRECTORY_RENDER_LIMIT = 300;
|
||||
|
||||
function getPrimaryMessageStepTexts(steps: TaskStep[]): string[] {
|
||||
return steps
|
||||
.filter((step) => step.kind === 'message' && step.parentId === 'agent-run' && !!step.detail)
|
||||
.map((step) => step.detail!);
|
||||
}
|
||||
|
||||
function buildQuestionDirectoryTitle(message: RawMessage, fallback: string): string {
|
||||
const normalized = extractText(message).replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return fallback;
|
||||
return normalized.length > 64 ? `${normalized.slice(0, 64)}…` : normalized;
|
||||
}
|
||||
|
||||
function isRealUserMessage(msg: RawMessage): boolean {
|
||||
if (msg.role !== 'user') return false;
|
||||
const content = msg.content;
|
||||
if (!Array.isArray(content)) return true;
|
||||
// If every block in the content is a tool_result, this is a Gateway
|
||||
// tool-result wrapper, not a real user message.
|
||||
const blocks = content as Array<{ type?: string }>;
|
||||
return blocks.length === 0 || !blocks.every((b) => b.type === 'tool_result');
|
||||
}
|
||||
|
||||
function generatedFileToTarget(file: GeneratedFile): FilePreviewTarget {
|
||||
return {
|
||||
filePath: file.filePath,
|
||||
@@ -142,6 +166,7 @@ export function Chat() {
|
||||
closeArtifactPanel();
|
||||
}, [currentSessionKey, closeArtifactPanel]);
|
||||
const [childTranscripts, setChildTranscripts] = useState<Record<string, RawMessage[]>>({});
|
||||
const [questionDirectoryOpenSessionKey, setQuestionDirectoryOpenSessionKey] = useState<string | null>(null);
|
||||
|
||||
// Callback for file cards in chat messages — opens the in-app preview
|
||||
// panel instead of the system default editor.
|
||||
@@ -278,21 +303,15 @@ export function Chat() {
|
||||
|
||||
const isEmpty = messages.length === 0 && !sending;
|
||||
const showScrollToLatest = !isEmpty && !isAtBottom;
|
||||
const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message));
|
||||
const subagentCompletionInfos = useMemo(
|
||||
() => messages.map((message) => parseSubagentCompletionInfo(message)),
|
||||
[messages],
|
||||
);
|
||||
// Build an index of the *next* real user message after each position.
|
||||
// Gateway history may contain `role: 'user'` messages that are actually
|
||||
// tool-result wrappers (Anthropic API format). These must NOT split
|
||||
// the run into multiple segments — only genuine user-authored messages
|
||||
// should act as run boundaries.
|
||||
const isRealUserMessage = (msg: RawMessage): boolean => {
|
||||
if (msg.role !== 'user') return false;
|
||||
const content = msg.content;
|
||||
if (!Array.isArray(content)) return true;
|
||||
// If every block in the content is a tool_result, this is a Gateway
|
||||
// tool-result wrapper, not a real user message.
|
||||
const blocks = content as Array<{ type?: string }>;
|
||||
return blocks.length === 0 || !blocks.every((b) => b.type === 'tool_result');
|
||||
};
|
||||
const nextUserMessageIndexes = new Array<number>(messages.length).fill(-1);
|
||||
let nextUserMessageIndex = -1;
|
||||
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
||||
@@ -302,6 +321,23 @@ export function Chat() {
|
||||
}
|
||||
}
|
||||
|
||||
const questionDirectoryItems = useMemo<QuestionDirectoryItem[]>(() => {
|
||||
const items: QuestionDirectoryItem[] = [];
|
||||
let questionOrdinal = 0;
|
||||
messages.forEach((message, index) => {
|
||||
if (!isRealUserMessage(message) || subagentCompletionInfos[index]) return;
|
||||
questionOrdinal += 1;
|
||||
items.push({
|
||||
index,
|
||||
ordinal: questionOrdinal,
|
||||
title: buildQuestionDirectoryTitle(message, t('questionDirectory.fallback', { number: questionOrdinal })),
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}, [messages, subagentCompletionInfos, t]);
|
||||
|
||||
const questionDirectoryVisible = questionDirectoryOpenSessionKey === currentSessionKey && questionDirectoryItems.length > 1;
|
||||
|
||||
// Indices of intermediate assistant process messages that are represented
|
||||
// in the ExecutionGraphCard (narration text and/or thinking). We suppress
|
||||
// them from the chat stream so they don't appear duplicated below the graph.
|
||||
@@ -694,13 +730,21 @@ export function Chat() {
|
||||
{/* Left column: chat */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center justify-end px-4 py-2">
|
||||
<ChatToolbar />
|
||||
<div className="no-drag relative z-20 flex shrink-0 items-center justify-end px-4 py-2">
|
||||
<ChatToolbar
|
||||
questionDirectoryOpen={questionDirectoryVisible}
|
||||
questionDirectoryCount={questionDirectoryItems.length}
|
||||
onToggleQuestionDirectory={() =>
|
||||
setQuestionDirectoryOpenSessionKey((openSessionKey) =>
|
||||
openSessionKey === currentSessionKey ? null : currentSessionKey,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden px-4 py-4">
|
||||
<div className="mx-auto flex h-full min-h-0 max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-7xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
||||
<div ref={scrollRef} className="min-h-0 min-w-0 flex-1 overflow-y-auto" data-testid="chat-scroll-container">
|
||||
<div
|
||||
ref={contentRef}
|
||||
@@ -862,6 +906,10 @@ export function Chat() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isEmpty && questionDirectoryVisible && (
|
||||
<QuestionDirectory items={questionDirectoryItems} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -948,6 +996,71 @@ export function Chat() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question Directory ─────────────────────────────────────────
|
||||
|
||||
function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const scrollRef = useRef<HTMLElement | null>(null);
|
||||
const visibleItems = items.slice(0, QUESTION_DIRECTORY_RENDER_LIMIT);
|
||||
const hiddenCount = Math.max(0, items.length - visibleItems.length);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}, [visibleItems.length]);
|
||||
|
||||
const handleJumpToMessage = (index: number) => {
|
||||
document.getElementById(`chat-message-${index}`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="chat-question-directory"
|
||||
className="w-full shrink-0 lg:w-64 xl:w-72"
|
||||
aria-label={t('questionDirectory.title')}
|
||||
>
|
||||
<div className="sticky top-2 max-h-full overflow-hidden rounded-2xl border border-black/5 bg-black/[0.02] p-3 shadow-sm dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 px-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('questionDirectory.title')}
|
||||
</h2>
|
||||
<span className="rounded-full bg-black/5 px-2 py-0.5 text-2xs font-medium text-muted-foreground dark:bg-white/10">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<nav ref={scrollRef} className="max-h-[calc(100vh-13rem)] space-y-1 overflow-y-auto pr-1">
|
||||
{visibleItems.map((item) => (
|
||||
<button
|
||||
key={item.index}
|
||||
type="button"
|
||||
data-testid={`chat-question-directory-item-${item.index}`}
|
||||
onClick={() => handleJumpToMessage(item.index)}
|
||||
className={cn(
|
||||
'group flex w-full items-start gap-2 rounded-xl px-2 py-2 text-left transition-colors',
|
||||
'text-foreground/70 hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
|
||||
)}
|
||||
title={item.title}
|
||||
>
|
||||
<span className="line-clamp-2 min-w-0 text-xs leading-5">
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<div className="px-2 py-2 text-xs leading-5 text-muted-foreground">
|
||||
{t('questionDirectory.moreHint', { count: hiddenCount })}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Welcome Screen ──────────────────────────────────────────────
|
||||
|
||||
function WelcomeScreen() {
|
||||
|
||||
106
tests/e2e/chat-question-directory.spec.ts
Normal file
106
tests/e2e/chat-question-directory.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
|
||||
|
||||
const SESSION_KEY = 'agent:main:main';
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
const longAnswer = [
|
||||
'This answer intentionally contains enough text to make the chat scrollable in the Electron window.',
|
||||
'It gives the question directory a meaningful target to jump to when the user selects an entry.',
|
||||
'The content itself is not important; the test only verifies that the in-chat question outline remains visible and clickable.',
|
||||
].join(' ');
|
||||
|
||||
const seededHistory = [
|
||||
{ role: 'user', content: 'First question: summarize the market opening.', timestamp: 1000 },
|
||||
{ role: 'assistant', content: `${longAnswer}\n\n${longAnswer}\n\n${longAnswer}`, timestamp: 1001 },
|
||||
{ role: 'user', content: 'Second question: list the strongest sectors.', timestamp: 1002 },
|
||||
{ role: 'assistant', content: `${longAnswer}\n\n${longAnswer}\n\n${longAnswer}`, timestamp: 1003 },
|
||||
{ role: 'user', content: 'Third question: explain notable risks.', timestamp: 1004 },
|
||||
{ role: 'assistant', content: `${longAnswer}\n\n${longAnswer}\n\n${longAnswer}`, timestamp: 1005 },
|
||||
{ role: 'user', content: 'Fourth question: prepare the final action plan.', timestamp: 1006 },
|
||||
{ role: 'assistant', content: 'Here is the final action plan.', timestamp: 1007 },
|
||||
];
|
||||
|
||||
test.describe('ClawX chat question directory', () => {
|
||||
test('shows a toolbar button that opens a clickable in-conversation question directory', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
|
||||
success: true,
|
||||
result: { messages: seededHistory },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
|
||||
success: true,
|
||||
result: { messages: seededHistory },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345 },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: {
|
||||
success: true,
|
||||
agents: [{ id: 'main', name: 'main' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
|
||||
const toggle = page.getByTestId('chat-question-directory-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
|
||||
const directory = page.getByTestId('chat-question-directory');
|
||||
await expect(directory).toBeVisible({ timeout: 30_000 });
|
||||
await expect(directory).toContainText('Question directory');
|
||||
await expect(directory).toContainText('First question: summarize the market opening.');
|
||||
await expect(directory).toContainText('Fourth question: prepare the final action plan.');
|
||||
await expect(directory.locator('button')).toHaveCount(4);
|
||||
|
||||
await page.getByTestId('chat-question-directory-item-6').click();
|
||||
await expect(page.getByTestId('chat-message-6')).toBeInViewport();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
122
tests/unit/chat-question-directory.test.tsx
Normal file
122
tests/unit/chat-question-directory.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Chat } from '@/pages/Chat';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: string | Record<string, unknown>) => {
|
||||
if (typeof options === 'string') return options;
|
||||
if (key === 'questionDirectory.fallback') return `Question ${String(options?.number ?? '')}`;
|
||||
if (key === 'questionDirectory.moreHint') return `${String(options?.count ?? '')} more questions not shown`;
|
||||
if (key === 'toolbar.currentAgent') return `Talking to ${String(options?.agent ?? '')}`;
|
||||
return typeof options?.defaultValue === 'string' ? options.defaultValue : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: (selector: (state: { status: { state: string; gatewayReady: boolean } }) => unknown) => selector({
|
||||
status: { state: 'running', gatewayReady: true },
|
||||
}),
|
||||
}));
|
||||
|
||||
const chatState = {
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: 'reply 1' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: 'reply 2' },
|
||||
],
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessionLabels: {},
|
||||
loading: false,
|
||||
loadingMoreHistory: false,
|
||||
hasMoreHistory: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
runError: null,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
activeRunId: null,
|
||||
sendMessage: vi.fn(),
|
||||
abortRun: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
loadMoreHistory: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
cleanupEmptySession: vi.fn(),
|
||||
lastUserMessageAt: null,
|
||||
};
|
||||
|
||||
vi.mock('@/stores/chat', () => ({
|
||||
useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: (selector: (state: { agents: Array<{ id: string; name: string; workspace: string }>; fetchAgents: () => void }) => unknown) => selector({
|
||||
agents: [{ id: 'main', name: 'main', workspace: '/workspace' }],
|
||||
fetchAgents: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/artifact-panel', () => ({
|
||||
useArtifactPanel: (selector: (state: { open: boolean; widthPct: number; openChanges: () => void; openPreview: () => void; close: () => void; openBrowser: () => void; tab: string }) => unknown) => selector({
|
||||
open: false,
|
||||
widthPct: 34,
|
||||
openChanges: vi.fn(),
|
||||
openPreview: vi.fn(),
|
||||
close: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
tab: 'changes',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
useStickToBottomInstant: () => ({
|
||||
contentRef: { current: null },
|
||||
scrollRef: { current: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({
|
||||
ChatInput: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatMessage', () => ({
|
||||
ChatMessage: ({ message }: { message: { content?: unknown } }) => <div>{typeof message.content === 'string' ? message.content : ''}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/file-preview/ArtifactPanel', () => ({
|
||||
ArtifactPanel: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/file-preview/PanelResizeDivider', () => ({
|
||||
PanelResizeDivider: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ExecutionGraphCard', () => ({
|
||||
ExecutionGraphCard: () => null,
|
||||
}));
|
||||
|
||||
describe('Chat question directory', () => {
|
||||
it('keeps real repeated questions as separate directory entries', async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Chat />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('chat-question-directory-toggle'));
|
||||
|
||||
const directory = await screen.findByTestId('chat-question-directory');
|
||||
expect(directory).toBeInTheDocument();
|
||||
expect(directory.querySelectorAll('button')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user