nandi/oripublic Fork 0
4166f8fba899b222fab287c398dcab9bf6f6e5c9
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

Add copy-paste functionality for code blocks and completion widgets 7895c1d · on 4166f8fba899b222fab287c398dcab9bf6f6e5c9 · k33g · 22h ago
ChatThread.tsx · 101 lines · 2.9 KBTypeScript Blame HistoryRaw
  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
 94
 95
 96
 97
 98
 99
100
101
/**
 * 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";
import { CopyButton } from "./CopyButton";

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">
					<CopyButton content={item.text} label="Copy response" />
					<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
					<CopyButton content={text} label="Copy thought" />
				</p>
				<div className="thought-body">
					<Markdown text={text} />
				</div>
			</div>
		);
	}
	return (
		<details className="thought">
			<summary className="thought-summary">
				💭 Thought for a moment
				<CopyButton content={text} label="Copy thought" />
			</summary>
			<div className="thought-body">
				<Markdown text={text} />
			</div>
		</details>
	);
}