c3f83d3922
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>
383 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|