Steer composer

Pro

Send a message into a run while it works, and see whether the agent has read it. Each message says where it is: sending, sent but not picked up yet, picked up (and when), or not delivered with the reason and a Retry. A message isn't shown as read until the service says so. The note above the box says when the agent reads messages, or that the run is quiet, offline or waiting on the person (a message doesn't answer its question). A finished run takes no messages and says so. Enter sends and Shift+Enter breaks a line.

Markdown

Preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/steer-composer

Brings in: button, locale, relative-time, run-heartbeat, status, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import type { SteerMessage } from "@/components/steer-composer"
import { SteerComposer } from "@/components/steer-composer"

const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString()

const initial: SteerMessage[] = [
  { id: "m1", text: "Skip the archived orders; they’re read-only.", sentAt: minutesAgo(6), state: "picked-up", pickedUpAt: minutesAgo(5) },
  { id: "m2", text: "Use the EU region for the new column’s default.", sentAt: minutesAgo(1), state: "queued" },
]

export default function Example() {
  const [messages, setMessages] = React.useState(initial)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  React.useEffect(() => () => timers.current.forEach(clearTimeout), [])
  const move = (id: string, patch: Partial<SteerMessage>, after: number) =>
    timers.current.push(setTimeout(() => setMessages((ms) => ms.map((m) => (m.id === id ? { ...m, ...patch } : m))), after))

  return (
    <SteerComposer
      className="max-w-md"
      agentName="Build agent"
      run={{ status: "running", lastHeardAt: minutesAgo(0.5) }}
      messages={messages}
      onSend={(text) => {
        const id = String(Date.now())
        setMessages((ms) => [...ms, { id, text, sentAt: new Date().toISOString(), state: "sending" }])
        // Your service says when it has the message, and again when the agent reads it.
        move(id, { state: "queued" }, 600)
        move(id, { state: "picked-up", pickedUpAt: new Date().toISOString() }, 4000)
      }}
      onWithdraw={(id) => setMessages((ms) => ms.filter((m) => m.id !== id))}
    />
  )
}

<SteerComposer>

PropTypeDefaultDescription
runrequired{ status: RunStatus; lastHeardAt?: Date | string | undefined; hostOffline?: HostOffline | undefined }—The run being steered: its status, when it was last heard from, and whether its host is offline.
messagesrequiredSteerMessage[]—Messages already sent, oldest first, each with where it is.
onSendrequired(text: string) => void—Called with the person's words. Add the message to `messages` (as "sending" or "queued") to show it.
onRetry((id: string) => void) | undefined—Called with a failed message's id when the person picks Retry. Without it, there's no Retry.
onWithdraw((id: string) => void) | undefined—Called with a queued message's id to take it back before the agent reads it. Without it, there's no Withdraw.
agentNamestring | undefined—The agent's name, from your app, used in "Build agent hasn't picked it up yet".
pickupNotestring | null | undefined—When your agent reads new messages, in your words. Defaults to "between steps"; null hides it.
staleAfternumber | undefinedSTALE_AFTERHow long a running run may be silent before it's called quiet, in milliseconds. Defaults to two minutes.
placeholderstring | undefined——
classNamestring | undefined—Extra classes for the outer element.

Strings

The fixed words, in English by default. Change any of them under steerComposer in <LocaleProvider strings={{ … }}>; the table is exported as steerComposerStrings.

KeyEnglish
labelMessage to the run
messagesMessages to the run
placeholderAdd a note or change direction
sendSend
sendingSending…
queuedSent. The agent hasn’t picked it up yet.
queuedBySent. {agent} hasn’t picked it up yet.
pickedUpPicked up
pickedUpByPicked up by {agent}
failedNot delivered
failedWhyNot delivered: {reason}
retryRetry
retryLabelRetry “{text}”
withdrawWithdraw
withdrawLabelWithdraw “{text}”
pickupNoteThe agent reads new messages between steps, so it may finish what it’s doing first.
waitingNoteThe run is waiting on you. A message here doesn’t answer what it asked.
staleNote{status}, so it may not read this soon.
offlineNote{status}. Messages wait until it’s back.
endedThis run has ended, so it can’t take messages.
hintSendEnter to send, Shift+Enter for a new line

Types

SteerState
Where a message to a running run is. "sending": on its way to your service. "queued": your service has it, and the agent hasn't read it yet. "picked-up": the agent has read it (at `pickedUpAt`). "failed": it wasn't delivered (`reason` says why).
"sending" | "queued" | "picked-up" | "failed"
SteerMessage
One message sent into a run while it works.
{ id: string /** The person's words, shown as plain text. */ text: string sentAt: Date | string state: SteerState /** When the agent read it, when the service reports that. */ pickedUpAt?: Date | string | undefined /** Why it wasn't delivered. Shown with "failed". */ reason?: string | undefined }