From 48c4df822a8062c6cc939d144bebc0c0f2de0328 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 9 May 2026 18:31:33 +0200 Subject: [PATCH] chat-messages: add timeline module with builder, grouping, items, and rendering. --- .../chat-messages/timeline/build-timeline.ts | 257 ++++++++++++++++++ .../chat-messages/timeline/data-renderer.tsx | 53 ++++ .../chat-messages/timeline/grouping.ts | 47 ++++ .../features/chat-messages/timeline/index.ts | 16 ++ .../chat-messages/timeline/items/index.ts | 3 + .../timeline/items/item-header.tsx | 52 ++++ .../timeline/items/reasoning-item.tsx | 15 + .../timeline/items/tool-call-item.tsx | 50 ++++ .../chat-messages/timeline/subagent-rename.ts | 47 ++++ .../timeline/timeline-group-row.tsx | 68 +++++ .../chat-messages/timeline/timeline.tsx | 187 +++++++++++++ .../features/chat-messages/timeline/types.ts | 84 ++++++ 12 files changed, 879 insertions(+) create mode 100644 surfsense_web/features/chat-messages/timeline/build-timeline.ts create mode 100644 surfsense_web/features/chat-messages/timeline/data-renderer.tsx create mode 100644 surfsense_web/features/chat-messages/timeline/grouping.ts create mode 100644 surfsense_web/features/chat-messages/timeline/index.ts create mode 100644 surfsense_web/features/chat-messages/timeline/items/index.ts create mode 100644 surfsense_web/features/chat-messages/timeline/items/item-header.tsx create mode 100644 surfsense_web/features/chat-messages/timeline/items/reasoning-item.tsx create mode 100644 surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx create mode 100644 surfsense_web/features/chat-messages/timeline/subagent-rename.ts create mode 100644 surfsense_web/features/chat-messages/timeline/timeline-group-row.tsx create mode 100644 surfsense_web/features/chat-messages/timeline/timeline.tsx create mode 100644 surfsense_web/features/chat-messages/timeline/types.ts diff --git a/surfsense_web/features/chat-messages/timeline/build-timeline.ts b/surfsense_web/features/chat-messages/timeline/build-timeline.ts new file mode 100644 index 000000000..7c78dfb7b --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/build-timeline.ts @@ -0,0 +1,257 @@ +import type { ItemStatus, ReasoningItem, TimelineItem, ToolCallItem } from "./types"; + +/** + * The thinking-step shape produced by the streaming pipeline (see + * ``data-thinking-step`` SSE events). Kept structural here so this + * builder doesn't depend on the legacy ``thinking-steps.tsx`` file. + */ +export interface ThinkingStepInput { + id: string; + title: string; + items: string[]; + status: "pending" | "in_progress" | "completed"; + metadata?: Record; +} + +/** + * The minimum tool-call-part shape we read from message content. We + * accept ``unknown[]`` and structurally narrow per part — the assistant- + * ui content type has many shapes, but only ``tool-call`` parts matter + * here. + */ +interface ToolCallPart { + type: "tool-call"; + toolCallId: string; + toolName: string; + args?: Record; + argsText?: string; + result?: unknown; + langchainToolCallId?: string; + metadata?: Record; +} + +function isToolCallPart(part: unknown): part is ToolCallPart { + if (!part || typeof part !== "object") return false; + const o = part as { type?: unknown; toolCallId?: unknown; toolName?: unknown }; + return ( + o.type === "tool-call" && typeof o.toolCallId === "string" && typeof o.toolName === "string" + ); +} + +function asNonEmptyString(v: unknown): string | undefined { + return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined; +} + +/** + * Derive coarse status for a tool-call from its result shape. Used + * when the tool-call has no joined thinking step (orphan path). + * + * - HITL ``__decided__: "reject"`` → ``cancelled`` + * - Has any result → ``completed`` + * - No result yet → ``running`` + * + * The per-tool component picks its own visual state from the result; + * this is only the timeline chrome's coarse signal. + */ +function deriveToolCallStatus(result: unknown): ItemStatus { + if (!result) return "running"; + if (typeof result === "object" && result !== null) { + const r = result as { __interrupt__?: unknown; __decided__?: unknown }; + if (r.__interrupt__ === true && r.__decided__ === "reject") return "cancelled"; + } + return "completed"; +} + +function mapStepStatus(status: ThinkingStepInput["status"]): ItemStatus { + if (status === "in_progress") return "running"; + return status; +} + +/** + * True when a tool-call's result carries an HITL interrupt. Catches + * both pre-decision (``__interrupt__: true``) and post-decision + * (``__interrupt__: true, __decided__: …``) states — the resume + * flow's decision-application spreads the original result and only + * adds ``__decided__``, so ``__interrupt__`` alone is the stable + * signal. + */ +function isInterruptInResult(result: unknown): boolean { + if (typeof result !== "object" || result === null) return false; + return (result as { __interrupt__?: unknown }).__interrupt__ === true; +} + +/** + * Build the set of tool-call ids that have been superseded by the + * resume stream's continuation. + * + * The challenge: during the live resume window, the in-memory message + * holds BOTH the rehydrated interrupt-frame parts (the OLD ``task`` + + * its inner ``update_notion_page`` whose result has ``__decided__``) + * AND the freshly-streamed resume parts (a NEW ``task`` + a NEW + * ``update_notion_page`` with the actual success result). We need to + * drop the entire OLD delegation chain so only the NEW one renders. + * + * Two-stage detection: + * + * 1. **Identify "interrupted spans"** — any spanId that contains at + * least one tool-call whose ``result.__interrupt__`` is true. This + * captures both the inner decided tool and its outer ``task`` + * wrapper (which itself has no result but shares the spanId). + * Without this the wrapper survives as an orphan parent — the + * stray "Notion" row we saw post-approve. + * + * 2. **Mark a tool-call as superseded** when (a) it sits in an + * interrupted span OR carries the interrupt marker directly, AND + * (b) a later tool-call with the same ``toolName`` in a DIFFERENT + * span exists. The "different span" guard prevents self-supersession + * within the same delegation episode. + * + * Mirrors the message-level rule in + * ``filterSupersededAbortedMessages`` but at the part level — same + * data-shape problem (interrupt frame + resume continuation cohabiting + * one in-memory message) one level down. + * + * Conservative: an interrupted tool-call with NO later same-named + * different-span successor stays (e.g. a reject that ended the run, a + * never-resumed decision). + */ +function collectSupersededToolCallIds(content: readonly unknown[]): Set { + const toolCallParts: ToolCallPart[] = []; + for (const part of content) { + if (isToolCallPart(part)) toolCallParts.push(part); + } + + const interruptedSpans = new Set(); + for (const part of toolCallParts) { + if (!isInterruptInResult(part.result)) continue; + const sid = asNonEmptyString(part.metadata?.spanId); + if (sid) interruptedSpans.add(sid); + } + + const superseded = new Set(); + for (let i = 0; i < toolCallParts.length; i++) { + const part = toolCallParts[i]; + const sid = asNonEmptyString(part.metadata?.spanId); + const inInterruptedSpan = sid !== undefined && interruptedSpans.has(sid); + const isDirectInterrupt = isInterruptInResult(part.result); + if (!inInterruptedSpan && !isDirectInterrupt) continue; + + for (let j = i + 1; j < toolCallParts.length; j++) { + const jsid = asNonEmptyString(toolCallParts[j].metadata?.spanId); + // Both-undefined counts as "different scopes" so standalone + // HITL tools (no delegation, no spanId) get caught. Naive + // ``jsid !== sid`` misses them since ``undefined !== + // undefined`` is false. + const sameSpan = sid !== undefined && jsid === sid; + if (toolCallParts[j].toolName === part.toolName && !sameSpan) { + superseded.add(part.toolCallId); + break; + } + } + } + + return superseded; +} + +/** + * Build the timeline's flat ``TimelineItem[]`` from thinking steps + + * message content tool-calls. + * + * 1. Index tool-call parts by ``metadata.thinkingStepId`` (O(1) join). + * 2. Walk thinking steps in order. Joined → ``ToolCallItem``; + * unjoined → ``ReasoningItem``. + * 3. Append unjoined tool-calls as orphan ``ToolCallItem``s (legacy + * history pre-``thinkingStepId``). + * + * Pure: no React, no I/O. ``result`` is forwarded verbatim — per-tool + * components own its discrimination. ``isThreadRunning`` lives in + * ``timeline.tsx`` as a runtime override. + */ +export function buildTimeline( + thinkingSteps: readonly ThinkingStepInput[], + content: readonly unknown[] | undefined +): TimelineItem[] { + const toolByStepId = new Map(); + const consumedToolCallIds = new Set(); + const supersededToolCallIds = content + ? collectSupersededToolCallIds(content) + : new Set(); + + if (content) { + for (const part of content) { + if (!isToolCallPart(part)) continue; + const tid = asNonEmptyString(part.metadata?.thinkingStepId); + if (tid) toolByStepId.set(tid, part); + } + } + + const items: TimelineItem[] = []; + + for (const step of thinkingSteps) { + const stepSpanId = asNonEmptyString(step.metadata?.spanId); + const joined = toolByStepId.get(step.id); + + // Drop the step entirely when it joins a superseded tool-call: + // the resume stream has emitted a fresh same-named tool-call + // (with its own thinking step) that takes over the row. + // Without this, the timeline shows two "Notion → Update + // Notion page" groups during the live resume window. + if (joined && supersededToolCallIds.has(joined.toolCallId)) { + consumedToolCallIds.add(joined.toolCallId); + continue; + } + + if (joined) { + consumedToolCallIds.add(joined.toolCallId); + const item: ToolCallItem = { + kind: "tool-call", + id: step.id, + status: mapStepStatus(step.status), + items: step.items.length > 0 ? step.items : undefined, + spanId: stepSpanId ?? asNonEmptyString(joined.metadata?.spanId), + toolName: joined.toolName, + toolCallId: joined.toolCallId, + args: joined.args ?? {}, + argsText: joined.argsText, + result: joined.result, + langchainToolCallId: joined.langchainToolCallId, + thinkingStepId: step.id, + }; + items.push(item); + continue; + } + + const reasoning: ReasoningItem = { + kind: "reasoning", + id: step.id, + status: mapStepStatus(step.status), + items: step.items.length > 0 ? step.items : undefined, + spanId: stepSpanId, + title: step.title, + }; + items.push(reasoning); + } + + if (content) { + for (const part of content) { + if (!isToolCallPart(part)) continue; + if (consumedToolCallIds.has(part.toolCallId)) continue; + if (supersededToolCallIds.has(part.toolCallId)) continue; + const orphan: ToolCallItem = { + kind: "tool-call", + id: part.toolCallId, + status: deriveToolCallStatus(part.result), + spanId: asNonEmptyString(part.metadata?.spanId), + toolName: part.toolName, + toolCallId: part.toolCallId, + args: part.args ?? {}, + argsText: part.argsText, + result: part.result, + langchainToolCallId: part.langchainToolCallId, + }; + items.push(orphan); + } + } + + return items; +} diff --git a/surfsense_web/features/chat-messages/timeline/data-renderer.tsx b/surfsense_web/features/chat-messages/timeline/data-renderer.tsx new file mode 100644 index 000000000..4ae160b84 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/data-renderer.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { makeAssistantDataUI, useAuiState } from "@assistant-ui/react"; +import { useMemo } from "react"; +import { buildTimeline, type ThinkingStepInput } from "./build-timeline"; +import { Timeline } from "./timeline"; + +/** + * assistant-ui data UI for the ``thinking-steps`` data-part. Receives + * the relay's step array as ``data``, reads message ``content`` via + * ``useAuiState``, builds the unified ``TimelineItem[]`` once + * (``buildTimeline`` is pure), and renders the ``Timeline``. + * + * ``isMessageStreaming`` is the AND of thread-running + this-message- + * is-last; that flag drives the ``isThreadRunning`` runtime override + * in ``Timeline`` (stale "running" → "completed" once the thread + * stops). Mirrors the legacy ``ThinkingStepsDataRenderer`` semantics. + */ +function TimelineDataRenderer({ data }: { name: string; data: unknown }) { + const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); + const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false); + const isMessageStreaming = isThreadRunning && isLastMessage; + const content = useAuiState(({ message }) => message?.content); + + const steps = useMemo( + () => (data as { steps: ThinkingStepInput[] } | null)?.steps ?? [], + [data] + ); + + const items = useMemo( + () => buildTimeline(steps, Array.isArray(content) ? content : undefined), + [steps, content] + ); + + if (items.length === 0) return null; + + return ( +
+ +
+ ); +} + +/** + * Drop-in replacement for the legacy ``ThinkingStepsDataUI``. Same + * registration name (``thinking-steps``) so consumers (assistant- + * message.tsx, public-thread.tsx, free-chat-page.tsx, etc.) just swap + * the import — no SSE relay changes, no message format changes. + */ +export const TimelineDataUI = makeAssistantDataUI({ + name: "thinking-steps", + render: TimelineDataRenderer, +}); diff --git a/surfsense_web/features/chat-messages/timeline/grouping.ts b/surfsense_web/features/chat-messages/timeline/grouping.ts new file mode 100644 index 000000000..1a4dfebcc --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/grouping.ts @@ -0,0 +1,47 @@ +import type { TimelineGroup, TimelineItem } from "./types"; + +/** + * Group consecutive delegated child items under their parent. + * + * The contract: the parent of a span is the FIRST item carrying that + * ``spanId``. Subsequent items with the same ``spanId`` are children. + * Items with no ``spanId`` are their own parent (no children). + * + * For ``task`` delegations specifically, the ``task`` tool-call IS the + * span owner — its ``spanId`` is set on the call itself, and child + * items emitted while the subagent is running carry the same ``spanId``. + * The ``task`` item must therefore become the parent header, NOT a + * child of itself. This is achieved by treating the FIRST occurrence + * of any ``spanId`` as the parent; downstream items with the same + * ``spanId`` are children. + * + * Defensive: if the very first item of a stream is a child of a span + * we haven't seen the parent for yet, it's promoted to a parent so it + * still renders. Real flows always emit the parent ``task`` first. + * + * Pure function. No React, no side effects. Trivially testable. + */ +export function groupItems(items: readonly TimelineItem[]): TimelineGroup[] { + const groups: TimelineGroup[] = []; + const spanParent = new Map(); + + for (const item of items) { + const sid = item.spanId; + if (!sid) { + groups.push({ parent: item, children: [] }); + continue; + } + + const existing = spanParent.get(sid); + if (existing) { + existing.children.push(item); + continue; + } + + const group: TimelineGroup = { parent: item, children: [] }; + groups.push(group); + spanParent.set(sid, group); + } + + return groups; +} diff --git a/surfsense_web/features/chat-messages/timeline/index.ts b/surfsense_web/features/chat-messages/timeline/index.ts new file mode 100644 index 000000000..5e731acd3 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/index.ts @@ -0,0 +1,16 @@ +/** + * Public surface of the ``timeline/`` slice. + * + * Consumers (assistant-message, public-thread, free-chat-page, etc.) + * import ONLY from this barrel. Internal modules — ``items/``, + * ``tool-registry/``, ``timeline-group-row``, ``build-timeline``, + * ``grouping``, ``subagent-rename`` — are intentionally NOT + * re-exported. Adding consumers? Talk to the architecture doc first + * (see §6 layering rules). + */ + +export type { ThinkingStepInput } from "./build-timeline"; +export { TimelineDataUI } from "./data-renderer"; +export { Timeline } from "./timeline"; +export type { TimelineToolComponent, TimelineToolProps } from "./tool-registry/types"; +export type { ItemStatus, ReasoningItem, TimelineGroup, TimelineItem, ToolCallItem } from "./types"; diff --git a/surfsense_web/features/chat-messages/timeline/items/index.ts b/surfsense_web/features/chat-messages/timeline/items/index.ts new file mode 100644 index 000000000..7b1817b61 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/items/index.ts @@ -0,0 +1,3 @@ +export { ItemHeader } from "./item-header"; +export { ReasoningItem } from "./reasoning-item"; +export { ToolCallItem } from "./tool-call-item"; diff --git a/surfsense_web/features/chat-messages/timeline/items/item-header.tsx b/surfsense_web/features/chat-messages/timeline/items/item-header.tsx new file mode 100644 index 000000000..192655986 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/items/item-header.tsx @@ -0,0 +1,52 @@ +import type { FC } from "react"; +import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought"; +import { cn } from "@/lib/utils"; +import type { ItemStatus } from "../types"; + +/** + * The title row + sub-bullets shared by every timeline item kind. The + * timeline's chrome (status dot, indent, vertical line) renders to the + * left; this fills the right column. + * + * Status-aware text styling matches the legacy ``StepBody`` semantics: + * running → emphasised (font-medium foreground) + * completed → muted + * pending → muted/60 + * error → destructive + * cancelled → strikethrough muted + * + * Sub-bullets render via ``ChainOfThoughtItem`` (reused from + * ``components/prompt-kit/chain-of-thought``) — same component the + * legacy ``StepBody`` used. + */ +export const ItemHeader: FC<{ + title: string; + status: ItemStatus; + items?: readonly string[]; + itemKey: string; +}> = ({ title, status, items, itemKey }) => ( +
+
+ {title} +
+ + {items && items.length > 0 && ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ )} +
+); diff --git a/surfsense_web/features/chat-messages/timeline/items/reasoning-item.tsx b/surfsense_web/features/chat-messages/timeline/items/reasoning-item.tsx new file mode 100644 index 000000000..3198d3375 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/items/reasoning-item.tsx @@ -0,0 +1,15 @@ +import type { FC } from "react"; +import type { ReasoningItem as ReasoningItemModel } from "../types"; +import { ItemHeader } from "./item-header"; + +/** + * Renders a ``kind: "reasoning"`` row — pure agent narration with no + * tool component beneath it. Just the shared header. + * + * Native ```` blocks (model-level reasoning) are NOT rendered + * here — they live in the body via assistant-ui's ``Reasoning`` + * component. + */ +export const ReasoningItem: FC<{ item: ReasoningItemModel }> = ({ item }) => ( + +); diff --git a/surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx b/surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx new file mode 100644 index 000000000..1848f0c5c --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/items/tool-call-item.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { FC } from "react"; +import { getToolDisplayName } from "@/contracts/enums/toolIcons"; +import { ToolCallIdProvider, useHitlBundle } from "@/features/chat-messages/hitl"; +import { resolveItemTitle } from "../subagent-rename"; +import { adaptItemToProps, FallbackToolBody, getToolComponent } from "../tool-registry"; +import type { ToolCallItem as ToolCallItemModel } from "../types"; +import { ItemHeader } from "./item-header"; + +/** + * Renders a ``kind: "tool-call"`` row: ``ItemHeader`` (title + items) + * plus the resolved tool body underneath. + * + * Tool body is selected from the registry; unknown names fall through + * to ``FallbackToolBody`` (which itself dispatches between HITL + * approval cards and the default visual card based on result shape). + * + * Multi-approval bundle behaviour: when the HITL bundle is active, all + * cards EXCEPT the current step are hidden so the user is paged + * through them one at a time. Hiding is local to this row — the header + * and the timeline chrome around it are unaffected (the row collapses + * to its header only). The bundle's ``PagerChrome`` is mounted once + * at the end of the timeline by ``timeline.tsx``. + * + * Every tool body is wrapped in ``ToolCallIdProvider`` so + * ``useHitlDecision`` (called inside HITL approval cards) can read the + * tool-call id from context and stage decisions in the bundle. + */ +export const ToolCallItem: FC<{ item: ToolCallItemModel }> = ({ item }) => { + const bundle = useHitlBundle(); + const hideForBundle = + bundle?.isInBundle(item.toolCallId) === true && !bundle.isCurrentStep(item.toolCallId); + + const title = resolveItemTitle(item, getToolDisplayName); + + const Body = getToolComponent(item.toolName) ?? FallbackToolBody; + const props = adaptItemToProps(item); + + return ( + <> + + {!hideForBundle && ( + + + + )} + + ); +}; diff --git a/surfsense_web/features/chat-messages/timeline/subagent-rename.ts b/surfsense_web/features/chat-messages/timeline/subagent-rename.ts new file mode 100644 index 000000000..87accd8d7 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/subagent-rename.ts @@ -0,0 +1,47 @@ +import type { TimelineItem, ToolCallItem } from "./types"; + +function asNonEmptyString(v: unknown): string | undefined { + return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined; +} + +/** + * Title-case a subagent identifier: + * "notion" → "Notion" + * "doc_research" → "Doc Research" + * "ux-review" → "Ux Review" + */ +export function titleCaseSubagent(raw: string): string { + return raw + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +/** + * Display title for a tool-call item. For the ``task`` delegation + * primitive, substitute ``args.subagent_type`` (e.g. "Notion" instead + * of the generic "Task" label). Returns ``undefined`` if no rename + * applies — caller falls back to ``getToolDisplayName(toolName)``. + */ +export function resolveSubagentTitle(item: ToolCallItem): string | undefined { + if (item.toolName !== "task") return undefined; + const subagent = asNonEmptyString(item.args?.subagent_type); + return subagent ? titleCaseSubagent(subagent) : undefined; +} + +/** + * Unified title resolver for any timeline item. Reasoning items use + * their own ``title``; tool-call items try the subagent rename first, + * then fall back to the resolver passed in (typically + * ``getToolDisplayName``). + * + * Pure: no React, no I/O. Trivially testable. + */ +export function resolveItemTitle( + item: TimelineItem, + getToolDisplayName: (toolName: string) => string +): string { + if (item.kind === "reasoning") return item.title; + return resolveSubagentTitle(item) ?? getToolDisplayName(item.toolName); +} diff --git a/surfsense_web/features/chat-messages/timeline/timeline-group-row.tsx b/surfsense_web/features/chat-messages/timeline/timeline-group-row.tsx new file mode 100644 index 000000000..4c33e52bd --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/timeline-group-row.tsx @@ -0,0 +1,68 @@ +"use client"; + +import type { FC } from "react"; +import { cn } from "@/lib/utils"; +import { ReasoningItem, ToolCallItem } from "./items"; +import type { ItemStatus, TimelineGroup, TimelineItem } from "./types"; + +function renderItem(item: TimelineItem) { + if (item.kind === "reasoning") return ; + return ; +} + +/** + * Single group row in the timeline tree: status dot + connector line in + * the gutter, parent item content + indented children in the body. + * + * The connector line overshoots by ~15px to land on the next group's + * dot center; the line passes BEHIND any indented children (whose + * column has no dot of its own) for a clean tree look. + */ +export const TimelineGroupRow: FC<{ + group: TimelineGroup; + parentStatus: ItemStatus; + showParentLine: boolean; +}> = ({ group, parentStatus, showParentLine }) => { + const hasChildren = group.children.length > 0; + + return ( +
+
+ {showParentLine && ( +
+ )} +
+ {parentStatus === "running" ? ( + + + + + ) : ( + + )} +
+
+ +
+ {renderItem(group.parent)} + + {hasChildren && ( +
+ {group.children.map((child) => ( +
{renderItem(child)}
+ ))} +
+ )} +
+
+ ); +}; diff --git a/surfsense_web/features/chat-messages/timeline/timeline.tsx b/surfsense_web/features/chat-messages/timeline/timeline.tsx new file mode 100644 index 000000000..cdabbb67a --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/timeline.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { ChevronRightIcon } from "lucide-react"; +import { type FC, useEffect, useMemo, useState } from "react"; +import { TextShimmerLoader } from "@/components/prompt-kit/loader"; +import { getToolDisplayName } from "@/contracts/enums/toolIcons"; +import { PagerChrome, useHitlBundle } from "@/features/chat-messages/hitl"; +import { cn } from "@/lib/utils"; +import { groupItems } from "./grouping"; +import { resolveItemTitle } from "./subagent-rename"; +import { TimelineGroupRow } from "./timeline-group-row"; +import type { ItemStatus, TimelineItem } from "./types"; + +/** + * Override coarse status when the thread isn't running anymore: a + * stale "running" must read as "completed" so the chrome stops + * pulsing. Mirrors the legacy ``getEffectiveStatus`` from + * ``thinking-steps.tsx``. + */ +function effectiveStatus(status: ItemStatus, isThreadRunning: boolean): ItemStatus { + if (status === "running" && !isThreadRunning) return "completed"; + return status; +} + +/** + * True when a tool-call's result is an HITL interrupt the user has + * NOT decided on yet. The backend marks the step as ``completed`` + * (the tool DID complete — it returned an interrupt as its result), + * which would normally collapse the timeline. This predicate lets the + * chrome treat "waiting on user" as still-in-progress. + * + * Decided interrupts (``__decided__`` set to "approve"/"reject"/ + * "edit") count as completed for chrome purposes — the resume stream + * will take it from there. + */ +function isPendingInterrupt(result: unknown): boolean { + if (typeof result !== "object" || result === null) return false; + const r = result as { __interrupt__?: unknown; __decided__?: unknown }; + return r.__interrupt__ === true && r.__decided__ === undefined; +} + +/** + * The chain-of-thought timeline. The "process" surface in the + * `body | timeline` split — owns chrome (collapsible header, tree + * dots/lines, indent, group iteration) and dispatches to per-kind + * items for the actual content. + * + * Rendering responsibilities (kept here, not on items): + * - Outer max-width container. + * - Collapsible header with state-aware label ("Reviewed" / + * "Processing" / current step title) and shimmer. + * - Open/close state derived from ``isThreadRunning`` + completion. + * - Status dot + vertical connector line per group (delegates the + * inner row to ``TimelineGroupRow``). + * - Mounting ``PagerChrome`` once at the bottom when the HITL bundle + * is active (multi-approval coordination — see + * ``hitl/bundle/bundle-context.tsx``). + * + * Pure consumption of ``TimelineItem[]`` — does NOT call + * ``buildTimeline`` itself. The data-renderer adapter does that and + * passes the items in. + */ +export const Timeline: FC<{ + items: readonly TimelineItem[]; + isThreadRunning?: boolean; +}> = ({ items, isThreadRunning = true }) => { + const bundle = useHitlBundle(); + + // Apply the runtime ``isThreadRunning`` override to every item once, + // up-front, so downstream code (grouping, group rows, item headers, + // status dot, all children) sees the corrected coarse status without + // having to thread a callback through. ``buildTimeline`` stays pure; + // the override is purely a render-time concern that lives here. + const effectiveItems = useMemo( + () => + items.map((it) => ({ + ...it, + status: effectiveStatus(it.status, isThreadRunning), + })), + [items, isThreadRunning] + ); + + const inProgressItem = useMemo( + () => effectiveItems.find((it) => it.status === "running"), + [effectiveItems] + ); + const inProgressTitle = useMemo( + () => (inProgressItem ? resolveItemTitle(inProgressItem, getToolDisplayName) : undefined), + [inProgressItem] + ); + + // Detect a tool-call that's parked on an HITL interrupt the user hasn't + // decided yet. Treated as "still in progress" by the chrome so the + // timeline doesn't auto-collapse on the user mid-decision (the LangGraph + // thread paused, but the agent's work is conceptually unfinished). + const pendingInterruptItem = useMemo( + () => effectiveItems.find((it) => it.kind === "tool-call" && isPendingInterrupt(it.result)), + [effectiveItems] + ); + const pendingInterruptTitle = useMemo( + () => + pendingInterruptItem ? resolveItemTitle(pendingInterruptItem, getToolDisplayName) : undefined, + [pendingInterruptItem] + ); + + const allCompleted = useMemo( + () => + effectiveItems.length > 0 && + !isThreadRunning && + !pendingInterruptItem && + effectiveItems.every((it) => it.status === "completed"), + [effectiveItems, isThreadRunning, pendingInterruptItem] + ); + const isProcessing = (isThreadRunning || !!pendingInterruptItem) && !allCompleted; + + const [isOpen, setIsOpen] = useState(() => isProcessing); + useEffect(() => { + if (isProcessing) { + setIsOpen(true); + return; + } + if (allCompleted) { + setIsOpen(false); + } + }, [allCompleted, isProcessing]); + + const groups = useMemo(() => groupItems(effectiveItems), [effectiveItems]); + + if (effectiveItems.length === 0) return null; + + const headerText = (() => { + if (allCompleted) return "Reviewed"; + if (inProgressTitle) return inProgressTitle; + // Pending HITL: prefer the tool's own name so the user knows WHICH + // approval is gating progress (e.g. "Update Notion page") rather + // than a generic "Awaiting approval" label. + if (pendingInterruptTitle) return pendingInterruptTitle; + if (isProcessing) return "Processing"; + return "Reviewed"; + })(); + + return ( +
+
+ + +
+
+
+ {groups.map((group, groupIndex) => ( + + ))} + + {bundle && } +
+
+
+
+
+ ); +}; diff --git a/surfsense_web/features/chat-messages/timeline/types.ts b/surfsense_web/features/chat-messages/timeline/types.ts new file mode 100644 index 000000000..37bd0fbc3 --- /dev/null +++ b/surfsense_web/features/chat-messages/timeline/types.ts @@ -0,0 +1,84 @@ +/** + * Coarse status used by the timeline's chrome (the colored dot, the + * "in progress" pulse). NOT consulted by per-tool components — those + * own their own visual state machines (e.g. ``useHitlPhase`` for HITL + * approval cards). + * + * - ``pending`` — known but not started yet (rare; usually only seen briefly during streaming) + * - ``running`` — currently executing (assistant-ui ``in_progress``) + * - ``completed`` — finished without error + * - ``cancelled`` — user rejected (HITL ``__decided__: "reject"``) + * - ``error`` — threw or returned an error result + */ +export type ItemStatus = "pending" | "running" | "completed" | "cancelled" | "error"; + +interface BaseItem { + /** + * Stable React key for the timeline. When a thinking-step row is joined + * with a tool-call part (via ``metadata.thinkingStepId``), this is the + * thinking-step ID — preserves identity across rehydration. For + * tool-calls with no joined step, this is the ``toolCallId``. + */ + id: string; + status: ItemStatus; + /** + * Optional sub-bullets shown beneath the row's title. Forwarded + * verbatim from ``ThinkingStep.items`` when the timeline item was + * built from a thinking-step row. + */ + items?: string[]; + /** + * Groups items into the delegation tree. All items emitted while a + * delegating ``task`` is open carry the same ``spanId``; the ``task`` + * step itself owns the span (see ``grouping.ts``). + */ + spanId?: string; +} + +/** + * Pure agent narration (e.g. "Reviewing the request", "Planning"). NOT + * a model-level ```` block — those are rendered in the BODY by + * the assistant-ui ``Reasoning`` component. This kind covers thinking- + * step rows that are NOT linked to a tool call. + */ +export interface ReasoningItem extends BaseItem { + kind: "reasoning"; + title: string; +} + +/** + * A tool invocation. Per-tool components (mounted by the timeline's + * tool-registry) discriminate the ``result`` shape internally to pick + * a view (interrupt → approval card; success → result card; etc.). + * + * The timeline does NOT inspect ``result`` beyond deriving ``status``. + */ +export interface ToolCallItem extends BaseItem { + kind: "tool-call"; + toolName: string; + /** The actual tool-call ID — used by HITL (bundle membership, ``ToolCallIdProvider``). */ + toolCallId: string; + args: Record; + argsText?: string; + result?: unknown; + langchainToolCallId?: string; + /** + * Set when the tool-call was joined with a thinking-step row via + * ``metadata.thinkingStepId``. In that case ``id`` is the + * thinking-step ID, not the ``toolCallId``. + */ + thinkingStepId?: string; +} + +export type TimelineItem = ReasoningItem | ToolCallItem; + +/** + * The output shape of the grouping pass. Each group is a parent item + * (typically a delegating ``task`` tool-call) plus the items emitted + * inside its span. Items with no ``spanId`` become parents with no + * children. + */ +export interface TimelineGroup { + parent: TimelineItem; + children: TimelineItem[]; +}