From b4e64ac6244ecb91e4d5e3294f062b8bf72c2fc1 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:17:41 +0530 Subject: [PATCH] feat(chat): Agentic task insights timeline + Agent Process Source panel (#3580) --- app/src/AppRoutes.tsx | 4 + app/src/lib/i18n/ar.ts | 6 + app/src/lib/i18n/bn.ts | 6 + app/src/lib/i18n/de.ts | 7 + app/src/lib/i18n/en.ts | 6 + app/src/lib/i18n/es.ts | 7 + app/src/lib/i18n/fr.ts | 7 + app/src/lib/i18n/hi.ts | 6 + app/src/lib/i18n/id.ts | 6 + app/src/lib/i18n/it.ts | 7 + app/src/lib/i18n/ko.ts | 6 + app/src/lib/i18n/pl.ts | 6 + app/src/lib/i18n/pt.ts | 6 + app/src/lib/i18n/ru.ts | 6 + app/src/lib/i18n/zh-CN.ts | 6 + app/src/pages/Conversations.tsx | 100 +++++--- .../components/AgentProcessSourcePanel.tsx | 147 ++++++++++++ .../components/AgentTimelineRail.tsx | 103 +++++++++ .../components/ToolTimelineBlock.tsx | 213 ++++++++++-------- .../AgentProcessSourcePanel.test.tsx | 137 +++++++++++ .../__tests__/AgentTimelineRail.test.tsx | 62 +++++ .../__tests__/ToolTimelineBlock.test.tsx | 34 +++ app/src/pages/dev/AgentInsightsPreview.tsx | 143 ++++++++++++ app/src/utils/toolTimelineFormatting.ts | 33 +++ app/test/vitest.config.ts | 2 + 25 files changed, 929 insertions(+), 137 deletions(-) create mode 100644 app/src/pages/conversations/components/AgentProcessSourcePanel.tsx create mode 100644 app/src/pages/conversations/components/AgentTimelineRail.tsx create mode 100644 app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx create mode 100644 app/src/pages/conversations/components/__tests__/AgentTimelineRail.test.tsx create mode 100644 app/src/pages/dev/AgentInsightsPreview.tsx diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 90eb96db1..3e85a8969 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -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 = () => { } /> + {/* Dev-only visual preview of the Agentic task insights surface. */} + } /> + {/* Default redirect based on auth status */} } /> diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index fd3e8ebc6..505813534 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'تنزيل أحدث إصدار', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index f7aab7761..ca8f68952 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'সর্বশেষ সংস্করণ ডাউনলোড করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4db6dbe52..c413038f3 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 8ee1d2054..c63556193 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d0a8b14c5..f4d4ae699 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 31577b572..7ac0abee5 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0d481d520..2dc92ee25 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'नवीनतम संस्करण डाउनलोड करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 26cd4d2ce..13dba411a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 89acd4911..65d1e66f7 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6a99c8577..45317eb5c 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '최신 버전 다운로드', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index f59c3131a..9a5ea5a8b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 6365cec8f..3d01393bd 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 781beac5a..0fec4603f 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Скачать последнюю версию', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d6cc9d474..4d8de78ba 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '下载最新版本', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index f52a76b2f..eab790f56 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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(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('text'); const [replyMode, setReplyMode] = useState('text'); const [isRecording, setIsRecording] = useState(false); @@ -1728,6 +1732,16 @@ const Conversations = ({ if (citations.length === 0) return null; return ; })()} + {shouldRenderTimelineBeforeLatestAgentMessage && + latestVisibleAgentMessage?.id === msg.id && ( + + )} {latestVisibleMessage?.id === msg.id && (

{formatRelativeTime(msg.createdAt)} @@ -1989,43 +2003,50 @@ const Conversations = ({ )} - {/* Inference status indicator */} - {selectedInferenceStatus && ( -

- - - {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 - }...`} - -
- )} + {/* 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) && ( +
+ + + {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 + }...`} + +
+ )} {/* Tool call timeline */} {selectedThreadToolTimeline.length > 0 && !shouldRenderTimelineBeforeLatestAgentMessage && ( @@ -2334,6 +2355,11 @@ const Conversations = ({ status={openSubagentEntry?.status} onClose={() => setOpenSubagentTaskId(null)} /> + setShowProcessSource(false)} + /> ); }; diff --git a/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx b/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx new file mode 100644 index 000000000..cc0306da0 --- /dev/null +++ b/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx @@ -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 ( + + + + + ); +} + +/** One web-source row: globe + hostname title (left) + full URL (right). */ +function AgentSourceRow({ source }: { source: AgentSource }) { + return ( +
  • + + + + {source.title} + + {source.url} + +
  • + ); +} + +/** + * 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 ( +
    + {/* Backdrop */} + + + + {/* Body — the full agent timeline, then the visited sources. */} +
    +
    +

    + {t('conversations.agentTaskInsights.stepsHeading')} +

    + {entries.length > 0 ? ( + + ) : ( +

    + {t('conversations.agentTaskInsights.noSteps')} +

    + )} +
    + + {sources.length > 0 ? ( +
    +

    + {t('conversations.agentTaskInsights.sourcesHeading')} +

    +
      + {sources.map(source => ( + + ))} +
    +
    + ) : null} +
    + +
    + ); +} diff --git a/app/src/pages/conversations/components/AgentTimelineRail.tsx b/app/src/pages/conversations/components/AgentTimelineRail.tsx new file mode 100644 index 000000000..d41b17cb6 --- /dev/null +++ b/app/src/pages/conversations/components/AgentTimelineRail.tsx @@ -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 ( + + + + ); +} + +/** + * 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 ( +
    + {/* Left rail: connector segments + spark node */} +
    + {!isFirst ? ( + + ) : null} + {!isLast ? ( + + ) : null} + + {icon ?? ( + + )} + +
    +
    {children}
    +
    + ); +} diff --git a/app/src/pages/conversations/components/ToolTimelineBlock.tsx b/app/src/pages/conversations/components/ToolTimelineBlock.tsx index e4d8b37bb..01703ae6b 100644 --- a/app/src/pages/conversations/components/ToolTimelineBlock.tsx +++ b/app/src/pages/conversations/components/ToolTimelineBlock.tsx @@ -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 ( -
    - {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', - }; +
    + + + {t('conversations.agentTaskInsights.title')} + + + ▶ + + +
    + {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 ( -
    - {expandable ? ( -
    - - - ▶ - - - {formatted.title} - - - {entry.status} - - - {workerRef ? ( -
    - {workerRef.before} - + {expandable ? ( +
    + + {formatted.title} + + ▶ + + + {workerRef ? ( +
    + {workerRef.before} + + {workerRef.after ?
    {workerRef.after}
    : null} +
    + ) : formatted.detail ? ( +
    + {formatted.detail} +
    + ) : detailContent ? ( +
    +                      {detailContent}
    +                    
    + ) : null} + {subagent ? ( + onViewSubagent(subagent) : undefined} /> - {workerRef.after ?
    {workerRef.after}
    : null} -
    - ) : formatted.detail ? ( -
    - {formatted.detail} -
    - ) : detailContent ? ( -
    -                    {detailContent}
    -                  
    - ) : null} - {subagent ? ( - onViewSubagent(subagent) : undefined} - /> - ) : null} -
    - ) : ( -
    - - {formatted.title} - - - {entry.status} - -
    - )} -
    - ); - })} -
    + ) : null} +
    + ) : ( +
    + {formatted.title} +
    + )} + + ); + })} +
    + ); } diff --git a/app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx b/app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx new file mode 100644 index 000000000..f9885ece1 --- /dev/null +++ b/app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx @@ -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({ui}); +} + +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( {}} />); + expect(screen.queryByTestId('agent-process-source-panel')).toBeNull(); + }); + + it('shows the panel title and steps when open', () => { + renderPanel( + {}} + /> + ); + 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( + {}} + /> + ); + 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( + {}} + /> + ); + const panel = screen.getByTestId('agent-process-source-panel'); + const allDetails = panel.querySelectorAll('details'); + // Every
    (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( + {}} + /> + ); + // 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( + {}} + /> + ); + expect(screen.queryByTestId('agent-source-row')).toBeNull(); + }); + + it('closes via the close button', async () => { + const onClose = vi.fn(); + renderPanel(); + await userEvent.click(screen.getByText('✕')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('closes on Escape', async () => { + const onClose = vi.fn(); + renderPanel(); + await userEvent.keyboard('{Escape}'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('closes on backdrop click', async () => { + const onClose = vi.fn(); + const { container } = renderPanel( + + ); + // 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); + }); +}); diff --git a/app/src/pages/conversations/components/__tests__/AgentTimelineRail.test.tsx b/app/src/pages/conversations/components/__tests__/AgentTimelineRail.test.tsx new file mode 100644 index 000000000..bac2e095f --- /dev/null +++ b/app/src/pages/conversations/components/__tests__/AgentTimelineRail.test.tsx @@ -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( + + Research Agent + + ); + 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( + + only + + ); + // 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( + + middle + + ); + expect(container.querySelectorAll('span[aria-hidden]')).toHaveLength(2); + }); +}); diff --git a/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx index 945a0e0c8..23a45635e 100644 --- a/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx +++ b/app/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsx @@ -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(); + 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(); + 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 = { diff --git a/app/src/pages/dev/AgentInsightsPreview.tsx b/app/src/pages/dev/AgentInsightsPreview.tsx new file mode 100644 index 000000000..6ad39630f --- /dev/null +++ b/app/src/pages/dev/AgentInsightsPreview.tsx @@ -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 ( +
    +

    {title}

    + {children} +
    + ); +} + +export default function AgentInsightsPreview() { + const [panelOpen, setPanelOpen] = useState(false); + + return ( +
    +
    +
    +

    + Agentic task insights — preview +

    +

    + Dev-only harness (#/dev/agent-insights). Sample data — not a live run. +

    +
    + +
    + setPanelOpen(true)} /> +
    + +
    + setPanelOpen(true)} /> +
    + +
    + +
    +
    + + setPanelOpen(false)} + /> +
    + ); +} diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 4cee6783c..4dbf427d6 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -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(); + 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 { diff --git a/app/test/vitest.config.ts b/app/test/vitest.config.ts index 4c39ef7a8..52ed1b354 100644 --- a/app/test/vitest.config.ts +++ b/app/test/vitest.config.ts @@ -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: {