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.
The failing test only touches the reducer, so the regression is most likely in applyPatch.
- offset
- 1
- limit
- 40
export function applyPatch(state: State, patch: Patch): State {
if (!patch) return state
return { ...state, ...patch }
}Installation
npx shadcn@latest add miskibin/chat-components/message-partsAlso 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
With resolveFileUrl
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.
- Rewrite the line chart cleanly
- Create the salary data module
- 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 wheninputcarries Cursordiff/streamContent/fileText, or classicold_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
undefinedfor 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.
displayis the command with the harness's wrapper peeled off —/bin/zsh -lc 'cd /repo && npm test'readsnpm test— andlabelis 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 | null1531×889out of the stand-in text a Read tool returns for an image.
MessageCode
block*MessageCodeBlockData{ language?, code, title? }. Languagemermaidis handed to the markdown renderer instead.
MessageArtifact
artifact*MessageArtifactData{ id, title, kind?, summary?, content?, onOpen? }. WithoutonOpenthe card is inert.
MessageAttachments
attachments*MessageAttachmentData[]{ id, name, mimeType, url }per file. Images render as a thumbnail linking to the full-sizeurl; 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 pxout of the stand-in text a Read of an image returns, as1531×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