Elements

Run activity

Commentary and tools in one disclosure, with the final answer always visible.

Inspecting the files

Working
fig. 01 · plays once, replay from the corner

Installation

Install manually
npx shadcn@latest add "@assistant-ui/elements-run-activity"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

RunActivity folds a run's public commentary and tools into one disclosure. While running, the collapsed view shows the latest nonempty activity label. After completion, the same row can read “Worked for 2m 13s”. Decisions and final answers stay outside the collapsed content.

The element is a props-driven React registry component. It does not execute tools, classify model output, or add a runtime. Use Tool timeline when you only need a list of tool steps and file statistics.

Getting started

import { useState } from "react";
import { RunActivity } from "@/components/assistant-ui/elements/run-activity";

function CompletedRun() {
  const [open, setOpen] = useState(false);
  return (
    <RunActivity
      status="complete"
      statusLabel="Worked for"
      durationLabel="2m 13s"
      open={open}
      onOpenChange={setOpen}
      entries={[
        {
          id: "commentary-1",
          kind: "commentary",
          label: "Inspecting the files",
          content: <p>I’ll inspect the files.</p>,
        },
        {
          id: "read-1",
          kind: "tool",
          label: "Reading thread.tsx",
          content: <p>Read thread.tsx</p>,
        },
        {
          id: "commentary-2",
          kind: "commentary",
          label: "Checking the fix",
          content: <p>I found the issue; checking the fix.</p>,
        },
        {
          id: "test-1",
          kind: "tool",
          label: "Running the tests",
          content: <p>All tests passed.</p>,
        },
      ]}
    >
      <p>The fix is ready. The tests pass.</p>
    </RunActivity>
  );
}

Only put public commentary and tool history in entries, in their original order. Supply stable IDs and short activity labels. Put the explicitly identified final answer in children; the element never guesses whether a text part is commentary or an answer. Empty runs show a status without an empty disclosure button.

With an external-store runtime

The demo on this page runs through useExternalStoreRuntime, ThreadPrimitive.Viewport, and MessagePrimitive.PartByIndex. Its complete, tested integration includes conversion, timing, status mapping, tool approvals, and scroll locking.

Keep one stable assistant message ID for each application-defined run, and give every message in the transcript its persisted createdAt; a message without one is stamped with the current time, which Next.js cacheComponents does not allow while prerendering. Your adapter must identify run boundaries and classify each part before rendering. The example stores only presentation annotations and timing in metadata.custom.activityPresentation; this is a recipe convention, not a built-in assistant-ui metadata field. Content, approval state, and message status are read from the live runtime, not copied into metadata.

Abridged external-store adapter
const transcript: readonly ActivityMessage[] = [
  {
    id: "user-1",
    role: "user",
    content: "Check the files.",
    createdAt: new Date("2026-01-01T00:00:00Z"),
  },
  run,
];

const runtime = useExternalStoreRuntime<ActivityMessage>({
  messages: transcript,
  isRunning,
  convertMessage: convertActivityMessage,
  onNew: sendMessage,
  onRespondToToolApproval: respondToApproval,
  onAddToolResult: recordToolResult,
  onResumeToolCall: resumeToolCall,
});

ActivityMessage is the recipe's ActivityRun | ThreadMessageLike union. Its convertActivityMessage passes ordinary messages through and annotates only explicit runs. In an application, transcript comes from state: onNew records the user turn as a normal message, sets isRunning, and updates the corresponding run as the response arrives. The callbacks above stand for your backend integration; the demo uses a scripted single run.

Register the example's ActivityRunMessage as the assistant renderer. It checks for activityPresentation before mounting the run disclosure. Ordinary assistant messages and the runtime's optimistic placeholder use MessagePrimitive.Parts with the existing text/tool renderers. This keeps an empty, running transcript and a new user turn safe before the backend supplies its first run.

The run conversion covers text and tool-call parts. It assigns each text part its application-owned id and preserves each tool's toolCallId. Annotations are keyed by part type and ID, so runtime normalization can remove blank text without shifting another part's classification. Supply nonempty IDs that are unique within each part type and stable throughout the run.

In the message renderer, read the annotations alongside s.message.parts and s.message.status with useAuiState. Look up each annotation by text:${part.id} or tool-call:${part.toolCallId}. Render each classified part with MessagePrimitive.PartByIndex, using its index in the converted message and your existing text/tool components. Put commentary and tool entries in the disclosure, answer parts in children, and attention parts in attention. Unclassified parts stay outside the disclosure. Do not also render MessagePrimitive.Parts for the whole message: that would duplicate the content.

The example keeps approval requests shown to the user, tool interruptions, tool errors, and runtime requires-action or incomplete tool parts in attention even if their original classification was tool. This includes status-only tool decisions without an approval or interrupt payload. Policy-approved tools with approval.isAutomatic stay in the history unless they encounter an error, finish incomplete, or need further attention. Once surfaced, an entry stays in the visible slot for that message instance after settlement. Persist its attention classification or approval/interrupt metadata to retain that placement after a reload. It uses the existing ToolFallback, so the runtime's approval or tool-result callback still receives the response. Supply your app's clarification and recovery renderers there as well. Classification alone does not implement an approval or retry: wire the appropriate external-store callbacks to your backend and persist their result.

For adjacent activity groups, prefer MessagePrimitive.GroupedParts with a custom groupBy and standalone tool handling. This recipe uses PartByIndex because a single run disclosure must include activity on both sides of a visible decision: GroupedParts coalesces adjacent parts, so an ungrouped decision splits that sequence into separate disclosures. The props-only element works with any runtime; this adapter example specifically covers external-store runs with explicit classification.

If your backend emits several messages per run, project them into one stable assistant message in the adapter, preserving their part order and explicit final-answer markers. Do not infer an answer from the last text part, punctuation, or a tool finishing. Retain stable part IDs and order while a run streams; start a new run/message ID for a new turn.

Lifecycle and persisted time

Runtime message statusElement statusExample label
runningrunningWorking
requires-actionrequires-actionNeeds your input
completecompleteWorked for
incomplete, reason cancelledcancelledStopped after
incomplete, reason errorerrorFailed after
Other incomplete reasonsincompleteIncomplete

All labels are supplied by the app for localization. Use a complete label such as “Completed” when no duration is available. An interrupted, cancelled, or failed run must not be relabeled as successful completion just because streaming stopped.

Persist the run's status, classification, start timestamp, and settled duration or end timestamp alongside its content. A running clock may derive elapsed time from that persisted start; a completed run must use the persisted duration, not the time since mounting. If your adapter supplies metadata.timing.totalStreamTime with the desired run scope, format that value directly. Missing timing should remain absent.

durationLabel accepts a React node so a small clock component can update without rerendering the transcript. The demo uses the existing useTaskElapsed utility with an application-owned start/end pair. Its scripted timestamps demonstrate the contract; they are not measurements of a live model. While running, only statusLabel enters the live region, so clock ticks and streaming tokens do not interrupt screen-reader announcements. Once the run settles, the announcement includes its recorded duration.

Expansion, focus, and scrolling

Keep open in the message renderer, initially false, keyed by the stable run/message ID. This keeps runs compact by default and preserves explicit expansion or collapse across new activity and completion. Do not force open to false when streaming ends: a user may be inspecting or focusing a control in the history.

Inside a runtime viewport, call useScrollLock before changing open, as the integration example does. The element does not call scrollIntoView or move focus when its status changes. Its disclosure uses the installed Base UI or Radix primitive, native keyboard activation, a visible focus ring, and reduced-motion styles. Applications with a custom virtualized transcript should apply their existing scroll anchoring around the disclosure.