Blocks / AI

Chat screen

An assistant chat with cited answers, suggested questions, a streamed reply and a composer that can stop it.

Width

Ask Larder

Marrow, Wilton Way. 212 invoices and 13 stock counts

Reads your last 90 days
Ask Larder answers from Marrow's invoices and stock counts since 15 August.

Ines17:42

Why did dairy cost so much more in October?

Larder17:42

Mostly butter. Neal's Yard Dairy put cultured butter up from £9.80 to £11.40 a kilo on 2 October1InvoicesNeal's Yard Dairy invoice NYD-4471Cultured butter, 5 kg block, £57.00 (£11.40/kg), and you bought 46 kg in October against 31 kg in September2Stock countsButter, monthly usageSeptember 31.0 kg, October 46.2 kg.

That accounts for about £240 of the £310 rise. The rest is the extra Comté for the set menu3InvoicesNeal's Yard Dairy invoice NYD-4502Comté 18 month, 4 kg, £96.00.

Ines17:44

Are we over-ordering it?

Larder17:44

A little. Friday counts show 4 to 6 kg still on the shelf each week1Stock countsFriday stock counts, OctoberButter left: 5.2, 4.1, 6.0, 4.8 kg, and 3.2 kg went in the bin in October as out of date2Waste logWaste log, OctoberCultured butter, past date, 3.2 kg, £36.48. Ordering 9 kg on Tuesdays instead of 12 would still cover a full weekend at last month's pace.

Enter to send, Shift+Enter for a new line

Install

Writes the block to components/blocks/chat-screen.tsx and its .css, and every part it uses to components/equip/, with the parts those are built from.

npx https://equip.studio311.app/equip.tgz add block:chat-screen

Built from 9 parts

Notes

  • Replace pickReply and the timer with your model's stream: append each chunk to the message's text and set streaming to false at the end.
  • Set --blk-chat-screen-height on the block to size the frame; the message list scrolls inside it and never moves the page.
  • Streamed replies are plain text, so their sources arrive as a list once the answer lands; the earlier answers show inline citations.
  • Stop, or Escape in the composer, ends the stream and keeps the words that had arrived.

Source

components/blocks/chat-screen.tsx316 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Avatar } from "@/components/equip/avatar";
import { Badge } from "@/components/equip/badge";
import { Button } from "@/components/equip/button";
import { ChatMessage } from "@/components/equip/chat-message";
import { Citation, CitationList, CitationListItem } from "@/components/equip/citation";
import { Heading } from "@/components/equip/heading";
import { PromptComposer } from "@/components/equip/prompt-composer";
import { StreamingText } from "@/components/equip/streaming-text";
import { Text } from "@/components/equip/text";
import "./chat-screen.css";

type Source = { href: string; title: string; source: string; snippet: string };
/** Prose with citation numbers between the runs, each number pointing into sources (1-based). */
type Reply = { parts: (string | number)[]; sources: Source[] };
type Step = string | number;
type Live =
  | { id: number; from: "user"; text: string }
  | { id: number; from: "assistant"; reply: Reply; chunks: Step[]; count: number; streaming: boolean; stopped: boolean };

const ASSISTANT = "Larder";

/* Canned answers for the demo. Swap pickReply for a call to your model and stream its tokens instead. */
const replies: Record<"waste" | "prices" | "order", Reply> = {
  waste: {
    parts: [
      "Last week 14.6 kg went in the bin, worth about £182",
      1,
      ". Half of that was prep: 4.1 kg of trimmed leeks and 2.8 kg of Thursday's focaccia. The leeks came in a 10 kg Natoora case",
      2,
      " when the menu uses about 6 kg a week. Two 5 kg orders would have saved roughly £30.",
    ],
    sources: [
      { href: "#waste-week-45", title: "Waste log, week 45", source: "Waste log", snippet: "Total 14.6 kg, £181.90. Prep trim 7.3 kg" },
      { href: "#invoice-nat-88213", title: "Natoora invoice NAT-88213", source: "Invoices", snippet: "Leeks, 10 kg case, £24.50" },
    ],
  },
  prices: {
    parts: [
      "Two suppliers raised prices this month. Flock & Herd put beef shin up from £12.50 to £13.50 a kilo on 3 November",
      1,
      ". Belazu's Arbequina oil went from £38 to £41 a tin",
      2,
      ". Everything else on your last 40 invoices held its price.",
    ],
    sources: [
      { href: "#invoice-fh-30417", title: "Flock & Herd invoice FH-30417", source: "Invoices", snippet: "Beef shin, 8 kg, £108.00 (£13.50/kg)" },
      { href: "#invoice-blz-5521", title: "Belazu invoice BLZ-5521", source: "Invoices", snippet: "Arbequina olive oil, 5 l tin, £41.00" },
    ],
  },
  order: {
    parts: [
      "For Saturday you need about 7 litres of double cream: 64 covers are booked",
      1,
      ", the panna cotta takes 4.5 litres and the sauces about 2.5. Friday's count shows 3.4 litres on the shelf",
      2,
      ". Order 4 litres from Neal's Yard Dairy before 15:00 tomorrow to have it on Saturday's van",
      3,
      ".",
    ],
    sources: [
      { href: "#bookings-saturday", title: "Bookings for Saturday", source: "Reservations", snippet: "64 covers across two sittings" },
      { href: "#count-friday", title: "Stock count, Friday 17:40", source: "Stock counts", snippet: "Double cream, 3.4 l" },
      { href: "#nyd-cutoffs", title: "Neal's Yard Dairy order cut-offs", source: "Suppliers", snippet: "Order by 15:00 for delivery in two days" },
    ],
  },
};

function pickReply(question: string): Reply {
  const q = question.toLowerCase();
  if (/bin|waste|throw|binned/.test(q)) return replies.waste;
  if (/price|supplier|cost|invoice/.test(q)) return replies.prices;
  return replies.order;
}

/**
 * Splits the prose into uneven runs of two to four words, the way a real stream arrives.
 * A citation is its own step, so it lands whole between the runs.
 */
function chunk(parts: Step[]): Step[] {
  const out: Step[] = [];
  let n = 0;
  for (const part of parts) {
    if (typeof part === "number") {
      out.push(part);
      continue;
    }
    const words = part.split(/(?<=\s)/);
    for (let i = 0; i < words.length; n++) {
      const size = 2 + (n % 3);
      out.push(words.slice(i, i + size).join(""));
      i += size;
    }
  }
  return out;
}

const suggestions = [
  "What went in the bin last week?",
  "Which supplier raised prices this month?",
  "How much double cream do we need for Saturday?",
];

function LarderMark() {
  return (
    <span className="blk-chat-screen-mark">
      <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10" /><path d="M8 4v2.5a3 3 0 0 1-1.5 2.6A3 3 0 0 0 5 11.7V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.3a3 3 0 0 0-1.5-2.6A3 3 0 0 1 16 6.5V4" /><path d="M5 13h14" /></svg>
    </span>
  );
}

const ines = <Avatar name="Ines Duarte" alt="" size="s" />;

/*
 * Ask Larder: an assistant over the kitchen's stock counts and invoices. The seeded turns
 * carry inline citations; a new question streams its answer with the citations landing in place, and the source list follows once it is done.
 */
export default function ChatScreen() {
  const id = useId();
  const titleId = `${id}-title`;
  const [live, setLive] = useState<Live[]>([]);
  const nextId = useRef(1);
  const logRef = useRef<HTMLDivElement>(null);
  const composerInput = useRef<HTMLTextAreaElement>(null);

  const active = live.find((m): m is Extract<Live, { from: "assistant" }> => m.from === "assistant" && m.streaming);
  const activeId = active?.id;
  const activeCount = active?.count ?? 0;
  const activeTotal = active?.chunks.length ?? 0;
  const busy = active != null;

  // One timer at a time, keyed on the stream and how far it has got; cleanup clears it on stop or unmount.
  useEffect(() => {
    if (activeId == null) return;
    const delay = activeCount === 0 ? 600 : 60 + (activeCount % 3) * 55;
    const timer = setTimeout(() => {
      setLive((all) =>
        all.map((m) => {
          if (m.id !== activeId || m.from !== "assistant") return m;
          const count = Math.min(m.count + 1, m.chunks.length);
          return { ...m, count, streaming: count < m.chunks.length };
        }),
      );
    }, delay);
    return () => clearTimeout(timer);
  }, [activeId, activeCount, activeTotal]);

  // Keep the newest words in view. Only the list scrolls, never the page around it.
  useEffect(() => {
    const el = logRef.current;
    if (el && live.length > 0) el.scrollTop = el.scrollHeight;
  }, [live.length, activeCount]);

  function send(text: string) {
    const question = text.trim();
    if (!question || busy) return;
    const reply = pickReply(question);
    const userId = nextId.current++;
    const answerId = nextId.current++;
    setLive((all) => [
      ...all,
      { id: userId, from: "user", text: question },
      { id: answerId, from: "assistant", reply, chunks: chunk(reply.parts), count: 0, streaming: true, stopped: false },
    ]);
  }

  function stop() {
    setLive((all) => all.map((m) => (m.from === "assistant" && m.streaming ? { ...m, streaming: false, stopped: true } : m)));
  }

  return (
    <section className="blk-chat-screen" aria-labelledby={titleId}>
      <div className="blk-chat-screen-frame">
        <header className="blk-chat-screen-head">
          <LarderMark />
          <div className="blk-chat-screen-who">
            <Heading level={1} size="s" id={titleId}>
              Ask Larder
            </Heading>
            <Text size="s" tone="quiet">
              Marrow, Wilton Way. 212 invoices and 13 stock counts
            </Text>
          </div>
          <Badge tone="neutral" dot className="blk-chat-screen-scope">
            Reads your last 90 days
          </Badge>
        </header>

        <div className="blk-chat-screen-log" ref={logRef} tabIndex={0} role="region" aria-label="Conversation">
          <ChatMessage from="system">Ask Larder answers from Marrow&apos;s invoices and stock counts since 15 August.</ChatMessage>

          <ChatMessage from="user" name="Ines" time="17:42" avatar={ines}>
            Why did dairy cost so much more in October?
          </ChatMessage>

          <ChatMessage from="assistant" name={ASSISTANT} time="17:42" avatar={<LarderMark />}>
            <p>
              Mostly butter. Neal&apos;s Yard Dairy put cultured butter up from £9.80 to £11.40 a kilo on 2 October
              <Citation
                index={1}
                href="#invoice-nyd-4471"
                title="Neal's Yard Dairy invoice NYD-4471"
                source="Invoices"
                snippet="Cultured butter, 5 kg block, £57.00 (£11.40/kg)"
              />
              , and you bought 46 kg in October against 31 kg in September
              <Citation
                index={2}
                href="#count-october"
                title="Butter, monthly usage"
                source="Stock counts"
                snippet="September 31.0 kg, October 46.2 kg"
              />
              .
            </p>
            <p>
              That accounts for about £240 of the £310 rise. The rest is the extra Comté for the set menu
              <Citation index={3} href="#invoice-nyd-4502" title="Neal's Yard Dairy invoice NYD-4502" source="Invoices" snippet="Comté 18 month, 4 kg, £96.00" />.
            </p>
            <CitationList>
              <CitationListItem index={1} href="#invoice-nyd-4471" title="Neal's Yard Dairy invoice NYD-4471" source="Invoices" />
              <CitationListItem index={2} href="#count-october" title="Butter, monthly usage" source="Stock counts" />
              <CitationListItem index={3} href="#invoice-nyd-4502" title="Neal's Yard Dairy invoice NYD-4502" source="Invoices" />
            </CitationList>
          </ChatMessage>

          <ChatMessage from="user" name="Ines" time="17:44" avatar={ines}>
            Are we over-ordering it?
          </ChatMessage>

          <ChatMessage from="assistant" name={ASSISTANT} time="17:44" avatar={<LarderMark />}>
            <p>
              A little. Friday counts show 4 to 6 kg still on the shelf each week
              <Citation index={1} href="#counts-fridays" title="Friday stock counts, October" source="Stock counts" snippet="Butter left: 5.2, 4.1, 6.0, 4.8 kg" />
              , and 3.2 kg went in the bin in October as out of date
              <Citation index={2} href="#waste-october" title="Waste log, October" source="Waste log" snippet="Cultured butter, past date, 3.2 kg, £36.48" />
              . Ordering 9 kg on Tuesdays instead of 12 would still cover a full weekend at last month&apos;s pace.
            </p>
            <CitationList>
              <CitationListItem index={1} href="#counts-fridays" title="Friday stock counts, October" source="Stock counts" />
              <CitationListItem index={2} href="#waste-october" title="Waste log, October" source="Waste log" />
            </CitationList>
          </ChatMessage>

          {live.map((m) =>
            m.from === "user" ? (
              <ChatMessage key={m.id} from="user" name="Ines" avatar={ines}>
                {m.text}
              </ChatMessage>
            ) : (
              <ChatMessage key={m.id} from="assistant" name={ASSISTANT} avatar={<LarderMark />}>
                {m.streaming && m.count === 0 && (
                  <Text size="s" tone="faint" className="blk-chat-screen-reading">
                    Reading the invoices
                  </Text>
                )}
                <StreamingText
                  streaming={m.streaming}
                  segments={m.chunks.slice(0, m.count).map((step) => {
                    if (typeof step === "string") return step;
                    const s = m.reply.sources[step - 1];
                    return <Citation key={s.href} index={step} href={s.href} title={s.title} source={s.source} snippet={s.snippet} />;
                  })}
                />
                {m.stopped && (
                  <Text size="s" tone="faint">
                    Stopped. Ask again for the full answer.
                  </Text>
                )}
                {!m.streaming && !m.stopped && (
                  <CitationList>
                    {m.reply.sources.map((s, i) => (
                      <CitationListItem key={s.href} index={i + 1} href={s.href} title={s.title} source={s.source} />
                    ))}
                  </CitationList>
                )}
              </ChatMessage>
            ),
          )}
        </div>

        <div className="blk-chat-screen-foot">
          {!busy && (
            <div className="blk-chat-screen-suggestions" role="group" aria-label="Suggested questions">
              {suggestions.map((s) => (
                <Button
                  key={s}
                  variant="outline"
                  size="s"
                  onClick={() => {
                    send(s);
                    // The chips leave while the answer streams, so focus goes on to the box rather than the page.
                    composerInput.current?.focus();
                  }}
                >
                  {s}
                </Button>
              ))}
            </div>
          )}
          <PromptComposer
            inputRef={composerInput}
            label="Ask about stock or invoices"
            placeholder="Ask about stock, orders or invoices"
            busy={busy}
            onSubmit={send}
            onStop={stop}
            hint="Enter to send, Shift+Enter for a new line"
          />
        </div>
      </div>
    </section>
  );
}
components/blocks/chat-screen.css129 lines
@layer blocks {
  .blk-chat-screen {
    /* The whole frame's height. The message list takes what the header and composer leave. */
    --blk-chat-screen-height: 40rem;

    container: blk-chat-screen / inline-size;
    padding: var(--eq-space-5) var(--eq-space-3);
    background: var(--eq-bg);
    color: var(--eq-ink);
  }

  .blk-chat-screen-frame {
    box-sizing: border-box;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto;
    height: var(--blk-chat-screen-height);
    max-width: 52rem;
    margin-inline: auto;
    overflow: hidden;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
  }

  .blk-chat-screen-head {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    gap: var(--eq-space-2) var(--eq-space-3);
    padding: var(--eq-space-3) var(--eq-space-4);
    border-bottom: 1px solid var(--eq-line);
  }

  .blk-chat-screen-who {
    display: grid;
    gap: 2px;
    min-width: 0;
  }

  .blk-chat-screen-who > * {
    margin: 0;
  }

  /* On a phone the badge drops under the name, lined up with it. */
  .blk-chat-screen-scope {
    grid-column: 2;
    justify-self: start;
  }

  .blk-chat-screen-mark {
    display: inline-grid;
    place-items: center;
    flex: none;
    width: var(--eq-h-s);
    height: var(--eq-h-s);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
  }

  /* The list scrolls inside the frame, over a faint ledger grid. */
  .blk-chat-screen-log {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-5);
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: var(--eq-space-5) var(--eq-space-4);
    background:
      radial-gradient(circle at 1px 1px, color-mix(in oklab, var(--eq-line-strong) 60%, transparent) 1px, transparent 0) 0 0 / 18px 18px,
      var(--eq-bg);
  }

  .blk-chat-screen-log:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  .blk-chat-screen-foot {
    display: grid;
    gap: var(--eq-space-3);
    padding: var(--eq-space-3) var(--eq-space-4) var(--eq-space-4);
    border-top: 1px solid var(--eq-line);
  }

  /* Chips scroll sideways on a phone rather than stacking three rows deep. */
  .blk-chat-screen-suggestions {
    display: flex;
    gap: var(--eq-space-2);
    overflow-x: auto;
    /* Room for the focus ring, which the scroll area would otherwise clip. */
    margin: -4px;
    padding: 4px;
    scrollbar-width: none;
  }

  .blk-chat-screen-suggestions > * {
    flex: none;
  }

  @container blk-chat-screen (width >= 40rem) {
    .blk-chat-screen-head {
      grid-template-columns: auto minmax(0, 1fr) auto;
    }

    .blk-chat-screen-scope {
      grid-column: auto;
    }

    .blk-chat-screen-log {
      padding-inline: var(--eq-space-6);
    }

    .blk-chat-screen-foot {
      padding-inline: var(--eq-space-6);
    }

    .blk-chat-screen-suggestions {
      flex-wrap: wrap;
      overflow-x: visible;
    }

    .blk-chat-screen-suggestions > * {
      flex: 0 1 auto;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index