Message parts

A recipe, not a package namespace. Todae knows no agent SDK's message shape. Your product maps each part of a reply onto the parts Todae already has. There is no MessageParts export. Nothing in @tounsoo/todae maps one SDK's parts for you.

On this page

The message below is one assistant reply inside Message.Root. Its pieces come from a local array, and the part types in that array are this page's. Use your SDK's names in their place. Message.Avatar, Message.Header, Message.Content, Message.Time and Message.Actions are the slots on the Message page. A message can hold more than one Message.Content, with these pieces between them.

Thought for 4 seconds

The focus ring color is a token. Search the docs for the page that sets it, and keep that page with the reply.

I'll find the page that sets the focus ring color and attach it here.

search_docs Done
Input
{
  "query": "focus ring color token"
}
Output
{
  "page": "/tokens",
  "heading": "Focus ring"
}
tokens.md18 KB

The map

Part types on this page
PartDraw with
textMessage.Content and Prose
reasoningDisclosure
tool-callDisclosure, Badge, DataList, CodeBlock
attachmentAttachment

Each name in the first column is this page's. Yours can differ. Text is Message.Content and Prose. Reasoning is a Disclosure, as Reasoning shows. A tool call is a Disclosure, a Badge, a DataList and a CodeBlock, as Tool call shows. A file is an Attachment. A citation is a marker inside the text, as Citations shows. Buttons under the message are Message actions, not a part in this array.

In the message

Keep the reply as an array of parts, in the order your stream gives them. Message.Root spaces each child from the next, so a part needs no margin of its own.

type Part =
  | { type: 'text'; id: string; text: string }
  | { type: 'reasoning'; id: string; text: string; thinking: boolean; seconds: number }
  | {
      type: 'tool-call';
      id: string;
      name: string;
      input: Record<string, unknown>;
      status: 'done';
      output: unknown;
    }
  | {
      type: 'tool-call';
      id: string;
      name: string;
      input: Record<string, unknown>;
      status: 'error';
      error: string;
    }
  | { type: 'attachment'; id: string; name: string; size: string };

The switch is the mapper. It stays in your app. A finished call uses the same status words as Tool call, Done and Failed, from one map. A call that is still running, or waiting for approval, is that recipe. The sample below is a call that has finished. The duration uses the same formatter as Reasoning. The words and the locale stay yours. A part type this switch does not list renders nothing.

import { Attachment, Badge, Code, CodeBlock, DataList, Disclosure, Message, Prose, Text, type BadgeTone } from '@tounsoo/todae';
import { FileText } from 'lucide-react';

const duration = new Intl.NumberFormat('en', { style: 'unit', unit: 'second', unitDisplay: 'long' });

const FINISHED: Record<'done' | 'error', { label: string; tone: BadgeTone }> = {
  done: { label: 'Done', tone: 'success' },
  error: { label: 'Failed', tone: 'critical' },
};

function PartView({ part }: { part: Part }) {
  switch (part.type) {
    case 'text':
      return (
        <Message.Content>
          <Prose.Root>
            <p>{part.text}</p>
          </Prose.Root>
        </Message.Content>
      );
    case 'reasoning':
      return (
        <Disclosure.Root open={part.thinking}>
          <Disclosure.Summary>{part.thinking ? 'Thinking' : `Thought for ${duration.format(part.seconds)}`}</Disclosure.Summary>
          <Text.Root tone="muted">
            {part.text}
          </Text.Root>
        </Disclosure.Root>
      );
    case 'tool-call': {
      const status = FINISHED[part.status];
      return (
        <Disclosure.Root>
          <Disclosure.Summary>
            <Code.Root>{part.name}</Code.Root> <Badge.Root tone={status.tone}>{status.label}</Badge.Root>
          </Disclosure.Summary>
          <DataList.Root orientation="vertical">
            <DataList.Item>
              <DataList.Term>Input</DataList.Term>
              <DataList.Details>
                <CodeBlock.Root tabIndex={0} role="region" aria-label={`${part.name} input`}>
                  {JSON.stringify(part.input, null, 2)}
                </CodeBlock.Root>
              </DataList.Details>
            </DataList.Item>
            {part.status === 'done' && (
              <DataList.Item>
                <DataList.Term>Output</DataList.Term>
                <DataList.Details>
                  <CodeBlock.Root tabIndex={0} role="region" aria-label={`${part.name} output`} style={{ maxBlockSize: '16rem' }}>
                    {JSON.stringify(part.output, null, 2)}
                  </CodeBlock.Root>
                </DataList.Details>
              </DataList.Item>
            )}
            {part.status === 'error' && (
              <DataList.Item>
                <DataList.Term>Error</DataList.Term>
                <DataList.Details>{part.error}</DataList.Details>
              </DataList.Item>
            )}
          </DataList.Root>
        </Disclosure.Root>
      );
    }
    case 'attachment':
      return (
        <Attachment.Root status="ready">
          <Attachment.Preview>
            <FileText aria-hidden />
          </Attachment.Preview>
          <Attachment.Name>{part.name}</Attachment.Name>
          <Attachment.Meta>{part.size}</Attachment.Meta>
        </Attachment.Root>
      );
    default: {
      const _exhaustive: never = part;
      void _exhaustive;
      return null;
    }
  }
}

Render the array inside the message. Leave out Attachment.Remove for a file that is already in the reply.

<Message.Root streaming={streaming} aria-label="Assistant">
  {parts.map((part) => (
    <PartView key={part.id} part={part} />
  ))}
</Message.Root>

While it streams

Set streaming from the agent's turn, as Tool call does. Leave it on while the reply is being written, including while a tool runs. Turn it off when the turn ends, and while a tool waits for a decision, so the Approve and Deny buttons on that recipe do not sit in a busy message.

Open the reasoning block while that part is still arriving, and close it when the answer starts. Reasoning says how open behaves when someone toggles the block. Replace the part in the array as words arrive, and keep its id, so the block the reader opened stays mounted.

Accessible composition

Todae handles: Message.Root with streaming sets aria-busy, which Conversation reads to announce the reply as it starts and as it finishes. Disclosure.Root is a native <details>, so the browser handles the toggle and the expanded state. An Attachment.Root is a group named by Attachment.Name.

You supply: An aria-label on the message, such as "Assistant". Summary text that says whether the reasoning is still arriving. A status word on the tool's badge, such as Done or Failed. The file name in Attachment.Name. A Tab stop, role and name on each CodeBlock, as on the Code page.

Watch for: A live region around the parts. Conversation already announces the message, and a region on the parts would read every word as it arrives. A tool status shown only by color.

When to use

When: One assistant message that mixes the reply's text with more than one other kind of part, such as reasoning and a tool call, or a tool call and a file.

When not: Text and reasoning, with no other kind → Reasoning. Text and tool calls, with no other kind → Tool call. A file in the composer, before it is sent → Attachment. Buttons under the message → Message actions.