feat(chat): add toolbar question directory (#1032)

This commit is contained in:
paisley
2026-05-18 16:37:29 +08:00
committed by GitHub
parent 8af070a7a0
commit a417d4ec6b
8 changed files with 405 additions and 15 deletions

View File

@@ -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"
}
}

View File

@@ -194,5 +194,10 @@
"withinTwoWeeks": "2週間以内",
"withinMonth": "1か月以内",
"older": "1か月より前"
},
"questionDirectory": {
"title": "質問目次",
"fallback": "質問 {{number}}",
"moreHint": "さらに {{count}} 件の質問は表示されていません"
}
}

View File

@@ -194,5 +194,10 @@
"withinTwoWeeks": "В течение 2 недель",
"withinMonth": "В течение месяца",
"older": "Старее месяца"
},
"questionDirectory": {
"title": "Список вопросов",
"fallback": "Вопрос {{number}}",
"moreHint": "Ещё {{count}} вопросов не показано"
}
}

View File

@@ -194,5 +194,10 @@
"withinTwoWeeks": "两周内",
"withinMonth": "一个月内",
"older": "一个月之前"
},
"questionDirectory": {
"title": "问题目录",
"fallback": "问题 {{number}}",
"moreHint": "还有 {{count}} 个问题未显示"
}
}

View File

@@ -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>

View File

@@ -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() {

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

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