web: professional UI overhaul — ink/slate design system, archive+active sections, skeletons, error boundary, conn banner, usage chip, message copy, spawn progress steps; 162 tests ≥95% coverage

This commit is contained in:
Raphael Westphal
2026-08-18 18:19:25 +02:00
parent 42fcaa1fe3
commit e626b9cc6e
12 changed files with 1583 additions and 552 deletions
+148 -105
View File
@@ -1,133 +1,176 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { ChatMessage, ToolState } from "./derive";
const PREVIEW_LEN: number = 120;
function oneLine(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}` : flat;
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}` : flat;
}
function CopyButton({ text }: { text: string }): React.ReactNode {
const [copied, setCopied] = useState<boolean>(false);
return (
<button
type="button"
className="msg-copy"
aria-label="Copy message"
onClick={() => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
});
}}
>
{copied ? "copied" : "copy"}
</button>
);
}
function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running
? "running…"
: tool.isError
? "error"
: "done";
const statusClass: string = tool.isError ? "tool-status-err" : "tool-status-ok";
return (
<details className="tool-card">
<summary>
<span className="tool-name">🛠 {tool.name}</span>
<span className={tool.running ? "" : statusClass}>{tool.running ? "working…" : status}</span>
</summary>
<div className="tool-body">
<div>
<strong>args</strong>
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>{tool.args}</pre>
</div>
<div>
<strong>result</strong>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>{tool.preview}</pre>
</div>
</div>
</details>
);
const status: string = tool.running
? "running…"
: tool.isError
? "error"
: "done";
const statusClass: string = tool.isError
? "tool-status-err"
: "tool-status-ok";
return (
<details className="tool-card">
<summary>
<span className="tool-name">🛠 {tool.name}</span>
<span className={tool.running ? "" : statusClass}>
{tool.running ? "working…" : status}
</span>
</summary>
<div className="tool-body">
<div>
<strong>args</strong>
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>
{tool.args}
</pre>
</div>
<div>
<strong>result</strong>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
{tool.preview}
</pre>
</div>
</div>
</details>
);
}
function Thinking({ text }: { text: string }) {
return (
<details className="thinking">
<summary>thinking</summary>
<div className="thinking-body">{text}</div>
</details>
);
return (
<details className="thinking">
<summary>thinking</summary>
<div className="thinking-body">{text}</div>
</details>
);
}
export function Bubble({ msg, tools }: { msg: ChatMessage; tools: Map<string, ToolState> }) {
const rowClass = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "system";
const msgTools: ToolState[] = [];
if (msg.role === "assistant") {
for (const c of msg.toolCalls) {
const t = tools.get(c.id);
if (t !== undefined) msgTools.push(t);
}
}
return (
<div className={`bubble-row ${rowClass}`}>
<div className="bubble">
{msg.thinking !== null && msg.thinking.length > 0 && <Thinking text={msg.thinking} />}
{msgTools.map((t) => (
<ToolCard key={t.id} tool={t} />
))}
{msg.role === "toolResult" ? (
<details className="tool-card">
<summary>
<span className="tool-name">result</span>
<span>{oneLine(msg.text)}</span>
</summary>
<div className="tool-body">{msg.text}</div>
</details>
) : (
msg.text.length > 0 && <div>{msg.text}</div>
)}
{msg.streaming && <span className="stream-caret"></span>}
</div>
</div>
);
export function Bubble({
msg,
tools,
}: {
msg: ChatMessage;
tools: Map<string, ToolState>;
}) {
const rowClass =
msg.role === "user"
? "user"
: msg.role === "assistant"
? "assistant"
: "system";
const msgTools: ToolState[] = [];
if (msg.role === "assistant") {
for (const c of msg.toolCalls) {
const t = tools.get(c.id);
if (t !== undefined) msgTools.push(t);
}
}
const copyable: boolean =
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
return (
<div className={`bubble-row ${rowClass}`}>
{copyable && <CopyButton text={msg.text} />}
<div className="bubble">
{msg.thinking !== null && msg.thinking.length > 0 && (
<Thinking text={msg.thinking} />
)}
{msgTools.map((t) => (
<ToolCard key={t.id} tool={t} />
))}
{msg.role === "toolResult" ? (
<details className="tool-card">
<summary>
<span className="tool-name">result</span>
<span>{oneLine(msg.text)}</span>
</summary>
<div className="tool-body">{msg.text}</div>
</details>
) : (
msg.text.length > 0 && <div>{msg.text}</div>
)}
{msg.streaming && <span className="stream-caret"></span>}
</div>
</div>
);
}
export function TypingIndicator() {
return (
<div className="bubble-row assistant" aria-live="polite">
<div className="typing">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</div>
);
return (
<div className="bubble-row assistant" aria-live="polite">
<div className="typing">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</div>
);
}
interface Props {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
}
export default function ChatStream({ messages, tools, busy }: Props) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true);
const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true);
const onScroll = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
};
const onScroll = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
};
useEffect(() => {
const el = scrollRef.current;
if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight;
}, [messages, busy]);
useEffect(() => {
const el = scrollRef.current;
if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight;
}, [messages, busy]);
useEffect(() => {
pinnedRef.current = true;
const el = scrollRef.current;
if (el !== null) el.scrollTop = el.scrollHeight;
}, []);
useEffect(() => {
pinnedRef.current = true;
const el = scrollRef.current;
if (el !== null) el.scrollTop = el.scrollHeight;
}, []);
const last: ChatMessage | undefined = messages[messages.length - 1];
const streamingOpen: boolean = last !== undefined && last.streaming;
const showTyping: boolean = busy && !streamingOpen;
const last: ChatMessage | undefined = messages[messages.length - 1];
const streamingOpen: boolean = last !== undefined && last.streaming;
const showTyping: boolean = busy && !streamingOpen;
return (
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner">
{messages.map((m) => (
<Bubble key={m.key} msg={m} tools={tools} />
))}
{showTyping && <TypingIndicator />}
</div>
</div>
);
return (
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner">
{messages.map((m) => (
<Bubble key={m.key} msg={m} tools={tools} />
))}
{showTyping && <TypingIndicator />}
</div>
</div>
);
}