Files
AgentHub/client/src/components/run/ChatOutline.tsx
T
nntrivi2001 c3f83d3922 perf(chat): hidden panes stop polling, rows stop re-rendering, a covered terminal stops drawing
The Workspace keeps panes of other workspaces and tabs mounted (display:none),
and every one of them kept following the live stream as if shown.

- useOnScreen (IntersectionObserver): ConversationView, the outline and the
  700 ms menu scan skip while their pane is hidden and catch up once on show.
- A new_event refetches the tail at most every 1.5 s (leading + trailing).
- MessageList: WorkGroup/MessageRow/EditedFiles compare only their own blocks
  and results, so a tail append no longer re-renders every row.
- A terminal under the chat is moved off the viewport with a transform, so
  xterm pauses drawing without a resize.
- PaneChat and TerminalView are memoized; LaneConsolePane passes a stable onExit.
- RunningStep's clock and the outline poll skip unchanged or hidden updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:26:57 +07:00

383 lines
13 KiB
TypeScript

/**
* @file ChatOutline.tsx
* @description The right-hand panel of a Chat pane, laid out like
* Claude Desktop's activity panel: the session's Progress (its
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one — how many
* steps are done of how many, a bar, and the step in progress highlighted), the Outputs
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
* this session" — the files attached to its prompts, the files it read and the
* memory files it used and its skills (a click opens the list with a
* preview — a skill's SKILL.md), and its MCP servers (a click opens the
* Claude config page). Every row but MCP drags into the prompt box: a file
* as an attachment, a group as all of its files, a lone skill as `/name`.
* Read from
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
* uses for the collapsed button's count.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
useEffect,
useState,
type ComponentType,
type DragEvent,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import {
BookOpen,
Check,
ChevronDown,
FileCode,
FileImage,
FileSearch,
FileSpreadsheet,
FileText,
History,
Paperclip,
Plug,
X,
} from "lucide-react";
import { api, type SessionOutline } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { isShown } from "../../hooks/useOnScreen";
import { displayName, extOf } from "./ChatFilePreview";
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
const POLL_MS = 5000;
const EMPTY: SessionOutline = {
progress: [],
outputs: [],
files: [],
uploads: [],
memory: [],
skills: [],
mcp: [],
};
/** The session's outline, re-read on a visibility-gated poll — skipped too
* while `onScreen` says the pane is hidden (another workspace or tab, kept
* mounted). An unchanged answer keeps the old object: no re-render. */
export function useSessionOutline(
sessionId: string,
onScreen?: RefObject<boolean>
): SessionOutline {
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
useEffect(() => {
let live = true;
const load = () =>
api.sessions
.outline(sessionId)
.then((o) => live && setOutline(keepIfSame<SessionOutline>({ ...EMPTY, ...o })))
.catch(() => {});
void load();
const timer = window.setInterval(() => {
if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId, onScreen]);
return outline;
}
export type OutlineList = "uploads" | "files" | "memory" | "skills";
/** A listed file as a prompt attachment: an upload by its bare path, as the
* composer attaches one; anything else as an `@` mention. */
export function outlineAttachment(path: string, upload = false): Attachment {
return { path: upload ? path : `@${path}`, name: displayName(path) };
}
const dragFiles =
(paths: string[], upload = false) =>
(e: DragEvent) =>
dragAttachments(
e,
paths.map((p) => outlineAttachment(p, upload))
);
function fileIcon(path: string) {
const ext = extOf(path).toLowerCase();
if (["xlsx", "xls", "csv", "tsv"].includes(ext)) return FileSpreadsheet;
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return FileImage;
if (
/^(c|cc|cpp|cs|css|go|h|html|java|js|jsx|json|kt|mjs|cjs|php|py|rb|rs|sh|sql|swift|ts|tsx|vue|ya?ml|toml)$/.test(
ext
)
)
return FileCode;
return FileText;
}
function Section({
title,
count,
action,
grow,
children,
}: {
title: string;
/** Beside the title: a count, or a "3/7". */
count?: number | string;
action?: ReactNode;
/** Takes the panel's free height and scrolls inside it. */
grow?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className={`flex flex-col px-3 pt-3 ${grow && open ? "min-h-0 flex-1" : ""}`}>
<div className="flex items-center gap-1.5 px-1 pb-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-1.5 text-sm font-medium text-fg-primary"
>
{title}
{count ? <span className="text-xs font-normal text-fg-muted">{count}</span> : null}
<ChevronDown
className={`h-3.5 w-3.5 text-fg-muted transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
<div className="ml-auto flex items-center">{action}</div>
</div>
{open && (
<div
className={
grow
? "min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,transparent,black_8px,black_calc(100%-8px),transparent)]"
: ""
}
>
{children}
</div>
)}
</section>
);
}
const rowClass =
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-[13px] text-fg-secondary enabled:hover:bg-surface-3";
/** A "Used in this session" row: icon, label, the first names, +N. */
function UsedRow({
icon: Icon,
label,
names,
onClick,
onDragStart,
to,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
names: string[];
onClick?: () => void;
/** The row drags into the prompt box. */
onDragStart?: (e: DragEvent) => void;
to?: string;
}) {
const shown = names.slice(0, 2).join(", ");
const inner = (
<>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="flex-shrink-0 text-fg-primary">{label}</span>
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">{shown}</span>
{names.length > 2 && (
<span className="flex-shrink-0 text-xs text-fg-muted">+{names.length - 2}</span>
)}
</>
);
const title = `${label}: ${names.join(", ")}`;
return to ? (
<Link to={to} title={title} className={`${rowClass} hover:bg-surface-3`}>
{inner}
</Link>
) : (
<button
type="button"
title={title}
onClick={onClick}
draggable={!!onDragStart}
onDragStart={onDragStart}
className={rowClass}
>
{inner}
</button>
);
}
const stem = (p: string) => displayName(p).replace(/\.[A-Za-z0-9]+$/, "");
export function ChatOutline({
outline: o,
onClose,
onOpenFile,
onOpenList,
}: {
outline: SessionOutline;
onClose: () => void;
onOpenFile: (path: string) => void;
onOpenList: (list: OutlineList) => void;
}) {
const { t } = useTranslation("run");
const href = useSettingsHref();
const doneSteps = o.progress.filter((p) => p.status === "completed").length;
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
const close = (
<button
type="button"
onClick={onClose}
title={t("chat.outline.hide")}
aria-label={t("chat.outline.hide")}
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
);
return (
<aside
data-testid="chat-outline"
aria-label={t("chat.outline.panel")}
// Over the conversation in a narrow pane (the container is PaneChat's
// root; `--chat-area` keeps the prompt box below it uncovered),
// beside it once the pane has room, as wide as PaneChat's splitter says.
className="absolute right-3 top-3 bottom-[calc(100%-var(--chat-area,100%)+0.75rem)] z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
>
{o.progress.length > 0 && (
<Section
title={t("chat.outline.progress")}
count={`${doneSteps}/${o.progress.length}`}
action={close}
>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={o.progress.length}
aria-valuenow={doneSteps}
aria-label={t("chat.outline.progress")}
className="mx-1.5 mb-1.5 h-1 overflow-hidden rounded-full bg-surface-3"
>
<div
className="h-full rounded-full bg-accent-solid transition-[width] duration-500"
style={{ width: `${(doneSteps / o.progress.length) * 100}%` }}
/>
</div>
{o.progress.map((task) => {
const done = task.status === "completed";
const now = task.status === "in_progress";
return (
<div
key={task.id}
className={`${rowClass} ${now ? "bg-accent/10 font-medium" : ""}`}
title={task.subject}
aria-current={now ? "step" : undefined}
>
{done ? (
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
<Check className="h-3 w-3" />
</span>
) : (
<span
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse-dot border-accent" : "border-border"}`}
/>
)}
<span
className={`min-w-0 flex-1 truncate ${done ? "text-fg-muted line-through" : task.status === "in_progress" ? "text-fg-primary" : ""}`}
>
{task.subject}
</span>
</div>
);
})}
</Section>
)}
<Section
title={t("chat.outline.outputs")}
count={o.outputs.length}
action={o.progress.length ? undefined : close}
grow
>
{o.outputs.length ? (
o.outputs.map((p) => {
const Icon = fileIcon(p);
return (
<button
key={p}
type="button"
title={p}
aria-label={`${extOf(p)}: ${stem(p)}`}
onClick={() => onOpenFile(p)}
draggable
onDragStart={dragFiles([p])}
className={rowClass}
>
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate">{stem(p)}</span>
<span className="flex-shrink-0 text-[11px] text-fg-muted">{extOf(p)}</span>
</button>
);
})
) : (
<p className="px-1.5 py-1 text-xs text-fg-muted">{t("chat.outline.empty")}</p>
)}
</Section>
{used > 0 && (
<Section title={t("chat.outline.used")}>
{o.uploads.length > 0 && (
<UsedRow
icon={Paperclip}
label={t("chat.outline.uploads")}
names={o.uploads.map(displayName)}
onClick={() => onOpenList("uploads")}
onDragStart={dragFiles(o.uploads, true)}
/>
)}
{o.files.length > 0 && (
<UsedRow
icon={FileSearch}
label={t("chat.outline.files")}
names={o.files.map(displayName)}
onClick={() => onOpenList("files")}
onDragStart={dragFiles(o.files)}
/>
)}
{o.memory.length > 0 && (
<UsedRow
icon={History}
label={t("chat.outline.memory")}
names={o.memory.map(stem)}
onClick={() => onOpenList("memory")}
onDragStart={dragFiles(o.memory)}
/>
)}
{o.skills.length > 0 && (
<UsedRow
icon={BookOpen}
label={t("chat.skills")}
names={o.skills}
onClick={() => onOpenList("skills")}
// One command per prompt: several are dragged one by one from the list.
onDragStart={o.skills.length === 1 ? (e) => dragSkill(e, o.skills[0]!) : undefined}
/>
)}
{o.mcp.length > 0 && (
<UsedRow
icon={Plug}
label={t("chat.outline.connectors")}
names={o.mcp}
to={href("agents/claude-code?tab=mcp")}
/>
)}
</Section>
)}
</aside>
);
}