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