feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)

Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
@@ -5,7 +5,11 @@
* the message stream via MessageList. Combines a WebSocket subscription, a
* visibility-gated polling fallback, and a manual refresh button so the view
* stays caught up even when hooks miss frames or the user is mid-text-only
* turn (no PreToolUse fires until Stop).
* turn (no PreToolUse fires until Stop). The view sticks to its end while
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,12 +83,21 @@ interface ConversationViewProps {
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
follow?: number;
}
export function ConversationView({
sessionId,
initialTranscriptId,
fill = false,
onOpenFile,
live = false,
follow = 0,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -105,6 +118,7 @@ export function ConversationView({
const lastLineRef = useRef(0);
const firstLineRef = useRef(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const fetchingRef = useRef(false);
// When a fetch is in flight and a new trigger arrives (WS event, poll,
@@ -392,6 +406,30 @@ export function ConversationView({
}
}, [hasMore, loadingHistory, loadHistory]);
// Stick to the end while the reader is there. Scroll events say whether
// they are; a size change — of the content, or of the box itself when a
// hidden pane is shown — never fires one, so it is watched here.
useEffect(() => {
const container = scrollContainerRef.current;
const content = contentRef.current;
if (!container || !content || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
});
observer.observe(container);
observer.observe(content);
return () => observer.disconnect();
}, []);
// The reader sent a message: show it, and what follows, as it lands.
useEffect(() => {
if (!follow) return;
isAtBottomRef.current = true;
setAwayFromEnd(false);
setShowNewMsg(false);
scrollToBottom();
}, [follow, scrollToBottom]);
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
@@ -450,38 +488,42 @@ export function ConversationView({
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
<div ref={contentRef}>
{/* History loading indicator */}
{loadingHistory && (
<div className="flex justify-center py-3">
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
</div>
)}
{/* Scroll-up for history hint */}
{hasMore && !loadingHistory && !loading && (
<div className="flex justify-center py-2">
<span className="text-[11px] text-fg-muted">
↑ {t("conversation.scrollForOlder")}
</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</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>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} />
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
{t("conversation.loading")}
</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>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
{t("conversation.emptyBody")}
</p>
</div>
) : (
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
)}
</div>
</div>
{/* Claude Desktop's round "back to the end" button */}
@@ -4,7 +4,16 @@
* transcript: alternating user / assistant rows with collapsible thinking
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
* MarkdownContent for prose. Used by ConversationView as the main body of
* the Conversation tab on the Session detail page.
* 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`). Each assistant turn that changed
* files ends with Claude Desktop's "Edited N files" card: every file with the
* lines added and removed (the tool results' `diff`); a click opens the file
* when the caller can (`onOpenFile`). A thinking block that carries text is
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -53,7 +62,8 @@
*
* ----------------------------------------------------------------------------- */
import { memo, useState, useMemo } from "react";
import { memo, useEffect, useState, useMemo } from "react";
import { ImageLightbox } from "../ImageLightbox";
import { useTranslation } from "react-i18next";
import i18n from "../../i18n";
import {
@@ -69,6 +79,8 @@ import {
Pencil,
Workflow,
Cog,
FileText,
Loader2,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
@@ -113,12 +125,16 @@ const SENDER_STYLES: Record<
};
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent";
import { fmt, formatModelName } from "../../lib/format";
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
interface MessageListProps {
messages: TranscriptMessage[];
loading: boolean;
/** Opens a file named in the "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** The session is running in a pane: show the step in progress. */
live?: boolean;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -135,6 +151,16 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
return map;
}
/** 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 {
return (
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
);
}
const usageTitle = (u: NonNullable<TranscriptMessage["usage"]>) =>
`input ${fmt(u.input_tokens || 0)} · cache read ${fmt(u.cache_read_input_tokens || 0)} · cache write ${fmt(u.cache_creation_input_tokens || 0)} · output ${fmt(u.output_tokens || 0)}`;
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
function isSkillContent(text: string): boolean {
return text.startsWith("Base directory for this skill:");
@@ -390,34 +416,120 @@ type Item =
| { kind: "row"; key: string; msg: TranscriptMessage }
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] };
| { kind: "work"; key: string; blocks: TranscriptContent[] }
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
const items: 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[] = [];
const endTurn = (key: string) => {
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
edits = [];
};
messages.forEach((msg, idx) => {
const key = keys[idx]!;
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
// Purely tool_result: shown inside its call's block.
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") return items.push({ kind: "user", key, msg });
if (sender === "user") {
endTurn(key);
return items.push({ kind: "user", key, msg });
}
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (block.type === "tool_use") {
if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if (block.type === "text" && block.text?.trim()) {
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
}
});
});
endTurn(keys[keys.length - 1] ?? "end");
return items;
}
export function MessageList({ messages, loading }: MessageListProps) {
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */
function EditedFiles({
blocks,
toolResultMap,
onOpenFile,
}: {
blocks: TranscriptContent[];
toolResultMap: Map<string, TranscriptContent>;
onOpenFile?: (path: string) => void;
}) {
const { t } = useTranslation("sessions");
// `counted`: a result carried its +/- lines (an older server sends none).
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
for (const b of blocks) {
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
if (files.size === 0) return null;
let added = 0;
let removed = 0;
let counted = false;
files.forEach((f) => {
added += f.added;
removed += f.removed;
counted ||= f.counted;
});
const counts = (a: number, r: number, show = counted) =>
show && (
<span className="flex-shrink-0 font-mono text-xs">
<span className="text-status-success">+{a}</span>{" "}
<span className="text-status-danger">−{r}</span>
</span>
);
return (
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
{counts(added, removed)}
</div>
{[...files].map(([p, f]) => (
<button
key={p}
type="button"
title={p}
disabled={!onOpenFile}
onClick={() => onOpenFile?.(p)}
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
>
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{p.split("/").pop()}</span>
{counts(f.added, f.removed, f.counted)}
{onOpenFile && <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />}
</button>
))}
</div>
);
}
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const pending = useMemo(
() => (live ? workingOn(messages, toolResultMap) : null),
[live, messages, toolResultMap]
);
const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
@@ -434,7 +546,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
}
return (
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
@@ -453,14 +565,133 @@ export function MessageList({ messages, loading }: MessageListProps) {
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
case "edits":
// The card closes a finished turn; one still working has none yet.
if (pending && item === items[items.length - 1]) return null;
return (
<EditedFiles
key={item.key}
blocks={item.blocks}
toolResultMap={toolResultMap}
onOpenFile={onOpenFile}
/>
);
}
})}
{pending && <RunningStep block={pending.block} since={pending.since} />}
</div>
);
}
// No line for this long and no call running: the turn died with no end
// 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. */
export function workingOn(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): { block: TranscriptContent | null; since: string | null } | null {
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;
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;
}
// 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;
}
return null;
}
/** Claude Desktop's live line: the step running now and for how long. */
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
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(" ");
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())}
</span>
)}
</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 (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
const DROP_IMAGE =
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
/** A user turn's text without its dropped-image paths, and those images' names. */
export function splitDropImages(text: string): { text: string; images: string[] } {
const images: string[] = [];
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
images.push(name);
return "";
});
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
}
function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
const src = `/api/run/drop/${encodeURIComponent(name)}`;
// Drops are pruned after a day: the name is all that is left to show.
if (gone)
return (
<span className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs text-fg-muted">
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{name.replace(/^[0-9a-f]{16}-?/, "") || name}</span>
</span>
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="block overflow-hidden rounded-xl border border-border bg-surface-2 hover:border-accent/50"
>
<img
src={src}
alt={name}
loading="lazy"
onError={() => setGone(true)}
className="max-h-48 max-w-64 object-contain"
/>
</button>
{open && <ImageLightbox src={src} alt={name} onClose={() => setOpen(false)} />}
</>
);
}
/** The human's turn: a bubble on the right, its time on hover. A long one (a
* paste, a compaction summary) shows its first lines until opened. */
const LONG_BUBBLE = 800;
@@ -468,35 +699,54 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
const { t } = useTranslation("sessions");
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
const [open, setOpen] = useState(false);
const blocks = useMemo(
() =>
msg.content.map((block) =>
block.type === "text" && block.text ? splitDropImages(block.text) : null
),
[msg.content]
);
const images = blocks.flatMap((b) => b?.images ?? []);
const hasText = blocks.some((b) => b?.text);
return (
<div
data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
<div
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{msg.content.map((block, b) => {
if (block.type !== "text" || !block.text) return null;
// Plain typing reads as typed; anything structured keeps its rendering.
const plain =
!hasTuiTags(block.text) &&
!isSkillContent(block.text) &&
!isTaskNotification(block.text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{stripAnsi(block.text).trim()}
</p>
) : (
renderTextBlock(block.text, b)
);
})}
</div>
{images.length > 0 && (
<div
data-testid="chat-user-images"
className="flex max-w-[85%] flex-wrap justify-end gap-2"
>
{images.map((name) => (
<DropImage key={name} name={name} />
))}
</div>
)}
{hasText && (
<div
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{blocks.map((block, b) => {
const text = block?.text;
if (!text) return null;
// Plain typing reads as typed; anything structured keeps its rendering.
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{stripAnsi(text).trim()}
</p>
) : (
renderTextBlock(text, b)
);
})}
</div>
)}
{long && (
<button
type="button"
@@ -540,8 +790,8 @@ const AssistantText = memo(function AssistantText({
>
{msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && (
<span>
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
<span title={usageTitle(msg.usage)}>
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
</span>
)}
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
@@ -592,6 +842,8 @@ const WORK_KIND: Record<string, string> = {
};
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
export function workLabel(blocks: TranscriptContent[]): string {
const counts = new Map<string, number>();
for (const b of blocks) {
@@ -600,7 +852,9 @@ export function workLabel(blocks: TranscriptContent[]): string {
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
if (!counts.size) return i18n.t("sessions:conversation.thinking");
// Claude Desktop's order: commands, edits, reads, … and other tools last.
const text = [...counts]
.sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b))
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
.join(", ");
return text.charAt(0).toUpperCase() + text.slice(1);
@@ -751,8 +1005,11 @@ const MessageRow = memo(function MessageRow({
</span>
)}
{msg.usage && (
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span
title={usageTitle(msg.usage)}
className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"
>
<span className="text-status-success/70">↓ {fmt(promptTokens(msg.usage))}</span>
<span className="text-fg-muted">·</span>
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
</span>
@@ -0,0 +1,93 @@
/**
* @file ConversationView.follow.test.tsx
* @description A chat sticks to its end: when it grows or is shown again
* after being hidden (a size change, which fires no scroll event) it scrolls
* to the newest while the reader is at the end, leaves a reader who scrolled
* up alone, and a send (`follow`) brings it back to the end.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let resize: (() => void) | null = null;
const original = globalThis.ResizeObserver;
beforeEach(() => {
resize = null;
// @ts-expect-error test stub
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
resize = cb;
}
observe() {}
disconnect() {}
};
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
cb(0);
return 0;
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
vi.restoreAllMocks();
});
/** The scroll box, with the layout jsdom does not compute. */
function box(height: number) {
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
return el;
}
describe("ConversationView sticks to its end", () => {
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
const view = render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const el = box(1000);
// Shown again after display:none, or taller: only a resize says so.
act(() => resize?.());
expect(el.scrollTop).toBe(1000);
// The reader scrolls up: growing no longer pulls them down.
el.scrollTop = 100;
fireEvent.scroll(el);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
act(() => resize?.());
expect(el.scrollTop).toBe(100);
// They send a message: back to the end, and it sticks again.
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
expect(el.scrollTop).toBe(1400);
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
act(() => resize?.());
expect(el.scrollTop).toBe(1600);
});
});
@@ -0,0 +1,70 @@
/**
* @file MessageList.edits.test.tsx
* @description Each assistant turn that changed files ends with Claude
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, and a row click handed
* to the caller.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const at = "2026-10-09T08:00:00.000Z";
const user = (text: string): TranscriptMessage =>
({
type: "user",
sender: "user",
timestamp: at,
content: [{ type: "text", text }],
}) as TranscriptMessage;
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
({
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id, name, input: { file_path } }],
}) as TranscriptMessage;
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
({
type: "user",
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
}) as TranscriptMessage;
describe("the Edited N files card", () => {
it("sums each file's lines over the turn and opens a file on click", () => {
const onOpenFile = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
messages={[
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
call("4", "Edit", "/w/c.ts"),
result("4", undefined, true),
user("thanks"),
call("5", "Read", "/w/a.ts"),
result("5"),
]}
/>
);
const cards = screen.getAllByTestId("edited-files");
expect(cards).toHaveLength(1); // the second turn only read
const card = cards[0]!;
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
});
});
@@ -0,0 +1,54 @@
/**
* @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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, splitDropImages } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
const SHOT = "366d0a87d12176a2-image.png";
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] });
// Windows' temp dir, no prefix name.
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
"0123456789abcdef.webp",
]);
// 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: [] });
}
});
it("shows the image instead of its path, full size on click", () => {
const messages = [
{
type: "user",
sender: "user",
timestamp: "2026-10-09T08:00:00.000Z",
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
},
] as TranscriptMessage[];
render(<MessageList messages={messages} loading={false} />);
const bubble = screen.getByTestId("chat-user");
expect(bubble).toHaveTextContent("look at this");
expect(bubble).not.toHaveTextContent("agenthub-drops");
const img = screen.getByRole("img", { name: SHOT });
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
fireEvent.click(img);
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
});
});
@@ -9,7 +9,7 @@
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, workLabel } from "../MessageList";
import { MessageList, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -76,5 +76,132 @@ describe("MessageList — sender attribution", () => {
"Ran 2 commands, edited a file, read a file"
);
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
// Desktop's fixed order, whatever ran first: other tools always last.
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
"Ran 1 command, read a file, used a tool"
);
});
it("reads a thinking block's text as narration, between two groups", () => {
const call = (id: string) =>
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
});
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
const say = (text: string) =>
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
render(
<MessageList
loading={false}
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
/>
);
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
expect(screen.getByText("Found an issue.")).toBeTruthy();
});
it("shows the whole prompt as ↓, not just its uncached tail", () => {
render(
<MessageList
loading={false}
messages={[
msg({
type: "assistant",
sender: "assistant",
content: [{ type: "text", text: "done" }],
usage: {
input_tokens: 2,
output_tokens: 40,
cache_read_input_tokens: 120000,
cache_creation_input_tokens: 3000,
},
}),
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
});
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: "assistant",
sender: "assistant",
timestamp: new Date(Date.now() - 65_000).toISOString(),
content: [
{
type: "tool_use",
id: "t1",
name: "Bash",
input: { command: "npm test", description: "Run the full client suite" },
},
],
}),
];
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/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
loading={false}
live
messages={[
...messages,
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
]}
/>
);
expect(screen.queryByTestId("chat-running-step")).toBeNull();
});
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";
const prompt = msg({ type: "user", sender: "user", timestamp: at });
const call = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
});
const result = msg({
sender: "tool",
timestamp: at,
content: [{ type: "tool_result", id: "t1", output: "ok" }],
});
const reply = (stop_reason?: string) =>
msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason,
content: [{ type: "text", text: "ok" }],
});
const results = (m: ReturnType<typeof msg>[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
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
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({
type: "user",
sender: "user",
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
});