Meowland docs
Workbench

Component reference

AssistantView

A complete default Assistant surface.

Preview

Complete Assistant composition

The same Assistant renders tools and tasks by default and keeps messages, draft, attachments, activity, questions and safe Markdown.

packages/meowtail/stories/agent-workflow.stories.tsxline 335tsx
export function agentAssistantDemo(): Child {
  const messages = signal<readonly AssistantMessage[]>([
    turn("ask-check", "Check the shared components and tell me what still needs a decision."),
    {
      id: "agent-run",
      role: "assistant",
      author: "Assistant",
      status: "streaming",
      parts: [
        {
          id: "summary",
          kind: "text",
          text: "Checking shared components. **Public progress summary**: The file check is in progress, the independent review finished executing, and its public reply is still pending.\n\n`<script>alert(1)</script>` is description text. Raw HTML is not executed either: <img src=x onerror=alert(1)>.",
        },
        {
          id: "inspect-tool",
          kind: "tool",
          icon: "file-text",
          label: "Read component files",
          summary: "packages/meowtail/src/chat/long-component-file-name.tsx",
          state: "running",
          statusLabel: "Receiving output",
          input: payload,
          output: resultLines,
          outputSummary: "Returned 24 records; the full result can be expanded.",
          maxLines: 3,
          defaultOpen: true,
          canCancel: true,
        },
        {
          id: "delegate-task",
          kind: "task",
          title: "Independent keyboard review",
          state: "success",
          statusLabel: "Succeeded · Public reply not received",
          executionText: "Execution returned; 5 / 5 items complete.",
          replyText: "Waiting for the public reply does not mean execution failed.",
          progress: { value: 5, max: 5, label: "Execution check 5 / 5 items" },
        },
        {
          id: "retry-tool",
          kind: "tool",
          icon: "database",
          label: "Read index",
          state: "error",
          statusLabel: "Local simulated read failed",
          input: "index.json",
          output: "Previous confirmed data is retained.",
          canRetry: true,
        },
        { id: "files", kind: "attachments", items: [attachment] },
        { id: "references", kind: "text", text: "Artifacts and sources provided by the host" },
      ],
    },
  ]);
  const draft = signal<AssistantDraft>({
    text: "Continue checking composition in a narrow container.",
    attachments: [],
  });
  const questions = signal<readonly AssistantQuestion[]>([
    {
      id: "scope",
      title: "Which areas should be checked next?",
      state: "open",
      description: "Waiting for your answer. No reply does not mean execution failed.",
      options: [
        { value: "tools", label: "Tools and tasks" },
        { value: "composer", label: "Input and attachments" },
      ],
    },
  ]);
  let alive = true;
  const timers = new Set<ReturnType<typeof setTimeout>>();
  const patchTool = (
    id: string,
    patch: Partial<Extract<AssistantMessage["parts"][number], { kind: "tool" }>>,
  ) => {
    messages.value = messages.value.map((message) => ({
      ...message,
      parts: message.parts.map((part) =>
        part.kind === "tool" && part.id === id ? { ...part, ...patch } : part,
      ),
    }));
  };
  return (
    <Stack
      style={{ height: "min(1000px, calc(100dvh - 48px))", minHeight: "420px" }}
      ref={() => () => {
        alive = false;
        timers.forEach(clearTimeout);
      }}
    >
      <Inline>
        <Button
          size="small"
          variant="outline"
          onClick={() => {
            const part = messages.value
              .flatMap((message) => message.parts)
              .find((item) => item.id === "inspect-tool");
            if (part?.kind !== "tool") return;
            patchTool("inspect-tool", {
              output: `${part.output ?? ""}\nNew output: A text update <img src=x onerror=alert(1)> stays inert.`,
              outputSummary: "Appended one local result; expansion and focus are unchanged.",
            });
          }}
        >
          Append tool output
        </Button>
      </Inline>
      <AssistantView
        title="Agent workflow"
        description="Shared default composition · Local data, not connected to an Agent runtime"
        messages={messages}
        draft={draft}
        onDraftChange={(next) => {
          draft.value = next;
        }}
        onSubmit={async (snapshot) => {
          messages.value = [
            ...messages.value,
            {
              id: `user-${messages.value.length}`,
              role: "user",
              author: "You",
              parts: [{ id: "text", kind: "text", text: snapshot.text }],
            },
          ];
          return { accepted: true };
        }}
        activity={{
          title: "Check shared components",
          statusText: "File check in progress",
          tone: "processing",
        }}
        work={[
          {
            id: "running",
            title: "File check",
            statusText: "Receiving output",
            tone: "processing",
            executionText: "Running",
          },
          {
            id: "queued",
            title: "Organize the report",
            statusText: "Queued · Not started yet",
            tone: "idle",
            executionText: "Waiting for scheduling",
          },
          {
            id: "returned",
            title: "Independent keyboard review",
            statusText: "Succeeded · Waiting for public reply",
            tone: "success",
            executionText: "Execution complete",
            replyText: "Public reply not received yet",
          },
        ]}
        questions={questions}
        onAnswer={(id, answer) => {
          questions.value = questions.value.map((question) =>
            question.id === id
              ? {
                  ...question,
                  state: "answered",
                  feedback: `The host received the scope choice: ${answer}`,
                }
              : question,
          );
        }}
        onMessageAction={() => {}}
        onToolAction={(_messageId, partId, action) => {
          if (action === "cancel") {
            patchTool(partId, {
              state: "cancelled",
              statusLabel: "The user canceled further checks",
              canCancel: false,
            });
            return;
          }
          patchTool(partId, {
            state: "running",
            statusLabel: "Retrying the local simulation",
            retryDisabled: true,
          });
          const timer = setTimeout(() => {
            timers.delete(timer);
            if (alive)
              patchTool(partId, {
                state: "success",
                statusLabel: "Local simulation retry succeeded",
                canRetry: false,
                output: "Index read completed in this local fixture.",
              });
          }, 650);
          timers.add(timer);
        }}
        renderPart={(part) =>
          part.id === "references" ? (
            <Stack gap="compact">
              <ResourceLink
                title="Component review artifact"
                detail="Example entry point · Uses a local anchor only"
                href="#agent-reference"
              />
              <ChatSources
                items={[
                  {
                    title: "Meowtail source",
                    url: "https://github.com/lidessen/meowland",
                    description: "An explicitly provided example source; not retrieved by the UI.",
                  },
                ]}
              />
              <span id="agent-reference">
                Artifacts, references and attachments are all provided by the host.
              </span>
            </Stack>
          ) : undefined
        }
      />
    </Stack>
  );
}

Installation

bun add meowtail

Usage

usage.tsts
import { AssistantView } from "meowtail";

Examples

The other stories that show this component.

Complete Assistant

Messages, safe rich text, process, draft, model selection, attachments, activity, work details and questions use one public component.

packages/meowtail/stories/assistant.stories.tsxline 19tsx
export function assistantDemo(): Child {
  let next = 20;
  const messages = signal<readonly AssistantMessage[]>(
    Array.from({ length: 10 }, (_, index) => ({
      id: `message-${index}`,
      role: index % 2 ? "assistant" : "user",
      author: index % 2 ? "Assistant" : "You",
      timestamp: `10:${String(index).padStart(2, "0")}`,
      parts: [
        {
          id: "text",
          kind: "text",
          text:
            index % 2
              ? "The default Assistant keeps **messages, process and actions** on one reading axis.\n\nYou can read earlier messages while new results arrive. This example uses local view models, with no product API or execution runtime."
              : "Review the shared Assistant layout and preserve my reading position.",
        },
        ...(index === 7
          ? [
              {
                id: "process",
                kind: "process",
                title: "Review process",
                state: "success",
                statusText: "3 steps",
                steps: [
                  {
                    id: "read",
                    title: "Read the layout",
                    description: "Checked the shared component contract and the reading region.",
                    statusText: "Reviewed",
                    tone: "success",
                  },
                  {
                    id: "check",
                    title: "Check interaction",
                    description: "Keep draft and focus while opening activity details.",
                    statusText: "Returned",
                    tone: "returned",
                  },
                  {
                    id: "decide",
                    title: "Wait for a decision",
                    description: "A returned execution does not imply the goal is complete.",
                    statusText: "Needs your answer",
                    tone: "warning",
                  },
                ],
              } as const,
            ]
          : []),
        ...(index === 9
          ? [
              {
                id: "context-note",
                kind: "text",
                text: "A local part override uses the same render tree.",
              } as const,
            ]
          : []),
      ],
    })),
  );
  const draft = signal<AssistantDraft>({
    text: "Keep this draft while reviewing the activity panel.",
    attachments: [],
  });
  const model = signal("balanced");
  const readState = signal<"ready" | "loading" | "error">("ready");
  const disabled = signal(false);
  const disabledReason = signal("");
  const work = signal<readonly AssistantWork[]>([
    {
      id: "review",
      title: "Review the reading experience",
      statusText: "Waiting for review",
      tone: "returned",
      executionText: "Execution returned",
      replyText: "Public reply not yet posted",
      detail:
        "## Review notes\n\nThe execution returned its findings. The goal is still open and the host has not published the final answer.",
      steps: [
        {
          id: "note",
          title: "Preserve accurate status",
          description: "No execution state is inferred by the UI.",
          statusText: "Returned",
          tone: "returned",
        },
      ],
    },
    {
      id: "queued",
      title: "Check attachment previews",
      statusText: "Requested; waiting for execution",
      tone: "idle",
      executionText: "Pending",
      summary: "This item is pending, with no evidence that it is running.",
    },
    {
      id: "running",
      title: "Prepare an independent example",
      statusText: "Rendering the public component",
      tone: "processing",
      executionText: "Running",
      summary: "The host explicitly reports an active execution.",
    },
  ]);
  const questions = signal<readonly AssistantQuestion[]>([
    {
      id: "layout",
      title: "Which reading layout should we use?",
      description: "You can select an option or write a different answer.",
      state: "open",
      options: [
        { value: "Single reading column", label: "Single reading column" },
        { value: "Reading column with activity", label: "Reading column with activity" },
      ],
    },
    { id: "review-time", title: "When should we review the result?", state: "open" },
  ]);
  const append = () => {
    messages.value = [
      ...messages.value,
      {
        id: `message-${next++}`,
        role: "assistant",
        author: "Assistant",
        parts: [
          {
            id: "text",
            kind: "text",
            text: "A new result arrived. Your current reading position should remain stable until you return to the latest message.",
          },
        ],
      },
    ];
  };
  return h(Stack, {
    style: { height: "min(900px, calc(100dvh - 48px))", minHeight: "360px" },
    children: [
      h(Inline, {
        wrap: true,
        children: [
          h(Button, {
            variant: "outline",
            size: "small",
            onClick: append,
            children: "Append result",
          }),
          h(Button, {
            variant: "outline",
            size: "small",
            onClick: () => {
              const last = messages.value.at(-1)!;
              messages.value = [
                ...messages.value.slice(0, -1),
                {
                  ...last,
                  status: "streaming",
                  parts: [
                    {
                      id: "text",
                      kind: "text",
                      text: `${last.parts[0]?.kind === "text" ? last.parts[0].text : ""} Further streamed text keeps the existing message instance.`,
                    },
                  ],
                },
              ];
            },
            children: "Stream text",
          }),
          h(Button, {
            variant: "quiet",
            size: "small",
            onClick: () => {
              readState.value = readState.value === "error" ? "ready" : "error";
            },
            children: "Toggle read failure",
          }),
          h(Button, {
            variant: "quiet",
            size: "small",
            onClick: () => {
              disabled.value = !disabled.value;
              disabledReason.value = disabled.value ? "The host has disabled input." : "";
            },
            children: "Toggle disabled",
          }),
        ],
      }),
      h("div", {
        style: { flex: "1", minHeight: 0 },
        children: h(DemoContext.Provider, {
          value: "Ancestor context retained",
          children: h(AssistantView, {
            title: "Shared Assistant",
            description: "A complete public UI · local fixture data",
            messages,
            draft,
            onDraftChange: (nextDraft: AssistantDraft) => {
              draft.value = nextDraft;
            },
            readState,
            disabled,
            disabledReason,
            models: [
              { value: "balanced", label: "Balanced model" },
              { value: "fast", label: "Fast model" },
            ],
            model,
            onModelChange: (value: string) => {
              model.value = value;
            },
            activity: {
              title: "Reviewing the UI",
              statusText: "Checking the shared composition",
              tone: "processing",
              description: "The host owns all activity facts.",
            },
            work,
            questions,
            onLoadOlder: () => {
              messages.value = [
                {
                  id: `older-${next++}`,
                  role: "system",
                  author: "Earlier context",
                  parts: [
                    {
                      id: "text",
                      kind: "text",
                      text: "An older message was prepended. The first visible existing message should stay at its reading offset.",
                    },
                  ],
                },
                ...messages.value,
              ];
            },
            onSubmit: async (snapshot: AssistantDraft) => {
              await new Promise((resolve) => setTimeout(resolve, 600));
              messages.value = [
                ...messages.value,
                {
                  id: `message-${next++}`,
                  role: "user",
                  author: "You",
                  parts: [
                    { id: "text", kind: "text", text: snapshot.text },
                    ...(snapshot.attachments.length
                      ? [{ id: "files", kind: "attachments", items: snapshot.attachments } as const]
                      : []),
                  ],
                },
              ];
              return { accepted: true };
            },
            onAnswer: async (id: string, answer: string) => {
              questions.value = questions.value.map((question) =>
                question.id === id
                  ? { ...question, state: "answered", feedback: `Host accepted: ${answer}` }
                  : question,
              );
            },
            onDismissQuestion: (id: string) => {
              questions.value = questions.value.filter((question) => question.id !== id);
            },
            onRefreshWork: (id: string) => {
              work.value = work.value.map((item) =>
                item.id === id
                  ? {
                      ...item,
                      readState: "error",
                      feedback:
                        "The fixture refresh failed; previously read details remain visible.",
                    }
                  : item,
              );
            },
            renderPart: (part) => (part.id === "context-note" ? h(ContextNote, null) : undefined),
          }),
        }),
      }),
    ],
  });
}

API reference

Data, permissions, network and business lifetime stay with the host.

Props

PropTypeDefault
title
string–
description
string–
messagesRequired
MaybeSignal<readonly AssistantMessage[]>–
readStateFirst loading, empty, and refresh failure with retained messages are separate states.
MaybeSignal<"ready" | "loading" | "error">–
readFeedback
MaybeSignal<string>–
activity
MaybeSignal<AssistantActivity | undefined>–
work
MaybeSignal<readonly AssistantWork[]>–
questions
MaybeSignal<readonly AssistantQuestion[]>–
draftSupplying draft makes the host its sole owner, even for a plain value.
MaybeSignal<AssistantDraft>–
defaultDraft
AssistantDraft–
readOnly
boolean–
onDraftChange
(draft: AssistantDraft) => void–
onSubmitOnly an explicit accepted:true may clear an unchanged submitted snapshot.
(draft: AssistantDraft) => { accepted: boolean } | Promise<{ accepted: boolean }>–
onAttachHost attachment ingestion. Return normalized entries or update controlled draft. No upload is implicit.
( files: readonly File[], ) => readonly AssistantAttachment[] | void | Promise<readonly AssistantAttachment[] | void>–
allowAttachmentsDisable local file selection when the host supports only already-normalized attachments.
boolean–
disabled
MaybeSignal<boolean>–
disabledReason
MaybeSignal<string>–
models
MaybeSignal<readonly AssistantChoice[]>–
model
MaybeSignal<string>–
onModelChange
(value: string) => void–
reasoningOptions
MaybeSignal<readonly AssistantChoice[]>–
reasoning
MaybeSignal<string>–
onReasoningChange
(value: string) => void–
onAnswer
(questionId: string, answer: string) => void | Promise<void>–
onDismissQuestion
(questionId: string) => void–
onToolActionAn explicit host intent. The host owns availability, execution and subsequent state.
(messageId: string, partId: string, action: "retry" | "cancel") => void–
onMessageActionFeedback and regeneration intents on a finished reply; supplying it shows those actions. Copy is built in.
(messageId: string, action: AssistantMessageAction) => void–
historyThe column of the reader's own messages over the transcript; on by default.
boolean–
onRefreshWork
(workId: string) => void–
onLoadOlder
() => void–
loadingOlder
MaybeSignal<boolean>–
renderPartReturn undefined to keep the default renderer for this one part.
(part: AssistantMessagePart, message: AssistantMessage) => Child | undefined–
renderWorkDetail
(work: AssistantWork) => Child | undefined–
composerTrailing
Child–
headerActions
Child–
labels
Partial<AssistantLabels>–

Also takes className, children and the attributes and events of its root element.

DOM parts

rootlayoutmessagesviewportcontentlatestcomposerfeedbackemptyactivityactivityHeaderactivityBodysectionworkDetailstatushistoryhistoryItemhistoryTrigger

Registry

The component is registered as assistant-view in the meowtail component registry.