From a417d4ec6b188e435a6f5b1bbf07a7969a13e2de Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Mon, 18 May 2026 16:37:29 +0800 Subject: [PATCH] feat(chat): add toolbar question directory (#1032) --- src/i18n/locales/en/chat.json | 5 + src/i18n/locales/ja/chat.json | 5 + src/i18n/locales/ru/chat.json | 5 + src/i18n/locales/zh/chat.json | 5 + src/pages/Chat/ChatToolbar.tsx | 33 ++++- src/pages/Chat/index.tsx | 139 ++++++++++++++++++-- tests/e2e/chat-question-directory.spec.ts | 106 +++++++++++++++ tests/unit/chat-question-directory.test.tsx | 122 +++++++++++++++++ 8 files changed, 405 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/chat-question-directory.spec.ts create mode 100644 tests/unit/chat-question-directory.test.tsx diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 0b1e771..83cf31e 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -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" } } \ No newline at end of file diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index b3e081f..6dc37c9 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -194,5 +194,10 @@ "withinTwoWeeks": "2週間以内", "withinMonth": "1か月以内", "older": "1か月より前" + }, + "questionDirectory": { + "title": "質問目次", + "fallback": "質問 {{number}}", + "moreHint": "さらに {{count}} 件の質問は表示されていません" } } \ No newline at end of file diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 1696224..f9b8590 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -194,5 +194,10 @@ "withinTwoWeeks": "В течение 2 недель", "withinMonth": "В течение месяца", "older": "Старее месяца" + }, + "questionDirectory": { + "title": "Список вопросов", + "fallback": "Вопрос {{number}}", + "moreHint": "Ещё {{count}} вопросов не показано" } } \ No newline at end of file diff --git a/src/i18n/locales/zh/chat.json b/src/i18n/locales/zh/chat.json index 8679b45..a593fae 100644 --- a/src/i18n/locales/zh/chat.json +++ b/src/i18n/locales/zh/chat.json @@ -194,5 +194,10 @@ "withinTwoWeeks": "两周内", "withinMonth": "一个月内", "older": "一个月之前" + }, + "questionDirectory": { + "title": "问题目录", + "fallback": "问题 {{number}}", + "moreHint": "还有 {{count}} 个问题未显示" } } diff --git a/src/pages/Chat/ChatToolbar.tsx b/src/pages/Chat/ChatToolbar.tsx index a8f720b..3c704f6 100644 --- a/src/pages/Chat/ChatToolbar.tsx +++ b/src/pages/Chat/ChatToolbar.tsx @@ -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 (
@@ -57,6 +68,24 @@ export function ChatToolbar() { )} + + + + + +

{t('questionDirectory.title')}

+
+
{/* Refresh */} diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 94453e7..ea635c4 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -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>({}); + const [questionDirectoryOpenSessionKey, setQuestionDirectoryOpenSessionKey] = useState(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(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(() => { + 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 */}
{/* Toolbar */} -
- +
+ + setQuestionDirectoryOpenSessionKey((openSessionKey) => + openSessionKey === currentSessionKey ? null : currentSessionKey, + ) + } + />
{/* Messages Area */}
-
+
)} + {!isEmpty && questionDirectoryVisible && ( + + )} +
@@ -948,6 +996,71 @@ export function Chat() { ); } +// ── Question Directory ───────────────────────────────────────── + +function QuestionDirectory({ items }: { items: QuestionDirectoryItem[] }) { + const { t } = useTranslation('chat'); + const scrollRef = useRef(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 ( + + ); +} + // ── Welcome Screen ────────────────────────────────────────────── function WelcomeScreen() { diff --git a/tests/e2e/chat-question-directory.spec.ts b/tests/e2e/chat-question-directory.spec.ts new file mode 100644 index 0000000..15d5248 --- /dev/null +++ b/tests/e2e/chat-question-directory.spec.ts @@ -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) + .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); + } + }); +}); diff --git a/tests/unit/chat-question-directory.test.tsx b/tests/unit/chat-question-directory.test.tsx new file mode 100644 index 0000000..44f5fbd --- /dev/null +++ b/tests/unit/chat-question-directory.test.tsx @@ -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) => { + 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 } }) =>
{typeof message.content === 'string' ? message.content : ''}
, +})); + +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( + + + , + ); + + 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); + }); +});