738 lines
20 KiB
TypeScript
738 lines
20 KiB
TypeScript
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) {
|
|
plain.push(line);
|
|
} else {
|
|
code.push(line);
|
|
}
|
|
}
|
|
if (code === null) {
|
|
flushPlain();
|
|
} else {
|
|
out.push(
|
|
<pre className="md-code" key={`c-${n}`}>
|
|
{code.join("\n")}
|
|
</pre>,
|
|
);
|
|
}
|
|
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();
|
|
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
|
|
}
|
|
|
|
/** true when the standalone result bubble is skipped because its ToolCard
|
|
* (which carries the output preview) is already rendered */
|
|
function resultSkipped(m: ChatMessage, tools: Map<string, ToolState>): boolean {
|
|
return (
|
|
m.role === "toolResult" &&
|
|
m.toolCallId !== null &&
|
|
tools.has(m.toolCallId)
|
|
);
|
|
}
|
|
|
|
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), COPY_FEEDBACK_MS);
|
|
});
|
|
}}
|
|
>
|
|
{copied ? "copied" : "copy"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ---------- pretty tool args ----------
|
|
|
|
// Keys shown first when present (most readable "what is this tool doing"
|
|
// signal); the rest follow in their original order.
|
|
const ARG_PRIORITY: string[] = [
|
|
"command",
|
|
"path",
|
|
"file_path",
|
|
"pattern",
|
|
"url",
|
|
"query",
|
|
"content",
|
|
"prompt",
|
|
"task",
|
|
"description",
|
|
];
|
|
|
|
// Above this many lines per side, LCS is skipped and the whole old block is
|
|
// rendered removed + the whole new block added.
|
|
const DIFF_MAX_LINES: number = 400;
|
|
|
|
export interface ArgEntry {
|
|
k: string | null;
|
|
v: string;
|
|
}
|
|
|
|
/** Parse a tool's args (JSON object, JSON string, or raw text) into labeled
|
|
* display entries. A lone string entry renders without a label. */
|
|
export function toolArgsEntries(argsText: string): ArgEntry[] {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(argsText);
|
|
} catch {
|
|
return [{ k: null, v: argsText }];
|
|
}
|
|
if (typeof parsed === "string") return [{ k: null, v: parsed }];
|
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
return [{ k: null, v: argsText }];
|
|
const obj = parsed as Record<string, unknown>;
|
|
const keys = Object.keys(obj);
|
|
const ordered = [
|
|
...ARG_PRIORITY.filter((k) => k in obj),
|
|
...keys.filter((k) => !ARG_PRIORITY.includes(k)),
|
|
];
|
|
const entries: ArgEntry[] = [];
|
|
for (const k of ordered) {
|
|
const raw = obj[k];
|
|
const v =
|
|
typeof raw === "string"
|
|
? raw
|
|
: (JSON.stringify(raw, null, 2) ?? String(raw));
|
|
entries.push({ k, v });
|
|
}
|
|
if (entries.length === 1 && entries[0] !== undefined)
|
|
return [{ k: null, v: entries[0].v }];
|
|
return entries;
|
|
}
|
|
|
|
/** One-line "what is this tool doing" for the collapsed summary line:
|
|
* the highest-priority arg's value (bash → command, read/write/edit → path). */
|
|
export function toolSummaryText(argsText: string): string {
|
|
const first = toolArgsEntries(argsText)[0];
|
|
return first === undefined ? "" : first.v;
|
|
}
|
|
|
|
// ---------- edit/write diff view ----------
|
|
|
|
export interface DiffLine {
|
|
kind: "ctx" | "add" | "del";
|
|
text: string;
|
|
}
|
|
|
|
/** Line-level LCS diff between two text blocks. */
|
|
export function lineDiff(a: string, b: string): DiffLine[] {
|
|
const left: string[] = a.split("\n");
|
|
const right: string[] = b.split("\n");
|
|
if (left.length > DIFF_MAX_LINES || right.length > DIFF_MAX_LINES)
|
|
return [
|
|
...left.map((text): DiffLine => ({ kind: "del", text })),
|
|
...right.map((text): DiffLine => ({ kind: "add", text })),
|
|
];
|
|
const n: number = left.length;
|
|
const m: number = right.length;
|
|
const dp: number[][] = Array.from({ length: n + 1 }, () =>
|
|
Array.from({ length: m + 1 }, (): number => 0),
|
|
);
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
const row: number[] | undefined = dp[i];
|
|
if (row === undefined) continue;
|
|
for (let j = m - 1; j >= 0; j--) {
|
|
const a: string | undefined = left[i];
|
|
const b: string | undefined = right[j];
|
|
row[j] =
|
|
a !== undefined && a === b
|
|
? (dp[i + 1]?.[j + 1] ?? 0) + 1
|
|
: Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
|
|
}
|
|
}
|
|
const out: DiffLine[] = [];
|
|
let i: number = 0;
|
|
let j: number = 0;
|
|
while (i < n && j < m) {
|
|
const a: string | undefined = left[i];
|
|
const b: string | undefined = right[j];
|
|
if (a !== undefined && a === b) {
|
|
out.push({ kind: "ctx", text: a });
|
|
i += 1;
|
|
j += 1;
|
|
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
|
|
if (a !== undefined) out.push({ kind: "del", text: a });
|
|
i += 1;
|
|
} else {
|
|
if (b !== undefined) out.push({ kind: "add", text: b });
|
|
j += 1;
|
|
}
|
|
}
|
|
for (; i < n; i++) {
|
|
const a: string | undefined = left[i];
|
|
if (a !== undefined) out.push({ kind: "del", text: a });
|
|
}
|
|
for (; j < m; j++) {
|
|
const b: string | undefined = right[j];
|
|
if (b !== undefined) out.push({ kind: "add", text: b });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface TextEdit {
|
|
oldText: string;
|
|
newText: string;
|
|
}
|
|
|
|
function parseArgsObj(argsText: string): Record<string, unknown> | null {
|
|
try {
|
|
const p: unknown = JSON.parse(argsText);
|
|
return typeof p === "object" && p !== null && !Array.isArray(p)
|
|
? (p as Record<string, unknown>)
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Diff blocks for edit/write tool args; null when the tool is neither or
|
|
* the args do not carry the expected fields. */
|
|
export function toolDiffBlocks(
|
|
name: string,
|
|
argsText: string,
|
|
): DiffLine[][] | null {
|
|
const obj: Record<string, unknown> | null = parseArgsObj(argsText);
|
|
if (obj === null) return null;
|
|
if (name === "write" && typeof obj.content === "string")
|
|
return [
|
|
obj.content
|
|
.split("\n")
|
|
.slice(0, DIFF_MAX_LINES)
|
|
.map((text): DiffLine => ({ kind: "add", text })),
|
|
];
|
|
if (name !== "edit") return null;
|
|
const edits: TextEdit[] = [];
|
|
if (Array.isArray(obj.edits)) {
|
|
for (const e of obj.edits) {
|
|
if (
|
|
typeof e === "object" &&
|
|
e !== null &&
|
|
typeof (e as Record<string, unknown>).oldText === "string" &&
|
|
typeof (e as Record<string, unknown>).newText === "string"
|
|
)
|
|
edits.push(e as TextEdit);
|
|
}
|
|
} else if (
|
|
typeof obj.oldText === "string" &&
|
|
typeof obj.newText === "string"
|
|
) {
|
|
edits.push({ oldText: obj.oldText, newText: obj.newText });
|
|
}
|
|
return edits.length === 0
|
|
? null
|
|
: edits.map((e): DiffLine[] => lineDiff(e.oldText, e.newText));
|
|
}
|
|
|
|
const DIFF_MARK: Record<DiffLine["kind"], string> = {
|
|
add: "+",
|
|
del: "-",
|
|
ctx: " ",
|
|
};
|
|
|
|
function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
|
|
return (
|
|
<pre className="diff">
|
|
{lines.map((l, i) => (
|
|
<span key={i} className={`diff-line diff-${l.kind}`}>
|
|
{DIFF_MARK[l.kind] + l.text}
|
|
{"\n"}
|
|
</span>
|
|
))}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
// Well-known tools get a compact glyph + their primary arg instead of
|
|
// the raw tool name (bash → "$ ls -la", read → "📄 src/main.ts", …).
|
|
const TOOL_ICON: Record<string, string> = {
|
|
bash: "$",
|
|
read: "📄",
|
|
edit: "✎",
|
|
write: "📝",
|
|
};
|
|
|
|
function ToolCard({ tool }: { tool: ToolState }) {
|
|
const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓";
|
|
const statusClass: string = tool.isError
|
|
? "tool-status-err"
|
|
: tool.running
|
|
? "tool-status-run"
|
|
: "tool-status-ok";
|
|
const summary: string = oneLine(toolSummaryText(tool.args));
|
|
const icon: string | undefined = TOOL_ICON[tool.name];
|
|
const label: string =
|
|
icon === undefined ? tool.name : `${icon} ${summary}`.trim();
|
|
const rest: string = icon === undefined ? summary : "";
|
|
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
|
|
return (
|
|
<details className="tool-card">
|
|
<summary>
|
|
<span className="tool-label">{label}</span>
|
|
{rest.length > 0 && <span className="tool-summary">{rest}</span>}
|
|
<span className={`tool-status ${statusClass}`}>{status}</span>
|
|
</summary>
|
|
<div className="tool-body">
|
|
{diffBlocks === null ? (
|
|
<div className="tool-args">
|
|
{toolArgsEntries(tool.args).map((e, i) => (
|
|
<div key={e.k ?? i} className="tool-arg">
|
|
{e.k !== null && <span className="arg-k">{e.k}</span>}
|
|
<pre className="arg-v">{e.v}</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="tool-diffs">
|
|
{diffBlocks.map((b, i) => (
|
|
<DiffBlock key={i} lines={b} />
|
|
))}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="arg-k">output</span>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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"
|
|
: 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);
|
|
}
|
|
}
|
|
// the ToolCard above already carries this result (output preview): skip
|
|
// the duplicate standalone "result" bubble when the tool state exists
|
|
if (resultSkipped(msg, tools)) return null;
|
|
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}`}
|
|
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 && (
|
|
<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>{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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
interface Props {
|
|
messages: ChatMessage[];
|
|
tools: Map<string, ToolState>;
|
|
busy: boolean;
|
|
/** messages queued client-side while the agent runs; sent on settle */
|
|
queued: string[];
|
|
/** drop a queued message before it is sent (the ✕ on a queued bubble) */
|
|
unqueue: (index: number) => void;
|
|
/** an older page exists beyond the loaded window (B1) */
|
|
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({
|
|
messages,
|
|
tools,
|
|
busy,
|
|
queued,
|
|
unqueue,
|
|
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 => {
|
|
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) return;
|
|
if (pinnedRef.current) el.scrollTop = el.scrollHeight;
|
|
setShowFab(awayFromBottom() > FAB_THRESHOLD_PX);
|
|
}, [messages, busy]);
|
|
|
|
useEffect(() => {
|
|
pinnedRef.current = true;
|
|
const el = scrollRef.current;
|
|
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) => !resultSkipped(m, tools) && searchHaystack(m).includes(q),
|
|
)
|
|
.map((m) => m.key);
|
|
}, [messages, tools, 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-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);
|
|
}}
|
|
>
|
|
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
|
|
</div>
|
|
))}
|
|
{queued.map((text, i) => (
|
|
<div className="bubble-row user" key={`q-${i}-${text}`}>
|
|
<div className="bubble queued-bubble">
|
|
<div className="queued-line">{text}</div>
|
|
<button
|
|
type="button"
|
|
className="icon-btn queued-remove"
|
|
aria-label="Remove queued message"
|
|
onClick={() => unqueue(i)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|