Elements

Elements · Agents

Background runs

Work still going somewhere else, and the results waiting to be collected.

Running elsewhere1 ready
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-background-inbox"
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.

A background inbox lists work that kept going after you looked away: still running, or finished and waiting to be opened. With a runtime the rows come from your own thread list; standalone you hold the array yourself.

Getting started

Every assistant-ui thread can keep running after you switch away from it. The thread list already tracks which ones are still active, so the inbox is a small projection of that state rather than a separate subsystem.

Build the run list from thread state

components/assistant-ui/elements/running-elsewhere.tsx
"use client";

import { useAui, useAuiState } from "@assistant-ui/react";
import type { BackgroundRun } from "@/components/assistant-ui/elements/background-inbox";

function formatElapsed(date: Date | undefined) {
  if (!date) return "";
  const minutes = Math.round((Date.now() - date.getTime()) / 60000);
  return minutes < 1 ? "just now" : minutes < 60 ? `${minutes}m` : `${Math.round(minutes / 60)}h`;
}

function useBackgroundRuns(): BackgroundRun[] {
  const aui = useAui();
  const threadIds = useAuiState((s) => s.threads.threadIds);

  return threadIds.map((_, index) => {
    const item = aui.threads.item({ index }).getState();
    return {
      id: item.id,
      title: item.title ?? "Untitled",
      state: item.isRunning ? "running" : "ready",
      elapsed: formatElapsed(item.lastMessageAt),
    };
  });
}

Render it and wire collection to switching threads

import { BackgroundInbox } from "@/components/assistant-ui/elements/background-inbox";

export function RunningElsewhere() {
  const aui = useAui();
  const runs = useBackgroundRuns();

  return (
    <BackgroundInbox
      runs={runs}
      onCollect={(id) => aui.threads.item({ id }).switchTo()}
    />
  );
}

ThreadListItemState.isRunning only tells you whether a run is still in progress, so this mapping can only ever produce "running" or "ready". It has no signal for a run that ended in an error, so a "failed" row needs its own bookkeeping on top of the thread list.

Anatomy

<div data-slot="background-inbox">
  <div>
    <span>Running elsewhere</span>
    <span>{/* "{ready} ready" or "{running} in flight" */}</span>
  </div>
  <button>
    {/* one per run */}
    <span>{/* spinner, check, or x */}</span>
    <span>{/* title */}</span>
    <span>{/* summary, when present */}</span>
    <span>{/* elapsed */}</span>
  </button>
</div>

The header summary only ever counts ready and running rows: it reads "{ready} ready" whenever at least one run is ready, otherwise "{running} in flight". A failed run counts toward neither number, so an inbox holding only failed runs reads "0 in flight". Each row is a button: disabled and unclickable while running, clickable for both ready and failed (so a failed run can still be opened, not just dismissed). summary is optional per row and only renders when present. With an empty runs array the header still renders with "0 in flight" and no rows follow; there is no placeholder message.

Examples

All three states

<BackgroundInbox
  runs={[
    { id: "1", title: "Refactor auth module", state: "running", elapsed: "2m" },
    { id: "2", title: "Nightly report", state: "ready", elapsed: "14m", summary: "3 files changed" },
    { id: "3", title: "Migrate schema", state: "failed", elapsed: "1h" },
  ]}
/>

Collecting into an archived thread

switchTo accepts { unarchive: true }, so a background run that finished on an archived thread can still be collected without a separate unarchive step:

onCollect={(id) => aui.threads.item({ id }).switchTo({ unarchive: true })}

Restyle the inbox

The root uses the shared paper surface; the summary count, per-run summary, and elapsed time use mono.

<BackgroundInbox className="max-w-md rounded-3xl" runs={runs} onCollect={onCollect} />

API reference

Thread list state

SelectorTypeDescription
s.threads.threadIdsreadonly string[]Ids of every open (non-archived) thread.
aui.threads.item({ index }).getState()ThreadListItemStateFull state for the thread at that index, including title, lastMessageAt, and isRunning.
.isRunningbooleanWhether that thread has a run in progress, including one that continues after you switch away from it.

Client calls

CallDescription
aui.threads.item({ id }).switchTo(options?)Makes that thread the active one. { unarchive: true } also unarchives it.