agents
Background Tasks Drawer
A side drawer that tracks an agent's background work live — running tasks with elapsed time, stats, and stop controls, plus an expandable history of finished commands and output.
"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import {
Bot,
Check,
ChevronDown,
ChevronRight,
Maximize2,
Minimize2,
Square,
Terminal,
Trash2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type BackgroundTaskStatus = "running" | "done" | "error" | "stopped";
export interface BackgroundTask {
id: string;
title: string;
/** "agent" tasks show live stats and a transcript link; "shell" tasks show their command and output. */
kind: "agent" | "shell";
/** Short label for the kind, e.g. "Agent" or "Bash". */
kindLabel?: string;
status: BackgroundTaskStatus;
elapsedSeconds: number;
/** Present-tense label for what the task is doing right now. */
activity?: string;
/** Compact live facts, e.g. ["Opus", "54.4k tokens", "6 tool uses"]. */
stats?: string[];
command?: string;
output?: string;
}
export interface BackgroundTasksDrawerProps {
open: boolean;
onClose: () => void;
tasks: BackgroundTask[];
onStop?: (id: string) => void;
onClearFinished?: () => void;
onViewTranscript?: (id: string) => void;
title?: string;
className?: string;
}
const STATUS_LABEL: Record<Exclude<BackgroundTaskStatus, "running">, string> = {
done: "Completed",
error: "Failed",
stopped: "Stopped",
};
function formatElapsed(seconds: number) {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s.toString().padStart(2, "0")}s`;
}
export function BackgroundTasksDrawer({
open,
onClose,
tasks,
onStop,
onClearFinished,
onViewTranscript,
title = "Background tasks",
className,
}: BackgroundTasksDrawerProps) {
const reduceMotion = useReducedMotion();
const [expanded, setExpanded] = React.useState(false);
const [finishedOpen, setFinishedOpen] = React.useState(true);
const panelRef = React.useRef<HTMLDivElement>(null);
// Only move focus when the drawer is opened after mount, never on initial render,
// so a drawer that starts open doesn't steal focus or scroll the page on load.
const wasOpen = React.useRef(open);
const running = tasks.filter((t) => t.status === "running");
const finished = tasks.filter((t) => t.status !== "running");
React.useEffect(() => {
const justOpened = open && !wasOpen.current;
wasOpen.current = open;
if (!open) return;
if (justOpened) panelRef.current?.focus({ preventScroll: true });
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.aside
ref={panelRef}
tabIndex={-1}
role="complementary"
aria-label={title}
initial={reduceMotion ? { opacity: 0 } : { x: "100%", opacity: 0.6 }}
animate={{ x: 0, opacity: 1 }}
exit={reduceMotion ? { opacity: 0 } : { x: "100%", opacity: 0.6 }}
transition={{ type: "spring", stiffness: 380, damping: 38 }}
className={cn(
"flex h-full flex-col overflow-hidden border-l bg-background shadow-xl outline-none transition-[width] duration-300",
expanded ? "w-full" : "w-full max-w-sm",
className
)}
>
<header className="flex items-center gap-1 px-4 pb-2 pt-3">
<h2 className="flex-1 text-sm font-medium">{title}</h2>
<IconButton
label="Clear finished tasks"
onClick={onClearFinished}
disabled={finished.length === 0}
>
<Trash2 className="size-3.5" />
</IconButton>
<IconButton
label={expanded ? "Collapse panel" : "Expand panel"}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
</IconButton>
<IconButton label="Close" onClick={onClose}>
<X className="size-4" />
</IconButton>
</header>
<p aria-live="polite" className="sr-only">
{running.length > 0
? `${running.length} task${running.length === 1 ? "" : "s"} running`
: "No tasks running"}
</p>
<LayoutGroup>
<div className="flex-1 space-y-4 overflow-y-auto px-4 pb-4">
<section>
<h3 className="py-2 text-xs text-muted-foreground">Running</h3>
{running.length === 0 ? (
<p className="rounded-xl border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
Nothing running right now
</p>
) : (
<ul className="space-y-2">
<AnimatePresence initial={false} mode="popLayout">
{running.map((task) => (
<RunningCard
key={task.id}
task={task}
onStop={onStop}
onViewTranscript={onViewTranscript}
/>
))}
</AnimatePresence>
</ul>
)}
</section>
{finished.length > 0 && (
<motion.section layout="position">
<button
type="button"
onClick={() => setFinishedOpen((v) => !v)}
aria-expanded={finishedOpen}
className="flex items-center gap-1 py-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Finished {finished.length}
<ChevronDown
className={cn("size-3 transition-transform", !finishedOpen && "-rotate-90")}
/>
</button>
<AnimatePresence initial={false}>
{finishedOpen && (
<motion.ul
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="space-y-2 overflow-hidden"
>
<AnimatePresence initial={false} mode="popLayout">
{finished.map((task) => (
<FinishedRow key={task.id} task={task} />
))}
</AnimatePresence>
</motion.ul>
)}
</AnimatePresence>
</motion.section>
)}
</div>
</LayoutGroup>
</motion.aside>
)}
</AnimatePresence>
);
}
function RunningCard({
task,
onStop,
onViewTranscript,
}: {
task: BackgroundTask;
onStop?: (id: string) => void;
onViewTranscript?: (id: string) => void;
}) {
const KindIcon = task.kind === "agent" ? Bot : Terminal;
const meta = task.stats ?? [];
return (
<motion.li
layoutId={task.id}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ type: "spring", stiffness: 420, damping: 36 }}
className="relative overflow-hidden rounded-xl bg-muted/60 p-3"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{task.title}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<KindIcon className="size-3" aria-hidden />
{task.kindLabel ?? (task.kind === "agent" ? "Agent" : "Shell")}
<span className="font-mono tabular-nums">{formatElapsed(task.elapsedSeconds)}</span>
</p>
</div>
{onStop && (
<button
type="button"
onClick={() => onStop(task.id)}
aria-label={`Stop ${task.title}`}
className="flex size-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground transition-colors hover:text-foreground"
>
<Square className="size-2.5 fill-current" />
</button>
)}
</div>
{(meta.length > 0 || task.activity) && (
<p className="mt-1.5 text-[11px] leading-relaxed text-muted-foreground">
{meta.join(" · ")}
{task.activity && (
<>
{meta.length > 0 && " · "}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={task.activity}
className="bg-clip-text text-transparent"
style={{
backgroundImage:
"linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)",
backgroundSize: "200% 100%",
}}
initial={{ opacity: 0, backgroundPositionX: "150%" }}
animate={{ opacity: 1, backgroundPositionX: "-50%" }}
exit={{ opacity: 0 }}
transition={{
opacity: { duration: 0.15 },
backgroundPositionX: { repeat: Infinity, repeatType: "loop", duration: 1.4, ease: "linear", repeatDelay: 0.8 },
}}
>
{task.activity}
</motion.span>
</AnimatePresence>
</>
)}
</p>
)}
{task.kind === "agent" && onViewTranscript && (
<button
type="button"
onClick={() => onViewTranscript(task.id)}
className="mt-1.5 text-xs font-medium text-sky-700 hover:underline dark:text-sky-400"
>
View transcript
</button>
)}
<motion.span
aria-hidden
className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-foreground/40 to-transparent"
initial={{ x: "-100%" }}
animate={{ x: "100%" }}
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
/>
</motion.li>
);
}
function FinishedRow({ task }: { task: BackgroundTask }) {
const [open, setOpen] = React.useState(false);
const hasDetails = Boolean(task.command || task.output);
const status = task.status as Exclude<BackgroundTaskStatus, "running">;
return (
<motion.li
layoutId={task.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, height: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 36 }}
className={cn(
"overflow-hidden rounded-xl border transition-colors",
open ? "bg-background" : "bg-muted/40"
)}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={hasDetails ? open : undefined}
disabled={!hasDetails}
className="flex w-full items-start gap-2 p-3 text-left disabled:cursor-default"
>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1 truncate text-sm text-foreground/70">
{task.title}
{hasDetails && (
<ChevronRight
className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")}
aria-hidden
/>
)}
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{task.kindLabel ?? (task.kind === "agent" ? "Agent" : "Shell")}
<StatusBadge status={status} />
<span className="font-mono tabular-nums">{formatElapsed(task.elapsedSeconds)}</span>
</p>
</div>
</button>
<AnimatePresence initial={false}>
{open && hasDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="space-y-2 px-3 pb-3">
{task.command && (
<pre className="whitespace-pre-wrap break-words rounded-lg border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed">
<span className="select-none text-muted-foreground">$ </span>
{task.command}
</pre>
)}
{task.output && (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-muted px-3 py-2 font-mono text-[11px] leading-relaxed text-foreground/80">
{task.output}
</pre>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.li>
);
}
function StatusBadge({ status }: { status: Exclude<BackgroundTaskStatus, "running"> }) {
return (
<span
className={cn(
"inline-flex items-center gap-1",
status === "done" && "text-emerald-700 dark:text-emerald-400",
status === "error" && "text-destructive"
)}
>
{status === "done" && <Check className="size-3" aria-hidden />}
{status === "error" && <X className="size-3" aria-hidden />}
{status === "stopped" && <Square className="size-2.5" aria-hidden />}
{STATUS_LABEL[status]}
</span>
);
}
function IconButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick?: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || !onClick}
aria-label={label}
title={label}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
{children}
</button>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Background Tasks Drawer
## Summary
A side drawer listing the work an agent has kicked off in the background — sub-agents, long shell commands, builds — split into a live "Running" section and a collapsible "Finished" history. Running tasks tick in real time (elapsed time, token and tool-use counts, a shimmering current-activity label) and can be stopped; finished tasks collapse to one line and expand to show the command and its output. It lets the user keep chatting while still being able to check on, inspect, or cancel what's happening off to the side.
## When to use
- An agent runs tasks that outlive a single turn or run in parallel with the conversation (a sub-agent, a dev server, a test run, an install).
- The user needs a single place to answer "what is still running, and what did it do?" without scrolling back through the transcript.
- Tasks can be cancelled independently and the user should be able to do so without interrupting the main agent.
## When not to use
- For steps that run inline and finish within the current response — use Expandable Trace or Tool Call Chip in the message instead.
- To visualise an orchestrator's tree of sub-agents as part of the answer itself — use Multi-Agent Trace; this drawer is a flat, app-level monitor, not a message.
- For a single long command whose output the user is actively watching — use Terminal Stream inline.
## Anatomy
- Header: title ("Background tasks"), a clear-finished action, an expand/collapse-width toggle, and a close button.
- Running section: a label and one card per active task, each with
- title,
- kind label (Agent / Bash) with a live elapsed timer,
- a compact stats line (model, tokens, tool uses) ending in the current activity, rendered with a shimmer,
- a "View transcript" link for agent tasks,
- a stop button,
- a thin sweeping progress line along the bottom edge to signal liveness.
- Finished section: a disclosure header with a count ("Finished 3"), then one row per task with title, kind, outcome (Completed / Failed / Stopped) and final duration; expanding a row reveals the command and its output in monospace blocks.
- Empty state: a dashed "Nothing running right now" placeholder under Running.
## Behavior
- The drawer slides in from the right edge and pushes no content — it overlays the app. Escape and the close button dismiss it; closing never stops tasks.
- Running cards update in place every second; the activity label crossfades when it changes rather than jumping.
- When a task finishes, fails, or is stopped, its card animates out of Running and into the top of Finished (shared layout), so the user can follow where it went.
- Stopping a task is immediate and moves it to Finished with a "Stopped" outcome; no confirmation, since nothing is destroyed.
- "Clear finished" removes only finished rows and is disabled when there are none.
- The Finished section can be collapsed; finished rows are collapsed by default and only expand on click.
- The expand toggle widens the drawer to the full container for reading long output.
## Content guidelines
- Task titles describe the goal in a few words ("Install dependencies", "Baseline build on origin/main"), not the raw command — the command lives in the details.
- Activity labels are short, present-tense phrases ("Running a command", "Editing component.tsx"), matching Thinking Loader and Tool Call Chip.
- Stats are terse and scannable: "54.4k tokens", "6 tool uses". Durations use "59s" under a minute and "2m 05s" above.
- Outcome words are past tense and consistent: Completed, Failed, Stopped.
## Accessibility
- The drawer is a labelled landmark (`aria-label` = its title) and receives focus (without scrolling the page) when the user opens it — never on initial render, so a drawer that starts open doesn't steal focus or jump the page on load; Escape closes it.
- A single `aria-live="polite"` region announces the running count, so per-second ticks don't flood assistive tech.
- Every icon-only control (clear, expand, close, stop) has an `aria-label` naming the action and, for stop, the task.
- Finished rows expose `aria-expanded`; the Finished disclosure does too.
- Outcome is conveyed by text plus icon, never color alone.
- Respect `prefers-reduced-motion`: the drawer fades instead of sliding.
## Related patterns
- Multi-Agent Trace shows parallel sub-agents inline in a response; this drawer is the persistent, app-level view of the same kind of work.
- Terminal Stream is what a single shell task's output looks like when watched live.
- Stop Generation Button handles cancelling the main response; the stop control here cancels one background task.
- Thinking Loader supplies the shimmer language used for the live activity label.