Plan Card
The plan a harness wrote before it started work — its markdown, its checklist, and the one Build button that moves the conversation on.
Retry a dropped stream once, then surface the failure instead of hanging.
What breaks today
A dropped socket leaves the turn spinning: nothing retries and nothing errors.
Steps
- Wrap the reader in
withRetry— one attempt, 400ms apart. - Emit an
errorevent when the second attempt fails. - Cover both paths in tests/stream-retry.test.ts.
- Wrap the reader in withRetry
- Emit an error event on the second failure
- Cover both paths with tests
Nothing sent yet.
Installation
npx shadcn@latest add miskibin/chat-components/plan-cardAlso installs message-markdown, todo-list.
Usage
"use client"
import { MessageList } from "@/components/ui/message-list"
export function Thread() {
return (
<MessageList
messages={messages}
// Only the newest plan gets the button; the list handles that.
onPlanBuild={(messageId, toolId) => build(messageId, toolId)}
/>
)
}Examples
It is the answer, not the process
A MessageToolCall whose arguments parse as a plan renders this card instead of a disclosure row, and Message keeps a trailing plan out of the “Worked for 12s” group — a proposal folded away is a proposal nobody acts on. Press Build here to see what the host is handed.
Retry a dropped stream once, then surface the failure instead of hanging.
What breaks today
A dropped socket leaves the turn spinning: nothing retries and nothing errors.
Steps
- Wrap the reader in
withRetry— one attempt, 400ms apart. - Emit an
errorevent when the second attempt fails. - Cover both paths in tests/stream-retry.test.ts.
- Wrap the reader in withRetry
- Emit an error event on the second failure
- Cover both paths with tests
Nothing sent yet.
Somewhere with more room
A plan is the one thing in a transcript that is a proposal rather than a record: it is read, argued with, then acted on. A host with a side panel can put it there — pass onPlanOpen to MessageToolCall (or MessageList, which carries it per message) and the transcript row collapses to its header and becomes the way back to it, rather than repeating a document already open beside it. Build goes wherever the plan is; it belongs to one of them, never both.
The transcript keeps the plan’s place in the thread, not a second copy of it.
Retry a dropped stream once, then surface the failure instead of hanging.
What breaks today
A dropped socket leaves the turn spinning: nothing retries and nothing errors.
Steps
- Wrap the reader in
withRetry— one attempt, 400ms apart. - Emit an
errorevent when the second attempt fails. - Cover both paths in tests/stream-retry.test.ts.
- Wrap the reader in withRetry
- Emit an error event on the second failure
- Cover both paths with tests
API reference
PlanCard
plan*PlanData{ title?, overview?, body, todos? }.bodyis markdown;parsePlanbuilds one from a tool call’s arguments.onBuild() => void- Renders the Build button. Omit it and the card is a plain record of what was proposed — which is what every plan but the newest one is.
buildLabelstring = "Build"- Label on the action.
busyboolean = false- Locks the action while the turn that carries the plan out is starting.
compactboolean = false- Draws the header alone — title and checklist count, no body and no Build. For a host that shows the plan somewhere with more room than a message column: the transcript still says a plan was written and where in the thread, without repeating a document already open beside it. Pair it with
onOpen, or the row is a label nobody can act on. onOpen() => void- Makes the header a button — the way back to the plan. An expanded card can carry it too.
Helpers
parsePlan(input?: string | null) => PlanData | null- The plan inside a tool call's arguments — null for anything else, and for a half-streamed blob, so a row only becomes a card once the whole plan has landed.
isPlanToolName(name: string) => boolean- A tool name carrying the word plan — ExitPlanMode, create_plan, update_plan.
hasWrittenPlan(tool: { name, input? }) => boolean- Both of the above at once. Message uses it to keep a plan out of the collapsed process stack.
Data slots
Every part below is addressable from a parent, so you can restyle internals without forking the component.
plan-cardplan-card-headerplan-card-titleplan-card-countplan-card-bodyplan-card-overviewplan-card-todosplan-card-actionsplan-card-buildplan-card-open