mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
chat-messages: add timeline module with builder, grouping, items, and rendering.
This commit is contained in:
parent
9e451a5907
commit
48c4df822a
12 changed files with 879 additions and 0 deletions
257
surfsense_web/features/chat-messages/timeline/build-timeline.ts
Normal file
257
surfsense_web/features/chat-messages/timeline/build-timeline.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown>;
|
||||||
|
argsText?: string;
|
||||||
|
result?: unknown;
|
||||||
|
langchainToolCallId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
const toolCallParts: ToolCallPart[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (isToolCallPart(part)) toolCallParts.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
const interruptedSpans = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string, ToolCallPart>();
|
||||||
|
const consumedToolCallIds = new Set<string>();
|
||||||
|
const supersededToolCallIds = content
|
||||||
|
? collectSupersededToolCallIds(content)
|
||||||
|
: new Set<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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<ThinkingStepInput[]>(
|
||||||
|
() => (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 (
|
||||||
|
<div className="mb-3 -mx-2 leading-normal">
|
||||||
|
<Timeline items={items} isThreadRunning={isMessageStreaming} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
47
surfsense_web/features/chat-messages/timeline/grouping.ts
Normal file
47
surfsense_web/features/chat-messages/timeline/grouping.ts
Normal file
|
|
@ -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<string, TimelineGroup>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
16
surfsense_web/features/chat-messages/timeline/index.ts
Normal file
16
surfsense_web/features/chat-messages/timeline/index.ts
Normal file
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { ItemHeader } from "./item-header";
|
||||||
|
export { ReasoningItem } from "./reasoning-item";
|
||||||
|
export { ToolCallItem } from "./tool-call-item";
|
||||||
|
|
@ -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 }) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-sm leading-5",
|
||||||
|
status === "running" && "text-foreground font-medium",
|
||||||
|
status === "completed" && "text-muted-foreground",
|
||||||
|
status === "pending" && "text-muted-foreground/60",
|
||||||
|
status === "error" && "text-destructive",
|
||||||
|
status === "cancelled" && "text-muted-foreground line-through"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items && items.length > 0 && (
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ChainOfThoughtItem key={`${itemKey}-${item}`} className="text-xs">
|
||||||
|
{item}
|
||||||
|
</ChainOfThoughtItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -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 ``<think>`` 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 }) => (
|
||||||
|
<ItemHeader title={item.title} status={item.status} items={item.items} itemKey={item.id} />
|
||||||
|
);
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<ItemHeader title={title} status={item.status} items={item.items} itemKey={item.id} />
|
||||||
|
{!hideForBundle && (
|
||||||
|
<ToolCallIdProvider toolCallId={item.toolCallId}>
|
||||||
|
<Body {...props} />
|
||||||
|
</ToolCallIdProvider>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 <ReasoningItem item={item} />;
|
||||||
|
return <ToolCallItem item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="relative flex gap-3">
|
||||||
|
<div className="relative flex flex-col items-center w-2 self-stretch">
|
||||||
|
{showParentLine && (
|
||||||
|
<div className="absolute left-1/2 top-[15px] -bottom-[15px] w-px -translate-x-1/2 bg-muted-foreground/30" />
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||||
|
{parentStatus === "running" ? (
|
||||||
|
<span className="relative flex size-2">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-2 rounded-full",
|
||||||
|
parentStatus === "error"
|
||||||
|
? "bg-destructive"
|
||||||
|
: parentStatus === "cancelled"
|
||||||
|
? "bg-muted-foreground/40"
|
||||||
|
: "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 pb-4">
|
||||||
|
{renderItem(group.parent)}
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="mt-2 ml-3 space-y-2">
|
||||||
|
{group.children.map((child) => (
|
||||||
|
<div key={child.id}>{renderItem(child)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
187
surfsense_web/features/chat-messages/timeline/timeline.tsx
Normal file
187
surfsense_web/features/chat-messages/timeline/timeline.tsx
Normal file
|
|
@ -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<TimelineItem[]>(
|
||||||
|
() =>
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
|
||||||
|
<div className="rounded-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
|
||||||
|
"text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<TextShimmerLoader text={headerText} size="sm" />
|
||||||
|
) : (
|
||||||
|
<span>{headerText}</span>
|
||||||
|
)}
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||||
|
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="mt-3 pl-1">
|
||||||
|
{groups.map((group, groupIndex) => (
|
||||||
|
<TimelineGroupRow
|
||||||
|
key={group.parent.id}
|
||||||
|
group={group}
|
||||||
|
parentStatus={group.parent.status}
|
||||||
|
showParentLine={groupIndex < groups.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{bundle && <PagerChrome />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
84
surfsense_web/features/chat-messages/timeline/types.ts
Normal file
84
surfsense_web/features/chat-messages/timeline/types.ts
Normal file
|
|
@ -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 ``<think>`` 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<string, unknown>;
|
||||||
|
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[];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue