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);
+ });
+});