affectUI › Screens › App screens

Agent / Thread

One conversation with your orchestrator – it answers in plain text with small task and decision cards, folds its parallel tools into one line, and keeps working through a dropped connection, a slow upload or a long time away.

Part of affectUI Pro. Install: npx shadcn@latest add https://www.affectui.com/r/agent-thread.json · Import from @/components/ui/agent-thread

When to use

  • You work through one orchestrator in one thread – it launches the sub-agents in the background and you only see the conversation, the tasks it mentions and the decisions it needs
  • A chat that has to hold up in real life: parallel tool calls, reading back while it streams, a dropped connection, slow uploads, dictation on a bad network, coming back after an hour

When not to use

  • Picking a model per message – the orchestrator chooses its models itself; pass `models` for a picker, or use AI Chat / Start
  • Watching every agent and trace of a run – use Agent / Orchestration or AI Chat / Run

Usage

import { AgentThreadScreen } from "@/components/ui/agent-thread";

export default function Page() {
  const { messages, send, stop, retry } = useOrchestrator(); // your stream – append to the last message as tokens arrive
  return (
    <AgentThreadScreen
      messages={messages}
      onSend={(text, attachments) => send(text, attachments)}
      onStop={stop}
      onRetry={retry}
      lastSeen={user.lastSeenAt}
      running={{ running: tasks.running, needs: inbox.open }}
      decisions={inbox.open}
      onDecide={(id, answer) => api.answer(id, answer)}
      onLater={(id) => api.snooze(id)}
      tasks={taskDetails}
      onShowTask={(id) => router.push(`/tasks?task=${id}`)}
      onOpenInbox={(item) => router.push(item ? `/inbox?item=${item}` : "/inbox")}
      upload={(file, progress, signal) => uploadWithProgress(file, progress, signal)}
      onNavigate={(label) => router.push(routes[label])}
    />
  );
}

Props

NameTypeDefaultDescription
onNavigate(label: string) => voidCalled with the label of a sidebar item – and with "Tasks" / "Inbox" when onOpenTask / onOpenInbox are left out
workspacestring"Fieldwork"The workspace in the sidebar
messages({ id; role: "user"; text; at; attachments?; status?: "sent" | "queued" | "sending" | "failed" } | { id; role: "agent"; at; parts; streaming?; stopped? })[]The conversation. An agent message is parts: { kind: "text", text } · { kind: "tools", id, tools: { id, label, icon?, status: "running" | "done" | "failed" | "stopped", startedAt?, ms?, error? }[] } · { kind: "task", task: { id, title, status, steps?: { done, total }, done? } } · { kind: "decision", ask: { id, question, task, kind? } } · { kind: "artifacts", names } · { kind: "error", text, retry? }. Stream by appending to the last text part and keep streaming: true until it ends. Pass a stable array (state) – the screen follows it. Left out: sample content, and anything you send gets a sample reply
onSend(text: string, attachments: ThreadAttachment[]) => voidCalled when a message goes out – written offline, it waits as "Waiting to send" and is called once you're back online. Attachments are the uploads that finished
onStop() => voidStop, or Esc in the field, while an answer streams
onRetry(messageId: string) => voidRetry on a failed message – or on an error line in an answer (that answer's id)
connection"online" | "offline" | "reconnecting"The connection, when your app knows it best (e.g. your socket's state). Left out, the screen follows the browser's online / offline events and retries on a countdown of 5, 10, 20, 30s
onReconnect() => voidRetry now in the banner – with `connection` set, reconnect here
lastSeennumberWhen you were last here, in ms – a "While you were away" card sums up what came after (done tasks, what needs you) where you left, and the thread opens there
awayAfternumber5Minutes in another tab after which coming back shows the away card too (visibilitychange)
onMarkRead() => voidMark as read on the line under the away card
decisions{ id; kind?: "decision" | "pick" | "approval"; question; task; taskId?; options?: { id; title; summary?; recommended?; wireframe? }[]; facts?: { label; value }[] }[]The decisions waiting on you, in order. The first docks as its own card just above the composer, with a "1 of 2" pager when there are more; 3–5 options, facts for an approval, wireframe tiles for a pick. Pass a stable array (state) – the screen follows it. Left out, the sample's
onDecide(id: string, answer: { option?: string; text?: string } | "approve" | "decline") => voidCalled when you answer – a click or 1–5 on a row, Enter on the focused row, or your own words typed in the card's last row, "Something else…". The answer shows as your message with "Answered: …" above it, the dock folds away and the next one docks; the head's "need you" counts down
onLater(id: string) => voidLater (or Esc in the dock): it folds to a one-line "1 decision waiting · Show" above the field
tasksRecord<string, { goal?; project?; started?; steps?: { label; done? }[]; history?; artifacts?; decided? }>What a task's tab in the work area shows, by task id – opened from a card's Open or the away card; another task takes the tab over, artifact tabs stay
onShowTask(taskId: string) => void"Show in Tasks" in a task's tab – the only way to the Tasks screen (default: onNavigate("Tasks"))
running{ running: number; needs: number }{ running: 9, needs: 5 }The head's quiet "9 running · 5 need you" – running opens Tasks, need you brings up the dock (the Inbox when nothing is docked); needs counts down as you answer and is the Inbox count in the sidebar too
onOpenTask(taskId: string) => voidCalled when a task opens as a tab in the work area – no page switch
onOpenInbox(itemId?: string) => voidThe head's "need you" when nothing is docked, the away card's Open Inbox, an "Asked" line for a question that isn't docked (default: onNavigate("Inbox"))
artifactsRecord<string, ShellArtifact>What each artifact chip opens in the work area – doc, table / sheet, code, image or files, as in the App Shell
suggestedWorkspaceSource[]Suggested in a New tab of the work area (its + and the corner button)
modelsChatModel[]Models to pick from in the composer – left out, there is no picker, since the orchestrator chooses its models itself
upload(file: File, progress: (p: number) => void, signal: AbortSignal) => Promise<void>Uploads a file you attached: report 0–1, stop on the signal (Cancel), reject for "Upload failed · Retry". Left out, uploads are simulated at 3 MB/s and pause while offline
transcribe(clip: ThreadAttachment) => Promise<string>Turns a voice note into text for the field – offline the clip waits as a chip and is transcribed once you're back. Left out, a sample sentence
simulate"live" | "scrolled" | "offline" | "uploading" | "dictating" | "away" | "error"A preset for previews: plays the sample story (you ask → parallel tools → streaming → a task card → the decision docks) or decision: the dock with four options and a second one to page to; or starts in one hard state. The switcher on this page's full-screen view is demo chrome that only sets this prop – it isn't part of the component
labels{ title?; orchestrator?; placeholder?; offline?; backOnline?; waiting?; away?; markRead?; jump?; needsYou?; decide?; open?; retry?; … }Labels, e.g. for a translation – title is the head's "Today"
user{ name: string; avatar?: string }{ name: "Alex Morgan" }Account at the bottom of the sidebar
classNamestringExtra class on the root

Keyboard

  • Enter: Send – offline it queues and goes out once you're back
  • Shift Enter: A new line – the field grows up to about ten lines, then scrolls
  • Esc: In the field: stop the answer while it streams; while dictating, discard the clip
  • End: Jump to the latest and follow it again – anywhere in the screen, as long as you're not typing
  • /: Focus the field
  • 1 / 2 / 3 / 4 / 5: Answer the docked decision with that row; the number after the last option goes to "Something else…"
  • ↑ ↓ / ← → / Home / End / Enter: In the dock: move through the rows (← → on tiles), Enter answers the focused one; a click answers at once, like Claude
  • Esc: In the dock: Later – it folds to one line above the field
  • Tab: Through the tool groups (Enter opens or folds one), every card's Open or Decide, the artifact chips, Retry and the composer's buttons

Accessibility

  • The conversation is role="log" with aria-live="polite" and aria-busy while an answer streams – a screen reader reads the finished answer, not every word
  • The connection banner is role="status"; "Waiting to send", "Back online", "Stopped" and upload failures are announced in a polite live region
  • The Jump pill is named with its count – "Jump to latest, 12 new lines, still writing" – and lists End as its shortcut
  • Each tool group is a button with aria-expanded; folded rows are inert, so Tab skips them; each row says whether it runs, failed or is done
  • Task cards are reached by their own Open, named with the task and its status
  • The dock is a role="group" labelled by its question; its rows are a listbox with a roving tabindex and aria-keyshortcuts 1–5; "Answered: Kafka" is announced politely and, answered from the keyboard, the focus moves to the composer
  • Where it asked, a button says "Asked: … · waiting" or "· answered below" – it brings the dock up (the focus lands on its option) or scrolls to your answer
  • Every control shows the 2px accent focus ring for the keyboard only (:focus-visible); the text field never shows one – only its edge firms up while you type
  • Shortcuts never fire while you type, apart from Enter, Shift Enter and Esc in the field

Responsive

  • Fills its container – give it the full viewport height in your app
  • One width, --th-col (680px): the orchestrator's text, every block – task cards, tool groups, the away card, error lines – the dock and the composer share the same edges; your bubbles stay right-aligned at most 80%
  • The decision is its own card, 8px above the composer and as wide – not an overlay: the thread above shrinks with it, so the latest message and the Jump pill stay above it. The composer always starts a new prompt; your own answer goes in the card's last row
  • Below 560px the column and the composer fill the width with 12px sides, bubbles take up to 88%, cards go full width and the composer hugs the edges
  • Artifacts open in the App Shell's work area – a panel on the right, on phones the shell's sheet
  • Touch: every control at least 36px. Dark mode built in: switches when an ancestor (usually <html>) has the class dark or data-theme="dark"

Requirements

  • React 19
  • @hugeicons/react
  • @hugeicons/core-free-icons
  • App Shell and Chat Composer – installed with it

Related

  • Agent / Inbox – What your orchestrator needs from you, one question at a time – pick an option, a variant or approve, send it in a keystroke and the next one glides in
  • Agent / Tasks – Your work as tasks, not agents – a calm list grouped by project, each row a ring, where it is in words and a small Needs you, and a click opens what happened so far and what it made
  • AI Chat / Agents – Chat with an orchestrating agent, the agents at work, and a brief to fill in beside it
  • Chat Composer – Prompt composer with model picker, voice input, app connections and approval request
  • Thinking State – AI working panel with a live timer, searched sources and thinking steps that appear one by one
  • Tool Call Card – An agent's tool call in one line – live timer, status and summary, folds open to highlighted arguments and result with copy, retry on errors and a group that sums them up