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:
+148
-105
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user