diff --git a/.cursor/rules/planning.mdc b/.cursor/rules/planning.mdc index 9ad8c58..d3cdcbe 100644 --- a/.cursor/rules/planning.mdc +++ b/.cursor/rules/planning.mdc @@ -8,3 +8,8 @@ alwaysApply: true Every implementation plan includes concrete Tests, Documentation, and UI/UX sections. Use `N/A` only with a specific reason. Plans that touch Dex must name the required skill references and visualization evidence. + +Plans that touch Agent conversation UI or a public API must answer: which +shared package or OpenAPI surface owns the change, and which extension points +(`sa-*`, slots, constructor injection) Studio or other embedders use. Do not +plan portal-only UI with a later-extraction step. diff --git a/.cursor/rules/project-core.mdc b/.cursor/rules/project-core.mdc index 242d1d3..ea0dbda 100644 --- a/.cursor/rules/project-core.mdc +++ b/.cursor/rules/project-core.mdc @@ -22,5 +22,15 @@ semantically synchronized. - Domain packages prohibit `any`, dynamic maps, and raw string states. - Never export Dex resource getters solely to expose descriptors. - Keep provider, MCP, Dex, application, transport, and UI boundaries separate. +- Assume a second consumer (SuperVerse Studio / agent-server). Application- + visible conversation UI, merge/sort helpers, and Tool presentation belong in + `@superdurable/superagent-ui` with `sa-*` / slots; portal `web/src` stays + transport, orchestration, branding, and thin mappers. Do not ship portal-only + UI to extract later. Public Go/OpenAPI surfaces stay in `agent` / `model`. - Never commit or log `.env` or credentials. - Every changing turn ends with a meaningful commit and clean worktree. +- `@superdurable/superagent-ui` GitHub Release tags and npm publishes must + target a commit already on `origin/main`. Never tag or publish from a + feature branch or PR head. Merge to `main` first, then tag `vX.Y.Z`. If a + tag was cut from a feature branch by mistake, supersede it with the next + patch from `main`. diff --git a/.cursor/rules/typescript-react.mdc b/.cursor/rules/typescript-react.mdc index 1d420c4..51accbd 100644 --- a/.cursor/rules/typescript-react.mdc +++ b/.cursor/rules/typescript-react.mdc @@ -14,3 +14,6 @@ alwaysApply: false - Keep components focused and preserve accessibility, keyboard, responsive, reconnecting, stale, terminal, and failure states. - Do not suppress type-aware, Hooks, or accessibility lint failures. +- Agent conversation presentation, ToolCall cards, and view-model merge/sort + logic live in `@superdurable/superagent-ui` with transport-free props and + render slots. Portal code maps generated types into those view models. diff --git a/AGENTS.md b/AGENTS.md index ae9511b..c06a493 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,10 +213,45 @@ execution history. vulnerability checks, generated-code drift, TypeScript strict checking, type-aware lint, component tests, and browser E2E. +## Embedder and SuperVerse reuse + +Assume a second consumer (SuperVerse Studio / agent-server) for application- +visible behavior. Do not implement that behavior only in the portal `web/src` +tree or only under `internal/`. + +### Frontend + +- New or changed Agent conversation presentation, interaction controls, and + pure view-model merge or sort logic belong in `@superdurable/superagent-ui` + first. +- Portal `web/src` keeps transport, Snapshot and stream orchestration, product + shell or branding, and thin mappers into package view models. +- Shared components must be extensible: transport-free props, default `sa-*` / + `--sa-*` styling, and render slots or equivalent extension points so Studio + can customize row markup without forking sort or pairing logic. +- Plans and PRs that touch Agent conversation UI must state the shared package + surface and how Studio consumes or customizes it. Do not ship portal-only UI + with a promise to extract later. + +### Backend + +- Application-visible APIs, types, and constructors go through the public + `agent` / `model` packages and OpenAPI. +- Embedder extension points use constructor injection, interfaces, or + configuration. Do not require copying Flows or hand-rolling parallel HTTP. +- Do not hide SuperVerse-needed semantics behind portal-only side paths. + +### Documentation + +When changing the public UI package or public Go/OpenAPI surface, update +`ARCHITECTURE.md` and `web/packages/superagent-ui/README.md` in the same change. + ## Plans and documentation Every implementation plan includes concrete `Tests`, `Documentation`, and -`UI/UX` sections. Use `N/A` only with a specific reason. +`UI/UX` sections. Use `N/A` only with a specific reason. Plans that touch Agent +conversation UI or a public API must also answer the shared-surface and +extension-point checklist under Embedder and SuperVerse reuse. Keep these documents current with the code: @@ -238,3 +273,16 @@ Keep these documents current with the code: and OpenAPI files use the repository Apache-2.0 header. - Generated files and the vendored Dex skill follow their own recorded license and are excluded from header rewriting. + +## @superdurable/superagent-ui releases + +GitHub Release tags and npm publishes for `@superdurable/superagent-ui` must +target a commit that is already on `origin/main`. + +- Never create a `vX.Y.Z` tag, GitHub Release, or npm publish from a feature + branch, PR head, or any commit that is not an ancestor of `origin/main`. +- Merge the change to `main` first. Only then tag `vX.Y.Z` on that `main` + commit and publish the Release so `npm-release.yml` and + `github-release-ui.yml` can pass their main-ancestor checks. +- If a tag was created from a feature branch by mistake, do not reuse it. + Delete or supersede it and cut the next patch version from `main`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8c72ca4..f22795d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -32,7 +32,7 @@ Agent state. Streams reduce latency but never become recovery state. | `internal/model` | Provider routing, protocol adapters, in-memory credential lookup | Dex resources or HTTP API responses | | `internal/mcp` | Trusted server config, discovery, policy, single-attempt sessions, brokers | Agent state transitions, retry loops, or exported Dex resources | | `web` | React portal and generated Fetch client | Handwritten API response types or durable-state reconstruction | -| `web/packages/superagent-ui` | Transport-free React conversation components and local interaction behavior | Dex/API clients, routing, durable state, or product workflows | +| `web/packages/superagent-ui` | Transport-free React conversation components, timeline merge/sort, ToolCall cards, and local interaction behavior | Dex/API clients, routing, durable state, or product workflows | Interfaces live at their consuming boundary. Concrete single-use components do not receive speculative interfaces, and there is no general-purpose helpers @@ -42,6 +42,9 @@ The Web application maps generated domain objects into the plain view models accepted by `@superdurable/superagent-ui`. The shared package never imports the generated client or reconstructs application state; this keeps it reusable by other products without coupling their transport or orchestration to this portal. +Conversation chronology (`buildConversationTimeline`), ToolCall pairing, and +shared chrome such as `ToolCallCard` / `ActivityRow` / `planActionPresentation` +live in the package. Portal and Studio supply slots or thin mappers only. The public `agent` package is a thin façade over `internal/agent`. It aliases the stable application types and delegates constructors, so embedders share the diff --git a/CLAUDE.md b/CLAUDE.md index ae9511b..c06a493 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,10 +213,45 @@ execution history. vulnerability checks, generated-code drift, TypeScript strict checking, type-aware lint, component tests, and browser E2E. +## Embedder and SuperVerse reuse + +Assume a second consumer (SuperVerse Studio / agent-server) for application- +visible behavior. Do not implement that behavior only in the portal `web/src` +tree or only under `internal/`. + +### Frontend + +- New or changed Agent conversation presentation, interaction controls, and + pure view-model merge or sort logic belong in `@superdurable/superagent-ui` + first. +- Portal `web/src` keeps transport, Snapshot and stream orchestration, product + shell or branding, and thin mappers into package view models. +- Shared components must be extensible: transport-free props, default `sa-*` / + `--sa-*` styling, and render slots or equivalent extension points so Studio + can customize row markup without forking sort or pairing logic. +- Plans and PRs that touch Agent conversation UI must state the shared package + surface and how Studio consumes or customizes it. Do not ship portal-only UI + with a promise to extract later. + +### Backend + +- Application-visible APIs, types, and constructors go through the public + `agent` / `model` packages and OpenAPI. +- Embedder extension points use constructor injection, interfaces, or + configuration. Do not require copying Flows or hand-rolling parallel HTTP. +- Do not hide SuperVerse-needed semantics behind portal-only side paths. + +### Documentation + +When changing the public UI package or public Go/OpenAPI surface, update +`ARCHITECTURE.md` and `web/packages/superagent-ui/README.md` in the same change. + ## Plans and documentation Every implementation plan includes concrete `Tests`, `Documentation`, and -`UI/UX` sections. Use `N/A` only with a specific reason. +`UI/UX` sections. Use `N/A` only with a specific reason. Plans that touch Agent +conversation UI or a public API must also answer the shared-surface and +extension-point checklist under Embedder and SuperVerse reuse. Keep these documents current with the code: @@ -238,3 +273,16 @@ Keep these documents current with the code: and OpenAPI files use the repository Apache-2.0 header. - Generated files and the vendored Dex skill follow their own recorded license and are excluded from header rewriting. + +## @superdurable/superagent-ui releases + +GitHub Release tags and npm publishes for `@superdurable/superagent-ui` must +target a commit that is already on `origin/main`. + +- Never create a `vX.Y.Z` tag, GitHub Release, or npm publish from a feature + branch, PR head, or any commit that is not an ancestor of `origin/main`. +- Merge the change to `main` first. Only then tag `vX.Y.Z` on that `main` + commit and publish the Release so `npm-release.yml` and + `github-release-ui.yml` can pass their main-ancestor checks. +- If a tag was created from a feature branch by mistake, do not reuse it. + Delete or supersede it and cut the next patch version from `main`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b897ce6..b71a5c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,12 @@ strict `vX.Y.Z` tag on `main`. The release workflows derive the package version from the tag in the temporary runner checkout. The committed workspace version does not require a manual release bump. +Never create the release tag from a feature branch or PR head. Merge to +`origin/main` first, then tag that main commit. Both `npm-release.yml` and +`github-release-ui.yml` reject tags whose commit is not an ancestor of +`origin/main`. A mistaken feature-branch tag must not be reused; cut the next +patch version from `main` instead. + The npm package must trust the `superdurable/superagent` GitHub repository with workflow filename `npm-release.yml`, no environment, and direct `npm publish` permission. The workflow uses npm trusted publishing and GitHub OIDC. Do not add diff --git a/web/package-lock.json b/web/package-lock.json index c3263d4..8f54e15 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1047,9 +1047,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1067,9 +1064,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1087,9 +1081,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1107,9 +1098,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1127,9 +1115,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1147,9 +1132,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5871,9 +5853,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5895,9 +5874,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5919,9 +5895,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5943,9 +5916,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/web/packages/superagent-ui/README.md b/web/packages/superagent-ui/README.md index f287048..5a22994 100644 --- a/web/packages/superagent-ui/README.md +++ b/web/packages/superagent-ui/README.md @@ -26,15 +26,53 @@ remain unchanged. ```tsx import { + ActivityRow, + ApprovalCard, ConversationComposer, + ConversationTimeline, MarkdownContent, PendingMessageQueue, PendingQuestionBatch, + PlanPanel, + TimerCard, + ToolCallCard, ToolRecoveryPanel, + buildConversationTimeline, + pairToolCallsById, + planActionPresentation, + useTimelineFollow, } from "@superdurable/superagent-ui"; import "@superdurable/superagent-ui/styles.css"; ``` +## Conversation timeline + +`buildConversationTimeline` merges durable messages, consumed-user projections, +reasoning, activities, and the live assistant into one chronologically ordered +list. `ConversationTimeline` applies that order and renders through slots +(`renderMessage`, `renderActivity`, `renderReasoning`, `renderAssistant`, +`renderConsumedUser`) so Studio can keep its own markup while sharing sort +logic. + +## Tool call cards + +`pairToolCallsById` joins assistant `toolCalls` to `role=tool` messages by call +id. `ToolCallCard` collapses request and result by default: + +- `apply_patch` → red/green patch diff from the request `patch` field +- `exec_short_command` / `exec_long_command` → highlighted `$` command plus grey + stdout/stderr (ANSI stripped) +- other tools → generic JSON request/result + +Override with `renderToolCall` when a product needs a custom row. + +## Shared chrome and helpers + +Also exported: `ActivityRow`, `PlanPanel` / `planActionPresentation`, +`ApprovalCard`, `TimerCard`, `mergeSequencedMessages`, `mergeActivityEvent`, +live-text helpers, and `useTimelineFollow` (optional element scroll root for +panel scrollers). + `ConversationComposer` is controlled. `PendingMessageQueue` receives plain view models and reports semantic actions by item ID, so it has no knowledge of Dex, HTTP, generated clients, or product-specific stores. `MarkdownContent` renders diff --git a/web/packages/superagent-ui/src/ActivityRow.tsx b/web/packages/superagent-ui/src/ActivityRow.tsx new file mode 100644 index 0000000..5dc8790 --- /dev/null +++ b/web/packages/superagent-ui/src/ActivityRow.tsx @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Super Durable, Inc. + * Licensed under the Apache License, Version 2.0. + * SPDX-License-Identifier: Apache-2.0 + */ + +"use client"; + +export function activityIcon(kind: string): string { + switch (kind) { + case "plan_started": + case "plan_updated": + case "plan_task_updated": + return "☷"; + case "input_consumed": + return "⇥"; + case "user_input_answered": + return "✓"; + case "steering_applied": + return "↪"; + case "model_started": + case "model_failed": + case "model_completed": + return "✦"; + case "model_tool_call": + case "tool_progress": + case "tool_failed": + case "tool_recovery_required": + case "tool_recovery_resolved": + case "tool_completed": + return "⚙"; + case "user_input_requested": + return "?"; + case "compaction_failed": + case "compacted": + case "snapshot_required": + return "↻"; + default: + return "•"; + } +} + +export function activityLabel(kind: string, toolName: string | null): string { + const label = kind + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + return toolName === null || toolName === "" + ? label + : `${label} · ${toolName}`; +} + +export function formatActivityTime(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(date); +} + +export interface ActivityRowProps { + kind: string; + message: string; + createdAt: string; + toolName?: string | null; + className?: string; +} + +export function ActivityRow({ + kind, + message, + createdAt, + toolName = null, + className = "sa-activity-row", +}: ActivityRowProps) { + return ( +
+ +
+ {activityLabel(kind, toolName)} + {message} +
+ +
+ ); +} diff --git a/web/packages/superagent-ui/src/ApprovalTimerCards.tsx b/web/packages/superagent-ui/src/ApprovalTimerCards.tsx new file mode 100644 index 0000000..0e36598 --- /dev/null +++ b/web/packages/superagent-ui/src/ApprovalTimerCards.tsx @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Super Durable, Inc. + * Licensed under the Apache License, Version 2.0. + * SPDX-License-Identifier: Apache-2.0 + */ + +"use client"; + +export interface ApprovalCardProps { + toolName: string; + argumentsJson: string; + disabled?: boolean; + isSubmitting?: boolean; + onApprove: () => void; + onReject: () => void; + className?: string; +} + +export function ApprovalCard({ + toolName, + argumentsJson, + disabled = false, + isSubmitting = false, + onApprove, + onReject, + className = "sa-approval-card", +}: ApprovalCardProps) { + const label = isSubmitting ? "Processing…" : null; + return ( +
+

Approval required

+

{toolName}

+
{argumentsJson}
+
+ + +
+
+ ); +} + +export interface TimerCardProps { + durationSeconds: number; + reason: string; + className?: string; +} + +export function TimerCard({ + durationSeconds, + reason, + className = "sa-timer-card", +}: TimerCardProps) { + return ( +
+

Durable timer

+

{durationSeconds}s

+

{reason}

+ Steering interrupts this wait at a safe boundary. +
+ ); +} diff --git a/web/packages/superagent-ui/src/ConversationTimeline.test.tsx b/web/packages/superagent-ui/src/ConversationTimeline.test.tsx new file mode 100644 index 0000000..1f8819f --- /dev/null +++ b/web/packages/superagent-ui/src/ConversationTimeline.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Super Durable, Inc. + * Licensed under the Apache License, Version 2.0. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ConversationTimeline } from "./ConversationTimeline"; +import { ToolCallCard } from "./ToolCallCard"; + +describe("ConversationTimeline", () => { + it("renders entries in chronological order and honors custom activity slot", () => { + render( + ( +
{`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 ( +
+ + {isExpanded && ( +
+
+
+

Plan revision {revision}

+

{statusLabel(status)}

+
+ {status !== "completed" && ( + + )} +
+ {action.reason !== null && status !== "completed" && ( +

{action.reason}

+ )} +
    + {tasks.map((task, index) => { + const taskStatus = statuses[index] ?? task.status; + return ( +
  1. + +
    + {statusLabel(taskStatus)} +

    {task.content}

    +
    +
  2. + ); + })} +
+
+ )} +
+ ); +} + +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 ·{" "} - {" "} - · {entry.value.isComplete ? "Complete" : "Streaming"} - - -
- ); - } - if (entry.kind === "activity") { - return ( -
- -
- {activityLabel(entry.value.value)} - {entry.value.value.message} -
-
+ )} + renderReasoning={(entry) => ( +
+ + Reasoning summary ·{" "} + {" "} + · {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 -
-

{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)} - -
- {message.content !== "" && - (message.role === MessageRole.ASSISTANT ? ( - - ) : ( -

{message.content}

- ))} - {visibleToolCalls.map((call) => ( -
- Tool request · {call.name} -
{call.argumentsJson}
-
- ))} -
- ); - })} + }} + /> {hasSidebar && (