/** * @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ĩ */ 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 ): SessionOutline { const [outline, setOutline] = useState(EMPTY); useEffect(() => { let live = true; const load = () => api.sessions .outline(sessionId) .then((o) => live && setOutline(keepIfSame({ ...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 (
{action}
{open && (
{children}
)}
); } 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 = ( <> {label} {shown} {names.length > 2 && ( +{names.length - 2} )} ); const title = `${label}: ${names.join(", ")}`; return to ? ( {inner} ) : ( ); } 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 = ( ); return ( ); }