web: integrate QoL slices 1-5 (model switch/rename, history deletes, usage stats, repo prepare+badges+imageUsed, markdown/search/FAB/timestamps) — 263 tests, ≥95% all metrics

This commit is contained in:
Raphael Westphal
2026-08-19 10:35:40 +02:00
parent 698b742cb3
commit c8ad68aaea
17 changed files with 2618 additions and 170 deletions
+302 -26
View File
@@ -1,9 +1,142 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChatMessage, ToolState } from "./derive";
const PREVIEW_LEN: number = 120;
const COPY_FEEDBACK_MS: number = 1200;
const PIN_THRESHOLD_PX: number = 80;
const FAB_THRESHOLD_PX: number = 400;
const FENCE: string = "```";
const ENTER_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape";
const INLINE_RE: RegExp =
/(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\s]+\))|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)/g;
const SAFE_URL_RE: RegExp = /^https?:\/\//i;
// ---------- safe mini-markdown (no deps, no innerHTML) ----------
function highlight(text: string, query: string): React.ReactNode {
if (query.length === 0 || text.length === 0) return text;
const hay: string = text.toLowerCase();
const needle: string = query.toLowerCase();
if (!hay.includes(needle)) return text;
const parts: React.ReactNode[] = [];
let from = 0;
let at: number = hay.indexOf(needle);
let n = 0;
while (at !== -1) {
if (at > from) parts.push(text.slice(from, at));
parts.push(
<mark className="hit" key={`h-${n}`}>
{text.slice(at, at + needle.length)}
</mark>,
);
n += 1;
from = at + needle.length;
at = hay.indexOf(needle, from);
}
if (from < text.length) parts.push(text.slice(from));
return parts;
}
function tokenNode(tok: string, query: string, key: string): React.ReactNode {
if (tok.startsWith("`")) return <code key={key}>{tok.slice(1, -1)}</code>;
if (tok.startsWith("**"))
return <strong key={key}>{highlight(tok.slice(2, -2), query)}</strong>;
if (tok.startsWith("*"))
return <em key={key}>{highlight(tok.slice(1, -1), query)}</em>;
// link token; INLINE_RE guarantees the [label](url) form
const closeAt: number = tok.indexOf("]");
const label: string = tok.slice(1, closeAt);
const url: string = tok.slice(closeAt + 2, -1);
if (SAFE_URL_RE.test(url))
return (
<a key={key} href={url} target="_blank" rel="noopener noreferrer">
{label}
</a>
);
// non-http scheme (javascript:, data:, …): the anchor is dropped, the
// raw token stays visible as plain text
return <span key={key}>{highlight(tok, query)}</span>;
}
function inlineNodes(
text: string,
query: string,
keyBase: string,
): React.ReactNode[] {
const out: React.ReactNode[] = [];
let from = 0;
let n = 0;
for (const m of text.matchAll(INLINE_RE)) {
const tok: string = m[0];
const at: number = m.index ?? 0;
if (at > from) out.push(highlight(text.slice(from, at), query));
out.push(tokenNode(tok, query, `${keyBase}-${n}`));
n += 1;
from = at + tok.length;
}
if (from < text.length) out.push(highlight(text.slice(from), query));
return out;
}
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*,
* [t](http…) links (http/https only). Plain segments keep the bubble's
* pre-wrap. Unterminated fences (streaming) render the tail as code. */
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
let plain: string[] = [];
let code: string[] | null = null;
let n = 0;
const flushPlain = (): void => {
if (plain.length > 0) {
out.push(...inlineNodes(plain.join("\n"), query, `p-${n}`));
n += 1;
plain = [];
}
};
for (const line of text.split("\n")) {
if (line.trimStart().startsWith(FENCE)) {
if (code === null) {
flushPlain();
code = [];
} else {
out.push(
<pre className="md-code" key={`c-${n}`}>
{code.join("\n")}
</pre>,
);
n += 1;
code = null;
}
} else if (code !== null) {
code.push(line);
} else {
plain.push(line);
}
}
if (code !== null) {
out.push(
<pre className="md-code" key={`c-${n}`}>
{code.join("\n")}
</pre>,
);
} else {
flushPlain();
}
return out;
}
function formatTs(ts: number): string {
return new Date(ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function searchHaystack(m: ChatMessage): string {
const names: string[] = m.toolCalls.map((c) => c.name);
return `${m.text} ${m.thinking ?? ""} ${names.join(" ")}`.toLowerCase();
}
function oneLine(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
@@ -76,10 +209,22 @@ function Thinking({ text }: { text: string }) {
export function Bubble({
msg,
tools,
query = "",
showTs = false,
}: {
msg: ChatMessage;
tools: Map<string, ToolState>;
/** live search query: matches in plain text get <mark class="hit"> */
query?: string;
showTs?: boolean;
}) {
if (msg.notice === true) {
return (
<div className="bubble-row system">
<div className="notice-line"> {msg.text}</div>
</div>
);
}
const rowClass =
msg.role === "user"
? "user"
@@ -95,8 +240,13 @@ export function Bubble({
}
const copyable: boolean =
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
return (
<div className={`bubble-row ${rowClass}`}>
<div
className={`bubble-row ${rowClass}`}
data-key={msg.key}
title={tsTitle.length > 0 ? tsTitle : undefined}
>
{copyable && <CopyButton text={msg.text} />}
<div className="bubble">
{msg.thinking !== null && msg.thinking.length > 0 && (
@@ -114,10 +264,15 @@ export function Bubble({
<div className="tool-body">{msg.text}</div>
</details>
) : (
msg.text.length > 0 && <div>{msg.text}</div>
msg.text.length > 0 && <div>{renderMarkdown(msg.text, query)}</div>
)}
{msg.streaming && <span className="stream-caret"></span>}
</div>
{showTs && msg.ts > 0 && (
<div className="ts" title={tsTitle}>
{formatTs(msg.ts)}
</div>
)}
</div>
);
}
@@ -142,6 +297,11 @@ interface Props {
hasOlder: boolean;
loadingOlder: boolean;
onLoadOlder: () => void;
/** search overlay open (the 🔍 button or the / hotkey in ChatView) */
searchOpen: boolean;
onSearchClose: () => void;
/** per-bubble timestamps (⋯ menu in ChatView) */
showTs: boolean;
}
export default function ChatStream({
@@ -151,20 +311,35 @@ export default function ChatStream({
hasOlder,
loadingOlder,
onLoadOlder,
searchOpen,
onSearchClose,
showTs,
}: Props) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true);
const rowRefs = useRef<Map<string, HTMLElement>>(new Map());
const [query, setQuery] = useState<string>("");
const [matchIdx, setMatchIdx] = useState<number>(0);
const [showFab, setShowFab] = useState<boolean>(false);
const awayFromBottom = (): number => {
const el = scrollRef.current;
if (el === null) return 0;
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const onScroll = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX;
if (scrollRef.current === null) return;
const away: number = awayFromBottom();
pinnedRef.current = away < PIN_THRESHOLD_PX;
setShowFab(away > FAB_THRESHOLD_PX);
};
useEffect(() => {
const el = scrollRef.current;
if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight;
if (el === null) return;
if (pinnedRef.current) el.scrollTop = el.scrollHeight;
setShowFab(awayFromBottom() > FAB_THRESHOLD_PX);
}, [messages, busy]);
useEffect(() => {
@@ -173,31 +348,132 @@ export default function ChatStream({
if (el !== null) el.scrollTop = el.scrollHeight;
}, []);
// overlay closed → query and cursor reset (ChatStream is keyed per session,
// but the overlay must also forget a stale query when merely closed)
useEffect(() => {
if (!searchOpen) {
setQuery("");
setMatchIdx(0);
}
}, [searchOpen]);
const matches: string[] = useMemo(() => {
const q: string = query.trim().toLowerCase();
if (q.length === 0) return [];
return messages
.filter((m) => searchHaystack(m).includes(q))
.map((m) => m.key);
}, [messages, query]);
// live events can shrink the match set under the cursor
useEffect(() => {
setMatchIdx((i) =>
matches.length === 0 ? 0 : Math.min(i, matches.length - 1),
);
}, [matches.length]);
const cycleMatch = (dir: 1 | -1): void => {
if (matches.length === 0) return;
const len: number = matches.length;
const next: number = (((matchIdx + dir) % len) + len) % len;
setMatchIdx(next);
const key: string | undefined = matches[next];
if (key !== undefined)
rowRefs.current.get(key)?.scrollIntoView({ block: "center" });
};
const jumpToBottom = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current = true;
el.scrollTop = el.scrollHeight;
setShowFab(false);
};
const currentKey: string | undefined =
query.length > 0 && matches.length > 0 ? matches[matchIdx] : undefined;
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">
{hasOlder && (
<div className="load-older">
<button
type="button"
className="btn-secondary"
aria-label="Load older messages"
disabled={loadingOlder}
onClick={onLoadOlder}
<div className="chat-stream">
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner">
{hasOlder && (
<div className="load-older">
<button
type="button"
className="btn-secondary"
aria-label="Load older messages"
disabled={loadingOlder}
onClick={onLoadOlder}
>
{loadingOlder ? "loading…" : "Load older"}
</button>
</div>
)}
{messages.map((m) => (
<div
key={m.key}
className={m.key === currentKey ? "msg-current" : undefined}
ref={(el: HTMLDivElement | null): void => {
if (el === null) rowRefs.current.delete(m.key);
else rowRefs.current.set(m.key, el);
}}
>
{loadingOlder ? "loading…" : "Load older"}
</button>
</div>
)}
{messages.map((m) => (
<Bubble key={m.key} msg={m} tools={tools} />
))}
{showTyping && <TypingIndicator />}
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
</div>
))}
{showTyping && <TypingIndicator />}
</div>
</div>
{searchOpen && (
<div className="search-bar">
<input
type="text"
autoFocus
value={query}
placeholder="Search…"
aria-label="Search"
onChange={(e) => {
setQuery(e.target.value);
setMatchIdx(0);
}}
onKeyDown={(e) => {
if (e.key === ESCAPE_KEY) onSearchClose();
else if (e.key === ENTER_KEY) {
e.preventDefault();
cycleMatch(e.shiftKey ? -1 : 1);
}
}}
/>
{query.length > 0 && (
<span className="search-count" aria-live="polite">
{matches.length === 0 ? 0 : matchIdx + 1}/{matches.length}
</span>
)}
<button
type="button"
className="icon-btn"
aria-label="Close search"
onClick={onSearchClose}
>
</button>
</div>
)}
{showFab && (
<button
type="button"
className="scroll-fab"
aria-label="Jump to latest"
onClick={jumpToBottom}
>
</button>
)}
</div>
);
}