Basic chat with an AI model of your choice — streaming replies, editable system prompt.
---
format: typebulb/v1
name: Chat
---
**code.tsx**
```tsx
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
interface Message {
role: "user" | "assistant";
content: string;
reasoning?: string;
model?: string;
error?: boolean;
}
interface Model {
provider: string;
name: string;
friendlyName: string;
default?: boolean;
}
const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
// Streaming: a live clip pinned to the newest lines. Finished: collapsed, expandable to re-read.
function Thinking({ text, streaming }: { text?: string; streaming?: boolean }) {
if (!text?.trim()) return null;
if (streaming) {
return (
<div className="think-live">
<span className="think-pulse">Thinking…</span>
<div className="think-clip"><div>{text}</div></div>
</div>
);
}
return (
<details className="think">
<summary>Thought · {fmtK(text.length)} chars</summary>
<div className="think-body">{text}</div>
</details>
);
}
function ModelOptions({ models }: { models: Model[] }) {
const groups = models.reduce((g: Record<string, Model[]>, m) => { (g[m.provider] ??= []).push(m); return g; }, {});
return <>{Object.entries(groups).map(([provider, ms]) => (
<optgroup key={provider} label={provider}>{ms.map(m => <option key={m.name} value={m.name}>{m.friendlyName}</option>)}</optgroup>
))}</>;
}
function App() {
const [models, setModels] = useState<Model[]>([]);
const [model, setModel] = useState<Model | null>(null);
const [system, setSystem] = useState("You are a helpful assistant.");
const [editingSystem, setEditingSystem] = useState(false);
// "default" = omit the effort param entirely (vendor default — on some models, e.g. Opus 4.8,
// that means no thinking at all; the resolved level is provider-dependent).
const [effort, setEffort] = useState<number | "default">(1);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [access, setAccess] = useState<AiAccess>("own");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const stopRef = useRef(false);
useEffect(() => {
async function load() {
const available = await tb.models();
setModels(available);
if (available.length > 0) setModel(available.find(m => m.default) ?? available[0]);
setAccess(await tb.aiAccess());
}
load();
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const run = async (history: Message[]) => {
if (!model || isLoading) return;
const reply: Message = { role: "assistant", content: "", model: model.friendlyName };
setMessages([...history, reply]);
setIsLoading(true);
stopRef.current = false;
try {
let answer = "";
let reasoning = "";
for await (const c of tb.ai.stream({
provider: model.provider,
model: model.name,
webSearch: false, // provider default ADDS a web_search tool; opt out so calls match the identity-probe batches
system,
...(effort === "default" ? {} : { effort: effort as 0 | 1 | 2 | 3 }),
messages: history.map(({ role, content }) => ({ role, content }))
})) {
if (stopRef.current) break; // breaking the loop aborts the stream
if (c.kind === "reasoning") reasoning += c.text;
else answer += c.text;
setMessages([...history, { ...reply, content: answer, reasoning }]);
}
} catch (error: any) {
setMessages([...history, { ...reply, content: `⚠ ${error?.message ?? error}`, error: true }]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
const send = () => {
const text = input.trim();
if (!text || !model || isLoading) return;
setInput("");
run([...messages, { role: "user", content: text }]);
};
// Wind the conversation back to message i: a user message returns to the composer for editing,
// an assistant one is dropped and re-run from the history before it.
const rewind = (i: number) => {
if (isLoading) return;
const target = messages[i];
if (target.role === "user") {
setMessages(messages.slice(0, i));
setInput(target.content);
inputRef.current?.focus();
} else {
run(messages.slice(0, i));
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const resetChat = () => setMessages([]);
return (
<div className="app">
<header>
<span className="brand">Chat</span>
<select className="model-pick" title="Model" value={model?.name || ""} onChange={e => {
const selected = models.find(m => m.name === e.target.value);
if (selected) setModel(selected);
}}>
<ModelOptions models={models} />
</select>
<select title="Reasoning effort — Default sends no effort param (provider decides)" value={effort}
onChange={e => setEffort(e.target.value === "default" ? "default" : +e.target.value)}>
<option value="default">Default</option>
<option value={0}>Minimal</option>
<option value={1}>Low</option>
<option value={2}>Med</option>
<option value={3}>High</option>
</select>
<button className="ghost" onClick={resetChat} disabled={messages.length === 0}>Reset</button>
</header>
{access === "none" && (
<div className="keys-note">
No API keys found — set a provider key in <code>.env</code> to chat.
</div>
)}
<div className="messages" ref={scrollRef}>
<div className="col">
<div className="msg system">
<div className="model-tag sys-tag">
<span>System</span>
<button className="link" onClick={() => setEditingSystem(e => !e)}>{editingSystem ? "done" : "edit"}</button>
<button className="link" onClick={() => setSystem("")} disabled={!system}>clear</button>
</div>
{editingSystem ? (
<textarea
autoFocus
value={system}
onChange={e => setSystem(e.target.value)}
onKeyDown={e => { if (e.key === "Escape") setEditingSystem(false); }}
placeholder="System instructions…"
/>
) : (
<div className="system-text" onClick={() => setEditingSystem(true)}>{system || "No system prompt."}</div>
)}
</div>
{messages.length === 0 && (
<div className="empty-state">Say something to start the conversation.</div>
)}
{messages.map((m, i) => {
const streaming = isLoading && i === messages.length - 1;
return m.role === "user" ? (
<div key={i} className="msg user">
<button className="rewind" title="Rewind to here — puts this message back in the composer"
onClick={() => rewind(i)} disabled={isLoading}>↩</button>
<div className="bubble">{m.content}</div>
</div>
) : (
<div key={i} className="msg assistant">
<div className="model-tag">
<span>{m.model ?? "Assistant"}</span>
<button className="rewind" title="Rewind to here — re-runs this reply"
onClick={() => rewind(i)} disabled={isLoading}>↻</button>
</div>
<Thinking text={m.reasoning} streaming={streaming} />
<div className="content">{m.content || (streaming ? <span className="cursor" /> : "…")}</div>
{m.error && !isLoading && i === messages.length - 1 && (
<button className="retry" onClick={() => rewind(i)}>↻ Retry</button>
)}
</div>
);
})}
</div>
</div>
<div className="composer">
<div className="composer-box">
<textarea
ref={inputRef}
rows={1}
placeholder={model ? "Message… (Enter to send, Shift+Enter for newline)" : "Loading models…"}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKeyDown}
disabled={!model}
/>
{isLoading ? (
<button className="send" title="Stop" onClick={() => { stopRef.current = true; }}>■</button>
) : (
<button className="send" title="Send" onClick={send} disabled={!model || !input.trim()}>↑</button>
)}
</div>
</div>
</div>
);
}
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);
```
**styles.css**
```css
:root {
--bg: #ffffff;
--text: #1c1c1e;
--muted: #6e6e78;
--border: #e4e4e7;
--accent: #2563eb;
--user-bg: #f1f3f6;
--raise: #fafafa;
}
html[data-theme="dark"] {
--bg: #151517;
--text: #ececf1;
--muted: #9d9da7;
--border: #2c2c31;
--accent: #4f8ef7;
--user-bg: #26262b;
--raise: #1b1b1e;
}
body {
margin: 0;
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
height: 100dvh;
overflow: hidden;
}
#root { height: 100%; }
.app {
display: flex;
flex-direction: column;
height: 100%;
}
header {
width: 100%;
max-width: 760px;
margin: 0 auto;
box-sizing: border-box;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 14px;
}
.brand { font-weight: 650; margin-right: 4px; }
header select {
font: inherit;
font-size: 13.5px;
padding: 4px 6px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
min-width: 0;
max-width: 42ch;
}
.ghost {
font: inherit;
font-size: 13.5px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.ghost:disabled { opacity: 0.4; cursor: default; }
.keys-note {
padding: 6px 14px;
font-size: 13.5px;
text-align: center;
color: var(--muted);
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px 14px;
}
.messages .col {
max-width: 760px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg { animation: rise 0.18s ease-out; }
@keyframes rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.msg.user {
align-self: flex-end;
max-width: 80%;
display: flex;
align-items: center;
gap: 6px;
}
.msg.user .bubble {
min-width: 0;
background: var(--user-bg);
padding: 8px 14px;
border-radius: 16px 16px 4px 16px;
white-space: pre-wrap;
}
/* Wind-back handle on every message — revealed on hover, out of the way otherwise */
.rewind {
flex-shrink: 0;
padding: 0 2px;
border: none;
background: none;
font: inherit;
font-size: 13px;
line-height: 1;
color: var(--muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.12s;
}
.msg:hover .rewind:not(:disabled), .rewind:focus-visible { opacity: 1; }
@media (hover: none) { .rewind:not(:disabled) { opacity: 0.6; } }
/* The system prompt, shown as the conversation's implicit first message */
.msg.system { color: var(--muted); }
.system-text { white-space: pre-wrap; cursor: text; }
.msg.system textarea {
width: 100%;
box-sizing: border-box;
min-height: 64px;
resize: vertical;
font: inherit;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--raise);
color: var(--text);
}
.link {
font: inherit;
font-size: 12.5px;
padding: 0;
border: none;
background: none;
color: var(--accent);
cursor: pointer;
}
.link:disabled { color: var(--muted); cursor: default; }
.model-tag {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12.5px;
color: var(--muted);
margin-bottom: 2px;
}
.sys-tag { gap: 10px; }
.content { white-space: pre-wrap; }
.retry {
margin-top: 6px;
font: inherit;
font-size: 13.5px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.cursor {
display: inline-block;
width: 8px;
height: 1em;
background: var(--muted);
vertical-align: text-bottom;
animation: blink 1s steps(2) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
/* Reasoning streaming in: a clip pinned to the newest lines, fading out at the top */
.think-live { margin: 2px 0 8px; }
.think-pulse {
font-size: 12.5px;
color: var(--muted);
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: 0.35; } }
.think-clip {
max-height: 108px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end; /* content taller than the clip overflows at the top */
margin-top: 4px;
mask-image: linear-gradient(to bottom, transparent, black 32px);
}
.think-clip > div {
font-size: 13px;
line-height: 1.5;
color: var(--muted);
white-space: pre-wrap;
}
/* Collapsed thinking transcript on a finished reply */
.think { margin: 2px 0 8px; }
.think > summary {
cursor: pointer;
width: fit-content;
font-size: 12.5px;
color: var(--muted);
list-style: none;
}
.think > summary::-webkit-details-marker { display: none; }
.think > summary::before { content: "▸ "; }
.think[open] > summary::before { content: "▾ "; }
.think-body {
margin-top: 6px;
max-height: 240px;
overflow-y: auto;
font-size: 13px;
line-height: 1.55;
color: var(--muted);
white-space: pre-wrap;
border-left: 2px solid var(--border);
padding-left: 10px;
}
.empty-state {
text-align: center;
margin-top: 10vh;
color: var(--muted);
}
.composer { padding: 8px 14px 14px; }
.composer-box {
max-width: 760px;
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 8px;
padding: 6px 6px 6px 12px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--raise);
}
.composer-box:focus-within { border-color: var(--accent); }
.composer-box textarea {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
resize: none;
padding: 6px 0;
max-height: 180px;
field-sizing: content; /* grows with input where supported */
}
.send {
width: 34px;
height: 34px;
flex-shrink: 0;
display: grid;
place-items: center;
border: none;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 16px;
cursor: pointer;
}
.send:disabled { opacity: 0.35; cursor: default; }
@media (max-width: 640px) {
.msg.user { max-width: 92%; }
header select.model-pick { max-width: 24ch; }
}
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"description": "Basic chat with an AI model of your choice — streaming replies, editable system prompt.",
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}
```