3a8be55b55
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
342 lines
11 KiB
TypeScript
342 lines
11 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), 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 } 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 { 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. */
|
|
export function useSessionOutline(sessionId: string): SessionOutline {
|
|
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
|
|
useEffect(() => {
|
|
let live = true;
|
|
const load = () =>
|
|
api.sessions
|
|
.outline(sessionId)
|
|
.then((o) => live && setOutline({ ...EMPTY, ...o }))
|
|
.catch(() => {});
|
|
void load();
|
|
const timer = window.setInterval(() => {
|
|
if (document.visibilityState === "visible") void load();
|
|
}, POLL_MS);
|
|
return () => {
|
|
live = false;
|
|
window.clearInterval(timer);
|
|
};
|
|
}, [sessionId]);
|
|
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;
|
|
count?: number;
|
|
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 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 chat in a narrow pane (the container is PaneChat's root),
|
|
// beside it once the pane has room, as wide as PaneChat's splitter says.
|
|
className="absolute inset-y-3 right-3 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")} action={close}>
|
|
{o.progress.map((task) => {
|
|
const done = task.status === "completed";
|
|
return (
|
|
<div key={task.id} className={rowClass} title={task.subject}>
|
|
{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 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>
|
|
);
|
|
}
|