(
+ {`message:${String(entry.sequence)}`}
+ )}
+ renderActivity={() => custom-activity
}
+ renderReasoning={() => null}
+ renderAssistant={() => null}
+ />,
+ );
+ const items = screen.getAllByText(/message:|custom-activity/);
+ expect(items.map((item) => item.textContent)).toEqual([
+ "message:1",
+ "custom-activity",
+ "message:2",
+ ]);
+ });
+});
+
+describe("ToolCallCard", () => {
+ it("collapses apply_patch by default and shows diff when open", () => {
+ const { container } = render(
+ ,
+ );
+ const details = container.querySelector("details");
+ expect(details?.open).toBe(false);
+ expect(screen.getByText(/Edited `demo.ts`/)).toBeInTheDocument();
+ });
+
+ it("renders exec command and grey output", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Ran git")).toBeInTheDocument();
+ expect(screen.getByText("clean")).toBeInTheDocument();
+ });
+});
diff --git a/web/packages/superagent-ui/src/ConversationTimeline.tsx b/web/packages/superagent-ui/src/ConversationTimeline.tsx
new file mode 100644
index 0000000..e180cf9
--- /dev/null
+++ b/web/packages/superagent-ui/src/ConversationTimeline.tsx
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+"use client";
+
+import type { ReactNode } from "react";
+
+import {
+ buildConversationTimeline,
+ type ConversationTimelineEntry,
+ type TimelineActivityEntry,
+ type TimelineConsumedUserEntry,
+ type TimelineLiveTextEntry,
+ type TimelineSequencedMessage,
+} from "./conversationTimeline.js";
+
+export interface ConversationTimelineProps {
+ messages: readonly TimelineSequencedMessage[];
+ consumedUserMessages?: readonly TimelineConsumedUserEntry[];
+ reasoning?: readonly TimelineLiveTextEntry[];
+ activities?: readonly TimelineActivityEntry[];
+ assistant?: TimelineLiveTextEntry | null;
+ renderMessage: (entry: TimelineSequencedMessage) => ReactNode;
+ renderActivity: (entry: TimelineActivityEntry) => ReactNode;
+ renderReasoning: (entry: TimelineLiveTextEntry) => ReactNode;
+ renderAssistant: (entry: TimelineLiveTextEntry) => ReactNode;
+ renderConsumedUser?: (entry: TimelineConsumedUserEntry) => ReactNode;
+ className?: string;
+ "aria-label"?: string;
+}
+
+export function ConversationTimeline({
+ messages,
+ consumedUserMessages = [],
+ reasoning = [],
+ activities = [],
+ assistant = null,
+ renderMessage,
+ renderActivity,
+ renderReasoning,
+ renderAssistant,
+ renderConsumedUser,
+ className = "sa-conversation-timeline",
+ "aria-label": ariaLabel,
+}: ConversationTimelineProps) {
+ const timeline = buildConversationTimeline(
+ messages,
+ consumedUserMessages,
+ reasoning,
+ activities,
+ assistant,
+ );
+ return (
+
+ {timeline.map((entry) =>
+ renderTimelineEntry(entry, {
+ renderMessage,
+ renderActivity,
+ renderReasoning,
+ renderAssistant,
+ renderConsumedUser,
+ }),
+ )}
+
+ );
+}
+
+function renderTimelineEntry(
+ entry: ConversationTimelineEntry,
+ renderers: {
+ renderMessage: (entry: TimelineSequencedMessage) => ReactNode;
+ renderActivity: (entry: TimelineActivityEntry) => ReactNode;
+ renderReasoning: (entry: TimelineLiveTextEntry) => ReactNode;
+ renderAssistant: (entry: TimelineLiveTextEntry) => ReactNode;
+ renderConsumedUser?: (entry: TimelineConsumedUserEntry) => ReactNode;
+ },
+): ReactNode {
+ switch (entry.kind) {
+ case "message":
+ return (
+
+ {renderers.renderMessage(entry.value)}
+
+ );
+ case "activity":
+ return (
+
+ {renderers.renderActivity(entry.value)}
+
+ );
+ case "reasoning":
+ return (
+
+ {renderers.renderReasoning(entry.value)}
+
+ );
+ case "assistant":
+ return (
+
+ {renderers.renderAssistant(entry.value)}
+
+ );
+ case "consumed-user":
+ return (
+
+ {renderers.renderConsumedUser?.(entry.value) ?? null}
+
+ );
+ }
+}
diff --git a/web/packages/superagent-ui/src/PlanPanel.tsx b/web/packages/superagent-ui/src/PlanPanel.tsx
new file mode 100644
index 0000000..14ae8e0
--- /dev/null
+++ b/web/packages/superagent-ui/src/PlanPanel.tsx
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+"use client";
+
+import { useState } from "react";
+
+import {
+ planActionPresentation,
+ type PlanActionGates,
+ type PlanStatusValue,
+ type PlanTaskStatusValue,
+} from "./planAction.js";
+
+export interface PlanPanelTask {
+ content: string;
+ status: PlanTaskStatusValue;
+}
+
+export interface PlanPanelProps {
+ revision: number;
+ status: PlanStatusValue;
+ tasks: readonly PlanPanelTask[];
+ taskStatuses?: readonly PlanTaskStatusValue[];
+ gates: PlanActionGates;
+ onExecute: (revision: number) => void;
+ className?: string;
+}
+
+export function PlanPanel({
+ revision,
+ status,
+ tasks,
+ taskStatuses,
+ gates,
+ onExecute,
+ className = "sa-plan-panel",
+}: PlanPanelProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const statuses = taskStatuses ?? tasks.map((task) => task.status);
+ const completedCount = statuses.filter(
+ (taskStatus) => taskStatus === "completed",
+ ).length;
+ const hasRunningTask = statuses.some(
+ (taskStatus) => taskStatus === "in_progress",
+ );
+ const action = planActionPresentation({ ...gates, planStatus: status });
+ return (
+
+ {
+ setIsExpanded((value) => !value);
+ }}
+ >
+
+ Plan · {String(completedCount)}/{String(tasks.length)} complete
+
+ {hasRunningTask && (
+
+ ●
+
+ )}
+ {isExpanded ? "▴" : "▾"}
+
+ {isExpanded && (
+
+
+
+
Plan revision {revision}
+
{statusLabel(status)}
+
+ {status !== "completed" && (
+
{
+ onExecute(revision);
+ }}
+ >
+ {action.label}
+
+ )}
+
+ {action.reason !== null && status !== "completed" && (
+
{action.reason}
+ )}
+
+ {tasks.map((task, index) => {
+ const taskStatus = statuses[index] ?? task.status;
+ return (
+
+ {taskIcon(taskStatus)}
+
+
{statusLabel(taskStatus)}
+
{task.content}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+function statusLabel(value: string): string {
+ return value
+ .split("_")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function taskIcon(status: PlanTaskStatusValue): string {
+ switch (status) {
+ case "completed":
+ return "✓";
+ case "in_progress":
+ return "●";
+ case "pending":
+ return "○";
+ }
+}
diff --git a/web/packages/superagent-ui/src/ToolCallCard.tsx b/web/packages/superagent-ui/src/ToolCallCard.tsx
new file mode 100644
index 0000000..84c0fe7
--- /dev/null
+++ b/web/packages/superagent-ui/src/ToolCallCard.tsx
@@ -0,0 +1,285 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+"use client";
+
+import type { ReactNode } from "react";
+
+import {
+ parsePatchDiff,
+ summarizePatchFiles,
+ type DiffLine,
+ type PatchFileDiff,
+} from "./parsePatchDiff.js";
+import type { TimelineToolCall } from "./conversationTimeline.js";
+import type { ToolCallResultView } from "./pairToolCalls.js";
+import {
+ formatShellCommand,
+ parseToolArguments,
+ projectCommandOutput,
+} from "./toolPayload.js";
+
+const APPLY_PATCH = "apply_patch";
+const EXEC_SHORT = "exec_short_command";
+const EXEC_LONG = "exec_long_command";
+
+export interface ToolCallCardProps {
+ call: TimelineToolCall;
+ result?: ToolCallResultView | null;
+ defaultOpen?: boolean;
+ renderToolCall?: (
+ call: TimelineToolCall,
+ result: ToolCallResultView | null,
+ ) => ReactNode;
+}
+
+export function ToolCallCard({
+ call,
+ result = null,
+ defaultOpen = false,
+ renderToolCall,
+}: ToolCallCardProps) {
+ if (renderToolCall !== undefined) {
+ return <>{renderToolCall(call, result)}>;
+ }
+ if (call.name === APPLY_PATCH) {
+ return (
+
+ );
+ }
+ if (call.name === EXEC_SHORT || call.name === EXEC_LONG) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+function ApplyPatchCard({
+ call,
+ result,
+ defaultOpen,
+}: {
+ call: TimelineToolCall;
+ result: ToolCallResultView | null;
+ defaultOpen: boolean;
+}) {
+ const argumentsObject = parseToolArguments(call.argumentsJson);
+ const patch =
+ typeof argumentsObject?.["patch"] === "string"
+ ? argumentsObject["patch"]
+ : "";
+ const files = parsePatchDiff(patch);
+ if (files === null) {
+ return (
+
+ );
+ }
+ const summary = summarizePatchFiles(files);
+ return (
+
+
+ {summary}
+
+ ▾
+
+
+
+ {files.map((file) => (
+
+ ))}
+ {result !== null &&
}
+ {result === null && (
+
Waiting for tool result…
+ )}
+
+
+ );
+}
+
+export function ApplyPatchDiff({ file }: { file: PatchFileDiff }) {
+ return (
+
+
+ {file.path}
+
+ +{String(file.added)} {" "}
+ -{String(file.removed)}
+
+
+
+ {file.lines.map((line, index) => (
+
+ ))}
+
+
+ );
+}
+
+function DiffLineRow({ line }: { line: DiffLine }) {
+ const marker = line.kind === "add" ? "+" : line.kind === "remove" ? "-" : " ";
+ const lineNumber =
+ line.kind === "add"
+ ? line.newLineNumber
+ : line.kind === "remove"
+ ? line.oldLineNumber
+ : (line.newLineNumber ?? line.oldLineNumber);
+ return (
+
+ {lineNumber ?? ""}
+ {marker}
+ {line.text}
+
+ );
+}
+
+function PatchResultFootnote({ content }: { content: string }) {
+ const paths = readChangedPaths(content);
+ if (paths.length === 0) return null;
+ return (
+
+ Changed {paths.map((path) => `\`${path}\``).join(", ")}
+
+ );
+}
+
+function readChangedPaths(content: string): string[] {
+ try {
+ const value: unknown = JSON.parse(content);
+ if (value === null || typeof value !== "object") return [];
+ const paths = (value as Record)["changed_paths"];
+ if (!Array.isArray(paths)) return [];
+ return paths.filter((path): path is string => typeof path === "string");
+ } catch {
+ return [];
+ }
+}
+
+export function ExecCommandCard({
+ call,
+ result = null,
+ defaultOpen = false,
+}: {
+ call: TimelineToolCall;
+ result?: ToolCallResultView | null;
+ defaultOpen?: boolean;
+}) {
+ const argumentsObject = parseToolArguments(call.argumentsJson);
+ const argv = Array.isArray(argumentsObject?.["argv"])
+ ? argumentsObject["argv"].filter(
+ (entry): entry is string => typeof entry === "string",
+ )
+ : [];
+ const command =
+ argv.length > 0 ? formatShellCommand(argv) : call.argumentsJson;
+ const summary =
+ argv.length > 0 ? `Ran ${argv[0] ?? "command"}` : `Ran ${call.name}`;
+ const output = result === null ? null : projectCommandOutput(result.content);
+ return (
+
+
+ {summary}
+
+ ▾
+
+
+
+
+ $ {" "}
+
+
+ {output !== null && (
+
{output || "(no output)"}
+ )}
+ {result === null && (
+
Waiting for tool result…
+ )}
+
+
+ );
+}
+
+function HighlightedShell({ command }: { command: string }) {
+ const tokens = command.split(/(\s+|&&|\|\||\||;)/);
+ return (
+
+ {tokens.map((token, index) => {
+ if (token.trim() === "") {
+ return {token} ;
+ }
+ if (
+ token === "&&" ||
+ token === "||" ||
+ token === "|" ||
+ token === ";"
+ ) {
+ return (
+
+ {token}
+
+ );
+ }
+ if (token.startsWith("-")) {
+ return (
+
+ {token}
+
+ );
+ }
+ if (index === 0 || tokens[index - 1]?.trim() === "&&") {
+ return (
+
+ {token}
+
+ );
+ }
+ return (
+
+ {token}
+
+ );
+ })}
+
+ );
+}
+
+function GenericToolCard({
+ call,
+ result,
+ defaultOpen,
+}: {
+ call: TimelineToolCall;
+ result: ToolCallResultView | null;
+ defaultOpen: boolean;
+}) {
+ return (
+
+
+
+ {call.name}
+ {result === null ? " · pending" : ""}
+
+
+ ▾
+
+
+
+
Request
+
{call.argumentsJson}
+ {result !== null ? (
+ <>
+
Result
+
{result.content}
+ >
+ ) : (
+
Waiting for tool result…
+ )}
+
+
+ );
+}
diff --git a/web/packages/superagent-ui/src/conversationTimeline.test.ts b/web/packages/superagent-ui/src/conversationTimeline.test.ts
new file mode 100644
index 0000000..ed658a1
--- /dev/null
+++ b/web/packages/superagent-ui/src/conversationTimeline.test.ts
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from "vitest";
+
+import {
+ buildConversationTimeline,
+ type TimelineActivityEntry,
+ type TimelineLiveTextEntry,
+ type TimelineSequencedMessage,
+} from "./conversationTimeline";
+
+describe("buildConversationTimeline", () => {
+ it("orders messages, activities, reasoning, and live assistant by time", () => {
+ const timeline = buildConversationTimeline(
+ messages(),
+ [
+ {
+ messageId: "consumed-1",
+ value: { content: "follow up", planMode: false },
+ createdAt: "2026-09-03T00:02:15Z",
+ consumedAfterSequence: 2,
+ },
+ ],
+ [reasoning("model-2", "2026-09-03T00:02:30Z")],
+ [
+ activity("model-2", "2026-09-03T00:02:00Z", "model_started", 4),
+ activity("model-1", "2026-09-03T00:01:30Z", "model_completed", 2),
+ ],
+ assistant("model-live", "2026-09-03T00:03:30Z"),
+ );
+
+ expect(timeline.map(timelineIdentity)).toEqual([
+ "message:1",
+ "message:2",
+ "activity:model-1:model_completed",
+ "activity:model-2:model_started",
+ "consumed-user:consumed-1",
+ "reasoning:model-2",
+ "message:3",
+ "assistant:model-live",
+ "message:4",
+ ]);
+ });
+
+ it("places anchored reasoning before its assistant when timestamps tie", () => {
+ const timeline = buildConversationTimeline(
+ messages(),
+ [],
+ [reasoning("model-1", "2026-09-03T00:01:00Z")],
+ [activity("model-1", "2026-09-03T00:01:01Z", "model_completed", 2)],
+ null,
+ );
+
+ expect(timeline.map(timelineIdentity)).toEqual([
+ "message:1",
+ "reasoning:model-1",
+ "message:2",
+ "activity:model-1:model_completed",
+ "message:3",
+ "message:4",
+ ]);
+ });
+});
+
+function messages(): TimelineSequencedMessage[] {
+ return [
+ message(1, "user", "2026-09-03T00:00:00Z"),
+ message(2, "assistant", "2026-09-03T00:01:00Z"),
+ message(3, "user", "2026-09-03T00:03:00Z"),
+ message(4, "assistant", "2026-09-03T00:04:00Z"),
+ ];
+}
+
+function message(
+ sequence: number,
+ role: "user" | "assistant",
+ createdAt: string,
+): TimelineSequencedMessage {
+ return {
+ sequence,
+ message: {
+ role,
+ content: `message ${String(sequence)}`,
+ toolCalls: [],
+ toolCallId: null,
+ toolName: null,
+ createdAt,
+ },
+ };
+}
+
+function reasoning(source: string, createdAt: string): TimelineLiveTextEntry {
+ return { source, createdAt, value: `${source} summary`, isComplete: true };
+}
+
+function assistant(source: string, createdAt: string): TimelineLiveTextEntry {
+ return { source, createdAt, value: "live reply", isComplete: false };
+}
+
+function activity(
+ source: string,
+ createdAt: string,
+ kind: string,
+ messageSequence: number | null,
+): TimelineActivityEntry {
+ return {
+ resumeToken: `${source}:${createdAt}:${kind}`,
+ source,
+ createdAt,
+ value: { kind, message: kind, messageSequence },
+ };
+}
+
+function timelineIdentity(
+ entry: ReturnType[number],
+): string {
+ switch (entry.kind) {
+ case "message":
+ return `message:${String(entry.value.sequence)}`;
+ case "consumed-user":
+ return `consumed-user:${entry.value.messageId}`;
+ case "reasoning":
+ return `reasoning:${entry.value.source}`;
+ case "activity":
+ return `activity:${entry.value.source}:${entry.value.value.kind}`;
+ case "assistant":
+ return `assistant:${entry.value.source}`;
+ }
+}
diff --git a/web/src/conversation-timeline.ts b/web/packages/superagent-ui/src/conversationTimeline.ts
similarity index 70%
rename from web/src/conversation-timeline.ts
rename to web/packages/superagent-ui/src/conversationTimeline.ts
index d941b86..3d826e3 100644
--- a/web/src/conversation-timeline.ts
+++ b/web/packages/superagent-ui/src/conversationTimeline.ts
@@ -1,40 +1,82 @@
/*
- * Copyright (c) 2022-2026 Super Durable, Inc.
+ * Copyright (c) 2026 Super Durable, Inc.
* Licensed under the Apache License, Version 2.0.
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- EventKind,
- MessageRole,
- type Sequence,
- type SequencedMessage,
-} from "./api/generated";
-import type {
- ActivityEntry,
- AssistantEntry,
- ConsumedUserEntry,
- ReasoningEntry,
-} from "./conversation-state";
+export type TimelineMessageRole = "system" | "user" | "assistant" | "tool";
+
+export interface TimelineToolCall {
+ id: string;
+ name: string;
+ argumentsJson: string;
+}
+
+export interface TimelineMessage {
+ role: TimelineMessageRole;
+ content: string;
+ toolCalls: readonly TimelineToolCall[];
+ toolCallId: string | null;
+ toolName: string | null;
+ createdAt: string;
+}
+
+export interface TimelineSequencedMessage {
+ sequence: number;
+ message: TimelineMessage;
+}
+
+export interface TimelineConsumedUserEntry {
+ messageId: string;
+ value: { content: string; planMode?: boolean };
+ createdAt: string;
+ consumedAfterSequence: number;
+}
+
+export interface TimelineLiveTextEntry {
+ source: string;
+ createdAt: string;
+ value: string;
+ isComplete: boolean;
+}
+
+export interface TimelineActivityEvent {
+ kind: string;
+ message: string;
+ callId?: string | null;
+ toolName?: string | null;
+ messageSequence: number | null;
+}
+
+export interface TimelineActivityEntry {
+ resumeToken: string;
+ source: string;
+ createdAt: string;
+ value: TimelineActivityEvent;
+}
export type ConversationTimelineEntry =
- | { kind: "message"; value: SequencedMessage }
- | { kind: "consumed-user"; value: ConsumedUserEntry }
- | { kind: "reasoning"; value: ReasoningEntry }
- | { kind: "activity"; value: ActivityEntry }
- | { kind: "assistant"; value: AssistantEntry };
+ | { kind: "message"; value: TimelineSequencedMessage }
+ | { kind: "consumed-user"; value: TimelineConsumedUserEntry }
+ | { kind: "reasoning"; value: TimelineLiveTextEntry }
+ | { kind: "activity"; value: TimelineActivityEntry }
+ | { kind: "assistant"; value: TimelineLiveTextEntry };
interface ModelWindow {
startedAt: number | null;
finishedAt: number | null;
}
+const MODEL_STARTED = "model_started";
+const MODEL_COMPLETED = "model_completed";
+const MODEL_FAILED = "model_failed";
+
export function buildConversationTimeline(
- messages: readonly SequencedMessage[],
- consumedUserMessages: readonly ConsumedUserEntry[],
- reasoning: readonly ReasoningEntry[],
- activities: readonly ActivityEntry[],
- assistant: AssistantEntry | null,
+ messages: readonly TimelineSequencedMessage[],
+ consumedUserMessages: readonly TimelineConsumedUserEntry[],
+ reasoning: readonly TimelineLiveTextEntry[],
+ activities: readonly TimelineActivityEntry[],
+ assistant: TimelineLiveTextEntry | null,
): ConversationTimelineEntry[] {
const explicitSequences = modelMessageSequences(activities);
const modelWindows = completedModelWindows(activities);
@@ -62,8 +104,8 @@ export function buildConversationTimeline(
function compareTimelineEntries(
left: ConversationTimelineEntry,
right: ConversationTimelineEntry,
- messages: readonly SequencedMessage[],
- explicitSequences: ReadonlyMap,
+ messages: readonly TimelineSequencedMessage[],
+ explicitSequences: ReadonlyMap,
modelWindows: ReadonlyMap,
): number {
const inputOrder = compareConsumedInputToDurableHistory(left, right);
@@ -111,8 +153,8 @@ function compareConsumedInputToDurableHistory(
function compareReasoningToAssistant(
left: ConversationTimelineEntry,
right: ConversationTimelineEntry,
- messages: readonly SequencedMessage[],
- explicitSequences: ReadonlyMap,
+ messages: readonly TimelineSequencedMessage[],
+ explicitSequences: ReadonlyMap,
modelWindows: ReadonlyMap,
): number {
if (left.kind === "reasoning" && right.kind === "message") {
@@ -141,11 +183,11 @@ function compareReasoningToAssistant(
}
function reasoningSequence(
- entry: ReasoningEntry,
- messages: readonly SequencedMessage[],
- explicitSequences: ReadonlyMap,
+ entry: TimelineLiveTextEntry,
+ messages: readonly TimelineSequencedMessage[],
+ explicitSequences: ReadonlyMap,
modelWindows: ReadonlyMap,
-): Sequence | undefined {
+): number | undefined {
return (
explicitSequences.get(entry.source) ??
inferMessageSequence(entry.source, messages, modelWindows)
@@ -161,7 +203,7 @@ function entryCreatedAt(entry: ConversationTimelineEntry): string {
function entryRank(entry: ConversationTimelineEntry): number {
switch (entry.kind) {
case "message":
- return entry.value.message.role === MessageRole.ASSISTANT ? 3 : 0;
+ return entry.value.message.role === "assistant" ? 3 : 0;
case "consumed-user":
return 0;
case "activity":
@@ -189,9 +231,9 @@ function entryIdentity(entry: ConversationTimelineEntry): string {
}
function modelMessageSequences(
- activities: readonly ActivityEntry[],
-): ReadonlyMap {
- const result = new Map();
+ activities: readonly TimelineActivityEntry[],
+): ReadonlyMap {
+ const result = new Map();
for (const activity of activities) {
if (activity.value.messageSequence !== null) {
result.set(activity.source, activity.value.messageSequence);
@@ -201,7 +243,7 @@ function modelMessageSequences(
}
function completedModelWindows(
- activities: readonly ActivityEntry[],
+ activities: readonly TimelineActivityEntry[],
): ReadonlyMap {
const result = new Map();
for (const activity of activities) {
@@ -211,14 +253,14 @@ function completedModelWindows(
startedAt: null,
finishedAt: null,
};
- if (activity.value.kind === EventKind.MODEL_STARTED) {
+ if (activity.value.kind === MODEL_STARTED) {
window.startedAt =
window.startedAt === null
? timestamp
: Math.min(window.startedAt, timestamp);
} else if (
- activity.value.kind === EventKind.MODEL_COMPLETED ||
- activity.value.kind === EventKind.MODEL_FAILED
+ activity.value.kind === MODEL_COMPLETED ||
+ activity.value.kind === MODEL_FAILED
) {
window.finishedAt =
window.finishedAt === null
@@ -232,9 +274,9 @@ function completedModelWindows(
function inferMessageSequence(
source: string,
- messages: readonly SequencedMessage[],
+ messages: readonly TimelineSequencedMessage[],
modelWindows: ReadonlyMap,
-): Sequence | undefined {
+): number | undefined {
const window = modelWindows.get(source);
if (window === undefined) return undefined;
const { startedAt, finishedAt } = window;
@@ -242,7 +284,7 @@ function inferMessageSequence(
return undefined;
}
const candidates = messages.filter(({ message }) => {
- if (message.role !== MessageRole.ASSISTANT) return false;
+ if (message.role !== "assistant") return false;
const timestamp = parseTimestamp(message.createdAt);
return (
timestamp !== null && timestamp >= startedAt && timestamp <= finishedAt
diff --git a/web/packages/superagent-ui/src/helpers.test.ts b/web/packages/superagent-ui/src/helpers.test.ts
new file mode 100644
index 0000000..c30d04f
--- /dev/null
+++ b/web/packages/superagent-ui/src/helpers.test.ts
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from "vitest";
+
+import { parsePatchDiff, summarizePatchFiles } from "./parsePatchDiff";
+import {
+ formatShellCommand,
+ projectCommandOutput,
+ stripAnsi,
+} from "./toolPayload";
+import { planActionPresentation } from "./planAction";
+import { mergeActivityEvent, mergeSequencedMessages } from "./viewStateMerge";
+
+describe("parsePatchDiff", () => {
+ it("parses unified diffs with add and remove lines", () => {
+ const files = parsePatchDiff(`--- a/demo.ts
++++ b/demo.ts
+@@ -1,3 +1,3 @@
+ context
+-old
++new
+`);
+ expect(files).not.toBeNull();
+ expect(files?.[0]?.path).toBe("demo.ts");
+ expect(files?.[0]?.added).toBe(1);
+ expect(files?.[0]?.removed).toBe(1);
+ expect(summarizePatchFiles(files ?? [])).toContain("+1 -1");
+ });
+
+ it("parses Codex update patches", () => {
+ const files = parsePatchDiff(`*** Begin Patch
+*** Update File: flow.json
+@@
+- "supervision"
++ "v2"
+*** End Patch`);
+ expect(files?.[0]?.path).toBe("flow.json");
+ expect(files?.[0]?.lines.some((line) => line.kind === "remove")).toBe(true);
+ expect(files?.[0]?.lines.some((line) => line.kind === "add")).toBe(true);
+ });
+});
+
+describe("toolPayload", () => {
+ it("strips ANSI and projects command stdout", () => {
+ expect(stripAnsi("\u001b[31mred\u001b[39m")).toBe("red");
+ expect(formatShellCommand(["bash", "-lc", "echo hi"])).toContain("bash");
+ expect(
+ projectCommandOutput(
+ JSON.stringify({
+ stdout: {
+ head: "\u001b[32mok\u001b[39m",
+ tail: "",
+ omitted_bytes: 0,
+ },
+ stderr: { head: "", tail: "", omitted_bytes: 0 },
+ }),
+ ),
+ ).toBe("ok");
+ });
+});
+
+describe("planActionPresentation", () => {
+ it("gates execute behind questions then recovery then approval", () => {
+ const base = {
+ planStatus: "active",
+ isExecutePlanPending: false,
+ isPlanExecutionRequested: false,
+ areMutationsDisabled: false,
+ hasPendingUserInput: false,
+ hasPendingApproval: false,
+ hasPendingToolRecovery: false,
+ hasPendingTimer: false,
+ hasPendingQueue: false,
+ isWaitingForInput: true,
+ isWaitingForMessage: true,
+ } as const;
+ expect(
+ planActionPresentation({ ...base, hasPendingUserInput: true }).label,
+ ).toBe("Answer questions first");
+ expect(
+ planActionPresentation({ ...base, hasPendingToolRecovery: true }).label,
+ ).toBe("Resolve tool recovery");
+ expect(
+ planActionPresentation({ ...base, hasPendingApproval: true }).label,
+ ).toBe("Resolve approval first");
+ expect(planActionPresentation(base).label).toBe("Continue plan");
+ });
+});
+
+describe("viewStateMerge", () => {
+ it("merges sequenced messages and dedupes activities", () => {
+ expect(
+ mergeSequencedMessages(
+ [{ sequence: 1, message: { content: "a" } }],
+ [{ sequence: 1, message: { content: "b" } }],
+ )[0]?.message,
+ ).toEqual({ content: "b" });
+ expect(
+ mergeActivityEvent(
+ [{ resumeToken: "one" }],
+ { resumeToken: "one" },
+ 10,
+ true,
+ ),
+ ).toHaveLength(1);
+ });
+});
diff --git a/web/packages/superagent-ui/src/index.ts b/web/packages/superagent-ui/src/index.ts
index f5c7ca0..237be51 100644
--- a/web/packages/superagent-ui/src/index.ts
+++ b/web/packages/superagent-ui/src/index.ts
@@ -6,12 +6,41 @@
"use client";
+export {
+ ActivityRow,
+ activityIcon,
+ activityLabel,
+ formatActivityTime,
+ type ActivityRowProps,
+} from "./ActivityRow.js";
+export {
+ ApprovalCard,
+ TimerCard,
+ type ApprovalCardProps,
+ type TimerCardProps,
+} from "./ApprovalTimerCards.js";
export {
ConversationComposer,
type ConversationComposerProps,
type ConversationSubmitShortcut,
} from "./ConversationComposer.js";
+export {
+ ConversationTimeline,
+ type ConversationTimelineProps,
+} from "./ConversationTimeline.js";
export { MarkdownContent } from "./MarkdownContent.js";
+export {
+ PlanPanel,
+ type PlanPanelProps,
+ type PlanPanelTask,
+} from "./PlanPanel.js";
+export {
+ planActionPresentation,
+ type PlanActionGates,
+ type PlanActionPresentation,
+ type PlanStatusValue,
+ type PlanTaskStatusValue,
+} from "./planAction.js";
export {
PendingQuestionBatch,
type PendingQuestion,
@@ -34,3 +63,57 @@ export {
type ToolRecoveryPanelProps,
type ToolRecoveryResolution,
} from "./ToolRecoveryPanel.js";
+export {
+ ToolCallCard,
+ ApplyPatchDiff,
+ ExecCommandCard,
+ type ToolCallCardProps,
+} from "./ToolCallCard.js";
+export {
+ buildConversationTimeline,
+ type ConversationTimelineEntry,
+ type TimelineActivityEntry,
+ type TimelineActivityEvent,
+ type TimelineConsumedUserEntry,
+ type TimelineLiveTextEntry,
+ type TimelineMessage,
+ type TimelineMessageRole,
+ type TimelineSequencedMessage,
+ type TimelineToolCall,
+} from "./conversationTimeline.js";
+export {
+ indexToolResultsByCallId,
+ isPairedToolResultMessage,
+ pairToolCallsById,
+ type ToolCallPair,
+ type ToolCallResultView,
+} from "./pairToolCalls.js";
+export {
+ parsePatchDiff,
+ summarizePatchFiles,
+ type DiffLine,
+ type DiffLineKind,
+ type PatchFileDiff,
+} from "./parsePatchDiff.js";
+export {
+ formatShellCommand,
+ parseJsonObject,
+ parseToolArguments,
+ projectCommandOutput,
+ stripAnsi,
+} from "./toolPayload.js";
+export {
+ appendLiveText,
+ completeLiveText,
+ mergeActivityEvent,
+ mergeSequencedMessages,
+ type ActivityEventLike,
+ type LiveTextLike,
+ type SequencedMessageLike,
+} from "./viewStateMerge.js";
+export {
+ useTimelineFollow,
+ type ScrollRoot,
+ type TimelineFollowOptions,
+ type TimelineFollowState,
+} from "./useTimelineFollow.js";
diff --git a/web/packages/superagent-ui/src/pairToolCalls.test.ts b/web/packages/superagent-ui/src/pairToolCalls.test.ts
new file mode 100644
index 0000000..4738670
--- /dev/null
+++ b/web/packages/superagent-ui/src/pairToolCalls.test.ts
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from "vitest";
+
+import type { TimelineSequencedMessage } from "./conversationTimeline";
+import { isPairedToolResultMessage, pairToolCallsById } from "./pairToolCalls";
+
+describe("pairToolCallsById", () => {
+ it("pairs assistant tool calls with tool results by call id", () => {
+ const messages: TimelineSequencedMessage[] = [
+ {
+ sequence: 1,
+ message: {
+ role: "assistant",
+ content: "",
+ createdAt: "2026-09-03T00:00:00Z",
+ toolCallId: null,
+ toolName: null,
+ toolCalls: [
+ {
+ id: "call-1",
+ name: "apply_patch",
+ argumentsJson: '{"patch":"x"}',
+ },
+ ],
+ },
+ },
+ {
+ sequence: 2,
+ message: {
+ role: "tool",
+ content: '{"changed_paths":["a.ts"]}',
+ createdAt: "2026-09-03T00:00:01Z",
+ toolCallId: "call-1",
+ toolName: "apply_patch",
+ toolCalls: [],
+ },
+ },
+ ];
+ const paired = pairToolCallsById(messages);
+ expect(paired.pairs).toHaveLength(1);
+ expect(paired.pairs[0]?.result?.content).toContain("changed_paths");
+ expect(paired.orphanToolMessages).toHaveLength(0);
+ const toolResult = messages[1];
+ expect(toolResult).toBeDefined();
+ if (toolResult === undefined) {
+ throw new Error("expected paired tool result message");
+ }
+ expect(
+ isPairedToolResultMessage(toolResult.message, paired.pairedToolCallIds),
+ ).toBe(true);
+ });
+
+ it("keeps pending calls and orphan tool messages", () => {
+ const messages: TimelineSequencedMessage[] = [
+ {
+ sequence: 1,
+ message: {
+ role: "assistant",
+ content: "",
+ createdAt: "2026-09-03T00:00:00Z",
+ toolCallId: null,
+ toolName: null,
+ toolCalls: [
+ {
+ id: "pending",
+ name: "exec_short_command",
+ argumentsJson: '{"argv":["true"]}',
+ },
+ ],
+ },
+ },
+ {
+ sequence: 2,
+ message: {
+ role: "tool",
+ content: "orphan",
+ createdAt: "2026-09-03T00:00:01Z",
+ toolCallId: "missing",
+ toolName: "other",
+ toolCalls: [],
+ },
+ },
+ ];
+ const paired = pairToolCallsById(messages);
+ expect(paired.pairs[0]?.result).toBeNull();
+ expect(paired.orphanToolMessages).toHaveLength(1);
+ });
+});
diff --git a/web/packages/superagent-ui/src/pairToolCalls.ts b/web/packages/superagent-ui/src/pairToolCalls.ts
new file mode 100644
index 0000000..fedd408
--- /dev/null
+++ b/web/packages/superagent-ui/src/pairToolCalls.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {
+ TimelineSequencedMessage,
+ TimelineToolCall,
+} from "./conversationTimeline.js";
+
+export interface ToolCallResultView {
+ content: string;
+ toolName: string | null;
+ sequence: number;
+ createdAt: string;
+}
+
+export interface ToolCallPair {
+ call: TimelineToolCall;
+ result: ToolCallResultView | null;
+ assistantSequence: number;
+}
+
+export function indexToolResultsByCallId(
+ messages: readonly TimelineSequencedMessage[],
+): ReadonlyMap {
+ const results = new Map();
+ for (const { sequence, message } of messages) {
+ if (message.role !== "tool" || message.toolCallId === null) continue;
+ results.set(message.toolCallId, {
+ content: message.content,
+ toolName: message.toolName,
+ sequence,
+ createdAt: message.createdAt,
+ });
+ }
+ return results;
+}
+
+export function pairToolCallsById(
+ messages: readonly TimelineSequencedMessage[],
+): {
+ pairs: ToolCallPair[];
+ pairedToolCallIds: ReadonlySet;
+ orphanToolMessages: TimelineSequencedMessage[];
+} {
+ const resultsByCallId = indexToolResultsByCallId(messages);
+ const pairedToolCallIds = new Set();
+ const pairs: ToolCallPair[] = [];
+ for (const { sequence, message } of messages) {
+ if (message.role !== "assistant") continue;
+ for (const call of message.toolCalls) {
+ const result = resultsByCallId.get(call.id) ?? null;
+ if (result !== null) pairedToolCallIds.add(call.id);
+ pairs.push({
+ call,
+ result,
+ assistantSequence: sequence,
+ });
+ }
+ }
+ const orphanToolMessages = messages.filter(
+ ({ message }) =>
+ message.role === "tool" &&
+ (message.toolCallId === null ||
+ !pairedToolCallIds.has(message.toolCallId)),
+ );
+ return { pairs, pairedToolCallIds, orphanToolMessages };
+}
+
+export function isPairedToolResultMessage(
+ message: TimelineSequencedMessage["message"],
+ pairedToolCallIds: ReadonlySet,
+): boolean {
+ return (
+ message.role === "tool" &&
+ message.toolCallId !== null &&
+ pairedToolCallIds.has(message.toolCallId)
+ );
+}
diff --git a/web/packages/superagent-ui/src/parsePatchDiff.ts b/web/packages/superagent-ui/src/parsePatchDiff.ts
new file mode 100644
index 0000000..d6d5e5e
--- /dev/null
+++ b/web/packages/superagent-ui/src/parsePatchDiff.ts
@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export type DiffLineKind = "context" | "add" | "remove" | "header" | "meta";
+
+export interface DiffLine {
+ kind: DiffLineKind;
+ text: string;
+ oldLineNumber: number | null;
+ newLineNumber: number | null;
+}
+
+export interface PatchFileDiff {
+ path: string;
+ added: number;
+ removed: number;
+ lines: DiffLine[];
+}
+
+export function parsePatchDiff(patch: string): PatchFileDiff[] | null {
+ const trimmed = patch.trim();
+ if (trimmed.length === 0) return null;
+ if (trimmed.includes("*** Begin Patch")) {
+ return parseCodexPatch(trimmed);
+ }
+ if (trimmed.startsWith("--- ") || trimmed.includes("\n--- ")) {
+ return parseUnifiedPatch(trimmed);
+ }
+ return null;
+}
+
+function parseUnifiedPatch(patch: string): PatchFileDiff[] {
+ const files: PatchFileDiff[] = [];
+ let current: PatchFileDiff | null = null;
+ let oldLine = 0;
+ let newLine = 0;
+ for (const rawLine of patch.split(/\r?\n/)) {
+ if (rawLine.startsWith("--- ")) {
+ current = null;
+ continue;
+ }
+ if (rawLine.startsWith("+++ ")) {
+ const path = stripPathPrefix(rawLine.slice(4).trim());
+ current = { path, added: 0, removed: 0, lines: [] };
+ files.push(current);
+ continue;
+ }
+ if (current === null) continue;
+ if (rawLine.startsWith("@@")) {
+ const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(rawLine);
+ if (match) {
+ oldLine = Number(match[1]);
+ newLine = Number(match[2]);
+ }
+ current.lines.push({
+ kind: "header",
+ text: rawLine,
+ oldLineNumber: null,
+ newLineNumber: null,
+ });
+ continue;
+ }
+ if (rawLine.startsWith("+")) {
+ current.added += 1;
+ current.lines.push({
+ kind: "add",
+ text: rawLine.slice(1),
+ oldLineNumber: null,
+ newLineNumber: newLine,
+ });
+ newLine += 1;
+ continue;
+ }
+ if (rawLine.startsWith("-")) {
+ current.removed += 1;
+ current.lines.push({
+ kind: "remove",
+ text: rawLine.slice(1),
+ oldLineNumber: oldLine,
+ newLineNumber: null,
+ });
+ oldLine += 1;
+ continue;
+ }
+ if (rawLine.startsWith(" ") || rawLine === "") {
+ const text = rawLine.startsWith(" ") ? rawLine.slice(1) : rawLine;
+ current.lines.push({
+ kind: "context",
+ text,
+ oldLineNumber: oldLine,
+ newLineNumber: newLine,
+ });
+ oldLine += 1;
+ newLine += 1;
+ }
+ }
+ return files.filter((file) => file.lines.length > 0 || file.path.length > 0);
+}
+
+function parseCodexPatch(patch: string): PatchFileDiff[] {
+ const files: PatchFileDiff[] = [];
+ let current: PatchFileDiff | null = null;
+ let oldLine = 1;
+ let newLine = 1;
+ for (const rawLine of patch.split(/\r?\n/)) {
+ if (rawLine.startsWith("*** Add File:")) {
+ current = {
+ path: rawLine.slice("*** Add File:".length).trim(),
+ added: 0,
+ removed: 0,
+ lines: [],
+ };
+ files.push(current);
+ oldLine = 1;
+ newLine = 1;
+ continue;
+ }
+ if (rawLine.startsWith("*** Update File:")) {
+ current = {
+ path: rawLine.slice("*** Update File:".length).trim(),
+ added: 0,
+ removed: 0,
+ lines: [],
+ };
+ files.push(current);
+ oldLine = 1;
+ newLine = 1;
+ continue;
+ }
+ if (rawLine.startsWith("*** Delete File:")) {
+ current = {
+ path: rawLine.slice("*** Delete File:".length).trim(),
+ added: 0,
+ removed: 1,
+ lines: [
+ {
+ kind: "remove",
+ text: "(deleted)",
+ oldLineNumber: null,
+ newLineNumber: null,
+ },
+ ],
+ };
+ files.push(current);
+ current = null;
+ continue;
+ }
+ if (current === null) continue;
+ if (rawLine.startsWith("@@")) {
+ const match = /@@(?: -(\d+))?/.exec(rawLine);
+ if (match?.[1] !== undefined) {
+ oldLine = Number(match[1]);
+ newLine = oldLine;
+ }
+ current.lines.push({
+ kind: "header",
+ text: rawLine,
+ oldLineNumber: null,
+ newLineNumber: null,
+ });
+ continue;
+ }
+ if (rawLine.startsWith("+")) {
+ current.added += 1;
+ current.lines.push({
+ kind: "add",
+ text: rawLine.slice(1),
+ oldLineNumber: null,
+ newLineNumber: newLine,
+ });
+ newLine += 1;
+ continue;
+ }
+ if (rawLine.startsWith("-")) {
+ current.removed += 1;
+ current.lines.push({
+ kind: "remove",
+ text: rawLine.slice(1),
+ oldLineNumber: oldLine,
+ newLineNumber: null,
+ });
+ oldLine += 1;
+ continue;
+ }
+ if (rawLine.startsWith(" ")) {
+ current.lines.push({
+ kind: "context",
+ text: rawLine.slice(1),
+ oldLineNumber: oldLine,
+ newLineNumber: newLine,
+ });
+ oldLine += 1;
+ newLine += 1;
+ }
+ }
+ return files;
+}
+
+function stripPathPrefix(path: string): string {
+ if (path.startsWith("a/") || path.startsWith("b/")) return path.slice(2);
+ return path;
+}
+
+export function summarizePatchFiles(files: readonly PatchFileDiff[]): string {
+ if (files.length === 0) return "Edited files";
+ if (files.length === 1) {
+ const file = files[0];
+ if (file === undefined) return "Edited files";
+ return `Edited \`${file.path}\` +${String(file.added)} -${String(file.removed)}`;
+ }
+ const added = files.reduce((sum, file) => sum + file.added, 0);
+ const removed = files.reduce((sum, file) => sum + file.removed, 0);
+ return `Edited ${String(files.length)} files +${String(added)} -${String(removed)}`;
+}
diff --git a/web/packages/superagent-ui/src/planAction.ts b/web/packages/superagent-ui/src/planAction.ts
new file mode 100644
index 0000000..a91481e
--- /dev/null
+++ b/web/packages/superagent-ui/src/planAction.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export type PlanTaskStatusValue = "pending" | "in_progress" | "completed";
+export type PlanStatusValue = string;
+
+export interface PlanActionGates {
+ planStatus: PlanStatusValue;
+ isExecutePlanPending: boolean;
+ isPlanExecutionRequested: boolean;
+ areMutationsDisabled: boolean;
+ hasPendingUserInput: boolean;
+ hasPendingApproval: boolean;
+ hasPendingToolRecovery: boolean;
+ hasPendingTimer: boolean;
+ hasPendingQueue: boolean;
+ isWaitingForInput: boolean;
+ isWaitingForMessage: boolean;
+}
+
+export interface PlanActionPresentation {
+ label: string;
+ isDisabled: boolean;
+ reason: string | null;
+}
+
+export function planActionPresentation(
+ gates: PlanActionGates,
+): PlanActionPresentation {
+ if (gates.planStatus === "completed") {
+ return { label: "Plan completed", isDisabled: true, reason: null };
+ }
+ if (gates.isExecutePlanPending) {
+ return {
+ label: "Requesting execution…",
+ isDisabled: true,
+ reason: "Waiting for the execution request to finish.",
+ };
+ }
+ if (gates.isPlanExecutionRequested) {
+ return {
+ label: "Execution requested",
+ isDisabled: true,
+ reason: "The Agent will start this Plan from its durable wait.",
+ };
+ }
+ if (gates.areMutationsDisabled) {
+ return {
+ label: "Syncing plan…",
+ isDisabled: true,
+ reason: "Waiting for the current durable state reconciliation.",
+ };
+ }
+ if (gates.hasPendingUserInput) {
+ return {
+ label: "Answer questions first",
+ isDisabled: true,
+ reason: "Submit the requested answers before continuing this Plan.",
+ };
+ }
+ if (gates.hasPendingToolRecovery) {
+ return {
+ label: "Resolve tool recovery",
+ isDisabled: true,
+ reason: "Resolve the unknown tool outcomes before continuing this Plan.",
+ };
+ }
+ if (gates.hasPendingApproval) {
+ return {
+ label: "Resolve approval first",
+ isDisabled: true,
+ reason: "Approve or reject the pending tool before continuing this Plan.",
+ };
+ }
+ if (gates.hasPendingTimer) {
+ return {
+ label: "Timer is active",
+ isDisabled: true,
+ reason:
+ "The Plan can continue after the durable Timer finishes or is steered.",
+ };
+ }
+ if (gates.hasPendingQueue) {
+ return {
+ label: "Resolve queued messages",
+ isDisabled: true,
+ reason:
+ "The Agent must consume or remove queued messages before continuing this Plan.",
+ };
+ }
+ if (!gates.isWaitingForInput || !gates.isWaitingForMessage) {
+ const isDraft = gates.planStatus === "draft";
+ return {
+ label: isDraft ? "Preparing plan…" : "Plan running…",
+ isDisabled: true,
+ reason: isDraft
+ ? "Execute becomes available after the Agent reaches its next durable wait."
+ : "Continue becomes available if unfinished tasks remain at the next durable wait.",
+ };
+ }
+ return {
+ label: gates.planStatus === "draft" ? "Execute plan" : "Continue plan",
+ isDisabled: false,
+ reason: null,
+ };
+}
diff --git a/web/packages/superagent-ui/src/toolPayload.ts b/web/packages/superagent-ui/src/toolPayload.ts
new file mode 100644
index 0000000..33d1e7d
--- /dev/null
+++ b/web/packages/superagent-ui/src/toolPayload.ts
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const ANSI_ESCAPE = new RegExp(
+ `${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`,
+ "g",
+);
+
+export function stripAnsi(value: string): string {
+ return value.replace(ANSI_ESCAPE, "");
+}
+
+export function formatShellCommand(argv: readonly string[]): string {
+ return argv.map(shellQuote).join(" ");
+}
+
+function shellQuote(argument: string): string {
+ if (argument === "") return "''";
+ if (/^[A-Za-z0-9_./:=+-]+$/.test(argument)) return argument;
+ return `'${argument.replace(/'/g, `'\\''`)}'`;
+}
+
+export function projectCommandOutput(content: string): string {
+ const parsed = parseJsonObject(content);
+ if (parsed === null) return stripAnsi(content);
+ const stdout = readOutputProjection(parsed["stdout"]);
+ const stderr = readOutputProjection(parsed["stderr"]);
+ const parts = [stdout, stderr].filter((part) => part.length > 0);
+ if (parts.length === 0) return stripAnsi(content);
+ return parts.join("\n");
+}
+
+function readOutputProjection(value: unknown): string {
+ if (typeof value === "string") return stripAnsi(value);
+ if (value === null || typeof value !== "object") return "";
+ const record = value as Record;
+ const head = typeof record["head"] === "string" ? record["head"] : "";
+ const tail = typeof record["tail"] === "string" ? record["tail"] : "";
+ const omitted =
+ typeof record["omitted_bytes"] === "number" && record["omitted_bytes"] > 0
+ ? `\n… ${String(record["omitted_bytes"])} bytes omitted …\n`
+ : head.length > 0 && tail.length > 0
+ ? "\n…\n"
+ : "";
+ return stripAnsi(`${head}${omitted}${tail}`);
+}
+
+export function parseJsonObject(
+ content: string,
+): Record | null {
+ try {
+ const value: unknown = JSON.parse(content);
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ return null;
+ }
+ return value as Record;
+ } catch {
+ return null;
+ }
+}
+
+export function parseToolArguments(
+ argumentsJson: string,
+): Record | null {
+ return parseJsonObject(argumentsJson);
+}
diff --git a/web/packages/superagent-ui/src/useTimelineFollow.ts b/web/packages/superagent-ui/src/useTimelineFollow.ts
new file mode 100644
index 0000000..84c9242
--- /dev/null
+++ b/web/packages/superagent-ui/src/useTimelineFollow.ts
@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type RefObject,
+} from "react";
+
+const bottomTolerance = 12;
+
+export type ScrollRoot = Window | HTMLElement;
+
+export interface TimelineFollowOptions {
+ flowRunKey: string;
+ contentVersion: string;
+ /** Defaults to `window`. Pass an element for panel scrollers (Studio). */
+ scrollRoot?: ScrollRoot | RefObject;
+}
+
+export interface TimelineFollowState {
+ hasUnseenContent: boolean;
+ jumpToLatest: () => void;
+ keepLatestVisible: () => void;
+}
+
+export function useTimelineFollow({
+ flowRunKey,
+ contentVersion,
+ scrollRoot,
+}: TimelineFollowOptions): TimelineFollowState {
+ const isFollowing = useRef(true);
+ const isJumping = useRef(false);
+ const previousFlowRunKey = useRef(null);
+ const previousContentVersion = useRef(null);
+ const resizeFrame = useRef(null);
+ const [hasUnseenContent, setHasUnseenContent] = useState(false);
+
+ const resolveRoot = useCallback((): ScrollRoot => {
+ if (scrollRoot === undefined) return window;
+ if ("current" in scrollRoot) return scrollRoot.current ?? window;
+ return scrollRoot;
+ }, [scrollRoot]);
+
+ const keepLatestVisible = useCallback(() => {
+ if (!isFollowing.current) return;
+ if (resizeFrame.current !== null)
+ window.cancelAnimationFrame(resizeFrame.current);
+ resizeFrame.current = window.requestAnimationFrame(() => {
+ resizeFrame.current = null;
+ scrollToBottom(resolveRoot(), "auto");
+ });
+ }, [resolveRoot]);
+
+ const jumpToLatest = useCallback(() => {
+ isFollowing.current = true;
+ setHasUnseenContent(false);
+ const prefersReducedMotion =
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ isJumping.current = !prefersReducedMotion;
+ scrollToBottom(resolveRoot(), prefersReducedMotion ? "auto" : "smooth");
+ if (prefersReducedMotion) isJumping.current = false;
+ }, [resolveRoot]);
+
+ useEffect(() => {
+ const root = resolveRoot();
+ let previousScroll = getScrollTop(root);
+ const updateFollowState = () => {
+ const currentScroll = getScrollTop(root);
+ const didScrollUp = currentScroll < previousScroll;
+ previousScroll = currentScroll;
+ if (isAtBottom(root)) {
+ isFollowing.current = true;
+ isJumping.current = false;
+ setHasUnseenContent(false);
+ return;
+ }
+ if (!isJumping.current && didScrollUp) isFollowing.current = false;
+ };
+ const keepBottomVisible = () => {
+ if (!isFollowing.current) {
+ updateFollowState();
+ return;
+ }
+ keepLatestVisible();
+ };
+ const cancelSmoothJump = () => {
+ isJumping.current = false;
+ };
+ const target: Window | HTMLElement = root;
+ target.addEventListener("scroll", updateFollowState, { passive: true });
+ window.addEventListener("resize", keepBottomVisible);
+ target.addEventListener("wheel", cancelSmoothJump, { passive: true });
+ target.addEventListener("touchstart", cancelSmoothJump, { passive: true });
+ target.addEventListener("pointerdown", cancelSmoothJump, {
+ passive: true,
+ });
+ updateFollowState();
+ return () => {
+ if (resizeFrame.current !== null)
+ window.cancelAnimationFrame(resizeFrame.current);
+ target.removeEventListener("scroll", updateFollowState);
+ window.removeEventListener("resize", keepBottomVisible);
+ target.removeEventListener("wheel", cancelSmoothJump);
+ target.removeEventListener("touchstart", cancelSmoothJump);
+ target.removeEventListener("pointerdown", cancelSmoothJump);
+ };
+ }, [keepLatestVisible, resolveRoot]);
+
+ useLayoutEffect(() => {
+ const didFlowRunChange = previousFlowRunKey.current !== flowRunKey;
+ const didContentChange = previousContentVersion.current !== contentVersion;
+ previousFlowRunKey.current = flowRunKey;
+ previousContentVersion.current = contentVersion;
+ const root = resolveRoot();
+
+ if (didFlowRunChange) {
+ isFollowing.current = true;
+ isJumping.current = false;
+ setHasUnseenContent(false);
+ scrollToBottom(root, "auto");
+ return;
+ }
+ if (!didContentChange) return;
+ if (isFollowing.current) {
+ scrollToBottom(root, "auto");
+ return;
+ }
+ setHasUnseenContent(true);
+ }, [contentVersion, flowRunKey, resolveRoot]);
+
+ return { hasUnseenContent, jumpToLatest, keepLatestVisible };
+}
+
+function getScrollTop(root: ScrollRoot): number {
+ return root === window ? window.scrollY : (root as HTMLElement).scrollTop;
+}
+
+function isAtBottom(root: ScrollRoot): boolean {
+ if (root === window) {
+ const distance =
+ document.documentElement.scrollHeight -
+ window.scrollY -
+ window.innerHeight;
+ return distance <= bottomTolerance;
+ }
+ const element = root as HTMLElement;
+ return (
+ element.scrollHeight - element.scrollTop - element.clientHeight <=
+ bottomTolerance
+ );
+}
+
+function scrollToBottom(root: ScrollRoot, behavior: ScrollBehavior): void {
+ if (root === window) {
+ window.scrollTo({
+ top: document.documentElement.scrollHeight,
+ behavior,
+ });
+ return;
+ }
+ const element = root as HTMLElement;
+ element.scrollTo({ top: element.scrollHeight, behavior });
+}
diff --git a/web/packages/superagent-ui/src/viewStateMerge.ts b/web/packages/superagent-ui/src/viewStateMerge.ts
new file mode 100644
index 0000000..8a97842
--- /dev/null
+++ b/web/packages/superagent-ui/src/viewStateMerge.ts
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2026 Super Durable, Inc.
+ * Licensed under the Apache License, Version 2.0.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export interface SequencedMessageLike {
+ sequence: number;
+ message: TMessage;
+}
+
+export function mergeSequencedMessages(
+ current: readonly SequencedMessageLike[],
+ incoming: readonly SequencedMessageLike[],
+ reset = false,
+): SequencedMessageLike[] {
+ const bySequence = new Map(
+ (reset ? [] : current).map((message) => [message.sequence, message]),
+ );
+ for (const message of incoming) bySequence.set(message.sequence, message);
+ return [...bySequence.values()].sort(
+ (left, right) => left.sequence - right.sequence,
+ );
+}
+
+export interface ActivityEventLike {
+ resumeToken: string;
+}
+
+export function mergeActivityEvent(
+ current: readonly TEvent[],
+ incoming: TEvent,
+ maximum: number,
+ shouldDisplay: boolean,
+): TEvent[] {
+ if (
+ !shouldDisplay ||
+ current.some((event) => event.resumeToken === incoming.resumeToken)
+ ) {
+ return [...current];
+ }
+ return [...current, incoming].slice(-maximum);
+}
+
+export interface LiveTextLike {
+ source: string;
+ createdAt: string;
+ value: string;
+ isComplete: boolean;
+}
+
+export function appendLiveText(
+ current: readonly TEntry[],
+ incoming: TEntry,
+): TEntry[] {
+ const index = current.findIndex((entry) => entry.source === incoming.source);
+ if (index < 0) return [...current, incoming];
+ const next = [...current];
+ const existing = next[index];
+ if (existing === undefined) return [...current, incoming];
+ next[index] = {
+ ...existing,
+ ...incoming,
+ value: existing.isComplete
+ ? incoming.value
+ : `${existing.value}${incoming.value}`,
+ isComplete: existing.isComplete || incoming.isComplete,
+ };
+ return next;
+}
+
+export function completeLiveText(
+ current: readonly TEntry[],
+ source: string,
+): TEntry[] {
+ return current.map((entry) =>
+ entry.source === source ? { ...entry, isComplete: true } : entry,
+ );
+}
diff --git a/web/packages/superagent-ui/styles.css b/web/packages/superagent-ui/styles.css
index 6681848..9010090 100644
--- a/web/packages/superagent-ui/styles.css
+++ b/web/packages/superagent-ui/styles.css
@@ -545,6 +545,290 @@
}
}
+.sa-conversation-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+
+.sa-tool-call {
+ margin: 8px 0 0;
+ border: 1px solid var(--sa-tool-border, #d9dee8);
+ border-radius: 10px;
+ background: var(--sa-tool-background, #f7f8fb);
+ overflow: hidden;
+}
+
+.sa-tool-call-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ cursor: pointer;
+ list-style: none;
+ font-size: 13px;
+ color: var(--sa-muted-color, #4b5565);
+}
+
+.sa-tool-call-summary::-webkit-details-marker {
+ display: none;
+}
+
+.sa-tool-call-chevron {
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-tool-call-body {
+ padding: 0 12px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.sa-tool-call-label {
+ margin: 0;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-tool-call-json,
+.sa-exec-command,
+.sa-exec-output,
+.sa-patch-diff-body {
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: 8px;
+ overflow: auto;
+ white-space: pre-wrap;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.sa-tool-call-json,
+.sa-exec-command {
+ background: var(--sa-code-panel-background, #eef1f6);
+ color: var(--sa-ink-color, #1f2430);
+}
+
+.sa-exec-output {
+ color: var(--sa-faint-color, #8b93a7);
+ background: transparent;
+ padding: 0 4px;
+}
+
+.sa-exec-prompt {
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-exec-binary {
+ color: var(--sa-exec-binary-color, #c47b2d);
+ font-weight: 600;
+}
+
+.sa-exec-flag {
+ color: var(--sa-exec-flag-color, #3b6ea8);
+}
+
+.sa-exec-arg {
+ color: var(--sa-exec-arg-color, #2f4f8f);
+}
+
+.sa-exec-operator {
+ color: var(--sa-exec-operator-color, #c47b2d);
+ font-weight: 700;
+}
+
+.sa-patch-diff {
+ border: 1px solid var(--sa-tool-border, #d9dee8);
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--sa-code-panel-background, #fbfcfe);
+}
+
+.sa-patch-diff-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--sa-tool-border, #d9dee8);
+ font-size: 12px;
+}
+
+.sa-patch-added {
+ color: var(--sa-diff-add-color, #1b7f3a);
+}
+
+.sa-patch-removed {
+ color: var(--sa-diff-remove-color, #b42318);
+}
+
+.sa-patch-diff-body {
+ display: grid;
+ gap: 0;
+ padding: 0;
+ background: transparent;
+}
+
+.sa-patch-line {
+ display: grid;
+ grid-template-columns: 3ch 2ch 1fr;
+ gap: 6px;
+ padding: 0 8px;
+}
+
+.sa-patch-line--add {
+ background: var(--sa-diff-add-background, #e8f8ee);
+}
+
+.sa-patch-line--remove {
+ background: var(--sa-diff-remove-background, #fdecec);
+}
+
+.sa-patch-line--header {
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-patch-gutter {
+ color: var(--sa-faint-color, #8b93a7);
+ text-align: right;
+}
+
+.sa-patch-marker {
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-tool-call-pending,
+.sa-tool-call-footnote {
+ margin: 0;
+ font-size: 12px;
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-activity-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 8px;
+ align-items: start;
+ padding: 8px 10px;
+ border: 1px solid var(--sa-tool-border, #d9dee8);
+ border-radius: 8px;
+ background: var(--sa-tool-background, #f7f8fb);
+ font-size: 11px;
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-activity-icon {
+ color: var(--sa-accent-color, #4353c7);
+}
+
+.sa-activity-body {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.sa-activity-body strong {
+ color: var(--sa-muted-color, #4b5565);
+ font-weight: 600;
+ text-transform: capitalize;
+}
+
+.sa-plan-panel,
+.sa-approval-card,
+.sa-timer-card {
+ border: 1px solid var(--sa-tool-border, #d9dee8);
+ border-radius: 12px;
+ background: var(--sa-tool-background, #f7f8fb);
+ padding: 12px;
+}
+
+.sa-plan-toggle {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ font: inherit;
+ cursor: pointer;
+ color: var(--sa-muted-color, #4b5565);
+}
+
+.sa-plan-content {
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.sa-plan-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: start;
+}
+
+.sa-plan-eyebrow,
+.sa-card-eyebrow {
+ margin: 0;
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-plan-tasks {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.sa-plan-tasks li {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 8px;
+}
+
+.sa-plan-action-reason {
+ margin: 0;
+ font-size: 12px;
+ color: var(--sa-faint-color, #8b93a7);
+}
+
+.sa-button-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.sa-button-row button,
+.sa-plan-heading button {
+ border: 0;
+ border-radius: 8px;
+ padding: 8px 12px;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ color: var(--sa-button-text-color, white);
+ background: var(--sa-button-background, #4757d6);
+}
+
+.sa-danger-button {
+ color: var(--sa-danger-color, #9b2c25) !important;
+ background: var(--sa-danger-background, #fff0ee) !important;
+}
+
@media (prefers-reduced-motion: reduce) {
.sa-queue-action--steer {
transition: none;
diff --git a/web/src/ConversationView.tsx b/web/src/ConversationView.tsx
index 41bc556..35e9059 100644
--- a/web/src/ConversationView.tsx
+++ b/web/src/ConversationView.tsx
@@ -14,24 +14,33 @@ import {
type SyntheticEvent,
} from "react";
import {
+ ActivityRow,
+ ApprovalCard,
ConversationComposer,
+ ConversationTimeline,
PendingMessageQueue,
PendingQuestionBatch,
+ TimerCard,
+ ToolCallCard,
ToolRecoveryPanel,
+ indexToolResultsByCallId,
+ isPairedToolResultMessage,
+ pairToolCallsById,
+ planActionPresentation as sharedPlanActionPresentation,
+ useTimelineFollow,
type PendingMessageQueueItem,
type PendingQuestion,
type PendingQuestionAnswer,
+ type TimelineSequencedMessage,
} from "@superdurable/superagent-ui";
import {
AgentStatus,
- EventKind,
MessageRole,
PlanStatus,
TaskStatus,
ToolRecoveryAction as TransportToolRecoveryAction,
ToolRecoveryResolution as TransportToolRecoveryResolution,
- type AgentEvent,
type CallId,
type FlowId,
type PendingUserMessage,
@@ -48,8 +57,6 @@ import {
type QueueCommandAction,
type ActiveConversationState,
} from "./conversation-state";
-import { buildConversationTimeline } from "./conversation-timeline";
-import { useTimelineFollow } from "./useTimelineFollow";
const MarkdownContent = lazy(async () => {
const module = await import("@superdurable/superagent-ui");
@@ -109,13 +116,10 @@ export function ConversationView({
description.pendingToolRecovery !== null ||
description.pendingTimer !== null ||
description.plan !== null;
- const timeline = buildConversationTimeline(
- snapshot.history.messages,
- state.consumedUserMessages,
- state.reasoning,
- state.activities,
- state.assistant,
- );
+ const timelineMessages = snapshot.history
+ .messages as TimelineSequencedMessage[];
+ const { pairedToolCallIds } = pairToolCallsById(timelineMessages);
+ const toolResultsByCallId = indexToolResultsByCallId(timelineMessages);
const liveContentVersion = [
String(state.activities.length),
state.activities.at(-1)?.resumeToken ?? "",
@@ -221,164 +225,145 @@ export function ConversationView({
: "Loading history…"}
)}
- {timeline.length === 0 && (
-
-
Start the conversation
-
Your messages and durable Agent replies will appear here.
-
- )}
- {timeline.map((entry) => {
- if (entry.kind === "reasoning") {
- return (
-
-
- Reasoning summary ·{" "}
-
- {formatTime(entry.value.createdAt)}
- {" "}
- · {entry.value.isComplete ? "Complete" : "Streaming"}
-
-
-
- );
- }
- if (entry.kind === "activity") {
- return (
-
-
- {activityIcon(entry.value.value.kind)}
-
-
- {activityLabel(entry.value.value)}
- {entry.value.value.message}
-
-
- {formatTime(entry.value.createdAt)}
+ {snapshot.history.messages.length === 0 &&
+ state.activities.length === 0 &&
+ state.reasoning.length === 0 &&
+ state.assistant === null &&
+ state.consumedUserMessages.length === 0 && (
+
+
Start the conversation
+
+ Your messages and durable Agent replies will appear here.
+
+
+ )}
+ (
+
+
+
User
+
+ {formatTime(entry.createdAt)}
-
- );
- }
- if (entry.kind === "assistant") {
- return (
-
-
- Assistant
-
- {formatTime(entry.value.createdAt)} ·{" "}
- {entry.value.isComplete ? "Finalizing" : "Streaming"}
-
-
-
-
+
+ {entry.value.content}
+
+ )}
+ renderReasoning={(entry) => (
+
+
+ Reasoning summary ·{" "}
+
+ {formatTime(entry.createdAt)}
+ {" "}
+ · {entry.isComplete ? "Complete" : "Streaming"}
+
+
+
+ )}
+ renderActivity={(entry) => (
+
+ )}
+ renderAssistant={(entry) => (
+
+
+ Assistant
+
+ {formatTime(entry.createdAt)} ·{" "}
+ {entry.isComplete ? "Finalizing" : "Streaming"}
+
+
+
+
+ )}
+ renderMessage={({ sequence, message }) => {
+ if (isPairedToolResultMessage(message, pairedToolCallIds)) {
+ return null;
+ }
+ const visibleToolCalls = message.toolCalls.filter(
+ (call) => !builtInToolNames.has(call.name),
);
- }
- if (entry.kind === "consumed-user") {
+ if (
+ (message.role === MessageRole.TOOL &&
+ message.toolName !== null &&
+ builtInToolNames.has(message.toolName)) ||
+ (message.content === "" && visibleToolCalls.length === 0)
+ ) {
+ return null;
+ }
return (
- User
-
- {formatTime(entry.value.createdAt)}
+ {messageRoleLabel(message.role)}
+
+ {formatTime(message.createdAt)}
- {entry.value.value.content}
+ {message.content !== "" &&
+ (message.role === MessageRole.ASSISTANT ? (
+
+ ) : (
+ {message.content}
+ ))}
+ {visibleToolCalls.map((call) => (
+
+ ))}
);
- }
- const { sequence, message } = entry.value;
- const visibleToolCalls = message.toolCalls.filter(
- (call) => !builtInToolNames.has(call.name),
- );
- if (
- (message.role === MessageRole.TOOL &&
- message.toolName !== null &&
- builtInToolNames.has(message.toolName)) ||
- (message.content === "" && visibleToolCalls.length === 0)
- ) {
- return null;
- }
- return (
-
-
- {messageRoleLabel(message.role)}
-
- {formatTime(message.createdAt)}
-
-
- {message.content !== "" &&
- (message.role === MessageRole.ASSISTANT ? (
-
- ) : (
- {message.content}
- ))}
- {visibleToolCalls.map((call) => (
-
- Tool request · {call.name}
- {call.argumentsJson}
-
- ))}
-
- );
- })}
+ }}
+ />
{hasSidebar && (
{description.pendingApproval !== null && (
-
- Approval required
- {description.pendingApproval.toolName}
- {description.pendingApproval.argumentsJson}
-
- {
- const callId = description.pendingApproval?.callId;
- if (callId !== undefined) onApproveTool(callId, true);
- }}
- >
- {state.pendingCommand?.command.kind === "approve"
- ? "Processing…"
- : "Approve"}
-
- {
- const callId = description.pendingApproval?.callId;
- if (callId !== undefined) onApproveTool(callId, false);
- }}
- >
- {state.pendingCommand?.command.kind === "approve"
- ? "Processing…"
- : "Reject"}
-
-
-
+ {
+ const callId = description.pendingApproval?.callId;
+ if (callId !== undefined) onApproveTool(callId, true);
+ }}
+ onReject={() => {
+ const callId = description.pendingApproval?.callId;
+ if (callId !== undefined) onApproveTool(callId, false);
+ }}
+ />
)}
{description.pendingToolRecovery !== null && (
@@ -414,12 +399,11 @@ export function ConversationView({
)}
{description.pendingTimer !== null && (
-
- Durable timer
- {description.pendingTimer.durationSeconds}s
- {description.pendingTimer.reason}
- Steering interrupts this wait at a safe boundary.
-
+
)}
{description.plan !== null && (
@@ -683,88 +667,24 @@ function planActionPresentation(
): PlanActionPresentation {
const { description } = state.snapshot;
const plan = description.plan;
- if (plan === null || plan.status === PlanStatus.COMPLETED) {
+ if (plan === null) {
return { label: "Plan completed", isDisabled: true, reason: null };
}
- if (state.pendingCommand?.command.kind === "execute-plan") {
- return {
- label: "Requesting execution…",
- isDisabled: true,
- reason: "Waiting for the execution request to finish.",
- };
- }
- if (description.isPlanExecutionRequested) {
- return {
- label: "Execution requested",
- isDisabled: true,
- reason: "The Agent will start this Plan from its durable wait.",
- };
- }
- if (areMutationsDisabled) {
- return {
- label: "Syncing plan…",
- isDisabled: true,
- reason: "Waiting for the current durable state reconciliation.",
- };
- }
- if (description.pendingUserInput !== null) {
- return {
- label: "Answer questions first",
- isDisabled: true,
- reason: "Submit the requested answers before continuing this Plan.",
- };
- }
- if (description.pendingApproval !== null) {
- return {
- label: "Resolve approval first",
- isDisabled: true,
- reason: "Approve or reject the pending tool before continuing this Plan.",
- };
- }
- if (description.pendingToolRecovery !== null) {
- return {
- label: "Resolve tool recovery",
- isDisabled: true,
- reason: "Resolve the unknown tool outcomes before continuing this Plan.",
- };
- }
- if (description.pendingTimer !== null) {
- return {
- label: "Timer is active",
- isDisabled: true,
- reason:
- "The Plan can continue after the durable Timer finishes or is steered.",
- };
- }
- if (
- description.pendingQueuedMessageCount > 0 ||
- description.pendingSteeredMessageCount > 0
- ) {
- return {
- label: "Resolve queued messages",
- isDisabled: true,
- reason:
- "The Agent must consume or remove queued messages before continuing this Plan.",
- };
- }
- if (
- !state.isWaitingForInput ||
- description.status !== AgentStatus.WAITING_FOR_MESSAGE
- ) {
- const isDraft = plan.status === PlanStatus.DRAFT;
- return {
- label: isDraft ? "Preparing plan…" : "Plan running…",
- isDisabled: true,
- reason: isDraft
- ? "Execute becomes available after the Agent reaches its next durable wait."
- : "Continue becomes available if unfinished tasks remain at the next durable wait.",
- };
- }
- return {
- label: plan.status === PlanStatus.DRAFT ? "Execute plan" : "Continue plan",
- isDisabled: false,
- reason: null,
- };
+ return sharedPlanActionPresentation({
+ planStatus: plan.status,
+ isExecutePlanPending: state.pendingCommand?.command.kind === "execute-plan",
+ isPlanExecutionRequested: description.isPlanExecutionRequested,
+ areMutationsDisabled,
+ hasPendingUserInput: description.pendingUserInput !== null,
+ hasPendingApproval: description.pendingApproval !== null,
+ hasPendingToolRecovery: description.pendingToolRecovery !== null,
+ hasPendingTimer: description.pendingTimer !== null,
+ hasPendingQueue:
+ description.pendingQueuedMessageCount > 0 ||
+ description.pendingSteeredMessageCount > 0,
+ isWaitingForInput: state.isWaitingForInput,
+ isWaitingForMessage: description.status === AgentStatus.WAITING_FOR_MESSAGE,
+ });
}
function TaskStatusIndicator({ status }: { status: TaskStatus }) {
@@ -941,48 +861,10 @@ function statusLabel(value: string): string {
.join(" ");
}
-function messageRoleLabel(role: MessageRole): string {
+function messageRoleLabel(role: string): string {
return role === "tool" ? "Tool result" : statusLabel(role);
}
-function activityLabel(event: AgentEvent): string {
- const label = statusLabel(event.kind);
- return event.toolName === null ? label : `${label} · ${event.toolName}`;
-}
-
-function activityIcon(kind: AgentEvent["kind"]): string {
- switch (kind) {
- case EventKind.PLAN_STARTED:
- case EventKind.PLAN_UPDATED:
- case EventKind.PLAN_TASK_UPDATED:
- return "☷";
- case EventKind.INPUT_CONSUMED:
- return "⇥";
- case EventKind.USER_INPUT_ANSWERED:
- return "✓";
- case EventKind.SNAPSHOT_REQUIRED:
- return "↻";
- case EventKind.STEERING_APPLIED:
- return "↪";
- case EventKind.COMPACTION_FAILED:
- case EventKind.COMPACTED:
- return "↻";
- case EventKind.MODEL_STARTED:
- case EventKind.MODEL_FAILED:
- case EventKind.MODEL_COMPLETED:
- return "✦";
- case EventKind.MODEL_TOOL_CALL:
- case EventKind.TOOL_PROGRESS:
- case EventKind.TOOL_FAILED:
- case EventKind.TOOL_COMPLETED:
- case EventKind.TOOL_RECOVERY_REQUIRED:
- case EventKind.TOOL_RECOVERY_RESOLVED:
- return "⚙";
- case EventKind.USER_INPUT_REQUESTED:
- return "?";
- }
-}
-
function taskIcon(status: TaskStatus): string {
switch (status) {
case TaskStatus.COMPLETED:
diff --git a/web/src/conversation-timeline.test.ts b/web/src/conversation-timeline.test.ts
deleted file mode 100644
index fc4cb6c..0000000
--- a/web/src/conversation-timeline.test.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-/*
- * Copyright (c) 2022-2026 Super Durable, Inc.
- * Licensed under the Apache License, Version 2.0.
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { describe, expect, it } from "vitest";
-
-import {
- EventKind,
- MessageRole,
- type AgentEvent,
- type SequencedMessage,
-} from "./api/generated";
-import type {
- ActivityEntry,
- AssistantEntry,
- ReasoningEntry,
-} from "./conversation-state";
-import { buildConversationTimeline } from "./conversation-timeline";
-
-describe("buildConversationTimeline", () => {
- it("orders messages, activities, reasoning, and live assistant by time", () => {
- const timeline = buildConversationTimeline(
- messages(),
- [
- {
- messageId: "consumed-1",
- value: { content: "follow up", planMode: false },
- createdAt: "2026-09-03T00:02:15Z",
- consumedAfterSequence: 2,
- },
- ],
- [reasoning("model-2", "2026-09-03T00:02:30Z")],
- [
- activity("model-2", "2026-09-03T00:02:00Z", EventKind.MODEL_STARTED, 4),
- activity(
- "model-1",
- "2026-09-03T00:01:30Z",
- EventKind.MODEL_COMPLETED,
- 2,
- ),
- ],
- assistant("model-live", "2026-09-03T00:03:30Z"),
- );
-
- expect(timeline.map(timelineIdentity)).toEqual([
- "message:1",
- "message:2",
- "activity:model-1:model_completed",
- "activity:model-2:model_started",
- "consumed-user:consumed-1",
- "reasoning:model-2",
- "message:3",
- "assistant:model-live",
- "message:4",
- ]);
- });
-
- it("places anchored reasoning before its assistant when timestamps tie", () => {
- const timeline = buildConversationTimeline(
- messages(),
- [],
- [reasoning("model-1", "2026-09-03T00:01:00Z")],
- [
- activity(
- "model-1",
- "2026-09-03T00:01:01Z",
- EventKind.MODEL_COMPLETED,
- 2,
- ),
- ],
- null,
- );
-
- expect(timeline.map(timelineIdentity)).toEqual([
- "message:1",
- "reasoning:model-1",
- "message:2",
- "activity:model-1:model_completed",
- "message:3",
- "message:4",
- ]);
- });
-
- it("places consumed input after the durable history watermark", () => {
- const timeline = buildConversationTimeline(
- [message(1, MessageRole.USER, "2026-09-03T00:02:00Z")],
- [
- {
- messageId: "steered-after-answer",
- value: { content: "steered after answer", planMode: false },
- createdAt: "2026-09-03T00:03:00Z",
- consumedAfterSequence: 1,
- },
- {
- messageId: "queued-after-steering",
- value: { content: "queued after steering", planMode: false },
- createdAt: "2026-09-03T00:01:00Z",
- consumedAfterSequence: 1,
- },
- ],
- [],
- [],
- null,
- );
-
- expect(timeline.map(timelineIdentity)).toEqual([
- "message:1",
- "consumed-user:steered-after-answer",
- "consumed-user:queued-after-steering",
- ]);
- });
-
- it("uses an explicit sequence when invalid timestamps need a tie-break", () => {
- const timeline = buildConversationTimeline(
- [
- message(1, MessageRole.USER, "2026-09-03T00:00:00Z"),
- message(2, MessageRole.ASSISTANT, "invalid"),
- ],
- [],
- [reasoning("retained-model", "invalid")],
- [
- activity(
- "retained-model",
- "2026-09-03T00:02:00Z",
- EventKind.MODEL_COMPLETED,
- 2,
- ),
- ],
- null,
- );
-
- expect(timeline.map(timelineIdentity)).toEqual([
- "message:1",
- "activity:retained-model:model_completed",
- "reasoning:retained-model",
- "message:2",
- ]);
- });
-
- it("keeps every distinct Activity resume token", () => {
- const first = activity(
- "tool-call",
- "2026-09-03T00:01:00Z",
- EventKind.TOOL_PROGRESS,
- null,
- );
- const second = { ...first, resumeToken: "second-token" };
- const timeline = buildConversationTimeline(
- [],
- [],
- [],
- [first, second],
- null,
- );
-
- expect(timeline).toHaveLength(2);
- expect(timeline.map(timelineIdentity)).toEqual([
- "activity:tool-call:tool_progress",
- "activity:tool-call:tool_progress",
- ]);
- });
-});
-
-function messages(): SequencedMessage[] {
- return [
- message(1, MessageRole.USER, "2026-09-03T00:00:00Z"),
- message(2, MessageRole.ASSISTANT, "2026-09-03T00:01:00Z"),
- message(3, MessageRole.USER, "2026-09-03T00:03:00Z"),
- message(4, MessageRole.ASSISTANT, "2026-09-03T00:04:00Z"),
- ];
-}
-
-function message(
- sequence: number,
- role: MessageRole,
- createdAt: string,
-): SequencedMessage {
- return {
- sequence,
- message: {
- role,
- content: `message ${String(sequence)}`,
- toolCalls: [],
- toolCallId: null,
- toolName: null,
- createdAt,
- },
- };
-}
-
-function reasoning(source: string, createdAt: string): ReasoningEntry {
- return { source, createdAt, value: `${source} summary`, isComplete: true };
-}
-
-function assistant(source: string, createdAt: string): AssistantEntry {
- return { source, createdAt, value: "live reply", isComplete: false };
-}
-
-function activity(
- source: string,
- createdAt: string,
- kind: AgentEvent["kind"],
- messageSequence: number | null,
-): ActivityEntry {
- return {
- resumeToken: `${source}:${createdAt}:${kind}`,
- source,
- createdAt,
- value: {
- kind,
- message: kind,
- callId: null,
- toolName: null,
- messageSequence,
- inputConsumption: null,
- },
- };
-}
-
-function timelineIdentity(
- entry: ReturnType[number],
-): string {
- switch (entry.kind) {
- case "message":
- return `message:${String(entry.value.sequence)}`;
- case "consumed-user":
- return `consumed-user:${entry.value.messageId}`;
- case "reasoning":
- return `reasoning:${entry.value.source}`;
- case "activity":
- return `activity:${entry.value.source}:${entry.value.value.kind}`;
- case "assistant":
- return `assistant:${entry.value.source}`;
- }
-}
diff --git a/web/src/useTimelineFollow.ts b/web/src/useTimelineFollow.ts
index 7e231b3..9521de6 100644
--- a/web/src/useTimelineFollow.ts
+++ b/web/src/useTimelineFollow.ts
@@ -4,135 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- useCallback,
- useEffect,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-
-const bottomTolerance = 12;
-
-interface TimelineFollowOptions {
- flowRunKey: string;
- contentVersion: string;
-}
-
-interface TimelineFollowState {
- hasUnseenContent: boolean;
- jumpToLatest: () => void;
- keepLatestVisible: () => void;
-}
-
-export function useTimelineFollow({
- flowRunKey,
- contentVersion,
-}: TimelineFollowOptions): TimelineFollowState {
- const isFollowing = useRef(true);
- const isJumping = useRef(false);
- const previousFlowRunKey = useRef(null);
- const previousContentVersion = useRef(null);
- const resizeFrame = useRef(null);
- const [hasUnseenContent, setHasUnseenContent] = useState(false);
-
- const keepLatestVisible = useCallback(() => {
- if (!isFollowing.current) return;
- if (resizeFrame.current !== null)
- window.cancelAnimationFrame(resizeFrame.current);
- resizeFrame.current = window.requestAnimationFrame(() => {
- resizeFrame.current = null;
- scrollToBottom("auto");
- });
- }, []);
-
- const jumpToLatest = useCallback(() => {
- isFollowing.current = true;
- setHasUnseenContent(false);
- const prefersReducedMotion =
- typeof window.matchMedia === "function" &&
- window.matchMedia("(prefers-reduced-motion: reduce)").matches;
- isJumping.current = !prefersReducedMotion;
- scrollToBottom(prefersReducedMotion ? "auto" : "smooth");
- if (prefersReducedMotion) isJumping.current = false;
- }, []);
-
- useEffect(() => {
- let previousScrollY = window.scrollY;
- const updateFollowState = () => {
- const currentScrollY = window.scrollY;
- const didScrollUp = currentScrollY < previousScrollY;
- previousScrollY = currentScrollY;
- if (isAtBottom()) {
- isFollowing.current = true;
- isJumping.current = false;
- setHasUnseenContent(false);
- return;
- }
- if (!isJumping.current && didScrollUp) isFollowing.current = false;
- };
- const keepBottomVisible = () => {
- if (!isFollowing.current) {
- updateFollowState();
- return;
- }
- keepLatestVisible();
- };
- const cancelSmoothJump = () => {
- isJumping.current = false;
- };
- window.addEventListener("scroll", updateFollowState, { passive: true });
- window.addEventListener("resize", keepBottomVisible);
- window.addEventListener("wheel", cancelSmoothJump, { passive: true });
- window.addEventListener("touchstart", cancelSmoothJump, { passive: true });
- window.addEventListener("pointerdown", cancelSmoothJump, {
- passive: true,
- });
- updateFollowState();
- return () => {
- if (resizeFrame.current !== null)
- window.cancelAnimationFrame(resizeFrame.current);
- window.removeEventListener("scroll", updateFollowState);
- window.removeEventListener("resize", keepBottomVisible);
- window.removeEventListener("wheel", cancelSmoothJump);
- window.removeEventListener("touchstart", cancelSmoothJump);
- window.removeEventListener("pointerdown", cancelSmoothJump);
- };
- }, [keepLatestVisible]);
-
- useLayoutEffect(() => {
- const didFlowRunChange = previousFlowRunKey.current !== flowRunKey;
- const didContentChange = previousContentVersion.current !== contentVersion;
- previousFlowRunKey.current = flowRunKey;
- previousContentVersion.current = contentVersion;
-
- if (didFlowRunChange) {
- isFollowing.current = true;
- isJumping.current = false;
- setHasUnseenContent(false);
- scrollToBottom("auto");
- return;
- }
- if (!didContentChange) return;
- if (isFollowing.current) {
- scrollToBottom("auto");
- return;
- }
- setHasUnseenContent(true);
- }, [contentVersion, flowRunKey]);
-
- return { hasUnseenContent, jumpToLatest, keepLatestVisible };
-}
-
-function isAtBottom(): boolean {
- const distance =
- document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
- return distance <= bottomTolerance;
-}
-
-function scrollToBottom(behavior: ScrollBehavior): void {
- window.scrollTo({
- top: document.documentElement.scrollHeight,
- behavior,
- });
-}
+export {
+ useTimelineFollow,
+ type TimelineFollowOptions,
+ type TimelineFollowState,
+} from "@superdurable/superagent-ui";
diff --git a/web/tests/full-stack.spec.ts b/web/tests/full-stack.spec.ts
index a0caf3a..e126b9c 100644
--- a/web/tests/full-stack.spec.ts
+++ b/web/tests/full-stack.spec.ts
@@ -656,7 +656,7 @@ test("reconciles accepted commands when their browser responses are lost", async
await expect(page.getByRole("alert")).toBeVisible();
await expect(approval).toHaveCount(0);
await expect(
- history.locator(".message-bubble.tool").filter({
+ history.locator(".sa-tool-call").filter({
hasText: '"echo":"ambiguous approval"',
}),
).toHaveCount(1);
@@ -1178,9 +1178,7 @@ test("renders Plan progress, clears an accepted input, and shows safe tool activ
await approval.getByRole("button", { name: "Approve" }).click();
await expect(approval).toHaveCount(0);
await expect(
- page
- .locator(".message-bubble.tool")
- .filter({ hasText: '"echo":"approved"' }),
+ page.locator(".sa-tool-call").filter({ hasText: '"echo":"approved"' }),
).toBeVisible();
await expect(
activity.filter({ hasText: "Model requested fixture__echo." }),
@@ -1201,9 +1199,7 @@ test("renders Plan progress, clears an accepted input, and shows safe tool activ
await approval.getByRole("button", { name: "Reject" }).click();
await expect(approval).toHaveCount(0);
await expect(
- page
- .locator(".message-bubble.tool")
- .filter({ hasText: "rejected_by_user" }),
+ page.locator(".sa-tool-call").filter({ hasText: "rejected_by_user" }),
).toBeVisible();
});
@@ -1546,9 +1542,7 @@ test("retries an external tool through Dex without requesting approval twice", a
await approval.getByRole("button", { name: "Approve" }).click();
await expect(approval).toHaveCount(0);
await expect(
- page
- .locator(".message-bubble.tool")
- .filter({ hasText: '"echo":"retry once"' }),
+ page.locator(".sa-tool-call").filter({ hasText: '"echo":"retry once"' }),
).toBeVisible();
const activity = page.locator(".activity-entry");
await expect(
@@ -1573,7 +1567,9 @@ test("persists manual tool recovery and resumes only after a user decision", asy
await expect(
recovery.getByRole("heading", { name: "Execution outcome is unknown" }),
).toBeVisible({ timeout: 30_000 });
- await expect(page.locator(".message-bubble.tool")).toHaveCount(0);
+ await expect(
+ page.locator(".sa-tool-call").filter({ hasText: '"outcome":' }),
+ ).toHaveCount(0);
await expect(page.getByRole("group", { name: "Agent status" })).toContainText(
"Waiting For Tool Recovery",
);
@@ -1617,7 +1613,7 @@ test("persists manual tool recovery and resumes only after a user decision", asy
await expect(recovery).toHaveCount(0, { timeout: 30_000 });
await expect(
- page.locator(".message-bubble.tool").filter({
+ page.locator(".sa-tool-call").filter({
hasText: '"outcome":"unknown"',
}),
).toBeVisible();
@@ -1649,9 +1645,7 @@ async function expectAgentWaitingForMessage(page: Page): Promise {
}
async function directTimelineText(history: Locator): Promise {
- return history
- .locator(":scope > article, :scope > details")
- .allTextContents();
+ return history.locator(".conversation-timeline > *").allTextContents();
}
async function expectMessageQueueExpanded(queue: Locator): Promise {
@@ -1660,17 +1654,15 @@ async function expectMessageQueueExpanded(queue: Locator): Promise {
}
async function directTimelineTimes(history: Locator): Promise {
- return history
- .locator(":scope > article, :scope > details")
- .evaluateAll((rows) =>
- rows.map((row) => {
- const dateTime = row.querySelector("time")?.getAttribute("datetime");
- if (dateTime === undefined || dateTime === null) {
- throw new Error("timeline row is missing a datetime");
- }
- return Date.parse(dateTime);
- }),
- );
+ return history.locator(".conversation-timeline > *").evaluateAll((rows) =>
+ rows.map((row) => {
+ const dateTime = row.querySelector("time")?.getAttribute("datetime");
+ if (dateTime === undefined || dateTime === null) {
+ throw new Error("timeline row is missing a datetime");
+ }
+ return Date.parse(dateTime);
+ }),
+ );
}
async function abortSuccessfulResponseOnce(