Message Parts

The atoms an assistant turn is made of — reasoning, tool calls, ask questions, highlighted code, and artifact cards. Use them directly when you build your own turn layout.

Open in

The failing test only touches the reducer, so the regression is most likely in applyPatch.

1export function applyPatch(state: State, patch: Patch): State {
2 return { ...state, ...patch }
3}
offset
1
limit
40
1 export function applyPatch(state: State, patch: Patch): State {
2+ if (!patch) return state
3 return { ...state, ...patch }
4 }
tslib/reducer.ts
export function applyPatch(state: State, patch: Patch): State {
  if (!patch) return state
  return { ...state, ...patch }
}
file,statements lib/reducer.ts,91% lib/store.ts,78%

Installation

npx shadcn@latest add miskibin/chat-components/message-parts

Also installs collapsible, message-markdown, ask-question, change-summary, file-icon, todo-list.

Usage

import {
  MessageArtifact,
  MessageCode,
  MessageProcess,
  MessageReasoning,
  MessageToolCalls,
} from "@/components/ui/message-parts"

export function Answer() {
  return (
    <>
      <MessageProcess seconds={12}>
        <MessageReasoning duration={7}>Checking the reducer…</MessageReasoning>
        <MessageToolCalls tools={tools} />
      </MessageProcess>
      <MessageCode block={{ language: "ts", code: source }} />
      <MessageArtifact artifact={{ id: "a1", title: "coverage.csv" }} />
    </>
  )
}

Examples

Shell rows say what ran

A harness wraps commands in its own shell — the row unwraps them and drops a leading cd, so the headline reads “Ran npm test” rather than /bin/zsh -lc …. Expand a row for the untouched command.

An image the agent touched

A harness hands back a line about the bytes when it reads a picture — image/png image, 420x180 px — which is all the row can show on its own. Give it resolveFileUrl and it shows the picture instead, with those dimensions moved up to the meta line; FilePreview does the same from imageSrc. Both are URLs rather than paths on purpose: how a file on the machine reaches the browser is the one thing only your app knows. A URL that fails to load falls back to a line of text, never a broken frame.

Without a resolver

1image/png image, 420x180 px

With resolveFileUrl

latency.png

Collapse a finished turn

MessageProcess is the shape a settled turn takes: everything it did before answering behind one Worked for 12s row. streaming is the whole state machine — true while the run is live (open, unlabelled, reading as a flat stack), false once it settles (collapsed, labelled). Message wires this for you from isAnimating and workedFor; mount it yourself only in a custom turn layout.

applyPatch spread a null patch straight onto the state. It now returns the state untouched, and the reducer test passes.

Style by tool status

Every row stamps data-status, so failures and running states are themed from an ancestor without the component knowing your palette.

The useful half of a tool call

An expanded row shows what the tool was asked and what it said — never the raw argument blob. The command loses its /bin/zsh -lc wrapper and gets its own block, the arguments left over are listed as pairs (the model’s own description is not one of them), and the output is stripped of <system-reminder> blocks and terminal colour, pretty-printed when it is JSON, and capped with a “show all” break. An mcp__server__tool name reads as server · tool.

cd packages/core && npm test -- stream
timeout
120000
PASS tests/reducer.test.ts
FAIL tests/stream.test.ts
  ● stream › retries once

Tests: 1 failed, 24 passed
owner
miskibin
repo
agent-ui
state
open
[
  {
    "number": 12,
    "title": "Plan card is unreadable",
    "state": "open"
  }
]

Plans, not payloads

A tool whose name says todo or plan is read as a checklist: todo_write, TodoWrite, write_todos, update_plan. The headline counts the plan instead of guessing a file was written, and the body renders TodoList rather than the raw arguments. For the same plan as a live bar above the composer, see TodoPanel.

  1. Rewrite the line chart cleanly
  2. Create the salary data module
  3. Run lint, typecheck and build

Collapse thresholds

A stack folds behind one summary line once it reaches collapseAt rows (three by default), and defaultOpen decides how it starts. Both stacks below hold the same six calls.

The folded row says what happened

A folded stack summarizes the work rather than counting the calls: each row is classified as a read, an edit, a command, a search, a browser step or a plan update, edits are deduped by file path, and the sentence comes out as “Read 2 files, changed 1 file, and ran 2 commands” — in the order the turn first did each of them, and one file rather than the two edits it took. The glyph follows the same classification — one kind of work gets its own, a mixed group gets the generic one — and data-kind carries it for styling. summarizeToolGroup and toolGroupAction are exported for a custom trigger.

Notes

API reference

MessageProcess

children*React.ReactNode
The turn's chronological thinking and tool stack, in render order.
secondsnumber
Elapsed time — becomes the “Worked for 12s” label.
labelReact.ReactNode
Overrides the derived label. Without either prop the row reads “Worked for a while”.
streamingboolean = false
True while the turn is still arriving: the stack stays open and the trigger is withheld, so live parts read as a flat list. When it flips to false the group folds into the labelled row.
defaultOpenboolean = false
Keep the stack expanded once the turn settles, instead of collapsing it.

MessageReasoning

children*string
Reasoning text, rendered as markdown.
defaultOpenboolean = false
Start expanded. Ignored while `streaming` is driving the open state.
streamingboolean = false
Keep the body open while this thought is still arriving. When it flips to false the disclosure collapses — same as Cursor after a turn finishes.
durationnumber
Seconds — "Thought for N seconds".

MessageToolCall / MessageToolCalls

tool*MessageToolCallData
Single row. { id, name, status?, input?, output? }. The headline is derived from the tool name and arguments. Edit / Write rows expand into a unified diff when input carries Cursor diff/streamContent/fileText, or classic old_string/new_string.
tools*MessageToolCallData[]
Stack of rows (MessageToolCalls).
onAskAnswer(toolId: string, result: AskQuestionResult) => void
Ask Question tools only. Continue and Skip both fire this — check result.skipped. The row reports its own id, so one handler can serve a whole turn without breaking memoization.
onOpenFile(tool: MessageToolCallData) => void
Opt in to a clickable headline: an Edit / Write / Read row that names a file splits into an open-file button plus a separate chevron for the inline body. Without it the header stays one disclosure, exactly as before.
fileActionsFileActionItem[]
Right-click menu on a row that names a file — same actions as the change-summary card. MessageToolCalls forwards it to every row.
resolveFileUrl(path: string) => string | undefined
Turns a path a tool names into a URL this page can load. Only the host knows how a file on the machine reaches the browser, so without it an image an agent wrote or read renders as the stand-in line its harness returned. Return undefined for anything you cannot serve.
collapseAtnumber = 3
Collapse behind "Used N tools" from this count up.
defaultOpenboolean
Overrides the automatic collapse decision.

Helpers

summarizeCommand(command: string) => { display: string; label: string }
What a shell row's headline says. display is the command with the harness's wrapper peeled off — /bin/zsh -lc 'cd /repo && npm test' reads npm test — and label is how it is named (“Ran npm test”). The full command stays in the expanded body and the title.
isImagePath(path?: string) => boolean
True for a file whose bytes are a picture — png, jpeg, gif, webp, avif, bmp, ico, svg.
imageDimensions(output?: string) => string | null
1531×889 out of the stand-in text a Read tool returns for an image.

MessageCode

block*MessageCodeBlockData
{ language?, code, title? }. Language mermaid is handed to the markdown renderer instead.

MessageArtifact

artifact*MessageArtifactData
{ id, title, kind?, summary?, content?, onOpen? }. Without onOpen the card is inert.

MessageAttachments

attachments*MessageAttachmentData[]
{ id, name, mimeType, url } per file. Images render as a thumbnail linking to the full-size url; anything else is a named chip.
classNamestring
Merged last.

Image helpers

isImagePath(path)(path?: string) => boolean
True for the extensions a browser can paint — png, jpg, jpeg, gif, webp, avif, bmp, ico, svg. It is what a tool row and a file panel agree on, so both decide the same way about the same file.
imageDimensions(output)(output?: string) => string | null
Pulls 1531x889 px out of the stand-in text a Read of an image returns, as 1531×889, for the row's meta line.

Data slots

Every part below is addressable from a parent, so you can restyle internals without forking the component.

message-processmessage-process-triggermessage-process-contentmessage-reasoningmessage-reasoning-triggermessage-reasoning-contentmessage-tool-callmessage-tool-call-triggermessage-tool-call-headermessage-tool-call-chevronmessage-tool-call-bodymessage-tool-callsmessage-tool-calls-triggermessage-tool-listmessage-tool-statsfile-iconask-questionmessage-tool-diffmessage-tool-diff-linemessage-tool-filemessage-tool-file-linemessage-tool-imagemessage-tool-image-errormessage-tool-show-allmessage-codemessage-code-headermessage-code-copymessage-code-fallbackmessage-artifactmessage-artifact-triggermessage-artifact-contentmessage-attachmentsmessage-attachment