Skip to content

Commit d2077fe

Browse files
feat(chatbot): open reasoning details in a dialog matching the XTM One web chat (#322) (#323)
* feat(chatbot): open reasoning details in a dialog matching the XTM One web chat (#322) * fix(chatbot): gate reasoning dialog on trace/transfer fields, a11y, docs (#322) Address the Copilot review pass plus re-review findings: - ChatMessages: the reasoning-details affordance now also renders when a message carries only toolCallTrace and/or transferChain (a backend can send the trace without tool_names/reasoning); added aria-label, aria-haspopup="dialog" and aria-expanded to the icon-only trigger - ReasoningDetailsDialog: the header summary count falls back to the trace length before the flat tool-name list, so it can never read "0 tool calls" above rendered trace rows; transfer-chain chips use a composite agentId-index key (the same agent can appear twice in a chain and older payloads have no agent_id) - README: document the new done-payload fields (tool_call_trace, transfer_chain, is_truncated) with the restore behavior, and the new translation keys * fix(chatbot): explicit boolean success coercion and dialog focus management (#322) Address the second Copilot review pass: - parseToolCallTrace: only a boolean `success` is honored; any missing or malformed value defaults to true explicitly (typeof check matching the file's defensive-parsing style) instead of the `!== false` comparison whose intent was easy to misread - ReasoningDetailsDialog: move initial keyboard focus to the Close button once the portal mounts (aria-modal dialogs must take focus) and hand focus back to the previously focused element - the trigger button - when the dialog closes * fix(chatbot): trap Tab focus inside the reasoning-details dialog (#322) Address the third Copilot review pass: aria-modal promises that focus stays inside the dialog, but Tab/Shift+Tab could still walk out into the panel behind the backdrop. The document keydown handler now cycles focus across the dialog's focusable elements (first <-> last, and pulls focus back in if it is outside), with dialogRef attached to the dialog element. * fix(chatbot): pretty-print trace outputs and share findChatbotRoot (#322) Address the fourth Copilot review pass: - ReasoningDetailsDialog: the Output block now goes through the same pretty-printing as Input (shared prettyTraceValue helper - compact JSON is expanded, plain text / oversized / malformed payloads stay raw), matching the XTM One web chat and the PR description - Extract findChatbotRoot to utils and reuse it from Tooltip, Dropdown and ReasoningDetailsDialog - the portal-targeting DOM walk was copy-pasted three times and could drift
1 parent eabb777 commit d2077fe

17 files changed

Lines changed: 564 additions & 80 deletions

packages/filigran-chatbot/README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,14 +256,30 @@ data: {"type": "status", "status": "analyzing"}
256256
data: {"type": "status", "status": "streaming"}
257257
data: {"type": "stream", "content": "The weather "}
258258
data: {"type": "stream", "content": "today is sunny."}
259-
data: {"type": "done", "content": "The weather today is sunny.", "conversation_id": "new-uuid", "tool_names": ["search_web"], "tool_call_count": 1, "iterations": 1, "reasoning": "Let me check the weather data first."}
259+
data: {"type": "done", "content": "The weather today is sunny.", "conversation_id": "new-uuid", "tool_names": ["search_web"], "tool_call_count": 1, "iterations": 1, "reasoning": "Let me check the weather data first.", "tool_call_trace": [{"name": "search_web", "input": "{\"query\": \"weather\"}", "output": "Sunny, 24C", "success": true}], "transfer_chain": [{"agent_id": "uuid", "agent_name": "General"}], "is_truncated": false}
260260
```
261261

262262
The optional `reasoning` field on `done` (and on restored session messages)
263263
carries the accumulated model reasoning / pre-tool preamble prose for the
264-
turn. When present it is surfaced in the per-message reasoning-details panel
264+
turn. When present it is surfaced in the per-message reasoning-details dialog
265265
(the "i" button), mirroring the XTM One web chat.
266266

267+
The reasoning-details dialog also consumes three more optional `done` fields
268+
(all parsed defensively — older backends without them fall back to the flat
269+
tool-name list and the plain "i" affordance):
270+
271+
- `tool_call_trace` — array of `{ name, input, output, success }` entries
272+
rendered as expandable rows (numbered, success/failure icon, pretty-printed
273+
JSON input, output)
274+
- `transfer_chain` — array of `{ agent_id, agent_name }` hops rendered as the
275+
agent transfer chain
276+
- `is_truncated``true` when the agent's iteration budget was exhausted;
277+
the message then shows an always-visible amber warning triangle instead of
278+
the hover-only "i" and the dialog opens with a "Turn limit reached" banner
279+
280+
The same fields are read from restored session messages, so the dialog
281+
survives a page reload.
282+
267283
#### Internal links
268284

269285
Assistant markdown links are routed through `onRelativeLinkClick` when they
@@ -435,8 +451,14 @@ function App() {
435451
- `'Browse agents'`
436452
- `'Create agent'`
437453
- `'Reasoning details'`
454+
- `'Reasoning details — turn limit reached'`
438455
- `'Model reasoning'`
439456
- `'iterations'`
457+
- `'transfer'` / `'transfers'`
458+
- `'Transfer chain'`
459+
- `'Turn limit reached.'`
460+
- `"The agent's iteration budget was exhausted - execution stopped before completing all planned steps. The final response is a best-effort summary of work done so far."`
461+
- `'Input'` / `'Output'` / `'(no output)'`
440462
- `'Download'`
441463
- `'tool call'` / `'tool calls'`
442464
- `'Uses AI. Verify results.'`

packages/filigran-chatbot/src/components/ChatMessages.tsx

Lines changed: 33 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useEffect, useRef, useState } from 'react';
22
import type { AgentStatusState, ChatAttachment, ChatMessage } from '../types';
33
import { splitFileMarkers } from '../utils';
4-
import { BrainIcon, DownloadIcon, FileIcon, InfoIcon, WrenchIcon } from './icons';
5-
import { ChatThinking, cleanReasoningText } from './ChatThinking';
4+
import { AlertTriangleIcon, DownloadIcon, FileIcon, InfoIcon } from './icons';
5+
import { ChatThinking } from './ChatThinking';
66
import { MarkdownMessage } from './MarkdownMessage';
7+
import { ReasoningDetailsDialog } from './ReasoningDetailsDialog';
78

89
interface ChatMessagesProps {
910
messages: ChatMessage[];
@@ -258,59 +259,36 @@ export const ChatMessages = ({
258259
</div>
259260
)}
260261

261-
{isAssistant && !isEmpty && !isStreamingMessage && ((msg.toolNames && msg.toolNames.length > 0) || (msg.reasoning ?? '').trim()) && (
262-
<>
263-
<button
264-
type="button"
265-
onClick={() => setToolDetailMsgId(toolDetailMsgId === msg.id ? null : msg.id)}
266-
className="mt-0.5 p-1 rounded-lg opacity-50 hover:opacity-100 hover:text-[var(--chat-accent)] transition-opacity"
267-
title={t('Reasoning details')}
268-
>
269-
<InfoIcon size={14} />
270-
</button>
271-
{toolDetailMsgId === msg.id && (
272-
<div className="mt-1.5 p-3 rounded-lg bg-gray-50 dark:bg-white/[0.04] border border-gray-200 dark:border-white/10 max-w-[90%]">
273-
{/* Header — mirrors the XTM One web chat "Reasoning details"
274-
dialog (title + iterations/calls summary) so the embedded
275-
chatbot and the native web chat read the same. */}
276-
<div className="flex items-center gap-1.5 mb-1">
277-
<WrenchIcon size={13} className="text-[var(--chat-accent)]" />
278-
<span className="text-[0.75rem] font-semibold text-gray-900 dark:text-white">{t('Reasoning details')}</span>
279-
</div>
280-
<p className="text-[0.7rem] text-gray-500 dark:text-white/40 mb-1.5">
281-
{msg.iterations && msg.iterations > 1 ? `${msg.iterations} ${t('iterations')} · ` : ''}
282-
{msg.toolCallCount ?? msg.toolNames?.length ?? 0}{' '}
283-
{(msg.toolCallCount ?? msg.toolNames?.length ?? 0) === 1 ? t('tool call') : t('tool calls')}
284-
</p>
285-
{(msg.reasoning ?? '').trim() && (
286-
<div className="mb-2">
287-
<div className="flex items-center gap-1.5 mb-1">
288-
<BrainIcon size={13} className="text-[var(--chat-accent)]/70" />
289-
<span className="text-[0.7rem] font-medium text-gray-500 dark:text-white/50">{t('Model reasoning')}</span>
290-
</div>
291-
<div className="rounded-md border border-gray-200 dark:border-white/[0.06] bg-white dark:bg-white/[0.01] px-2.5 py-2 max-h-44 overflow-y-auto">
292-
<p className="m-0 whitespace-pre-wrap break-words text-[0.7rem] leading-5 text-gray-500 dark:text-white/45">
293-
{cleanReasoningText(msg.reasoning!)}
294-
</p>
295-
</div>
296-
</div>
297-
)}
298-
{msg.toolNames && msg.toolNames.length > 0 && (
299-
<div className="flex flex-wrap gap-1">
300-
{Array.from(new Set(msg.toolNames)).map((tn) => (
301-
<span
302-
key={tn}
303-
className="inline-flex items-center px-2 py-0.5 rounded-full border border-gray-200 dark:border-white/10 text-[0.68rem] font-mono text-gray-500 dark:text-white/40"
304-
>
305-
{tn.replace(/_/g, ' ')}
306-
</span>
307-
))}
308-
</div>
309-
)}
310-
</div>
311-
)}
312-
</>
313-
)}
262+
{isAssistant &&
263+
!isEmpty &&
264+
!isStreamingMessage &&
265+
((msg.toolNames && msg.toolNames.length > 0) ||
266+
(msg.reasoning ?? '').trim() ||
267+
(msg.toolCallTrace && msg.toolCallTrace.length > 0) ||
268+
(msg.transferChain && msg.transferChain.length > 0) ||
269+
msg.isTruncated) && (
270+
<>
271+
<button
272+
type="button"
273+
onClick={() => setToolDetailMsgId(toolDetailMsgId === msg.id ? null : msg.id)}
274+
className={`mt-0.5 p-1 rounded-lg transition-opacity ${
275+
msg.isTruncated
276+
? // A truncated turn must be visible at a glance (not
277+
// gated on hover) so the user notices the warning —
278+
// mirrors the XTM One web chat affordance.
279+
'opacity-100 text-amber-500 dark:text-amber-400 hover:text-amber-600 dark:hover:text-amber-300'
280+
: 'opacity-50 hover:opacity-100 hover:text-[var(--chat-accent)]'
281+
}`}
282+
title={msg.isTruncated ? t('Reasoning details — turn limit reached') : t('Reasoning details')}
283+
aria-label={msg.isTruncated ? t('Reasoning details — turn limit reached') : t('Reasoning details')}
284+
aria-haspopup="dialog"
285+
aria-expanded={toolDetailMsgId === msg.id}
286+
>
287+
{msg.isTruncated ? <AlertTriangleIcon size={14} /> : <InfoIcon size={14} />}
288+
</button>
289+
{toolDetailMsgId === msg.id && <ReasoningDetailsDialog msg={msg} onClose={() => setToolDetailMsgId(null)} t={t} />}
290+
</>
291+
)}
314292
</div>
315293
);
316294
})}

packages/filigran-chatbot/src/components/ChatPanel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
22
import type { ChatAttachment, ChatMessage, ChatPanelProps } from '../types';
33
import { hexAlpha, identity } from '../utils';
4-
import { parseAttachments } from '../hooks/protocols/parseRestEvent';
4+
import { parseAttachments, parseToolCallTrace, parseTransferChain } from '../hooks/protocols/parseRestEvent';
55
import { useChat } from '../hooks/useChat';
66
import { useAgents } from '../hooks/useAgents';
77
import { useConversations } from '../hooks/useConversations';
@@ -308,6 +308,9 @@ export const ChatPanel: FunctionComponent<ChatPanelProps> = ({
308308
tool_call_count?: unknown;
309309
iterations?: unknown;
310310
reasoning?: unknown;
311+
tool_call_trace?: unknown;
312+
transfer_chain?: unknown;
313+
is_truncated?: unknown;
311314
},
312315
i: number,
313316
) => ({
@@ -329,6 +332,9 @@ export const ChatPanel: FunctionComponent<ChatPanelProps> = ({
329332
toolCallCount: typeof m.tool_call_count === 'number' ? m.tool_call_count : undefined,
330333
iterations: typeof m.iterations === 'number' ? m.iterations : undefined,
331334
reasoning: typeof m.reasoning === 'string' ? m.reasoning : undefined,
335+
toolCallTrace: parseToolCallTrace(m.tool_call_trace),
336+
transferChain: parseTransferChain(m.transfer_chain),
337+
isTruncated: m.is_truncated === true || undefined,
332338
}),
333339
);
334340
setMessages(restored);

packages/filigran-chatbot/src/components/Dropdown.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from 'react';
22
import { createPortal } from 'react-dom';
33
import { useClickOutside } from '../hooks/useClickOutside';
4+
import { findChatbotRoot } from '../utils';
45

56
interface DropdownProps {
67
open: boolean;
@@ -11,15 +12,6 @@ interface DropdownProps {
1112
children: React.ReactNode;
1213
}
1314

14-
function findChatbotRoot(el: HTMLElement | null): HTMLElement {
15-
let node = el;
16-
while (node) {
17-
if (node.classList.contains('filigran-chatbot')) return node;
18-
node = node.parentElement;
19-
}
20-
return document.body;
21-
}
22-
2315
export const Dropdown = ({ open, onClose, anchorRef, placement = 'bottom-start', width = 280, children }: DropdownProps) => {
2416
const panelRef = useRef<HTMLDivElement>(null);
2517
const [pos, setPos] = useState({ top: 0, left: 0 });

0 commit comments

Comments
 (0)