Heidi UI

Agent interactions

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

Content under pressure

Content under pressure

Long lines, code, and a growing composer stay inside their surfaces.

Try scrolling while the response grows.
  1. You

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

  2. Assistant

    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.

  3. Reference

    https://fieldwork.example/work/a-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginninga-considered-beginning

    const collection = {"name":"Fieldwork","description":"A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. A considered beginning. "};

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>
  );
}