1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
|
/**
* ChatThread renders the conversation: user prompts, streamed agent answers
* (markdown) and inline errors, auto-scrolled to the latest entry.
*
* @example
* <ChatThread thread={[{ kind: "user", text: "hi" }]} turnActive={false} />
*/
import { useEffect, useRef } from "react";
import type { ThreadItem } from "../reducer";
import { Markdown } from "./Markdown";
import { ToolCallCard } from "./ToolCallCard";
import { WorkingIndicator } from "./WorkingIndicator";
interface ChatThreadProps {
thread: ThreadItem[];
turnActive: boolean;
}
export function ChatThread({ thread, turnActive }: ChatThreadProps) {
const endRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: thread and turnActive intentionally retrigger the scroll-to-bottom on every conversation change.
useEffect(() => {
// Optional call: test DOMs (jsdom) do not implement scrollIntoView.
endRef.current?.scrollIntoView?.({ behavior: "smooth" });
}, [thread, turnActive]);
return (
<section className="thread" aria-label="conversation">
{thread.length === 0 && (
<p className="thread-empty">
Send a prompt to start working with the agent.
</p>
)}
{thread.map((item, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the thread is append-only (never reordered or spliced), so the index is a stable identity.
<ThreadEntry key={index} item={item} />
))}
{turnActive && <WorkingIndicator />}
<div ref={endRef} />
</section>
);
}
function ThreadEntry({ item }: { item: ThreadItem }) {
switch (item.kind) {
case "user":
return <div className="message message-user">{item.text}</div>;
case "agent":
return (
<div className="message message-agent">
<Markdown text={item.text} />
</div>
);
case "thought":
return <ThoughtEntry text={item.text} closed={item.closed} />;
case "tool":
return <ToolCallCard call={item.call} />;
case "error":
return (
<div className="message message-error" role="alert">
{item.text}
</div>
);
}
}
/**
* ThoughtEntry renders one thinking block: while the thought streams its
* content stays visible; once the turn ends it collapses into a reopenable
* summary.
*/
function ThoughtEntry({ text, closed }: { text: string; closed: boolean }) {
if (!closed) {
return (
<div className="thought thought-live">
<p className="thought-summary">💭 Thinking…</p>
<div className="thought-body">
<Markdown text={text} />
</div>
</div>
);
}
return (
<details className="thought">
<summary className="thought-summary">💭 Thought for a moment</summary>
<div className="thought-body">
<Markdown text={text} />
</div>
</details>
);
}
|