Blocks / AI
Chat screen
An assistant chat with cited answers, suggested questions, a streamed reply and a composer that can stop it.
Ask Larder
Marrow, Wilton Way. 212 invoices and 13 stock counts
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-screenBuilt 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'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'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'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;
}
}
}