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.

Open in
Streaming retry1/3

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

  1. Wrap the reader in withRetry — one attempt, 400ms apart.
  2. Emit an error event when the second attempt fails.
  3. Cover both paths in tests/stream-retry.test.ts.
  1. Wrap the reader in withRetry
  2. Emit an error event on the second failure
  3. Cover both paths with tests

Nothing sent yet.

Installation

npx shadcn@latest add miskibin/chat-components/plan-card

Also 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.

Streaming retry1/3

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

  1. Wrap the reader in withRetry — one attempt, 400ms apart.
  2. Emit an error event when the second attempt fails.
  3. Cover both paths in tests/stream-retry.test.ts.
  1. Wrap the reader in withRetry
  2. Emit an error event on the second failure
  3. 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.

Streaming retry1/3

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

  1. Wrap the reader in withRetry — one attempt, 400ms apart.
  2. Emit an error event when the second attempt fails.
  3. Cover both paths in tests/stream-retry.test.ts.
  1. Wrap the reader in withRetry
  2. Emit an error event on the second failure
  3. Cover both paths with tests

API reference

PlanCard

plan*PlanData
{ title?, overview?, body, todos? }. body is markdown; parsePlan builds 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