Heidi UI

Agent interactions

A composer that protects your draft. A conversation that keeps your place.

Rejected message

Rejected message

Recover the exact unsent message without losing your next draft.

Send, edit your next draft, then reject the first.
  1. You

    Write a launch note for our design studio. Keep it warm and direct.

  2. Assistant

    Here’s a first direction. Give it room to breathe, and let the work do the talking.

Interactive examples use local fixtures. No messages are sent to a model.

Two components. Your application stays in charge.

Add either component to an existing React project configured for shadcn. The copied files include their styles.

npx shadcn@latest add <this-origin>/registry/heidi-composer.json <this-origin>/registry/heidi-conversation-viewport.json

Composer

Return an explicit acceptance receipt. A newer draft survives delayed acceptance, and a rejected message stays recoverable.

<Composer
  label="Your message"
  onSubmit={async ({ text }) => {
    const receipt = await saveMessage(text);
    return receipt.ok
      ? { status: "accepted" }
      : { status: "rejected" };
  }}
/>

Conversation viewport

Render your own messages. The viewport follows new content until you scroll away; it has no message store or SDK dependency.

<ConversationViewport
  key={conversationId}
  label="Conversation"
>
  {messages.map(renderMessage)}
</ConversationViewport>

These source-copy interaction blocks are separate from the 25 primitives in Heidi UI 0.2.8. React 19 and CSS Modules are required.

Read the running example
"use client";

import { useEffect, useRef, useState } from "react";
import { Composer, type ComposerDraft, type ComposerReceipt } from "@/components/heidi-agent-ui/composer";
import { ConversationViewport } from "@/components/heidi-agent-ui/conversation-viewport";
import type { AgentInteractionScenarioId } from "../_lib/agent-interaction-scenarios";
import styles from "./interactions.module.css";

const RESPONSE = [
  "A small studio. A considered beginning.",
  "Today we’re opening Fieldwork, a design studio for people making useful things. We work on identities, websites, and the details that make a product feel like itself.",
  "Our first collection brings together three recent projects: a neighbourhood bookshop, a tool for independent makers, and a new home for a ceramics practice.",
  "Each started with the same question: what does this actually need? The answers are different. The care is the same.",
  "If you’re working on something and want a thoughtful partner, we’d love to hear about it.",
  "See the work. Tell us what you’re making."
].join("\n\n");

type Message = { id: number; role: "you" | "assistant"; text: string };

/** This fixture drives the GUI and browser contracts; it never calls a model. */
export function InteractionExample({ scenario }: { scenario: AgentInteractionScenarioId }) {
  const [messages, setMessages] = useState<Message[]>([
    { id: 1, role: "you", text: "Write a launch note for our design studio. Keep it warm and direct." },
    { id: 2, role: "assistant", text: scenario === "long-content" ? RESPONSE : "Here’s a first direction. Give it room to breathe, and let the work do the talking." }
  ]);
  const [streaming, setStreaming] = useState(false);
  const [announcement, setAnnouncement] = useState<{ id: string; text: string } | null>(null);
  const [waiting, setWaiting] = useState(false);
  const [rejectedOnce, setRejectedOnce] = useState(false);
  const sequence = useRef(2);
  const pending = useRef<{ accept: (receipt: ComposerReceipt) => void; draft: ComposerDraft } | null>(null);
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);
  useEffect(() => () => { if (timer.current != null) clearInterval(timer.current); }, []);

  function addMessage(text: string) {
    const id = ++sequence.current;
    setMessages(current => [...current, { id, role: "you", text }]);
    return id;
  }

  function startStream() {
    if (timer.current != null) return;
    const id = ++sequence.current;
    let length = 0;
    setMessages(current => [...current, { id, role: "assistant", text: "" }]);
    setStreaming(true);
    setAnnouncement({ id: `start-${id}`, text: "Response started." });
    timer.current = setInterval(() => {
      length = Math.min(RESPONSE.length, length + 18);
      setMessages(current => current.map(message => message.id === id ? { ...message, text: RESPONSE.slice(0, length) } : message));
      if (length === RESPONSE.length) {
        clearInterval(timer.current!);
        timer.current = null;
        setStreaming(false);
        setAnnouncement({ id: `end-${id}`, text: "Response ready." });
      }
    }, 90);
  }

  function submit(draft: ComposerDraft): ComposerReceipt | Promise<ComposerReceipt> {
    if (scenario === "rejected-send" && !rejectedOnce) {
      setRejectedOnce(true);
      return new Promise(resolve => {
        pending.current = { accept: resolve, draft };
        setWaiting(true);
      });
    }
    if (scenario === "delayed-receipt") {
      return new Promise(resolve => {
        pending.current = { accept: resolve, draft };
        setWaiting(true);
      });
    }
    addMessage(draft.text);
    return { status: "accepted" };
  }

  function resolveSubmission() {
    const request = pending.current;
    if (request == null) return;
    pending.current = null;
    setWaiting(false);
    if (scenario === "rejected-send") {
      request.accept({ message: "The connection failed. Your message is safe here.", status: "rejected" });
    } else {
      addMessage(request.draft.text);
      request.accept({ status: "accepted" });
    }
  }

  const initialDraft = scenario === "long-content"
    ? "A quieter headline, please.\n\nKeep the introduction to two sentences.\nMake the invitation feel personal.\nAvoid a sales pitch.\nUse concrete words.\nKeep the same sense of care.\n\nThen show me a second direction."
    : scenario === "streaming" ? "" : "Make the opening a little more personal.";

  return (
    <div className={styles.example} data-interaction-example={scenario}>
      <div className={styles.demoControls}>
        <span>{scenario === "delayed-receipt" ? "Send, then write your next draft." : scenario === "rejected-send" ? "Send, edit your next draft, then reject the first." : "Try scrolling while the response grows."}</span>
        {waiting ? <button onClick={resolveSubmission} type="button">{scenario === "rejected-send" ? "Reject earlier message" : "Accept earlier message"}</button> : null}
        {(scenario === "streaming" || scenario === "long-content") ? <button disabled={streaming} onClick={startStream} type="button">{streaming ? "Streaming…" : "Stream a response"}</button> : null}
      </div>
      <div className={styles.workspace}>
        <ConversationViewport announcement={announcement} className={styles.conversation} label="Launch note conversation">
          <ol className={styles.messages}>
            {messages.map(message => (
              <li className={message.role === "you" ? styles.userMessage : styles.assistantMessage} data-message-id={message.id} key={message.id}>
                <span className={styles.messageAuthor}>{message.role === "you" ? "You" : "Assistant"}</span>
                <p>{message.text || "Preparing a response…"}</p>
              </li>
            ))}
            {scenario === "long-content" ? <li className={styles.assistantMessage}>
              <span className={styles.messageAuthor}>Reference</span>
              <p>{`https://fieldwork.example/work/${"a-considered-beginning".repeat(18)}`}</p>
              <pre><code>{`const collection = ${JSON.stringify({ name: "Fieldwork", description: "A considered beginning. ".repeat(15) })};`}</code></pre>
            </li> : null}
          </ol>
        </ConversationViewport>
        <div className={styles.composerDock}>
          <Composer defaultValue={initialDraft} label="Your message" onSubmit={submit} placeholder="Make it your own…" />
        </div>
      </div>
    </div>
  );
}