feat(chat): Agentic task insights timeline + Agent Process Source panel (#3580)

This commit is contained in:
Cyrus Gray
2026-06-11 10:17:41 +05:30
committed by GitHub
parent 620bfe7b16
commit b4e64ac624
25 changed files with 929 additions and 137 deletions
+4
View File
@@ -8,6 +8,7 @@ import { getIsMobile } from './lib/platform';
import Accounts from './pages/Accounts';
import Activity from './pages/Activity';
import Brain from './pages/Brain';
import AgentInsightsPreview from './pages/dev/AgentInsightsPreview';
import Home from './pages/Home';
import Invites from './pages/Invites';
import Notifications from './pages/Notifications';
@@ -196,6 +197,9 @@ const AppRoutes = () => {
<Route path="/ptt-overlay" element={<PttOverlayPage />} />
{/* Dev-only visual preview of the Agentic task insights surface. */}
<Route path="/dev/agent-insights" element={<AgentInsightsPreview />} />
{/* Default redirect based on auth status */}
<Route path="*" element={<DefaultRedirect />} />
</Routes>
+6
View File
@@ -2741,6 +2741,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'اكتمل',
'conversations.subagent.statusFailed': 'فشل',
'conversations.subagent.statusAwaitingUser': 'في انتظار المستخدم',
'conversations.agentTaskInsights.title': 'رؤى مهام الوكيل',
'conversations.agentTaskInsights.processSourceTitle': 'مصدر عملية الوكيل',
'conversations.agentTaskInsights.stepsHeading': 'الخطوات',
'conversations.agentTaskInsights.sourcesHeading': 'المصادر',
'conversations.agentTaskInsights.noSteps': 'لم يتم تسجيل أي خطوات',
'conversations.agentTaskInsights.viewProcessSource': 'عرض مصدر عملية الوكيل الكامل',
'daemon.serviceBlockingGate.body': 'المحتوى',
'daemon.serviceBlockingGate.downloadHint': 'تلميح التنزيل',
'daemon.serviceBlockingGate.downloadLatest': 'تنزيل أحدث إصدار',
+6
View File
@@ -2797,6 +2797,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'সম্পন্ন',
'conversations.subagent.statusFailed': 'ব্যর্থ',
'conversations.subagent.statusAwaitingUser': 'ব্যবহারকারীর অপেক্ষায়',
'conversations.agentTaskInsights.title': 'এজেন্ট টাস্ক অন্তর্দৃষ্টি',
'conversations.agentTaskInsights.processSourceTitle': 'এজেন্ট প্রক্রিয়া উৎস',
'conversations.agentTaskInsights.stepsHeading': 'ধাপসমূহ',
'conversations.agentTaskInsights.sourcesHeading': 'উৎসসমূহ',
'conversations.agentTaskInsights.noSteps': 'কোনো ধাপ রেকর্ড করা হয়নি',
'conversations.agentTaskInsights.viewProcessSource': 'সম্পূর্ণ এজেন্ট প্রক্রিয়ার উৎস দেখুন',
'daemon.serviceBlockingGate.body': 'বডি',
'daemon.serviceBlockingGate.downloadHint': 'ডাউনলোড হিন্ট',
'daemon.serviceBlockingGate.downloadLatest': 'সর্বশেষ সংস্করণ ডাউনলোড করুন',
+7
View File
@@ -2867,6 +2867,13 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'abgeschlossen',
'conversations.subagent.statusFailed': 'fehlgeschlagen',
'conversations.subagent.statusAwaitingUser': 'warte auf Benutzer',
'conversations.agentTaskInsights.title': 'Agenten-Aufgabeneinblicke',
'conversations.agentTaskInsights.processSourceTitle': 'Agentenprozess-Quelle',
'conversations.agentTaskInsights.stepsHeading': 'Schritte',
'conversations.agentTaskInsights.sourcesHeading': 'Quellen',
'conversations.agentTaskInsights.noSteps': 'Keine Schritte aufgezeichnet',
'conversations.agentTaskInsights.viewProcessSource':
'Vollständige Agentenprozess-Quelle anzeigen',
'daemon.serviceBlockingGate.body': 'Körper',
'daemon.serviceBlockingGate.downloadHint': 'Hinweis herunterladen',
'daemon.serviceBlockingGate.downloadLatest': 'Lade die neueste Version herunter',
+6
View File
@@ -3333,6 +3333,12 @@ const en: TranslationMap = {
'conversations.subagent.statusCompleted': 'completed',
'conversations.subagent.statusFailed': 'failed',
'conversations.subagent.statusAwaitingUser': 'awaiting user',
'conversations.agentTaskInsights.title': 'Agentic task insights',
'conversations.agentTaskInsights.processSourceTitle': 'Agent Process Source',
'conversations.agentTaskInsights.stepsHeading': 'Steps',
'conversations.agentTaskInsights.sourcesHeading': 'Sources',
'conversations.agentTaskInsights.noSteps': 'No steps recorded',
'conversations.agentTaskInsights.viewProcessSource': 'View full agent process Source',
'daemon.serviceBlockingGate.body':
'Retrying in the background. This usually resolves in a few seconds.',
'daemon.serviceBlockingGate.downloadHint':
+7
View File
@@ -2850,6 +2850,13 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'completado',
'conversations.subagent.statusFailed': 'fallido',
'conversations.subagent.statusAwaitingUser': 'esperando al usuario',
'conversations.agentTaskInsights.title': 'Información de tareas del agente',
'conversations.agentTaskInsights.processSourceTitle': 'Fuente del proceso del agente',
'conversations.agentTaskInsights.stepsHeading': 'Pasos',
'conversations.agentTaskInsights.sourcesHeading': 'Fuentes',
'conversations.agentTaskInsights.noSteps': 'No hay pasos registrados',
'conversations.agentTaskInsights.viewProcessSource':
'Ver la fuente completa del proceso del agente',
'daemon.serviceBlockingGate.body': 'Cuerpo',
'daemon.serviceBlockingGate.downloadHint': 'Sugerencia de descarga',
'daemon.serviceBlockingGate.downloadLatest': 'Descargar la última versión',
+7
View File
@@ -2863,6 +2863,13 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'terminé',
'conversations.subagent.statusFailed': 'échoué',
'conversations.subagent.statusAwaitingUser': "en attente de l'utilisateur",
'conversations.agentTaskInsights.title': "Aperçu des tâches de l'agent",
'conversations.agentTaskInsights.processSourceTitle': "Source du processus de l'agent",
'conversations.agentTaskInsights.stepsHeading': 'Étapes',
'conversations.agentTaskInsights.sourcesHeading': 'Sources',
'conversations.agentTaskInsights.noSteps': 'Aucune étape enregistrée',
'conversations.agentTaskInsights.viewProcessSource':
"Voir la source complète du processus de l'agent",
'daemon.serviceBlockingGate.body': 'Corps',
'daemon.serviceBlockingGate.downloadHint': 'Indice de téléchargement',
'daemon.serviceBlockingGate.downloadLatest': 'Télécharger la dernière version',
+6
View File
@@ -2800,6 +2800,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'पूर्ण',
'conversations.subagent.statusFailed': 'विफल',
'conversations.subagent.statusAwaitingUser': 'उपयोगकर्ता की प्रतीक्षा',
'conversations.agentTaskInsights.title': 'एजेंट कार्य अंतर्दृष्टि',
'conversations.agentTaskInsights.processSourceTitle': 'एजेंट प्रक्रिया स्रोत',
'conversations.agentTaskInsights.stepsHeading': 'चरण',
'conversations.agentTaskInsights.sourcesHeading': 'स्रोत',
'conversations.agentTaskInsights.noSteps': 'कोई चरण दर्ज नहीं किया गया',
'conversations.agentTaskInsights.viewProcessSource': 'पूर्ण एजेंट प्रक्रिया स्रोत देखें',
'daemon.serviceBlockingGate.body': 'विवरण',
'daemon.serviceBlockingGate.downloadHint': 'डाउनलोड संकेत',
'daemon.serviceBlockingGate.downloadLatest': 'नवीनतम संस्करण डाउनलोड करें',
+6
View File
@@ -2805,6 +2805,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'selesai',
'conversations.subagent.statusFailed': 'gagal',
'conversations.subagent.statusAwaitingUser': 'menunggu pengguna',
'conversations.agentTaskInsights.title': 'Wawasan tugas agen',
'conversations.agentTaskInsights.processSourceTitle': 'Sumber proses agen',
'conversations.agentTaskInsights.stepsHeading': 'Langkah',
'conversations.agentTaskInsights.sourcesHeading': 'Sumber',
'conversations.agentTaskInsights.noSteps': 'Tidak ada langkah yang tercatat',
'conversations.agentTaskInsights.viewProcessSource': 'Lihat sumber proses agen lengkap',
'daemon.serviceBlockingGate.body': 'Isi',
'daemon.serviceBlockingGate.downloadHint': 'Petunjuk unduhan',
'daemon.serviceBlockingGate.downloadLatest': 'Unduh Versi Terbaru',
+7
View File
@@ -2843,6 +2843,13 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'completato',
'conversations.subagent.statusFailed': 'non riuscito',
'conversations.subagent.statusAwaitingUser': "in attesa dell'utente",
'conversations.agentTaskInsights.title': 'Approfondimenti attività agente',
'conversations.agentTaskInsights.processSourceTitle': "Origine del processo dell'agente",
'conversations.agentTaskInsights.stepsHeading': 'Passaggi',
'conversations.agentTaskInsights.sourcesHeading': 'Fonti',
'conversations.agentTaskInsights.noSteps': 'Nessun passaggio registrato',
'conversations.agentTaskInsights.viewProcessSource':
"Visualizza l'origine completa del processo dell'agente",
'daemon.serviceBlockingGate.body': 'Corpo',
'daemon.serviceBlockingGate.downloadHint': 'Suggerimento di download',
'daemon.serviceBlockingGate.downloadLatest': "Scarica l'ultima versione",
+6
View File
@@ -2775,6 +2775,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': '완료됨',
'conversations.subagent.statusFailed': '실패',
'conversations.subagent.statusAwaitingUser': '사용자 대기 중',
'conversations.agentTaskInsights.title': '에이전트 작업 인사이트',
'conversations.agentTaskInsights.processSourceTitle': '에이전트 프로세스 소스',
'conversations.agentTaskInsights.stepsHeading': '단계',
'conversations.agentTaskInsights.sourcesHeading': '소스',
'conversations.agentTaskInsights.noSteps': '기록된 단계 없음',
'conversations.agentTaskInsights.viewProcessSource': '전체 에이전트 프로세스 소스 보기',
'daemon.serviceBlockingGate.body': '본문',
'daemon.serviceBlockingGate.downloadHint': '다운로드 안내',
'daemon.serviceBlockingGate.downloadLatest': '최신 버전 다운로드',
+6
View File
@@ -2832,6 +2832,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'zakończono',
'conversations.subagent.statusFailed': 'niepowodzenie',
'conversations.subagent.statusAwaitingUser': 'oczekiwanie na użytkownika',
'conversations.agentTaskInsights.title': 'Wgląd w zadania agenta',
'conversations.agentTaskInsights.processSourceTitle': 'Źródło procesu agenta',
'conversations.agentTaskInsights.stepsHeading': 'Kroki',
'conversations.agentTaskInsights.sourcesHeading': 'Źródła',
'conversations.agentTaskInsights.noSteps': 'Brak zarejestrowanych kroków',
'conversations.agentTaskInsights.viewProcessSource': 'Zobacz pełne źródło procesu agenta',
'daemon.serviceBlockingGate.body':
'Rdzeń OpenHuman nie odpowiada. Spróbuj ponownie lub pobierz najnowszą wersję aplikacji.',
'daemon.serviceBlockingGate.downloadHint':
+6
View File
@@ -2849,6 +2849,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'concluído',
'conversations.subagent.statusFailed': 'falhou',
'conversations.subagent.statusAwaitingUser': 'aguardando usuário',
'conversations.agentTaskInsights.title': 'Insights de tarefas do agente',
'conversations.agentTaskInsights.processSourceTitle': 'Fonte do processo do agente',
'conversations.agentTaskInsights.stepsHeading': 'Etapas',
'conversations.agentTaskInsights.sourcesHeading': 'Fontes',
'conversations.agentTaskInsights.noSteps': 'Nenhuma etapa registrada',
'conversations.agentTaskInsights.viewProcessSource': 'Ver a fonte completa do processo do agente',
'daemon.serviceBlockingGate.body': 'Corpo',
'daemon.serviceBlockingGate.downloadHint': 'Dica de download',
'daemon.serviceBlockingGate.downloadLatest': 'Baixar Versão Mais Recente',
+6
View File
@@ -2822,6 +2822,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': 'завершено',
'conversations.subagent.statusFailed': 'ошибка',
'conversations.subagent.statusAwaitingUser': 'ожидание пользователя',
'conversations.agentTaskInsights.title': 'Сведения о задачах агента',
'conversations.agentTaskInsights.processSourceTitle': 'Источник процесса агента',
'conversations.agentTaskInsights.stepsHeading': 'Шаги',
'conversations.agentTaskInsights.sourcesHeading': 'Источники',
'conversations.agentTaskInsights.noSteps': 'Шаги не записаны',
'conversations.agentTaskInsights.viewProcessSource': 'Показать полный источник процесса агента',
'daemon.serviceBlockingGate.body': 'Текст',
'daemon.serviceBlockingGate.downloadHint': 'Подсказка по загрузке',
'daemon.serviceBlockingGate.downloadLatest': 'Скачать последнюю версию',
+6
View File
@@ -2662,6 +2662,12 @@ const messages: TranslationMap = {
'conversations.subagent.statusCompleted': '已完成',
'conversations.subagent.statusFailed': '失败',
'conversations.subagent.statusAwaitingUser': '等待用户',
'conversations.agentTaskInsights.title': '智能体任务洞察',
'conversations.agentTaskInsights.processSourceTitle': '智能体处理来源',
'conversations.agentTaskInsights.stepsHeading': '步骤',
'conversations.agentTaskInsights.sourcesHeading': '来源',
'conversations.agentTaskInsights.noSteps': '未记录任何步骤',
'conversations.agentTaskInsights.viewProcessSource': '查看完整的智能体处理来源',
'daemon.serviceBlockingGate.body': '核心服务不可用,请等待或下载最新版本。',
'daemon.serviceBlockingGate.downloadHint': '下载最新版本',
'daemon.serviceBlockingGate.downloadLatest': '下载最新版本',
+63 -37
View File
@@ -82,6 +82,7 @@ import {
AgentMessageText,
BubbleMarkdown,
} from './conversations/components/AgentMessageBubble';
import { AgentProcessSourcePanel } from './conversations/components/AgentProcessSourcePanel';
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
import { SubagentDrawer } from './conversations/components/SubagentDrawer';
import { TaskKanbanBoard } from './conversations/components/TaskKanbanBoard';
@@ -207,6 +208,9 @@ const Conversations = ({
// Sub-agent whose full live transcript is open in the drawer, keyed by the
// owning timeline row's spawn `taskId`. Null when the drawer is closed.
const [openSubagentTaskId, setOpenSubagentTaskId] = useState<string | null>(null);
// Whether the consolidated "Agent Process Source" panel is open (the full
// agent-run timeline + visited sources for the current thread).
const [showProcessSource, setShowProcessSource] = useState(false);
const [inputMode, setInputMode] = useState<InputMode>('text');
const [replyMode, setReplyMode] = useState<ReplyMode>('text');
const [isRecording, setIsRecording] = useState(false);
@@ -1728,6 +1732,16 @@ const Conversations = ({
if (citations.length === 0) return null;
return <CitationChips citations={citations} />;
})()}
{shouldRenderTimelineBeforeLatestAgentMessage &&
latestVisibleAgentMessage?.id === msg.id && (
<button
type="button"
onClick={() => setShowProcessSource(true)}
data-testid="view-process-source"
className="px-1 text-[11px] font-medium text-primary-600 hover:underline dark:text-primary-300">
{t('conversations.agentTaskInsights.viewProcessSource')}
</button>
)}
{latestVisibleMessage?.id === msg.id && (
<p className="px-1 text-[10px] text-stone-400 dark:text-neutral-500">
{formatRelativeTime(msg.createdAt)}
@@ -1989,43 +2003,50 @@ const Conversations = ({
</div>
</div>
)}
{/* Inference status indicator */}
{selectedInferenceStatus && (
<div className="flex items-center gap-2 px-1 py-1.5 text-xs text-stone-500 dark:text-neutral-400">
<span className="inline-block w-2 h-2 rounded-full bg-primary-400 animate-pulse" />
<span>
{selectedInferenceStatus.phase === 'thinking' &&
(selectedInferenceStatus.iteration > 0
? t('chat.thinkingIteration').replace(
'{n}',
String(selectedInferenceStatus.iteration)
)
: t('chat.thinkingDots'))}
{selectedInferenceStatus.phase === 'tool_use' &&
`${
formatTimelineEntry(
activeToolTimelineEntry ?? {
id: 'active-tool',
name: selectedInferenceStatus.activeTool ?? 'tool',
round: selectedInferenceStatus.iteration,
status: 'running',
}
).title
}...`}
{selectedInferenceStatus.phase === 'subagent' &&
`${
formatTimelineEntry(
activeSubagentTimelineEntry ?? {
id: 'active-subagent',
name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`,
round: selectedInferenceStatus.iteration,
status: 'running',
}
).title
}...`}
</span>
</div>
)}
{/* Inference status indicator.
For the tool_use / subagent phases this line just restates the
active row already shown in the agentic-task-insights timeline,
so suppress it once that timeline is on screen — keep it only
for the `thinking` phase (which has no timeline row yet) or when
there is no timeline to fall back on. */}
{selectedInferenceStatus &&
(selectedInferenceStatus.phase === 'thinking' ||
selectedThreadToolTimeline.length === 0) && (
<div className="flex items-center gap-2 px-1 py-1.5 text-xs text-stone-500 dark:text-neutral-400">
<span className="inline-block w-2 h-2 rounded-full bg-primary-400 animate-pulse" />
<span>
{selectedInferenceStatus.phase === 'thinking' &&
(selectedInferenceStatus.iteration > 0
? t('chat.thinkingIteration').replace(
'{n}',
String(selectedInferenceStatus.iteration)
)
: t('chat.thinkingDots'))}
{selectedInferenceStatus.phase === 'tool_use' &&
`${
formatTimelineEntry(
activeToolTimelineEntry ?? {
id: 'active-tool',
name: selectedInferenceStatus.activeTool ?? 'tool',
round: selectedInferenceStatus.iteration,
status: 'running',
}
).title
}...`}
{selectedInferenceStatus.phase === 'subagent' &&
`${
formatTimelineEntry(
activeSubagentTimelineEntry ?? {
id: 'active-subagent',
name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`,
round: selectedInferenceStatus.iteration,
status: 'running',
}
).title
}...`}
</span>
</div>
)}
{/* Tool call timeline */}
{selectedThreadToolTimeline.length > 0 &&
!shouldRenderTimelineBeforeLatestAgentMessage && (
@@ -2334,6 +2355,11 @@ const Conversations = ({
status={openSubagentEntry?.status}
onClose={() => setOpenSubagentTaskId(null)}
/>
<AgentProcessSourcePanel
open={showProcessSource}
entries={selectedThreadToolTimeline}
onClose={() => setShowProcessSource(false)}
/>
</div>
);
};
@@ -0,0 +1,147 @@
import { useEffect } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import type { ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { type AgentSource, extractAgentSources } from '../../../utils/toolTimelineFormatting';
import { AgentSparkIcon } from './AgentTimelineRail';
import { ToolTimelineBlock } from './ToolTimelineBlock';
/** Compact globe glyph for a source row. Inherits `currentColor`. */
function GlobeIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
aria-hidden
className={className}
focusable="false">
<circle cx="6" cy="6" r="5" fill="none" stroke="currentColor" strokeWidth="1" />
<path
d="M1 6h10M6 1c1.8 1.4 1.8 8.6 0 10M6 1c-1.8 1.4-1.8 8.6 0 10"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
);
}
/** One web-source row: globe + hostname title (left) + full URL (right). */
function AgentSourceRow({ source }: { source: AgentSource }) {
return (
<li>
<a
href={source.url}
target="_blank"
rel="noreferrer noopener"
className="flex items-center justify-between gap-3 rounded-md px-1.5 py-1 text-[11px] hover:bg-stone-50 dark:hover:bg-neutral-800/60"
data-testid="agent-source-row">
<span className="flex min-w-0 items-center gap-1.5">
<GlobeIcon className="shrink-0 text-stone-400 dark:text-neutral-500" />
<span className="truncate text-stone-700 dark:text-neutral-200">{source.title}</span>
</span>
<span className="shrink-0 truncate text-stone-400 dark:text-neutral-500">{source.url}</span>
</a>
</li>
);
}
/**
* The consolidated "Agent Process Source" side panel from the Figma Chat
* design — slid in from the right (~600px) when the user clicks
* "View full agent process Source →" beneath a settled answer.
*
* Unlike {@link SubagentDrawer} (which drills into one sub-agent's live
* transcript), this panel shows the *whole* run: the full agent-insights
* timeline plus the distinct web sources the agents visited. It reuses
* {@link ToolTimelineBlock} as a single source of truth.
*
* Note: this panel IS the full-processing view, so it does NOT forward an
* `onViewSubagent` handler — the rows render without the redundant
* "view full processing →" affordance.
*/
export function AgentProcessSourcePanel({
open,
entries,
onClose,
}: {
open: boolean;
entries: ToolTimelineEntry[];
onClose: () => void;
}) {
const { t } = useT();
// Close on Escape for keyboard parity with the backdrop click.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const sources = extractAgentSources(entries);
return (
<div className="fixed inset-0 z-50 flex justify-end" data-testid="agent-process-source-panel">
{/* Backdrop */}
<button
type="button"
aria-label={t('conversations.subagent.close')}
className="absolute inset-0 bg-stone-900/30 dark:bg-black/50"
onClick={onClose}
/>
<aside className="relative flex h-full w-full max-w-[600px] flex-col bg-white shadow-xl dark:bg-neutral-900">
{/* Header */}
<header className="flex items-center gap-2.5 border-b border-stone-200 px-4 py-3 dark:border-neutral-800">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-50 text-primary-500 dark:bg-primary-500/15">
<AgentSparkIcon />
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-stone-800 dark:text-neutral-100">
{t('conversations.agentTaskInsights.processSourceTitle')}
</span>
<button
type="button"
onClick={onClose}
aria-label={t('conversations.subagent.close')}
className="shrink-0 rounded-full p-1.5 text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-200">
</button>
</header>
{/* Body — the full agent timeline, then the visited sources. */}
<div className="flex-1 space-y-5 overflow-y-auto px-4 py-4">
<section>
<h3 className="mb-2 text-[10px] font-semibold tracking-wide text-stone-400 uppercase dark:text-neutral-500">
{t('conversations.agentTaskInsights.stepsHeading')}
</h3>
{entries.length > 0 ? (
<ToolTimelineBlock entries={entries} expandAllRows />
) : (
<p className="text-xs text-stone-400 italic dark:text-neutral-500">
{t('conversations.agentTaskInsights.noSteps')}
</p>
)}
</section>
{sources.length > 0 ? (
<section>
<h3 className="mb-2 text-[10px] font-semibold tracking-wide text-stone-400 uppercase dark:text-neutral-500">
{t('conversations.agentTaskInsights.sourcesHeading')}
</h3>
<ul className="space-y-0.5">
{sources.map(source => (
<AgentSourceRow key={source.id} source={source} />
))}
</ul>
</section>
) : null}
</div>
</aside>
</div>
);
}
@@ -0,0 +1,103 @@
import type { ReactNode } from 'react';
import type { ToolTimelineEntryStatus } from '../../../store/chatRuntimeSlice';
/**
* Small "spark" glyph used as each agent's node on the timeline rail —
* mirrors the Figma "Intelligence" icon. Inherits `currentColor` so the
* caller controls its tone (muted while running, solid when done).
*/
export function AgentSparkIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
aria-hidden
className={className}
focusable="false">
<path
d="M6 0.4 L7.25 4.75 L11.6 6 L7.25 7.25 L6 11.6 L4.75 7.25 L0.4 6 L4.75 4.75 Z"
fill="currentColor"
/>
</svg>
);
}
/**
* Map a timeline row's lifecycle status to the agent-name text treatment.
*
* The Figma "Agentic task insights" design conveys per-agent progress
* through the *name text* rather than a progress bar: an in-flight agent
* pulses in a muted tone, a finished agent reads solid/full-strength, and
* a failed agent is tinted with the coral error token. (Per product
* direction — no numeric progress signal exists from the core, so we never
* fabricate one.)
*/
export function agentNameTone(status: ToolTimelineEntryStatus | undefined): string {
switch (status) {
case 'success':
// Done — full-strength foreground ("full white" in the dark mockup).
return 'text-stone-700 dark:text-neutral-100';
case 'error':
return 'text-coral-600 dark:text-coral-300';
case 'awaiting_user':
return 'animate-pulse text-amber-600 dark:text-amber-300';
default:
// running / unknown — in progress: muted + blinking.
return 'animate-pulse text-stone-400 dark:text-neutral-500';
}
}
/**
* One row on the agent-insights timeline rail: a left column carrying the
* spark node icon plus the vertical connector that threads consecutive
* agents together, and an indented content column for the row body.
*
* The connector is drawn as two absolutely-positioned segments (above /
* below the icon) so the line visually breaks at each node and is clipped
* at the first/last rows — producing the continuous-but-segmented rail in
* the Figma frames. The icon sits on an opaque chip matching the chat
* surface so the line reads as passing *behind* it.
*/
export function AgentTimelineRail({
isFirst = false,
isLast = false,
icon,
iconClassName,
children,
}: {
isFirst?: boolean;
isLast?: boolean;
/** Override the default spark glyph (e.g. the "thoughts" reasoning row). */
icon?: ReactNode;
/** Tone applied to the default spark glyph. */
iconClassName?: string;
children: ReactNode;
}) {
return (
<div className="relative flex gap-2.5" data-testid="agent-timeline-row">
{/* Left rail: connector segments + spark node */}
<div className="relative flex w-3 shrink-0 justify-center">
{!isFirst ? (
<span
aria-hidden
className="absolute top-0 left-1/2 h-[9px] w-px -translate-x-1/2 bg-stone-200 dark:bg-neutral-800"
/>
) : null}
{!isLast ? (
<span
aria-hidden
className="absolute top-[9px] bottom-0 left-1/2 w-px -translate-x-1/2 bg-stone-200 dark:bg-neutral-800"
/>
) : null}
<span className="relative z-10 mt-0.5 flex h-3 w-3 items-center justify-center bg-[#f6f6f6] dark:bg-neutral-950">
{icon ?? (
<AgentSparkIcon className={iconClassName ?? 'text-stone-400 dark:text-neutral-500'} />
)}
</span>
</div>
<div className="min-w-0 flex-1 pb-2">{children}</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
import type { SubagentActivity, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { formatTimelineEntry, formatToolName } from '../../../utils/toolTimelineFormatting';
import { parseWorkerThreadRef } from '../utils/workerThreadRef';
import { agentNameTone, AgentTimelineRail } from './AgentTimelineRail';
import { WorkerThreadRefCard, type WorkerThreadStatus } from './WorkerThreadRefCard';
/**
@@ -161,121 +162,133 @@ export function SubagentActivityBlock({
);
}
function normalizeToolBody(value?: string): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
if (trimmed === '{}' || trimmed === '[]' || trimmed === 'null') return undefined;
return value;
}
/**
* Neutral surface tones for an expanded row's body (worker-thread card,
* detail bubble, code block). Per the Figma "Agentic task insights"
* design these read as plain light cards rather than status-coloured
* panels — the row's *status* is conveyed by the agent name (see
* {@link agentNameTone}), so the body stays visually quiet.
*/
const BODY_SURFACE = 'bg-stone-50 dark:bg-neutral-800/60';
/**
* The agent-run timeline rendered above an assistant answer — the
* "Agentic task insights" surface from the Figma Chat design.
*
* Each {@link ToolTimelineEntry} is a row on a shared vertical timeline
* rail ({@link AgentTimelineRail}); the agent name carries the run state
* (pulsing while in flight, solid when done) and expands in place to show
* its detail/code/sub-agent activity. The whole group sits under a
* collapsible "⚙️ Working… / Agentic task insights" header so the user can
* fold the live activity away.
*/
export function ToolTimelineBlock({
entries,
onViewSubagent,
expandAllRows = false,
}: {
entries: ToolTimelineEntry[];
/** Opens the full-transcript drawer for a subagent row. When omitted,
* subagent cards render without the "view full processing" affordance
* (e.g. interrupted-snapshot rendering with no live driver). */
onViewSubagent?: (subagent: SubagentActivity) => void;
/** Expand every row's details by default (used by the "Agent Process
* Source" panel, where the whole run should be visible at a glance).
* In the inline chat only the latest running row auto-expands. */
expandAllRows?: boolean;
}) {
const { t } = useT();
const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id;
const normalizeToolBody = (value?: string): string | undefined => {
if (!value) return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
if (trimmed === '{}' || trimmed === '[]' || trimmed === 'null') return undefined;
return value;
};
if (entries.length === 0) return null;
// The group header is a static section label — the live "working" state is
// conveyed by the pulsing agent-name rows (and the chat's own activity
// indicator), so the header does NOT repeat a "Working…" string.
return (
<div className="mb-2 space-y-1 px-1 py-0">
{entries.map(entry => {
const formatted = formatTimelineEntry(entry);
const detailContent =
normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer);
const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail);
const subagent = entry.subagent;
// A subagent row should always render the expandable details so
// its live activity is visible — even when there is no prompt
// detail to show. Mirrors the rule that a non-subagent row only
// expands when it has detail content.
const expandable = detailContent != null || subagent != null;
const shouldAutoExpand = latestRunningEntryId != null && latestRunningEntryId === entry.id;
const statusTone =
entry.status === 'running'
? {
pill: 'bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-300',
bubble: 'bg-amber-50 dark:bg-amber-500/10 text-amber-900 dark:text-amber-200',
code: 'text-amber-800 dark:text-amber-300',
chevron: 'text-amber-500 dark:text-amber-400',
}
: entry.status === 'success'
? {
pill: 'bg-sage-100 dark:bg-sage-500/20 text-sage-600 dark:text-sage-300',
bubble: 'bg-sage-50 dark:bg-sage-500/10 text-sage-900 dark:text-sage-200',
code: 'text-sage-800 dark:text-sage-300',
chevron: 'text-sage-500 dark:text-sage-400',
}
: {
pill: 'bg-coral-100 dark:bg-coral-500/20 text-coral-600 dark:text-coral-300',
bubble: 'bg-coral-50 dark:bg-coral-500/10 text-coral-900 dark:text-coral-200',
code: 'text-coral-800 dark:text-coral-300',
chevron: 'text-coral-500 dark:text-coral-400',
};
<details open className="group/insights mb-2 px-1 py-0" data-testid="agent-task-insights">
<summary className="mb-1.5 flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
<span className="text-[11px] font-medium text-stone-500 dark:text-neutral-400">
{t('conversations.agentTaskInsights.title')}
</span>
<span className="text-[9px] text-stone-400 transition-transform group-open/insights:rotate-90 dark:text-neutral-500">
</span>
</summary>
<div className="text-xs text-stone-400 dark:text-neutral-500">
{entries.map((entry, index) => {
const formatted = formatTimelineEntry(entry);
const detailContent =
normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer);
const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail);
const subagent = entry.subagent;
// A subagent row should always render the expandable details so
// its live activity is visible — even when there is no prompt
// detail to show. Mirrors the rule that a non-subagent row only
// expands when it has detail content.
const expandable = detailContent != null || subagent != null;
const shouldAutoExpand =
expandAllRows || (latestRunningEntryId != null && latestRunningEntryId === entry.id);
const nameTone = agentNameTone(entry.status);
return (
<div
key={entry.id}
className="flex flex-col gap-1 text-xs text-stone-400 dark:text-neutral-500">
{expandable ? (
<details open={shouldAutoExpand} className="ml-1 group">
<summary className="flex cursor-pointer list-none items-center gap-2 select-none marker:hidden">
<span
className={`text-[10px] transition-transform group-open:rotate-90 ${statusTone.chevron}`}>
</span>
<span className="font-medium text-stone-600 dark:text-neutral-300">
{formatted.title}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${statusTone.pill}`}>
{entry.status}
</span>
</summary>
{workerRef ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[11px] whitespace-pre-wrap break-words ${statusTone.bubble}`}>
{workerRef.before}
<WorkerThreadRefCard
ref={workerRef.ref}
status={workerStatusFromEntry(entry.status)}
return (
<AgentTimelineRail
key={entry.id}
isFirst={index === 0}
isLast={index === entries.length - 1}>
{expandable ? (
<details open={shouldAutoExpand} className="group/row">
<summary className="flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
<span className={`text-[11px] font-medium ${nameTone}`}>{formatted.title}</span>
<span className="text-[9px] text-stone-300 transition-transform group-open/row:rotate-90 dark:text-neutral-600">
</span>
</summary>
{workerRef ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[11px] whitespace-pre-wrap break-words text-stone-600 dark:text-neutral-300 ${BODY_SURFACE}`}>
{workerRef.before}
<WorkerThreadRefCard
ref={workerRef.ref}
status={workerStatusFromEntry(entry.status)}
/>
{workerRef.after ? <div className="mt-1">{workerRef.after}</div> : null}
</div>
) : formatted.detail ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[11px] whitespace-pre-wrap break-words text-stone-600 dark:text-neutral-300 ${BODY_SURFACE}`}>
{formatted.detail}
</div>
) : detailContent ? (
<pre
className={`mt-1 max-h-24 overflow-y-auto rounded px-2 py-1 font-mono text-[10px] whitespace-pre-wrap break-all text-stone-600 dark:text-neutral-300 ${BODY_SURFACE}`}>
{detailContent}
</pre>
) : null}
{subagent ? (
<SubagentActivityBlock
subagent={subagent}
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
/>
{workerRef.after ? <div className="mt-1">{workerRef.after}</div> : null}
</div>
) : formatted.detail ? (
<div
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[11px] whitespace-pre-wrap break-words ${statusTone.bubble}`}>
{formatted.detail}
</div>
) : detailContent ? (
<pre
className={`mt-1 max-h-24 overflow-y-auto rounded px-2 py-1 font-mono text-[10px] whitespace-pre-wrap break-all ${statusTone.bubble} ${statusTone.code}`}>
{detailContent}
</pre>
) : null}
{subagent ? (
<SubagentActivityBlock
subagent={subagent}
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
/>
) : null}
</details>
) : (
<div className="ml-1 flex items-center gap-2">
<span className="font-medium text-stone-600 dark:text-neutral-300">
{formatted.title}
</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${statusTone.pill}`}>
{entry.status}
</span>
</div>
)}
</div>
);
})}
</div>
) : null}
</details>
) : (
<div className="flex items-center">
<span className={`text-[11px] font-medium ${nameTone}`}>{formatted.title}</span>
</div>
)}
</AgentTimelineRail>
);
})}
</div>
</details>
);
}
@@ -0,0 +1,137 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { describe, expect, it, vi } from 'vitest';
import { store } from '../../../../store';
import type { ToolTimelineEntry } from '../../../../store/chatRuntimeSlice';
import { AgentProcessSourcePanel } from '../AgentProcessSourcePanel';
function renderPanel(ui: React.ReactNode) {
return render(<Provider store={store}>{ui}</Provider>);
}
const fetchEntry = (id: string, url: string): ToolTimelineEntry => ({
id,
name: 'web_fetch',
round: 1,
status: 'success',
argsBuffer: JSON.stringify({ url }),
});
describe('AgentProcessSourcePanel', () => {
it('renders nothing while closed', () => {
renderPanel(<AgentProcessSourcePanel open={false} entries={[]} onClose={() => {}} />);
expect(screen.queryByTestId('agent-process-source-panel')).toBeNull();
});
it('shows the panel title and steps when open', () => {
renderPanel(
<AgentProcessSourcePanel
open
entries={[fetchEntry('e1', 'https://news-gazette.com/article')]}
onClose={() => {}}
/>
);
expect(screen.getByTestId('agent-process-source-panel')).toBeInTheDocument();
expect(screen.getByText('Agent Process Source')).toBeInTheDocument();
});
it('lists the distinct web sources the agents visited (deduped by URL)', () => {
renderPanel(
<AgentProcessSourcePanel
open
entries={[
fetchEntry('e1', 'https://news-gazette.com/a'),
fetchEntry('e2', 'https://news-gazette.com/a'), // duplicate URL → collapsed
fetchEntry('e3', 'https://example.org/b'),
]}
onClose={() => {}}
/>
);
const rows = screen.getAllByTestId('agent-source-row');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('news-gazette.com');
expect(rows[1].textContent).toContain('example.org');
});
it('expands every step row by default (whole run visible at a glance)', () => {
renderPanel(
<AgentProcessSourcePanel
open
entries={[
fetchEntry('e1', 'https://news-gazette.com/a'),
fetchEntry('e2', 'https://example.org/b'),
]}
onClose={() => {}}
/>
);
const panel = screen.getByTestId('agent-process-source-panel');
const allDetails = panel.querySelectorAll('details');
// Every <details> (the group + each expandable row) is open.
expect(allDetails.length).toBeGreaterThan(1);
allDetails.forEach(d => expect(d.hasAttribute('open')).toBe(true));
});
it('never shows the "view full processing" affordance (the panel IS that view)', () => {
renderPanel(
<AgentProcessSourcePanel
open
entries={[
{
id: 'sa',
name: 'subagent:researcher',
round: 1,
status: 'success',
subagent: {
taskId: 'sub-1',
agentId: 'researcher',
toolCalls: [{ callId: 'c1', toolName: 'web_search', status: 'success' }],
},
},
]}
onClose={() => {}}
/>
);
// The subagent activity renders, but with no onView → no button.
expect(screen.getByTestId('subagent-activity')).toBeInTheDocument();
expect(screen.queryByTestId('subagent-view-processing')).toBeNull();
});
it('renders no source rows when no web tools were used', () => {
renderPanel(
<AgentProcessSourcePanel
open
entries={[{ id: 'x', name: 'file_read', round: 1, status: 'success' }]}
onClose={() => {}}
/>
);
expect(screen.queryByTestId('agent-source-row')).toBeNull();
});
it('closes via the close button', async () => {
const onClose = vi.fn();
renderPanel(<AgentProcessSourcePanel open entries={[]} onClose={onClose} />);
await userEvent.click(screen.getByText('✕'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('closes on Escape', async () => {
const onClose = vi.fn();
renderPanel(<AgentProcessSourcePanel open entries={[]} onClose={onClose} />);
await userEvent.keyboard('{Escape}');
expect(onClose).toHaveBeenCalledTimes(1);
});
it('closes on backdrop click', async () => {
const onClose = vi.fn();
const { container } = renderPanel(
<AgentProcessSourcePanel open entries={[]} onClose={onClose} />
);
// The backdrop is the first (full-bleed) Close button.
const backdrop = container.querySelector('button[aria-label="Close"]');
expect(backdrop).not.toBeNull();
await userEvent.click(backdrop as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,62 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { agentNameTone, AgentTimelineRail } from '../AgentTimelineRail';
describe('agentNameTone', () => {
it('pulses + mutes a running agent (in progress)', () => {
const tone = agentNameTone('running');
expect(tone).toContain('animate-pulse');
expect(tone).toContain('text-stone-400');
});
it('pulses an awaiting-user agent', () => {
expect(agentNameTone('awaiting_user')).toContain('animate-pulse');
});
it('renders a done agent solid (no pulse)', () => {
const tone = agentNameTone('success');
expect(tone).not.toContain('animate-pulse');
expect(tone).toContain('text-stone-700');
});
it('tints a failed agent with the error token', () => {
expect(agentNameTone('error')).toContain('coral');
});
it('treats an unknown status as in-progress', () => {
expect(agentNameTone(undefined)).toContain('animate-pulse');
});
});
describe('AgentTimelineRail', () => {
it('renders the row content and a spark node', () => {
render(
<AgentTimelineRail isFirst isLast>
<span>Research Agent</span>
</AgentTimelineRail>
);
const row = screen.getByTestId('agent-timeline-row');
expect(row.textContent).toContain('Research Agent');
expect(row.querySelector('svg')).not.toBeNull();
});
it('omits the upper connector on the first row and the lower connector on the last', () => {
const { container } = render(
<AgentTimelineRail isFirst isLast>
<span>only</span>
</AgentTimelineRail>
);
// first+last single row → no connector segments at all
expect(container.querySelectorAll('span[aria-hidden]')).toHaveLength(0);
});
it('draws both connectors on a middle row', () => {
const { container } = render(
<AgentTimelineRail isFirst={false} isLast={false}>
<span>middle</span>
</AgentTimelineRail>
);
expect(container.querySelectorAll('span[aria-hidden]')).toHaveLength(2);
});
});
@@ -147,6 +147,40 @@ describe('SubagentActivityBlock', () => {
});
});
describe('ToolTimelineBlock — agentic task insights surface', () => {
it('wraps rows in the "Agentic task insights" group and conveys run state on the name', () => {
const entries: ToolTimelineEntry[] = [
{ id: 'r', name: 'web_search', round: 1, status: 'running', argsBuffer: '{"query":"f1"}' },
{
id: 'd',
name: 'file_read',
round: 1,
status: 'success',
argsBuffer: '{"path":"/a/b.txt"}',
},
];
renderInStore(<ToolTimelineBlock entries={entries} />);
const group = screen.getByTestId('agent-task-insights');
expect(group).toBeInTheDocument();
// Static section label — NOT a duplicate "Working…" string (the live
// state lives on the pulsing row names, not the header).
expect(group.textContent).toContain('Agentic task insights');
expect(group.textContent).not.toContain('Working');
// Two rows on the timeline rail.
expect(screen.getAllByTestId('agent-timeline-row')).toHaveLength(2);
// Running row name pulses; done row name is solid.
const running = screen.getByText('Searching: f1');
const done = screen.getByText('Reading file');
expect(running.className).toContain('animate-pulse');
expect(done.className).not.toContain('animate-pulse');
});
it('renders nothing for an empty timeline', () => {
const { container } = renderInStore(<ToolTimelineBlock entries={[]} />);
expect(container.querySelector('[data-testid="agent-task-insights"]')).toBeNull();
});
});
describe('ToolTimelineBlock — subagent rendering', () => {
it('expands a subagent row even without prompt detail and shows child tool calls', () => {
const entry: ToolTimelineEntry = {
+143
View File
@@ -0,0 +1,143 @@
import { useState } from 'react';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
import { AgentProcessSourcePanel } from '../conversations/components/AgentProcessSourcePanel';
import { ToolTimelineBlock } from '../conversations/components/ToolTimelineBlock';
/**
* Dev-only visual preview of the "Agentic task insights" Chat surface.
*
* Renders {@link ToolTimelineBlock} and {@link AgentProcessSourcePanel} with
* hand-built sample timeline entries so the layout, the timeline rail, the
* name blink/done/error states, the collapsible accordion, and the source
* panel can be eyeballed under plain `pnpm dev` — no core / model / live
* agent run required. Reachable at `#/dev/agent-insights`.
*
* Not linked from any nav; throwaway harness for design review.
*/
// A live run: a mix of running (pulsing), done, errored, sub-agent, web
// sources and a code block — exercises every row variant at once.
const RUNNING_ENTRIES: ToolTimelineEntry[] = [
{
id: 's-slack',
name: 'subagent:integrations_agent',
round: 1,
status: 'running',
sourceToolName: 'slack',
subagent: {
taskId: 'sub-slack',
agentId: 'integrations_agent',
mode: 'typed',
childIteration: 6,
prompt:
'Search for any issues or problem reports raised in Slack in the last 24 hours across all channels.',
toolCalls: [
{ callId: 'c1', toolName: 'composio_list_tools', status: 'success', elapsedMs: 38400 },
{ callId: 'c2', toolName: 'slack_list_all_channels', status: 'success', elapsedMs: 2900 },
{ callId: 'c3', toolName: 'slack_fetch_conversation_history', status: 'running' },
],
},
},
{
id: 'e-search',
name: 'web_search',
round: 1,
status: 'success',
argsBuffer: JSON.stringify({ query: 'monaco gp 2026 results' }),
},
{
id: 'e-fetch1',
name: 'web_fetch',
round: 1,
status: 'success',
argsBuffer: JSON.stringify({ url: 'https://news-gazette.com/sport/f1-monaco' }),
},
{
id: 'e-fetch2',
name: 'web_fetch',
round: 1,
status: 'success',
argsBuffer: JSON.stringify({ url: 'https://example.org/standings' }),
},
{
id: 'e-shell',
name: 'shell',
round: 2,
status: 'success',
argsBuffer: JSON.stringify({ command: 'cat report.py | head -20' }),
},
{
id: 'e-err',
name: 'file_read',
round: 2,
status: 'error',
argsBuffer: JSON.stringify({ path: '/tmp/missing.txt' }),
},
];
// A settled run (all done) — every name reads solid, no pulse.
const SETTLED_ENTRIES: ToolTimelineEntry[] = RUNNING_ENTRIES.map(e => ({
...e,
status: e.status === 'error' ? 'error' : 'success',
subagent: e.subagent
? {
...e.subagent,
childIteration: undefined,
iterations: 6,
elapsedMs: 49200,
toolCalls: e.subagent.toolCalls.map(c => ({ ...c, status: 'success', elapsedMs: 2600 })),
}
: undefined,
}));
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border border-stone-200 bg-[#f6f6f6] p-4 dark:border-neutral-800 dark:bg-neutral-950">
<h2 className="mb-3 text-sm font-semibold text-stone-700 dark:text-neutral-200">{title}</h2>
{children}
</section>
);
}
export default function AgentInsightsPreview() {
const [panelOpen, setPanelOpen] = useState(false);
return (
<div className="h-full overflow-y-auto bg-white p-8 dark:bg-neutral-900">
<div className="mx-auto max-w-2xl space-y-6">
<header>
<h1 className="text-lg font-bold text-stone-900 dark:text-neutral-100">
Agentic task insights preview
</h1>
<p className="text-xs text-stone-500 dark:text-neutral-400">
Dev-only harness (#/dev/agent-insights). Sample data not a live run.
</p>
</header>
<Section title="Running — names pulse while in progress, solid when done, coral on error">
<ToolTimelineBlock entries={RUNNING_ENTRIES} onViewSubagent={() => setPanelOpen(true)} />
</Section>
<Section title="Settled — all done (solid names)">
<ToolTimelineBlock entries={SETTLED_ENTRIES} onViewSubagent={() => setPanelOpen(true)} />
</Section>
<Section title="Agent Process Source panel">
<button
type="button"
onClick={() => setPanelOpen(true)}
className="rounded-lg bg-primary-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-600">
View full agent process Source
</button>
</Section>
</div>
<AgentProcessSourcePanel
open={panelOpen}
entries={SETTLED_ENTRIES}
onClose={() => setPanelOpen(false)}
/>
</div>
);
}
+33
View File
@@ -160,6 +160,39 @@ export function promptFromArgsBuffer(argsBuffer?: string): string | undefined {
return parseToolArgs(argsBuffer)?.prompt?.trim() || undefined;
}
/** A web source an agent fetched/browsed during a run. */
export interface AgentSource {
/** Stable id (the originating timeline entry id). */
id: string;
/** Display title — the URL hostname. */
title: string;
/** Full URL. */
url: string;
}
/** Tools whose `url` arg represents a real web source the agent visited. */
const URL_SOURCE_TOOLS = new Set(['web_fetch', 'http_request', 'curl', 'browser', 'browser_open']);
/**
* Extract the distinct web sources an agent run touched, for the
* "Agent Process Source" panel. Derived from real `url` args on
* fetch/browse timeline entries — never fabricated. Deduplicated by URL,
* preserving first-seen order.
*/
export function extractAgentSources(entries: ToolTimelineEntry[]): AgentSource[] {
const seen = new Set<string>();
const sources: AgentSource[] = [];
for (const entry of entries) {
const baseName = entry.name.replace(/^subagent:/, '');
if (!URL_SOURCE_TOOLS.has(baseName)) continue;
const url = parseToolArgs(entry.argsBuffer)?.url?.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
sources.push({ id: entry.id, title: hostnameFromUrl(url) ?? url, url });
}
return sources;
}
const MAX_DETAIL_LEN = 120;
function truncateDetail(value: string): string {
+2
View File
@@ -70,6 +70,8 @@ export default defineConfig({
"src/**/types.ts",
"src/**/types/*.ts",
"src/types/**",
// Dev-only visual harnesses (not shipped, not unit-tested by design).
"src/pages/dev/**",
],
reporter: ["text", "text-summary", "html", "lcov"],
// thresholds: {