mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(chat): Agentic task insights timeline + Agent Process Source panel (#3580)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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': 'تنزيل أحدث إصدار',
|
||||
|
||||
@@ -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': 'সর্বশেষ সংস্করণ ডাউনলোড করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'नवीनतम संस्करण डाउनलोड करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '최신 버전 다운로드',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Скачать последнюю версию',
|
||||
|
||||
@@ -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': '下载最新版本',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user