Elements

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". With onCollect, each row is a button: disabled while running and clickable for both ready and failed. Without it, every row is non-interactive status content. 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.