Compare this week's sessions to last week
Which engine cites Northwind most often?
Tabbed chat panel with reasoning replies and a growing composer.
AgentComponentNo dependencies Beautiful UI, MIT
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- ---
import ChatComposer from "../components/viewership/chat-composer/ChatComposer.astro";
---
<ChatComposer /> | Prop | Type | Default | Description |
|---|---|---|---|
threads | { label: string; prompt?: string; replies?: { label; sub; time; body }[] }[] | [{ label: "Chat" }] | One tab per thread, with an opening prompt and replies. |
active | number | 0 | Initially selected tab. |
placeholder | string | "Ask anything" | Composer placeholder. |
demo | boolean | false | Add the user's message and replay scripted replies. Off by default; the component only emits `vs-chat-submit`. |
labels | { tabs?, input?, send?, newChat?, history?, more?, for? } | · | Accessible names, plus the word between a reply's sub-label and time. |
---
/**
* ChatComposer: a tabbed chat panel with reasoning replies above an
* auto-growing composer. Tabs follow the WAI-ARIA tabs pattern; each tab is
* its own thread.
*
* Ported from Beautiful UI `components/primitives/ChatComposer.tsx`
* (MIT, © 2026 Shane Levine).
*
* Nothing is sent. Submit emits `vs-chat-submit` { text, tab, index } and
* clears the composer. To render messages, dispatch `vs-chat-append` on the
* root: { role: "user", text } or { role: "reply", label, sub, time, body }
* (optional `index` picks a tab). With `demo`, submit also appends locally
* and replays the active thread's scripted replies. The header buttons emit
* `vs-chat-action` { action: "new" | "history" | "more" }.
*
* @example
* <ChatComposer threads={[{ label: "Reports", prompt: "...", replies: [...] }]} />
*/
import type { HTMLAttributes } from "astro/types";
/** One agent reasoning row: "<label> <sub> for <time>" over a body line. */
export type ChatReply = { label: string; sub: string; time: string; body: string };
/** One tab: an optional opening prompt and the replies that follow it. */
export type ChatThread = { label: string; prompt?: string; replies?: ChatReply[] };
type Props = HTMLAttributes<"div"> & {
threads?: ChatThread[];
/** Index of the initially selected tab. */
active?: number;
placeholder?: string;
/** Demo mode: append the user's message locally and replay scripted replies. */
demo?: boolean;
labels?: {
tabs?: string;
input?: string;
send?: string;
newChat?: string;
history?: string;
more?: string;
/** Word between a reply's sub-label and its duration. */
for?: string;
};
};
const {
threads = [{ label: "Chat" }],
active = 0,
placeholder = "Ask anything",
demo = false,
labels,
class: className,
...rest
} = Astro.props;
const text = {
tabs: "Conversations",
input: "Chat prompt",
send: "Send",
newChat: "New chat",
history: "History",
more: "More options",
for: "for",
...labels,
};
const uid = `vs-chat-${Math.random().toString(36).slice(2, 8)}`;
const current = Math.min(Math.max(active, 0), threads.length - 1);
---
<div class:list={["vs-chat-composer", className]} data-demo={demo ? "" : undefined} data-for={text.for} {...rest}>
<div class="head">
<div class="tabs" role="tablist" aria-label={text.tabs}>
{threads.map((t, i) => (
<button
type="button"
role="tab"
class="tab"
id={`${uid}-tab-${i}`}
aria-controls={`${uid}-panel-${i}`}
aria-selected={i === current ? "true" : "false"}
tabindex={i === current ? 0 : -1}
>
{t.label}
</button>
))}
</div>
<div class="actions">
<button type="button" class="icon-btn" data-action="new" aria-label={text.newChat}>
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"></path></svg>
</button>
<button type="button" class="icon-btn" data-action="history" aria-label={text.history}>
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
</button>
<button type="button" class="icon-btn" data-action="more" aria-label={text.more}>
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"></circle><circle cx="12" cy="12" r="1.8"></circle><circle cx="19" cy="12" r="1.8"></circle></svg>
</button>
</div>
</div>
{threads.map((t, i) => (
<div
class="panel"
role="tabpanel"
id={`${uid}-panel-${i}`}
aria-labelledby={`${uid}-tab-${i}`}
tabindex="0"
hidden={i !== current}
>
<div class="log" role="log" aria-live="polite" data-replies={JSON.stringify(t.replies ?? [])}>
{t.prompt && (
<div class="user">
<div class="bubble">{t.prompt}</div>
</div>
)}
{(t.replies ?? []).map((r) => (
<div class="reply">
<div class="reply-inner">
<div class="reply-head">
<span class="reply-label">{r.label}</span>
<span class="reply-sub">{r.sub}</span>
<span class="reply-time">{text.for} {r.time}</span>
</div>
<p class="reply-body">{r.body}</p>
</div>
</div>
))}
</div>
</div>
))}
<div class="composer-wrap">
<div class="composer">
<textarea rows="1" placeholder={placeholder} aria-label={text.input}></textarea>
<div class="send-row">
<button type="button" class="send" aria-label={text.send} disabled>
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"></path></svg>
</button>
</div>
</div>
</div>
<template data-tpl="user">
<div class="user is-new"><div class="bubble"></div></div>
</template>
<template data-tpl="reply">
<div class="reply is-new">
<div class="reply-inner">
<div class="reply-head">
<span class="reply-label"></span>
<span class="reply-sub"></span>
<span class="reply-time"></span>
</div>
<p class="reply-body"></p>
</div>
</div>
</template>
</div>
<script>
type Reply = { label: string; sub: string; time: string; body: string };
type AppendDetail = ({ role: "user"; text: string } | ({ role: "reply"; resolving?: boolean } & Reply)) & { index?: number };
function init(root: HTMLElement) {
const tabs = [...root.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
const panels = [...root.querySelectorAll<HTMLElement>('[role="tabpanel"]')];
const composer = root.querySelector<HTMLElement>(".composer")!;
const input = root.querySelector<HTMLTextAreaElement>("textarea")!;
const send = root.querySelector<HTMLButtonElement>(".send")!;
const userTpl = root.querySelector<HTMLTemplateElement>('template[data-tpl="user"]')!;
const replyTpl = root.querySelector<HTMLTemplateElement>('template[data-tpl="reply"]')!;
const forWord = root.dataset.for ?? "for";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
let timers: ReturnType<typeof setTimeout>[] = [];
let index = Math.max(0, tabs.findIndex((t) => t.getAttribute("aria-selected") === "true"));
/* ── tabs ── */
const select = (next: number, focus = false) => {
index = (next + tabs.length) % tabs.length;
tabs.forEach((tab, i) => {
const on = i === index;
tab.setAttribute("aria-selected", String(on));
tab.tabIndex = on ? 0 : -1;
panels[i].hidden = !on;
});
if (focus) tabs[index].focus();
};
tabs.forEach((tab, i) => {
tab.addEventListener("click", () => select(i));
tab.addEventListener("keydown", (event) => {
const moves: Record<string, number> = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 };
if (!(event.key in moves)) return;
event.preventDefault();
select(moves[event.key], true);
});
});
root.querySelectorAll<HTMLButtonElement>("[data-action]").forEach((button) =>
button.addEventListener("click", () =>
root.dispatchEvent(new CustomEvent("vs-chat-action", { bubbles: true, detail: { action: button.dataset.action } })),
),
);
/* ── messages ── */
const scrollDown = (panel: HTMLElement) =>
panel.scrollTo({ top: panel.scrollHeight, behavior: reduce.matches ? "auto" : "smooth" });
const append = (detail: AppendDetail) => {
const panel = panels[detail.index ?? index];
const log = panel?.querySelector<HTMLElement>(".log");
if (!panel || !log) return null;
let node: HTMLElement;
if (detail.role === "user") {
node = (userTpl.content.firstElementChild as HTMLElement).cloneNode(true) as HTMLElement;
node.querySelector(".bubble")!.textContent = detail.text;
} else {
node = (replyTpl.content.firstElementChild as HTMLElement).cloneNode(true) as HTMLElement;
node.querySelector(".reply-label")!.textContent = detail.label;
node.querySelector(".reply-sub")!.textContent = detail.sub;
node.querySelector(".reply-time")!.textContent = `${forWord} ${detail.time}`;
node.querySelector(".reply-body")!.textContent = detail.body;
if (detail.resolving) node.classList.add("is-resolving");
}
log.append(node);
scrollDown(panel);
return node;
};
root.addEventListener("vs-chat-append", (event) => {
const detail = (event as CustomEvent<AppendDetail>).detail;
if (detail) append(detail);
});
/* demo: replay the thread's scripted replies */
const replay = (target: number) => {
const log = panels[target].querySelector<HTMLElement>(".log")!;
const replies = JSON.parse(log.dataset.replies || "[]") as Reply[];
if (reduce.matches) {
replies.forEach((r) => append({ role: "reply", ...r, index: target }));
return;
}
let at = 500;
replies.forEach((r, i) => {
const last = i === replies.length - 1 && replies.length > 1;
timers.push(
setTimeout(() => {
const node = append({ role: "reply", ...r, index: target, resolving: last });
if (node && last) timers.push(setTimeout(() => node.classList.remove("is-resolving"), 1200));
}, at),
);
at += 1400;
});
};
/* ── composer ── */
const grow = () => {
if (CSS.supports("field-sizing", "content")) return;
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
};
const sync = () => {
send.disabled = input.value.trim().length === 0;
grow();
};
const submit = () => {
const value = input.value.trim();
if (!value) return;
root.dispatchEvent(
new CustomEvent("vs-chat-submit", { bubbles: true, detail: { text: value, tab: tabs[index]?.textContent?.trim() ?? "", index } }),
);
input.value = "";
sync();
if ("demo" in root.dataset) {
timers.forEach(clearTimeout);
timers = [];
append({ role: "user", text: value });
replay(index);
}
};
input.addEventListener("input", sync);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit();
}
});
send.addEventListener("click", submit);
composer.addEventListener("click", (event) => {
if (!(event.target as Element).closest("button")) input.focus();
});
document.addEventListener("astro:before-swap", () => timers.forEach(clearTimeout), { once: true });
sync();
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-chat-composer:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>
<style>
.vs-chat-composer {
display: flex;
flex-direction: column;
align-self: flex-start;
width: 100%;
max-width: 380px;
height: 288px;
overflow: hidden;
border-radius: 14px;
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
}
/* header: tabs + actions */
.head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
padding: 6px;
border-bottom: 1px solid var(--vs-line);
}
.tabs {
display: flex;
align-items: center;
}
.tab {
padding: 3px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 13px;
line-height: 1.5;
opacity: 0.5;
cursor: pointer;
transition:
background-color 100ms,
opacity 100ms;
}
.tab:hover {
opacity: 0.75;
}
.tab[aria-selected="true"] {
background: var(--vs-field);
opacity: 1;
}
.actions {
display: flex;
align-items: center;
gap: 4px;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--vs-ink-3);
cursor: pointer;
transition:
background-color 100ms,
color 100ms;
}
.icon-btn:hover {
background: var(--vs-hover);
color: var(--vs-ink-2);
}
:is(.tab, .icon-btn, .send):focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
/* conversation: fixed region so the card never changes shape */
.panel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 10px 12px 4px;
overflow-y: auto;
}
.panel[hidden] {
display: none;
}
.panel:focus-visible {
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-accent) 70%, transparent);
}
.log {
display: flex;
flex-direction: column;
gap: 10px;
}
/* user bubble: right aligned, soft block */
.user {
display: flex;
justify-content: flex-end;
padding-left: 56px;
}
.bubble {
padding: 6px 12px;
border-radius: 12px;
background: var(--vs-field);
font-size: 13px;
line-height: 1.4;
color: var(--vs-ink);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.user.is-new {
animation: vs-chat-composer-rise 300ms var(--vs-ease-out) both;
}
/* reasoning reply: the outer element enters, the inner one resolves
(separate elements so the entrance fill does not mask the dim) */
.reply-inner {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
transform-origin: top left;
transition:
opacity 400ms var(--vs-ease-out),
filter 400ms var(--vs-ease-out),
transform 400ms var(--vs-ease-out);
}
.reply.is-new {
animation: vs-fade-up 400ms var(--vs-ease-out) both;
}
.reply.is-resolving .reply-inner {
opacity: 0.55;
filter: blur(0.5px);
transform: scale(0.985);
}
.reply-head {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
line-height: 1.3;
}
.reply-label {
font-weight: 500;
color: var(--vs-ink);
}
.reply-sub {
color: var(--vs-ink-2);
}
.reply-time {
color: var(--vs-ink);
}
.reply-body {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--vs-ink);
}
/* composer */
.composer-wrap {
flex-shrink: 0;
margin-top: auto;
padding: 6px;
}
.composer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid var(--vs-line);
border-radius: var(--vs-radius-control);
background: var(--vs-field);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.035);
cursor: text;
transition:
border-color 150ms,
box-shadow 150ms;
}
.composer:focus-within {
border-color: var(--vs-line-strong);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.025);
}
.composer:has(textarea:focus-visible) {
box-shadow: var(--vs-focus-ring);
}
textarea {
display: block;
width: 100%;
min-height: 18px;
max-height: 92px;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: var(--vs-ink);
font: inherit;
font-size: 13px;
line-height: 1.4;
resize: none;
outline: none;
overflow-y: auto;
field-sizing: content;
}
textarea::placeholder {
color: var(--vs-ink-3);
}
.send-row {
display: flex;
align-items: center;
justify-content: flex-end;
}
.send {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 8px;
background: var(--vs-ink);
color: var(--vs-surface);
cursor: pointer;
transition:
background-color 200ms,
color 200ms,
transform 200ms;
}
.send:enabled:active {
transform: scale(0.96);
}
.send:disabled {
background: var(--vs-line-strong);
color: var(--vs-ink-2);
cursor: default;
}
@keyframes vs-chat-composer-rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.user.is-new,
.reply.is-new {
animation: none;
}
.reply.is-resolving .reply-inner {
opacity: 1;
filter: none;
transform: none;
}
.send:enabled:active {
transform: none;
}
}
</style> Ported from Beautiful UI, MIT. Notices.