- )}
+ {/* 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) && (
+
+ );
+}
+
+/**
+ * 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 */}
+
+
+
+ );
+}
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 (
+
+ );
+}
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 (
-