fix: v2.7.4 — Changes matches JetBrains (#72), the chat says when Claude waits on a trust prompt (#73), Vietnamese Telex in Safari
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again (a staged change since undone on disk) is no longer listed — JetBrains' GitChangesCollector drops it the same way. Mode-only changes stay listed. statusCounts (the dirty-tree safety checks) is unchanged. - Chat (#73): Claude Code runs no hook before a folder is trusted, so a new pane's session has no row; the chat now says the session has not started, offers Open terminal, and asks before typing a message or /model //effort into the pane (an Enter would pick the trust prompt's "No, exit"). - Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on each key (insertReplacementText), which xterm.js ignores — only each word's first letter reached the pane. The edit is now sent as DELs + the new tail. - Also: Ctrl+V pastes a screenshot into the terminal as a file path; the folder browser fallback lists files for a file field (`files=1`); the macOS native picker opens in front of the browser; a hidden tab is marked while a chat waits or once a turn ended; chat live line shows running calls, turn time and tokens, and "Done in …".
This commit is contained in:
@@ -9,7 +9,11 @@
|
||||
* the reader is there: anything that makes it taller — a new line, the live
|
||||
* step, an image loading, the pane shown again after being hidden by CSS
|
||||
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
|
||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
|
||||
* chat passes in whether the CLI waits on the user (`waiting`, with
|
||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
||||
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -62,7 +66,7 @@ import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
@@ -89,6 +93,15 @@ interface ConversationViewProps {
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
follow?: number;
|
||||
/** The CLI is asking the user something (a permission prompt). */
|
||||
waiting?: boolean;
|
||||
/** Shows the pane's terminal, where that question is answered. */
|
||||
onOpenTerminal?: () => void;
|
||||
/** The newest reply's prompt size (input + cache) and model: how full the
|
||||
* context is. */
|
||||
onContext?: (tokens: number, model: string | null) => void;
|
||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConversationView({
|
||||
@@ -98,9 +111,26 @@ export function ConversationView({
|
||||
onOpenFile,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
onContext,
|
||||
onNotStarted,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -178,6 +208,7 @@ export function ConversationView({
|
||||
limit: 50,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
@@ -189,6 +220,7 @@ export function ConversationView({
|
||||
// to show yet, and the poll below picks it up once it exists.
|
||||
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
||||
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
setNotStarted(notYet);
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
@@ -228,6 +260,7 @@ export function ConversationView({
|
||||
limit: 50,
|
||||
});
|
||||
if (gen !== genRef.current) return; // another transcript is shown now
|
||||
setNotStarted(false);
|
||||
if (result.messages.length === 0) return;
|
||||
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -494,7 +527,9 @@ export function ConversationView({
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||
(the outline's file button floats there). */}
|
||||
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
@@ -516,6 +551,18 @@ export function ConversationView({
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.notStartedBody")}
|
||||
</p>
|
||||
{onOpenTerminal && (
|
||||
<button type="button" onClick={onOpenTerminal} className="btn-secondary mt-3 text-xs">
|
||||
{t("conversation.openTerminal")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
@@ -524,7 +571,14 @@ export function ConversationView({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
|
||||
<MessageList
|
||||
messages={messages}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||
* the Conversation tab on the Session detail page. An image the prompt box
|
||||
* attached reaches the transcript as its saved path; a user turn shows the
|
||||
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
||||
* image instead (`GET /api/run/drop/:name`) — any other attached file as a
|
||||
* chip that opens its preview through `onOpenFile` —, and Claude Code's own
|
||||
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
||||
* a System row; URLs and absolute paths in a turn are links (a path opens
|
||||
* through `onOpenFile`), and its `/command` and `@path` tokens chips coloured
|
||||
@@ -18,7 +19,12 @@
|
||||
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||
* now — the call still running, else "Thinking…" — and for how long.
|
||||
* now — the calls still running (several when it runs them in parallel),
|
||||
* else "Thinking…", or the question the CLI waits on with a way to its
|
||||
* terminal (`waiting`) — and, like the terminal's status line, how long the
|
||||
* turn has run and the output tokens it has made; a call running long shows
|
||||
* its own time, and a line silent for minutes turns amber. A turn that ended
|
||||
* closes with how long it took and its output.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -67,7 +73,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Fragment, memo, useEffect, useState, useMemo } from "react";
|
||||
import { Fragment, memo, useContext, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "../../i18n";
|
||||
@@ -86,6 +92,9 @@ import {
|
||||
Cog,
|
||||
FileText,
|
||||
Loader2,
|
||||
Check,
|
||||
Hand,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
|
||||
@@ -142,6 +151,11 @@ interface MessageListProps {
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** The session is running in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** The CLI is asking the user something (a permission prompt): the live
|
||||
* line says so instead of "Thinking…". */
|
||||
waiting?: boolean;
|
||||
/** Shows the pane's terminal, where that question is answered. */
|
||||
onOpenTerminal?: () => void;
|
||||
}
|
||||
|
||||
/** Build a map from tool_use id → tool_result for matching */
|
||||
@@ -160,7 +174,7 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
||||
|
||||
/** The whole prompt a message was answered from: `input_tokens` is only its
|
||||
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
|
||||
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||
return (
|
||||
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
||||
);
|
||||
@@ -444,7 +458,8 @@ type Item =
|
||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
|
||||
| { kind: "edits"; key: string; blocks: TranscriptContent[] }
|
||||
| { kind: "done"; key: string; ms: number; output: number };
|
||||
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||
|
||||
@@ -453,9 +468,20 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
// The file-changing calls of the turn so far, closed into one card when the
|
||||
// human speaks again (or the list ends).
|
||||
let edits: TranscriptContent[] = [];
|
||||
// The turn's prompt time, its newest assistant line and its output so far,
|
||||
// for the "Done in …" line under a turn that ended.
|
||||
let start: string | null = null;
|
||||
let reply: TranscriptMessage | null = null;
|
||||
let output = outputCounter();
|
||||
const endTurn = (key: string) => {
|
||||
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
|
||||
edits = [];
|
||||
if (start && reply?.timestamp && reply.stop_reason === "end_turn") {
|
||||
const ms = parseDate(reply.timestamp).getTime() - parseDate(start).getTime();
|
||||
items.push({ kind: "done", key: `done:${key}`, ms, output: output.total });
|
||||
}
|
||||
reply = null;
|
||||
output = outputCounter();
|
||||
};
|
||||
messages.forEach((msg, idx) => {
|
||||
const key = keys[idx]!;
|
||||
@@ -465,6 +491,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
if (sender === "user") {
|
||||
endTurn(key);
|
||||
start = msg.timestamp ?? null;
|
||||
return items.push({ kind: "user", key, msg });
|
||||
}
|
||||
if (sender !== "assistant") {
|
||||
@@ -487,6 +514,8 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
}
|
||||
return items.push({ kind: "row", key, msg });
|
||||
}
|
||||
reply = msg;
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
@@ -575,13 +604,21 @@ function EditedFiles({
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
|
||||
export function MessageList({
|
||||
messages,
|
||||
loading,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const pending = useMemo(
|
||||
() => (live ? workingOn(messages, toolResultMap) : null),
|
||||
[live, messages, toolResultMap]
|
||||
);
|
||||
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
|
||||
@@ -631,9 +668,19 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
case "done":
|
||||
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
{pending && (
|
||||
<RunningStep
|
||||
{...pending}
|
||||
start={stats?.start ?? null}
|
||||
tokens={stats?.output ?? 0}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
@@ -643,88 +690,290 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
// written (a crash, a kill) — stop saying it works.
|
||||
const IDLE_AFTER_MS = 15 * 60_000;
|
||||
|
||||
/** What the model is doing while its turn is not over: the call still
|
||||
* waiting for its result (`block`), else thinking (`block: null`); `since`
|
||||
* is that line's time. Null once the turn ended. */
|
||||
/** What the model is doing while its turn is not over: the calls of the turn
|
||||
* still waiting for their results (`blocks`, oldest first — parallel calls
|
||||
* finish in any order), `since` the oldest one's time, `last` the newest
|
||||
* line's; `blocks` is empty while it thinks. Null once the turn ended. */
|
||||
export function workingOn(
|
||||
messages: TranscriptMessage[],
|
||||
results: Map<string, TranscriptContent>,
|
||||
now = Date.now()
|
||||
): { block: TranscriptContent | null; since: string | null } | null {
|
||||
): { blocks: TranscriptContent[]; since: string | null; last: string | null } | null {
|
||||
const blocks: TranscriptContent[] = [];
|
||||
let since: string | null = null;
|
||||
let last: string | null = null;
|
||||
// The newest line's verdict: undefined until it is found.
|
||||
let alive: boolean | undefined;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||
// A rename marker or a harness line says nothing about the turn.
|
||||
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
|
||||
const since = msg.timestamp ?? null;
|
||||
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
|
||||
const at = msg.timestamp ?? null;
|
||||
const fresh = !at || now - parseDate(at).getTime() < IDLE_AFTER_MS;
|
||||
const newest = alive === undefined;
|
||||
if (newest) last = at;
|
||||
if (msg.type === "assistant") {
|
||||
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
|
||||
if (call?.id && !results.has(call.id)) return { block: call, since };
|
||||
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
|
||||
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
|
||||
// "end_turn": over, whatever the transcript failed to pair.
|
||||
if (newest && msg.stop_reason === "end_turn") return null;
|
||||
for (const b of [...msg.content].reverse()) {
|
||||
if (b.type === "tool_use" && b.id && !results.has(b.id)) {
|
||||
blocks.unshift(b);
|
||||
since = at;
|
||||
}
|
||||
}
|
||||
// "tool_use": a call follows. None (an older server): over.
|
||||
if (newest) alive = msg.stop_reason === "tool_use" && fresh;
|
||||
continue;
|
||||
}
|
||||
// A prompt or a tool result: the model answers it next — unless the user stopped it.
|
||||
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
|
||||
if (text.startsWith("[Request interrupted")) return null;
|
||||
return fresh ? { block: null, since } : null;
|
||||
if (newest) alive = fresh;
|
||||
// The prompt that opened the turn: nothing before it is this turn's.
|
||||
if (text) break;
|
||||
}
|
||||
return null;
|
||||
if (blocks.length) return { blocks, since, last };
|
||||
return alive ? { blocks, since: null, last } : null;
|
||||
}
|
||||
|
||||
/** Claude Desktop's live line: the step running now and for how long. */
|
||||
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
|
||||
/** Output tokens counted once per API call: Claude Code writes one line per
|
||||
* content block, each repeating its call's usage — consecutive lines with
|
||||
* the same prompt size are one call. */
|
||||
function outputCounter() {
|
||||
let total = 0;
|
||||
let call: string | null = null;
|
||||
let callOutput = 0;
|
||||
return {
|
||||
add(u: NonNullable<TranscriptMessage["usage"]>) {
|
||||
const key = `${u.input_tokens}:${u.cache_read_input_tokens ?? 0}:${u.cache_creation_input_tokens ?? 0}`;
|
||||
if (key !== call) {
|
||||
total += callOutput;
|
||||
call = key;
|
||||
callOutput = 0;
|
||||
}
|
||||
callOutput = Math.max(callOutput, u.output_tokens || 0);
|
||||
},
|
||||
get total() {
|
||||
return total + callOutput;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The live turn so far, as the terminal counts it: `start` is the prompt
|
||||
* that opened it, `output` the output tokens of every API call since. */
|
||||
export function turnStats(messages: TranscriptMessage[]): { start: string | null; output: number } {
|
||||
const output = outputCounter();
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.type === "assistant") {
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
continue;
|
||||
}
|
||||
const sender = msg.sender ?? "user";
|
||||
if (sender === "user" && msg.content.some((c) => c.type === "text")) {
|
||||
return { start: msg.timestamp ?? null, output: output.total };
|
||||
}
|
||||
}
|
||||
return { start: null, output: output.total };
|
||||
}
|
||||
|
||||
// A call running this long shows its own time beside the turn's.
|
||||
const STEP_SHOWN_MS = 30_000;
|
||||
// No new line for this long: the line turns amber — the step may be stuck.
|
||||
const QUIET_MS = 3 * 60_000;
|
||||
|
||||
/** A number that counts up to its new value instead of jumping (none when
|
||||
* the user asked for reduced motion). */
|
||||
function useCountUp(value: number): number {
|
||||
const [shown, setShown] = useState(value);
|
||||
const shownRef = useRef(value);
|
||||
useEffect(() => {
|
||||
const from = shownRef.current;
|
||||
if (from === value) return;
|
||||
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduce || typeof requestAnimationFrame === "undefined") {
|
||||
shownRef.current = value;
|
||||
setShown(value);
|
||||
return;
|
||||
}
|
||||
const t0 = performance.now();
|
||||
let raf = 0;
|
||||
const step = (t: number) => {
|
||||
const k = Math.min(1, (t - t0) / 600);
|
||||
shownRef.current = Math.round(from + (value - from) * k);
|
||||
setShown(shownRef.current);
|
||||
if (k < 1) raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [value]);
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** A call's one-line title: its description (Claude Code's Bash
|
||||
* `description`), else the tool and its target. */
|
||||
function stepTitle(block: TranscriptContent): string {
|
||||
const input = block.input as Record<string, unknown> | undefined;
|
||||
return typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** The terminal's live line: the step running now (or the question the CLI
|
||||
* waits on), then the turn's time and output tokens so far. */
|
||||
function RunningStep({
|
||||
blocks,
|
||||
since,
|
||||
last,
|
||||
start,
|
||||
tokens,
|
||||
waiting,
|
||||
onOpenTerminal,
|
||||
}: {
|
||||
blocks: TranscriptContent[];
|
||||
since: string | null;
|
||||
last: string | null;
|
||||
start: string | null;
|
||||
tokens: number;
|
||||
waiting: boolean;
|
||||
onOpenTerminal?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const input = block?.input as Record<string, unknown> | undefined;
|
||||
const title = !block
|
||||
? t("conversation.working")
|
||||
: typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
const counted = useCountUp(tokens);
|
||||
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
|
||||
const first = blocks[0];
|
||||
const step = first ? age(since) : 0;
|
||||
const quiet = !waiting && age(last) > QUIET_MS;
|
||||
const title = waiting
|
||||
? first
|
||||
? t("conversation.waitingFor", { step: stepTitle(first) })
|
||||
: t("conversation.waiting")
|
||||
: first
|
||||
? stepTitle(first)
|
||||
: t("conversation.working");
|
||||
const extra = [
|
||||
start && formatMs(age(start)),
|
||||
tokens > 0 && t("conversation.turnTokens", { tokens: fmt(counted) }),
|
||||
quiet && !first && t("conversation.quiet", { time: formatMs(age(last)) }),
|
||||
].filter(Boolean);
|
||||
const amber = waiting || quiet;
|
||||
return (
|
||||
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
{since && (
|
||||
<span className="flex-shrink-0 font-mono text-xs">
|
||||
{formatMs(now - parseDate(since).getTime())}
|
||||
<div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{waiting ? (
|
||||
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
|
||||
) : (
|
||||
<Loader2
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 animate-spin ${quiet ? "text-status-warning" : "text-accent"}`}
|
||||
/>
|
||||
)}
|
||||
<span className={`min-w-0 truncate ${amber ? "text-status-warning" : "text-shimmer"}`}>
|
||||
{title}
|
||||
</span>
|
||||
{blocks.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll((v) => !v)}
|
||||
aria-expanded={showAll}
|
||||
className="flex-shrink-0 rounded-full border border-border px-1.5 text-xs hover:bg-surface-2"
|
||||
>
|
||||
{t("conversation.moreSteps", { count: blocks.length - 1 })}
|
||||
</button>
|
||||
)}
|
||||
{step > STEP_SHOWN_MS && (
|
||||
<span
|
||||
className={`flex-shrink-0 font-mono text-xs ${step > QUIET_MS ? "text-status-warning" : ""}`}
|
||||
>
|
||||
{formatMs(step)}
|
||||
</span>
|
||||
)}
|
||||
{extra.length > 0 && (
|
||||
<span className="flex-shrink-0 font-mono text-xs">{`(${extra.join(" · ")})`}</span>
|
||||
)}
|
||||
{waiting && onOpenTerminal && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-open-terminal"
|
||||
onClick={onOpenTerminal}
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded-md border border-status-warning/40 px-2 py-0.5 text-xs text-status-warning hover:bg-status-warning/10"
|
||||
>
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
{t("conversation.openTerminal")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showAll && blocks.length > 1 && (
|
||||
<ul className="ml-5 space-y-0.5 text-xs">
|
||||
{blocks.map((b, i) => (
|
||||
<li key={b.id ?? i} className="truncate">
|
||||
{stepTitle(b)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Under a turn that ended: how long it took and the output it made. */
|
||||
function TurnDone({ ms, output }: { ms: number; output: number }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
return (
|
||||
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<Check className="h-3 w-3 flex-shrink-0" />
|
||||
{[
|
||||
t("conversation.turnDone", { time: formatMs(ms) }),
|
||||
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
// What the prompt box sends for an attached image: its saved path under the
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
|
||||
// What the prompt box sends for an attached file: its saved path under the
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
||||
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||
const DROP_IMAGE =
|
||||
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
const DROP_PATH =
|
||||
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
|
||||
const IMAGE_EXT = /\.(?:png|jpe?g|gif|webp)$/i;
|
||||
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
|
||||
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
||||
|
||||
/** A user turn's text without its dropped-image paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), and those images' names. */
|
||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||
/** A user turn's text without its dropped-file paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), the images' names and the other
|
||||
* files' full paths. */
|
||||
export function splitDropImages(text: string): {
|
||||
text: string;
|
||||
images: string[];
|
||||
files: string[];
|
||||
} {
|
||||
const images: string[] = [];
|
||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||
images.push(name);
|
||||
const files: string[] = [];
|
||||
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
|
||||
if (IMAGE_EXT.test(name)) images.push(name);
|
||||
else files.push(full);
|
||||
return "";
|
||||
});
|
||||
return {
|
||||
text: images.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
text:
|
||||
images.length || files.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
images,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -735,6 +984,30 @@ function imageSourcePaths(msg: TranscriptMessage): string[] | null {
|
||||
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
|
||||
}
|
||||
|
||||
/** A dropped file other than an image: a chip that opens its preview when the
|
||||
* page can (`OpenFileContext`). */
|
||||
function DropFile({ path }: { path: string }) {
|
||||
const open = useContext(OpenFileContext);
|
||||
const name = path
|
||||
.split(/[\\/]/)
|
||||
.pop()!
|
||||
.replace(/^[0-9a-f]{16}-?/i, "");
|
||||
const ext = /\.([A-Za-z0-9]+)$/.exec(name)?.[1]?.toUpperCase();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!open}
|
||||
onClick={() => open?.(path)}
|
||||
title={path}
|
||||
className="flex max-w-64 items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-left text-xs enabled:hover:border-accent/50"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate text-fg-primary">{name}</span>
|
||||
{ext && <span className="shrink-0 font-mono text-[10px] text-fg-muted">{ext}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DropImage({ name }: { name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
@@ -782,6 +1055,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
[msg.content]
|
||||
);
|
||||
const images = blocks.flatMap((b) => b?.images ?? []);
|
||||
const files = blocks.flatMap((b) => b?.files ?? []);
|
||||
const hasText = blocks.some((b) => b?.text);
|
||||
return (
|
||||
<div
|
||||
@@ -789,7 +1063,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||
className="group flex flex-col items-end gap-1"
|
||||
>
|
||||
{images.length > 0 && (
|
||||
{images.length + files.length > 0 && (
|
||||
<div
|
||||
data-testid="chat-user-images"
|
||||
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
||||
@@ -797,6 +1071,9 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
{images.map((name) => (
|
||||
<DropImage key={name} name={name} />
|
||||
))}
|
||||
{files.map((path) => (
|
||||
<DropFile key={path} path={path} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasText && (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @file ConversationView.notYet.test.tsx
|
||||
* @description A live pane's new session has no row until its first hook:
|
||||
* its 404 shows an empty chat, not "Session not found"; a replay still shows it.
|
||||
* its 404 shows no "Session not found" (a replay still shows it) but says the
|
||||
* session has not started — the CLI may be asking in the terminal, e.g. to
|
||||
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
|
||||
@@ -34,4 +36,23 @@ describe("ConversationView before a session's first hook", () => {
|
||||
render(<ConversationView sessionId="gone" fill />);
|
||||
expect(await screen.findByText("Session not found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
|
||||
const onOpenTerminal = vi.fn();
|
||||
const onNotStarted = vi.fn();
|
||||
render(
|
||||
<ConversationView
|
||||
sessionId="new"
|
||||
fill
|
||||
live
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onNotStarted={onNotStarted}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
|
||||
expect(screen.queryByText("No conversation records found.")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Open terminal"));
|
||||
expect(onOpenTerminal).toHaveBeenCalled();
|
||||
expect(onNotStarted).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file MessageList.images.test.tsx
|
||||
* @description An image the prompt box attached reaches the transcript as its
|
||||
* saved drop path; the user's turn shows the image (served by
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||
* other path as typed. Claude Code turns a pasted drop path into an image
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page; any other
|
||||
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
|
||||
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||
* URLs and absolute paths in a turn are links.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,7 +19,7 @@ describe("dropped images in a user turn", () => {
|
||||
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
||||
expect(
|
||||
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
|
||||
// Windows' temp dir, no prefix name.
|
||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||
"0123456789abcdef.webp",
|
||||
@@ -28,16 +28,36 @@ describe("dropped images in a user turn", () => {
|
||||
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||
text: "fix this",
|
||||
images: [SHOT],
|
||||
files: [],
|
||||
});
|
||||
// Not a drop, or not an image: left as typed.
|
||||
for (const text of [
|
||||
"/home/me/shot.png",
|
||||
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
|
||||
]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [] });
|
||||
// Any other dropped file comes out by its full path.
|
||||
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
|
||||
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
|
||||
// Not a drop: left as typed.
|
||||
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a dropped file as a chip that opens its preview", () => {
|
||||
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
|
||||
const opened: string[] = [];
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T08:00:00.000Z",
|
||||
content: [{ type: "text", text: `summarise ${pdf}` }],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).not.toHaveTextContent("0123456789abcdef");
|
||||
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
|
||||
expect(opened).toEqual([pdf]);
|
||||
});
|
||||
|
||||
it("shows the image instead of its path, full size on click", () => {
|
||||
const messages = [
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, workLabel, workingOn } from "../MessageList";
|
||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -142,11 +142,17 @@ describe("MessageList — sender attribution", () => {
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
const messages = [
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
|
||||
msg({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||
content: [{ type: "text", text: "test it" }],
|
||||
}),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||
timestamp: new Date(Date.now() - 5_000).toISOString(),
|
||||
usage: { input_tokens: 2, output_tokens: 3500 },
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
@@ -160,7 +166,8 @@ describe("MessageList — sender attribution", () => {
|
||||
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
expect(row).toHaveTextContent(/1m \ds/);
|
||||
// Like the terminal: the turn's time since the prompt, and its output.
|
||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
@@ -175,6 +182,98 @@ describe("MessageList — sender attribution", () => {
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
});
|
||||
|
||||
it("counts a turn's output once per API call, since its prompt", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const line = (cache: number, out: number) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "text", text: "x" }],
|
||||
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
|
||||
});
|
||||
const messages = [
|
||||
msg({ sender: "user" }),
|
||||
line(10, 999), // an earlier turn
|
||||
msg({ sender: "user", timestamp: at }),
|
||||
line(100, 40),
|
||||
line(100, 40), // the same call's second block
|
||||
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
|
||||
line(200, 25),
|
||||
];
|
||||
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
|
||||
});
|
||||
|
||||
it("keeps every parallel call of the turn running until its own result", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
|
||||
});
|
||||
const done = (id: string) =>
|
||||
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
// Claude Code writes each parallel call on its own line; one result is in.
|
||||
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
|
||||
const results = new Map([["b", m[4]!.content[0]!]]);
|
||||
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
|
||||
// A call left unpaired in an EARLIER turn is not this one's.
|
||||
const next = [
|
||||
...m,
|
||||
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
|
||||
];
|
||||
expect(workingOn(next, results, now)?.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("says when the CLI waits on the user, and offers the terminal", () => {
|
||||
let opened = 0;
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
live
|
||||
waiting
|
||||
onOpenTerminal={() => opened++}
|
||||
messages={[
|
||||
msg({ sender: "user", timestamp: new Date().toISOString() }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
|
||||
fireEvent.click(screen.getByTestId("chat-open-terminal"));
|
||||
expect(opened).toBe(1);
|
||||
});
|
||||
|
||||
it("closes an ended turn with its time and output", () => {
|
||||
const reply = (out: number, stop_reason: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: "2026-06-26T08:16:08.000Z",
|
||||
stop_reason,
|
||||
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
});
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
|
||||
});
|
||||
|
||||
it("knows what a live turn is doing until it ends", () => {
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
@@ -205,10 +304,11 @@ describe("MessageList — sender attribution", () => {
|
||||
);
|
||||
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
||||
|
||||
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
|
||||
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
|
||||
const thinking = { blocks: [], since: null, last: at };
|
||||
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
|
||||
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
|
||||
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
||||
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
||||
const stopped = msg({
|
||||
|
||||
Reference in New Issue
Block a user