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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user