feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
@@ -76,9 +76,16 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
initialTranscriptId?: string | null;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
||||
export function ConversationView({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -91,6 +98,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
// Scrolled up from the end: a chat (`fill`) offers the way back down.
|
||||
const [awayFromEnd, setAwayFromEnd] = useState(false);
|
||||
|
||||
// Track JSONL line numbers for incremental requests and history loading
|
||||
const lastLineRef = useRef(0);
|
||||
@@ -370,6 +379,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// Detect if at bottom
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setAwayFromEnd(!atBottom);
|
||||
|
||||
// Hide "new messages" indicator when scrolled to bottom
|
||||
if (atBottom) {
|
||||
@@ -390,10 +400,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
|
||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && (
|
||||
{!loading && !fill && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -441,7 +451,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
@@ -474,8 +484,28 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claude Desktop's round "back to the end" button */}
|
||||
{fill && awayFromEnd && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="conversation-to-end"
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
setShowNewMsg(false);
|
||||
}}
|
||||
aria-label={t("conversation.newMessages")}
|
||||
title={t("conversation.newMessages")}
|
||||
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
{showNewMsg && (
|
||||
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && (
|
||||
{showNewMsg && !fill && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -381,6 +381,8 @@ interface MarkdownContentProps {
|
||||
text: string;
|
||||
/** Tighter spacing for nested contexts (list items, quotes). */
|
||||
dense?: boolean;
|
||||
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
|
||||
prose?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_STYLES = [
|
||||
@@ -392,12 +394,16 @@ const HEADING_STYLES = [
|
||||
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
|
||||
];
|
||||
|
||||
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
|
||||
const blocks = parseBlocks(text);
|
||||
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
||||
// A paragraph or list item takes the root's size and colour in prose.
|
||||
const bodyText = prose ? "" : "text-sm text-fg-secondary";
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
|
||||
<div
|
||||
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
|
||||
>
|
||||
{blocks.map((b, idx) => {
|
||||
switch (b.kind) {
|
||||
case "code":
|
||||
@@ -420,7 +426,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -433,7 +439,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -503,7 +509,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
|
||||
case "para":
|
||||
return (
|
||||
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
|
||||
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
|
||||
{renderInline(b.text, `p${idx}`)}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ const SENDER_STYLES: Record<
|
||||
headerText: "text-status-warning",
|
||||
},
|
||||
};
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { fmt, formatModelName } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
@@ -264,6 +264,60 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** A text block of any sender: a task notification or skill body collapsed,
|
||||
* TUI markup as segments, anything else as markdown. */
|
||||
function renderTextBlock(text: string, key: number): React.ReactNode {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />}
|
||||
title={i18n.t("sessions:conversation.taskNotification")}
|
||||
borderClass="border-syntax-property/20"
|
||||
bgClass="bg-syntax-property/5"
|
||||
textClass="text-syntax-property"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect skill content, collapsed by default
|
||||
if (isSkillContent(text)) {
|
||||
const pathMatch = text.match(/^Base directory for this skill:\s*(\S+)/);
|
||||
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-syntax-builtin/20"
|
||||
bgClass="bg-syntax-builtin/5"
|
||||
textClass="text-syntax-builtin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
||||
// can appear inline (sometimes interleaved with prose). Parse the text
|
||||
// into segments and render each with the appropriate visual treatment.
|
||||
if (hasTuiTags(text)) {
|
||||
const segments = parseTuiSegments(text);
|
||||
return (
|
||||
<div key={key} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic collapsible content block */
|
||||
function CollapsibleBlock({
|
||||
text,
|
||||
@@ -326,10 +380,46 @@ export function messageKeys(messages: TranscriptMessage[]): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** One thing on screen, in the Claude Desktop layout: a human message as a
|
||||
* bubble, the assistant's prose bare, and everything it did between two
|
||||
* pieces of prose — tool calls and thinking, across messages (Claude Code
|
||||
* writes each as its own line) — as ONE collapsed line; any other sender
|
||||
* keeps its attributed row. */
|
||||
type Item =
|
||||
| { kind: "event"; key: string; msg: TranscriptMessage }
|
||||
| { 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[] };
|
||||
|
||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
||||
const items: Item[] = [];
|
||||
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 !== "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 (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()) {
|
||||
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
|
||||
}
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, loading }: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -344,23 +434,280 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg, idx) => {
|
||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
||||
// not as a user/assistant row.
|
||||
if (msg.type === "session_event") {
|
||||
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
return (
|
||||
<SessionEventRow
|
||||
key={item.key}
|
||||
title={item.msg.title}
|
||||
timestamp={item.msg.timestamp}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||
case "user":
|
||||
return <UserBubble key={item.key} msg={item.msg} />;
|
||||
case "text":
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||
}
|
||||
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
|
||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
|
||||
return null;
|
||||
}
|
||||
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
/** 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;
|
||||
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);
|
||||
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>
|
||||
{long && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="text-xs text-fg-muted hover:text-fg-secondary"
|
||||
>
|
||||
{open ? t("conversation.showLess") : t("conversation.showMore")}
|
||||
</button>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className={`${meta} opacity-0 transition-opacity group-hover:opacity-100`}>
|
||||
{formatLocalTime(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** The assistant's prose, bare on the page; model, tokens and time on hover. */
|
||||
const AssistantText = memo(function AssistantText({
|
||||
msg,
|
||||
text,
|
||||
}: {
|
||||
msg: TranscriptMessage;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="chat-assistant"
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
|
||||
className="group min-w-0"
|
||||
>
|
||||
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
|
||||
renderTextBlock(text, 0)
|
||||
) : (
|
||||
<MarkdownContent text={stripAnsi(text)} prose />
|
||||
)}
|
||||
<div
|
||||
className={`${meta} mt-1 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100`}
|
||||
>
|
||||
{msg.model && <span>{formatModelName(msg.model)}</span>}
|
||||
{msg.usage && (
|
||||
<span>
|
||||
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** A muted line that opens to show what it stands for. */
|
||||
function Disclosure({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1 text-sm text-fg-muted transition-colors hover:text-fg-secondary"
|
||||
>
|
||||
{label}
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
{open && <div className="mt-2 space-y-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The kind of work a tool call is, for the group's label (Claude Code and
|
||||
* Codex names); anything else counts as a tool. */
|
||||
const WORK_KIND: Record<string, string> = {
|
||||
bash: "commands",
|
||||
shell: "commands",
|
||||
exec_command: "commands",
|
||||
powershell: "commands",
|
||||
edit: "edits",
|
||||
multiedit: "edits",
|
||||
write: "edits",
|
||||
notebookedit: "edits",
|
||||
apply_patch: "edits",
|
||||
read: "reads",
|
||||
grep: "searches",
|
||||
glob: "searches",
|
||||
ls: "searches",
|
||||
websearch: "web",
|
||||
webfetch: "web",
|
||||
task: "agents",
|
||||
agent: "agents",
|
||||
};
|
||||
|
||||
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
|
||||
export function workLabel(blocks: TranscriptContent[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of blocks) {
|
||||
if (b.type !== "tool_use") continue;
|
||||
const kind = WORK_KIND[(b.name ?? "").toLowerCase()] ?? "tools";
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
if (!counts.size) return i18n.t("sessions:conversation.thinking");
|
||||
const text = [...counts]
|
||||
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
|
||||
.join(", ");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/** One step inside an opened group, as Claude Desktop lists them: its own
|
||||
* description (Claude Code's Bash `description`) or the tool and target as
|
||||
* the title, the call itself under it in a capped scroll box, the result one
|
||||
* click further. Thinking is a step too, closed. */
|
||||
function WorkStep({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const thinking = block.type === "thinking";
|
||||
const [open, setOpen] = useState(!thinking);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const input = block.input as Record<string, unknown> | undefined;
|
||||
const title = thinking
|
||||
? t("conversation.thinking")
|
||||
: typeof input?.description === "string" && input.description
|
||||
? input.description
|
||||
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`flex w-full min-w-0 items-center gap-1 text-left text-sm transition-colors hover:text-fg-secondary ${
|
||||
result?.is_error ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{thinking ? (
|
||||
<div className="max-h-60 overflow-auto text-fg-muted">
|
||||
<MarkdownContent text={block.text ?? ""} dense />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{/* The description is the title already. */}
|
||||
{renderInput(
|
||||
input ? { ...block, input: { ...input, description: undefined } } : block
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showResult}
|
||||
onClick={() => setShowResult(!showResult)}
|
||||
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-fg-secondary"
|
||||
>
|
||||
{result.is_error ? t("conversation.tool.error") : t("conversation.tool.output")}
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 transition-transform ${showResult ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{result && showResult && (
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{renderResult(result, block.name ?? "")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the assistant did between two replies as one line, opening onto
|
||||
* one framed list of its steps. */
|
||||
const WorkGroup = memo(function WorkGroup({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
}: {
|
||||
blocks: TranscriptContent[];
|
||||
toolResultMap: ReturnType<typeof buildToolResultMap>;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="chat-tools">
|
||||
<Disclosure label={workLabel(blocks)}>
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{blocks.map((block, i) => (
|
||||
<WorkStep
|
||||
key={block.id ?? i}
|
||||
block={block}
|
||||
result={block.id ? (toolResultMap.get(block.id) ?? null) : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** One user/assistant row. Memoized: a live tail append re-renders only the
|
||||
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||
const MessageRow = memo(function MessageRow({
|
||||
@@ -419,59 +766,7 @@ const MessageRow = memo(function MessageRow({
|
||||
|
||||
{/* Content blocks */}
|
||||
{msg.content.map((block, bIdx) => {
|
||||
if (block.type === "text" && block.text) {
|
||||
// Detect task notifications, collapsed by default
|
||||
if (isTaskNotification(block.text)) {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
|
||||
}
|
||||
title={t("conversation.taskNotification")}
|
||||
borderClass="border-syntax-property/20"
|
||||
bgClass="bg-syntax-property/5"
|
||||
textClass="text-syntax-property"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Detect skill content, collapsed by default
|
||||
if (isSkillContent(block.text)) {
|
||||
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
|
||||
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
||||
title={skillPath}
|
||||
borderClass="border-syntax-builtin/20"
|
||||
bgClass="bg-syntax-builtin/5"
|
||||
textClass="text-syntax-builtin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
||||
// can appear inline (sometimes interleaved with prose). Parse the text
|
||||
// into segments and render each with the appropriate visual treatment.
|
||||
if (hasTuiTags(block.text)) {
|
||||
const segments = parseTuiSegments(block.text);
|
||||
return (
|
||||
<div key={bIdx} className="space-y-2 min-w-0">
|
||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={bIdx} className="min-w-0">
|
||||
<MarkdownContent text={stripAnsi(block.text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (block.type === "text" && block.text) return renderTextBlock(block.text, bIdx);
|
||||
|
||||
if (block.type === "thinking" && block.text) {
|
||||
const thinkKey = bIdx;
|
||||
|
||||
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
|
||||
}
|
||||
|
||||
/** Build a one-line summary of the tool call to show in the collapsed header. */
|
||||
function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
export function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const input = toolUse.input;
|
||||
if (!input || typeof input !== "object" || "_truncated" in input) return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
|
||||
|
||||
/** Render the input pane with tool-aware formatting. */
|
||||
function renderInput(toolUse: TranscriptContent) {
|
||||
export function renderInput(toolUse: TranscriptContent) {
|
||||
const input = toolUse.input;
|
||||
if (!input) return null;
|
||||
|
||||
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
}
|
||||
|
||||
/** Render the result pane: detect diff/json/text. */
|
||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
export function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
const text = toolResult.output ?? "";
|
||||
if (text.length === 0)
|
||||
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* @file MessageList.sender.test.tsx
|
||||
* @description Verifies the transcript renders each message under its TRUE
|
||||
* sender label — User / Assistant / Main agent / System — instead of labeling
|
||||
* every `type:"user"` line "User" (reported transcript mis-attribution).
|
||||
* @description Verifies the transcript renders each message by its TRUE
|
||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, workLabel } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -36,8 +38,8 @@ describe("MessageList — sender attribution", () => {
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
|
||||
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
|
||||
expect(screen.getByText("System")).toBeInTheDocument();
|
||||
expect(screen.getByText("Main agent")).toBeInTheDocument();
|
||||
});
|
||||
@@ -48,7 +50,31 @@ describe("MessageList — sender attribution", () => {
|
||||
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
|
||||
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
|
||||
});
|
||||
|
||||
it("folds consecutive tool calls — one per transcript line — into one line", () => {
|
||||
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 messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
const group = screen.getAllByTestId("chat-tools");
|
||||
expect(group).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
|
||||
expect(group[0]).toHaveTextContent("echo b");
|
||||
});
|
||||
|
||||
it("names a mixed run by what it did, like Claude Desktop", () => {
|
||||
const use = (name: string) => ({ type: "tool_use" as const, name });
|
||||
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
|
||||
"Ran 2 commands, edited a file, read a file"
|
||||
);
|
||||
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user