58 / Agent UI
Stopped Run
What the thread says after an answer ended early, with whatever it had already written kept above the line.
The retry helper backs off between attempts, so the fix is to keep the delay and remove the listener before adding it again. The reconnect path currently registers
"use client"
import * as React from "react"
import { StoppedRun } from "mischief-ui/stopped-run"
export function StoppedRunDemo() {
const [reason, setReason] = React.useState<"stopped" | "error">("stopped")
return (
<div className="w-full max-w-lg space-y-3">
<StoppedRun
reason={reason}
elapsed={7.4}
onRetry={() => {}}
onResume={reason === "stopped" ? () => {} : undefined}
>
<p>
The retry helper backs off between attempts, so the fix is to keep the
delay and remove the listener before adding it again. The reconnect
path currently registers
</p>
</StoppedRun>
<button
type="button"
onClick={() => setReason(reason === "stopped" ? "error" : "stopped")}
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-4"
>
Show it as {reason === "stopped" ? "a failure" : "a stop"}
</button>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/stopped-runimport { StoppedRun } from "mischief-ui/stopped-run"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { CircleAlert, Hand, RotateCw, Timer } from "lucide-react" import { cn } from "@/lib/utils" export type StoppedReason = "stopped" | "error" | "limit" | "timeout" export type StoppedRunProps = Omit< React.HTMLAttributes<HTMLDivElement>, "children"> & { reason?: StoppedReasonUsage
export function Ended({ text }) {
return (
<StoppedRun reason="stopped" elapsed={7.4} onRetry={again}>
{text}
</StoppedRun>
)
}Keep what it wrote
Clearing the half-written answer when a run ends is the tidy choice and the wrong one. Someone stopped it because they had read enough, and the part they read is usually the part they wanted.
So the text stays, faded at its bottom edge, with a rule and a sentence underneath saying it ended and why.
Half a sentence is not an answer
The fade says incomplete to someone who can see it and nothing at all to someone who cannot. A screen reader would otherwise read the fragment in the same voice as a finished answer and stop, which is how a truncated instruction becomes a followed one.
The partial text is a group named as an incomplete answer, so it is announced as such before it is read, and the reason is announced through a live region as soon as it appears.
Why it ended
| Reason | What it says |
|---|---|
stopped | You stopped this answer |
error | Something went wrong |
limit | It reached its length limit |
timeout | It took too long and was cut off |
Carry on is offered only where carrying on makes sense, which is why it is a prop rather than something inferred: a stopped answer can be resumed and a failed one usually has to be run again.
API
reason"stopped" | "error" | "limit" | "timeout"Why it ended. Defaults to stopped.childrenReactNodeWhat arrived before it ended.messagestringReplaces the default sentence.elapsednumberSeconds it ran for.onRetry() => voidRun it again from the start.onResume() => voidCarry on from where it stopped.retryLabel, resumeLabelstringThe two buttons' text....rootPropsHTMLAttributes<HTMLDivElement>Native root attributes.Accessibility
The partial answer is a group named as incomplete, so assistive technology says so before reading it rather than delivering a fragment in the voice of a finished answer. The reason is announced through a polite live region and is also written on the element as data-reason. The fade over the text is decoration and repeats nothing that is not in the sentence below it. Both actions are ordinary buttons at 32px with visible focus.