feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* @file ChatAddMenu.tsx
|
||||
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
|
||||
* files (the prompt box's own picker), what is already there to attach —
|
||||
* Project files (a search of the box's folder, folders too:
|
||||
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
|
||||
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
|
||||
* `lib/promptAttach.ts`) —, Skills (the user's and the project's)
|
||||
* and Plugins → one plugin → the skills and commands it ships. Picking one
|
||||
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||
* and Manage opens the Claude config page on that tab.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
History,
|
||||
Paperclip,
|
||||
Plug,
|
||||
Plus,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
interface Lists {
|
||||
skills: Entry[];
|
||||
plugins: { name: string; items: Entry[] }[];
|
||||
}
|
||||
|
||||
const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name);
|
||||
|
||||
function loadLists(cwd: string): Promise<Lists> {
|
||||
return Promise.all([
|
||||
api.ccConfig.skills(undefined, cwd).catch(() => ({ items: [] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([sk, pl]) => ({
|
||||
skills: sk.items
|
||||
.map((s) => ({ name: s.name, description: s.frontmatter?.description }))
|
||||
.filter((s, i, all) => all.findIndex((o) => o.name === s.name) === i)
|
||||
.sort(byName),
|
||||
plugins: pl.plugins
|
||||
.filter((p) => p.enabled !== false && p.contributes)
|
||||
.map((p) => ({
|
||||
name: p.name,
|
||||
items: [...p.contributes!.items.skills, ...p.contributes!.items.commands]
|
||||
.map((it) => ({ name: it.name, description: it.description ?? undefined }))
|
||||
.filter((it, i, all) => all.findIndex((o) => o.name === it.name) === i)
|
||||
.sort(byName),
|
||||
}))
|
||||
.filter((p, i, all) => p.items.length > 0 && all.findIndex((o) => o.name === p.name) === i)
|
||||
.sort(byName),
|
||||
}));
|
||||
}
|
||||
|
||||
const PANEL =
|
||||
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
|
||||
function Row({
|
||||
icon,
|
||||
label,
|
||||
more,
|
||||
active,
|
||||
onClick,
|
||||
onHover,
|
||||
title,
|
||||
testId,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
more?: boolean;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
onHover?: () => void;
|
||||
title?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onHover}
|
||||
className={`${ROW} ${active ? "bg-surface-3 text-fg-primary" : ""}`}
|
||||
>
|
||||
<span className="shrink-0 text-fg-muted">{icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{more && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-muted" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Project files: a search of the box's folder, shortest paths first. */
|
||||
function ProjectFiles({ cwd, onPick }: { cwd: string; onPick: (rel: string) => void }) {
|
||||
const { t } = useTranslation("run");
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<string[] | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const tid = window.setTimeout(() => {
|
||||
api.run
|
||||
.files(cwd, q, true)
|
||||
.then((r) => live && setItems(r.items))
|
||||
.catch(() => live && setItems([]));
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(tid);
|
||||
};
|
||||
}, [cwd, q]);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t("chat.searchFiles")}
|
||||
data-testid="chat-add-project-search"
|
||||
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{!items || items.length === 0 ? (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{items ? t("chat.none") : "…"}</p>
|
||||
) : (
|
||||
items.map((p) => (
|
||||
<Row
|
||||
key={p}
|
||||
icon={
|
||||
p.endsWith("/") ? (
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
label={p}
|
||||
title={p}
|
||||
testId={`chat-add-project-${p}`}
|
||||
onClick={() => onPick(p)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Sub = "project" | "context" | "recent" | "skills" | "plugins";
|
||||
|
||||
export function ChatAddMenu({
|
||||
cwd,
|
||||
laneId = null,
|
||||
skills,
|
||||
onUpload,
|
||||
onInsert,
|
||||
onAttach,
|
||||
}: {
|
||||
cwd: string;
|
||||
/** The workspace, for its Context files; none hides them. */
|
||||
laneId?: number | null;
|
||||
/** Offer Skills and Plugins: Claude only, they are Claude Code's. */
|
||||
skills: boolean;
|
||||
onUpload: () => void;
|
||||
/** `/<name>` picked. */
|
||||
onInsert: (command: string) => void;
|
||||
/** An existing file picked: a project path, a context file, an upload. */
|
||||
onAttach: (items: Attachment[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sub, setSub] = useState<Sub | null>(null);
|
||||
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||
const [plugin, setPlugin] = useState<string | null>(null);
|
||||
const [lists, setLists] = useState<Lists | null>(null);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (skills && !lists) void loadLists(cwd).then(setLists);
|
||||
|
||||
const away = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", away);
|
||||
document.addEventListener("keydown", esc);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", away);
|
||||
document.removeEventListener("keydown", esc);
|
||||
};
|
||||
}, [open, skills, lists, cwd]);
|
||||
|
||||
// Read again at each opening: either list may have grown since.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setRecent(recentUploads());
|
||||
setContext(null);
|
||||
if (laneId === null) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(laneId)
|
||||
.then(
|
||||
(v) =>
|
||||
live &&
|
||||
setContext(
|
||||
v.sources
|
||||
.filter((s) => s.kind === "file" && !s.missing)
|
||||
.map((s) => ({ path: `@${s.path}`, name: s.name }))
|
||||
)
|
||||
)
|
||||
.catch(() => live && setContext([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [open, laneId]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setSub(null);
|
||||
setPlugin(null);
|
||||
};
|
||||
const pick = (command: string) => {
|
||||
close();
|
||||
onInsert(command);
|
||||
};
|
||||
const attach = (item: Attachment) => {
|
||||
close();
|
||||
onAttach([item]);
|
||||
};
|
||||
const manage = (tab: "skills" | "plugins") => {
|
||||
close();
|
||||
navigate(`/cc-config?tab=${tab}`);
|
||||
};
|
||||
|
||||
const entries = (items: Entry[], prefix: string, from?: string) =>
|
||||
items.length === 0 ? (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{lists ? t("chat.none") : "…"}</p>
|
||||
) : (
|
||||
items.map((it) => (
|
||||
<Row
|
||||
key={it.name}
|
||||
icon={<ScrollText className="h-4 w-4" />}
|
||||
label={it.name}
|
||||
title={[it.description, from].filter(Boolean).join("\n") || undefined}
|
||||
testId={`chat-add-skill-${prefix}${it.name}`}
|
||||
onClick={() => pick(`/${prefix}${it.name}`)}
|
||||
/>
|
||||
))
|
||||
);
|
||||
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-add"
|
||||
aria-label={t("chat.add")}
|
||||
title={t("chat.add")}
|
||||
aria-expanded={open}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
||||
<Row
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
label={t("chat.upload")}
|
||||
testId="chat-add-upload"
|
||||
onHover={() => setSub(null)}
|
||||
onClick={() => {
|
||||
close();
|
||||
onUpload();
|
||||
}}
|
||||
/>
|
||||
<Row
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
label={t("chat.projectFiles")}
|
||||
more
|
||||
active={sub === "project"}
|
||||
testId="chat-add-project"
|
||||
onHover={() => setSub("project")}
|
||||
onClick={() => setSub(sub === "project" ? null : "project")}
|
||||
/>
|
||||
{laneId !== null && (
|
||||
<Row
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label={t("chat.contextFiles")}
|
||||
more
|
||||
active={sub === "context"}
|
||||
testId="chat-add-context"
|
||||
onHover={() => setSub("context")}
|
||||
onClick={() => setSub(sub === "context" ? null : "context")}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
icon={<History className="h-4 w-4" />}
|
||||
label={t("chat.recentUploads")}
|
||||
more
|
||||
active={sub === "recent"}
|
||||
testId="chat-add-recent"
|
||||
onHover={() => setSub("recent")}
|
||||
onClick={() => setSub(sub === "recent" ? null : "recent")}
|
||||
/>
|
||||
{skills && (
|
||||
<>
|
||||
<Row
|
||||
icon={<ScrollText className="h-4 w-4" />}
|
||||
label={t("chat.skills")}
|
||||
more
|
||||
active={sub === "skills"}
|
||||
testId="chat-add-skills"
|
||||
onHover={() => setSub("skills")}
|
||||
onClick={() => setSub(sub === "skills" ? null : "skills")}
|
||||
/>
|
||||
<Row
|
||||
icon={<Plug className="h-4 w-4" />}
|
||||
label={t("chat.plugins")}
|
||||
more
|
||||
active={sub === "plugins"}
|
||||
testId="chat-add-plugins"
|
||||
onHover={() => setSub("plugins")}
|
||||
onClick={() => setSub(sub === "plugins" ? null : "plugins")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sub === "project" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
||||
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sub === "context" || sub === "recent") && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{(sub === "context" ? context : recent)?.length ? (
|
||||
(sub === "context" ? context! : recent).map((it) => (
|
||||
<Row
|
||||
key={it.path}
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
label={it.name}
|
||||
title={it.path.replace(/^@/, "")}
|
||||
testId={`chat-add-${sub}-${it.name}`}
|
||||
onClick={() => attach(it)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">
|
||||
{sub === "context" && !context ? "…" : t("chat.none")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === "skills" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
icon={<Settings2 className="h-4 w-4" />}
|
||||
label={t("chat.manageSkills")}
|
||||
onClick={() => manage("skills")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === "plugins" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{lists && lists.plugins.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||
)}
|
||||
{!lists && <p className="px-3 py-1.5 text-xs text-fg-muted">…</p>}
|
||||
{lists?.plugins.map((p) => (
|
||||
<Row
|
||||
key={p.name}
|
||||
icon={<Plug className="h-4 w-4" />}
|
||||
label={p.name}
|
||||
more
|
||||
active={plugin === p.name}
|
||||
testId={`chat-add-plugin-${p.name}`}
|
||||
onHover={() => setPlugin(p.name)}
|
||||
onClick={() => setPlugin(plugin === p.name ? null : p.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
icon={<Settings2 className="h-4 w-4" />}
|
||||
label={t("chat.managePlugins")}
|
||||
onClick={() => manage("plugins")}
|
||||
/>
|
||||
{shownPlugin && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{entries(
|
||||
shownPlugin.items,
|
||||
`${shownPlugin.name}:`,
|
||||
t("chat.fromPlugin", { name: shownPlugin.name })
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @file ChatFilePreview.tsx
|
||||
* @description A file a Chat pane's session touched, shown the way Claude
|
||||
* Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the
|
||||
* content) and `FileListModal` (a list on the left, the picked file's preview
|
||||
* on the right — Claude's Uploads dialog). Content comes only through routes
|
||||
* that already guard it: a file inside the workspace through
|
||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
|
||||
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
export interface PreviewScope {
|
||||
laneId: number | null;
|
||||
/** The workspace's own folder. */
|
||||
laneCwd: string;
|
||||
/** The pane's folder — the workspace's, or the session's own worktree. */
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
|
||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||
export const extOf = (p: string) => {
|
||||
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
||||
return m ? m[1]!.toUpperCase() : "";
|
||||
};
|
||||
/** An attached file's name without the drop dir's random prefix. */
|
||||
export const displayName = (p: string) =>
|
||||
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
||||
|
||||
type Content =
|
||||
| { kind: "loading" }
|
||||
| { kind: "image"; src: string }
|
||||
| { kind: "text"; text: string; truncated: boolean }
|
||||
| { kind: "none" };
|
||||
|
||||
function useContent(path: string, scope: PreviewScope): Content {
|
||||
const [content, setContent] = useState<Content>({ kind: "loading" });
|
||||
useEffect(() => {
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
setContent(
|
||||
IMAGE_RE.test(path)
|
||||
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
|
||||
: { kind: "none" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
const show = (p: Promise<Content>) => {
|
||||
setContent({ kind: "loading" });
|
||||
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
||||
};
|
||||
const base = scope.cwd.replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
|
||||
// Claude Code's own files (memory): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
|
||||
show(
|
||||
api.ccConfig
|
||||
.file(path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
||||
);
|
||||
} else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = path.slice(base.length + 1);
|
||||
const target =
|
||||
base === scope.laneCwd.replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
if (IMAGE_RE.test(path)) {
|
||||
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
||||
return;
|
||||
}
|
||||
show(
|
||||
api.lanes
|
||||
.gitFile(target, rel)
|
||||
.then((f) =>
|
||||
f.available && !f.binary
|
||||
? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated }
|
||||
: { kind: "none" }
|
||||
)
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]);
|
||||
return content;
|
||||
}
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
const { t } = useTranslation("run");
|
||||
const content = useContent(path, scope);
|
||||
if (content.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "image") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={content.src}
|
||||
alt={baseName(path)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "none") {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p>{t("chat.outline.noPreview")}</p>
|
||||
<p className="break-all font-mono text-xs">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{isMarkdown(path) ? (
|
||||
<MarkdownPreview text={content.text} className="p-4" />
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
||||
{content.text}
|
||||
</pre>
|
||||
)}
|
||||
{content.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||
export function FilePreview({
|
||||
path,
|
||||
scope,
|
||||
onClose,
|
||||
className = "",
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
return (
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
||||
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
aria-label={t("chat.outline.copyPath", { path })}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("chat.outline.close")}
|
||||
aria-label={t("chat.outline.close")}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} scope={scope} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||
export function FileListModal({
|
||||
title,
|
||||
paths,
|
||||
scope,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
paths: string[];
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState(paths[0] ?? "");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
// Into document.body, like ImageLightbox: a pane's own stacking layer
|
||||
// would otherwise draw over a `fixed` overlay opened from inside it.
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
data-testid="chat-file-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
>
|
||||
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
|
||||
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
|
||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||
{paths.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(p)}
|
||||
title={p}
|
||||
aria-current={p === picked}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 truncate">{displayName(p)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @file ChatOutline.tsx
|
||||
* @description The right-hand panel of a Chat pane shown alone, 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 (a click opens the list with a preview), its skills
|
||||
* and MCP servers (a click opens the Claude config page). 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 ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
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,
|
||||
to,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
names: string[];
|
||||
onClick?: () => 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} 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 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")}
|
||||
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
|
||||
>
|
||||
{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)}
|
||||
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")}
|
||||
/>
|
||||
)}
|
||||
{o.files.length > 0 && (
|
||||
<UsedRow
|
||||
icon={FileSearch}
|
||||
label={t("chat.outline.files")}
|
||||
names={o.files.map(displayName)}
|
||||
onClick={() => onOpenList("files")}
|
||||
/>
|
||||
)}
|
||||
{o.memory.length > 0 && (
|
||||
<UsedRow
|
||||
icon={History}
|
||||
label={t("chat.outline.memory")}
|
||||
names={o.memory.map(stem)}
|
||||
onClick={() => onOpenList("memory")}
|
||||
/>
|
||||
)}
|
||||
{o.skills.length > 0 && (
|
||||
<UsedRow
|
||||
icon={BookOpen}
|
||||
label={t("chat.skills")}
|
||||
names={o.skills}
|
||||
to="/settings/agents/claude-code?tab=skills"
|
||||
/>
|
||||
)}
|
||||
{o.mcp.length > 0 && (
|
||||
<UsedRow
|
||||
icon={Plug}
|
||||
label={t("chat.outline.connectors")}
|
||||
names={o.mcp}
|
||||
to="/settings/agents/claude-code?tab=mcp"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,9 @@
|
||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
|
||||
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
|
||||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||
* Appearance, `lib/chatUi.ts`) is only what a session shows first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -28,7 +29,7 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useChatUi } from "../../lib/chatUi";
|
||||
import { useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
@@ -50,6 +51,8 @@ export interface LaneConsolePaneProps {
|
||||
onRunExit?: () => void;
|
||||
/** The terminal takes the keyboard when it appears (default). */
|
||||
autoFocus?: boolean;
|
||||
/** The only pane shown: its Chat view gets the right panel. */
|
||||
solo?: boolean;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||
@@ -68,6 +71,7 @@ export function LaneConsolePane({
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
solo = false,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
@@ -78,11 +82,11 @@ export function LaneConsolePane({
|
||||
// The tool of the run on screen — a session's own, which need not be the
|
||||
// workspace's; the workspace's with no run.
|
||||
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
||||
const chat = useChatUi();
|
||||
// A run learns its session id after it starts (Codex): the page's poll has it.
|
||||
const chatSession = handle
|
||||
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
||||
: null;
|
||||
const chat = useSessionChat(chatSession);
|
||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,10 +196,12 @@ export function LaneConsolePane({
|
||||
{chatable && chat && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
||||
<PaneChat
|
||||
runId={handle.id}
|
||||
run={handle}
|
||||
sessionId={chatSession}
|
||||
tool={handle.tool}
|
||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||
laneId={laneId}
|
||||
laneCwd={currentLane?.cwd || ""}
|
||||
outline={solo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,84 +5,319 @@
|
||||
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
||||
* attached underneath — a message is sent through that terminal
|
||||
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
|
||||
* nothing new reaches the server. A permission prompt or a menu still needs the
|
||||
* terminal; the hint under the box says so.
|
||||
* nothing new reaches the server. The box carries Claude Desktop's controls:
|
||||
* the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
|
||||
* context files, recent uploads —, skills, plugins' skills), and for a
|
||||
* Claude pane the Model/Effort chip — a change is typed into the pane as
|
||||
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||
* Shown alone (`outline`), it also has Claude Desktop's activity panel
|
||||
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
|
||||
* file button with the output count until clicked (remembered per browser);
|
||||
* a file clicked there opens its preview in the panel's place
|
||||
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
||||
* the workspace's folder —, a group
|
||||
* of files a list with a preview (`FileListModal`). A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
|
||||
* conversation to its end and keeps it there (`follow`), so the message and
|
||||
* the step running after it come into sight on their own. While the session's
|
||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { submitToRun } from "../../lib/panePaste";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { ChatAddMenu } from "./ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
|
||||
import { Select } from "../Select";
|
||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
["default", "Default"],
|
||||
["acceptEdits", "AcceptEdits"],
|
||||
["plan", "Plan"],
|
||||
["auto", "Auto"],
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
||||
|
||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||
const { t } = useTranslation(["run", "lanes"]);
|
||||
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const read = useCallback(
|
||||
() =>
|
||||
api.run
|
||||
.permissionMode(runId)
|
||||
.then((r) => isSettable(r.mode) && setMode(r.mode))
|
||||
.catch(() => {}),
|
||||
[runId]
|
||||
);
|
||||
useEffect(() => {
|
||||
void read();
|
||||
}, [read]);
|
||||
// Bypass is in a session's cycle only when it was started with it.
|
||||
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
|
||||
return (
|
||||
<div data-testid="pane-chat-mode">
|
||||
<Select<PermissionMode>
|
||||
variant="ghost"
|
||||
label={t("run:fields.permissionMode")}
|
||||
value={mode}
|
||||
disabled={busy}
|
||||
onChange={(v) => {
|
||||
setBusy(true);
|
||||
api.run
|
||||
.setPermissionMode(runId, v)
|
||||
.then((r) => setMode(r.mode))
|
||||
.catch((err: unknown) => {
|
||||
toast.error(
|
||||
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
|
||||
);
|
||||
void read();
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
options={offered.map(([value, key]) => ({
|
||||
value,
|
||||
label: t(`lanes:home.permission${key}`),
|
||||
hint: t(`run:fields.permission${key}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneChat({
|
||||
runId,
|
||||
run,
|
||||
sessionId,
|
||||
tool,
|
||||
cwd,
|
||||
laneId = null,
|
||||
laneCwd = "",
|
||||
outline = false,
|
||||
}: {
|
||||
runId: string;
|
||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||
sessionId: string;
|
||||
tool: string;
|
||||
cwd: string;
|
||||
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
||||
laneId?: number | null;
|
||||
laneCwd?: string;
|
||||
/** The pane is shown alone: room for the right panel. */
|
||||
outline?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
// Collapsed until asked for, like Claude Desktop's activity panel.
|
||||
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
|
||||
const openOutline = (open: boolean) => {
|
||||
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
||||
setOutlineOpen(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
// The main agent is "working" from a prompt's submit to its Stop hook.
|
||||
const [working, setWorking] = useState(false);
|
||||
const readWorking = useCallback(
|
||||
() =>
|
||||
api.agents
|
||||
.list({ session_id: sessionId, status: "working" })
|
||||
// With session_id the server answers every agent of the session, its
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
|
||||
.catch(() => {}),
|
||||
[sessionId]
|
||||
);
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
useLiveRefresh(readWorking, {
|
||||
throttleMs: 500,
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working ? 5000 : undefined,
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
// A control is a command typed into the pane, like the user would.
|
||||
const command = (line: string) => {
|
||||
const ok = submitToRun(runId, line);
|
||||
if (!ok) toast.error(t("chat.notSent"));
|
||||
return ok;
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (!text || uploading) return;
|
||||
if (!submitToRun(runId, text)) {
|
||||
toast.error(t("chat.notSent"));
|
||||
return;
|
||||
}
|
||||
if (!command(text)) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
live
|
||||
follow={follow}
|
||||
onOpenFile={(path) => {
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview, when it has room for one.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
if (outline) setPreview(path);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||
{outline && !outlineOpen && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
data-testid="chat-outline-show"
|
||||
onClick={() => openOutline(true)}
|
||||
title={t("chat.outline.show")}
|
||||
aria-label={t("chat.outline.show")}
|
||||
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{activity.outputs.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
pickFiles={pickFiles}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={claude}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<ChatAddMenu
|
||||
cwd={cwd}
|
||||
laneId={laneId}
|
||||
skills={claude}
|
||||
onUpload={() => pickFiles.current?.()}
|
||||
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{claude && (
|
||||
<ModelMenu
|
||||
value={model}
|
||||
onChange={(v) => {
|
||||
if (command(`/model ${v || "default"}`)) setModel(v);
|
||||
}}
|
||||
tool={run.tool}
|
||||
effort={effort}
|
||||
onEffortChange={(e) => {
|
||||
if (command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{working && !text ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-stop"
|
||||
onClick={() => {
|
||||
// An interrupted turn fires no Stop hook: clear it here.
|
||||
if (interruptRun(runId)) setWorking(false);
|
||||
else toast.error(t("chat.notStopped"));
|
||||
}}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{outline &&
|
||||
(preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when there is room, over it when not.
|
||||
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{list && (
|
||||
<FileListModal
|
||||
title={t(`chat.outline.${list}`)}
|
||||
paths={activity[list]}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GripVertical,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -191,9 +193,37 @@ function PaneMenu({
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** A pane's split-right / split-down menus: move a hidden run beside
|
||||
* `slot`, or start a new terminal or AI tool there. In a pane header when the view is
|
||||
* split, beside Tools in the tab bar while one pane is shown. */
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
||||
const sessionId = run.sessionId;
|
||||
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-chat-toggle-${run.slot ?? 0}`}
|
||||
aria-pressed={chat}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => setSessionChat(sessionId, !chat)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{chat ? (
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A pane's chat/terminal toggle and split-right / split-down menus: move a
|
||||
* hidden run beside `slot`, or start a new terminal or AI tool there. In a
|
||||
* pane header when the view is split, beside Tools in the tab bar while one
|
||||
* pane is shown. */
|
||||
export function SplitMenus({
|
||||
slot,
|
||||
shown,
|
||||
@@ -222,6 +252,7 @@ export function SplitMenus({
|
||||
const full = shown.length >= MAX_PANES;
|
||||
return (
|
||||
<>
|
||||
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
@@ -446,6 +477,8 @@ export function PaneGrid({
|
||||
key={slot}
|
||||
data-testid={`pane-${slot}`}
|
||||
data-focused={r && focused ? "true" : undefined}
|
||||
// A menu opened in this pane stays inside it (`menuRoom`).
|
||||
data-menu-bounds
|
||||
className={r ? "absolute flex flex-col" : "hidden"}
|
||||
style={r ? pct(r) : undefined}
|
||||
onPointerDownCapture={() => r && focus(slot)}
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
||||
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a Files panel row
|
||||
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||
@@ -19,7 +23,17 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
@@ -42,9 +56,16 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||
import type { LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { Select, menuRoom } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ImageLightbox } from "../ImageLightbox";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
droppedAttachment,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -158,13 +179,16 @@ interface PromptEditorProps {
|
||||
* sends them with the text through `promptWithImages`. */
|
||||
images: PromptImage[];
|
||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||
/** Borderless, for a composer box that draws its own frame. */
|
||||
/** Borderless, for a composer box that draws its own frame — and grows
|
||||
* with its text, from `rows` up to 40% of the screen, then scrolls. */
|
||||
bare?: boolean;
|
||||
/** An image upload started (true) or settled (false): the caller holds its
|
||||
* send button meanwhile, or the prompt would go out without the image. */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
||||
lane?: number | string;
|
||||
/** Set to a function that opens a file picker (a composer's + menu). */
|
||||
pickFiles?: MutableRefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
/** An attached file: its path on the server's machine, and a local preview
|
||||
@@ -177,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
|
||||
export const PASTE_AS_FILE_CHARS = 5000;
|
||||
|
||||
export function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -190,9 +217,18 @@ export function PromptEditor({
|
||||
bare,
|
||||
onBusyChange,
|
||||
lane,
|
||||
pickFiles,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// A composer grows with its text: reset to its rows, then take what the
|
||||
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||
useLayoutEffect(() => {
|
||||
const ta = taRef.current;
|
||||
if (!bare || !ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [bare, value]);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
@@ -324,7 +360,7 @@ export function PromptEditor({
|
||||
const busyRef = useRef(onBusyChange);
|
||||
busyRef.current = onBusyChange;
|
||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const attachImages = (list: FileList | File[] | null | undefined) => {
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
@@ -336,6 +372,7 @@ export function PromptEditor({
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
})
|
||||
.catch(() => setUpload("failed"));
|
||||
@@ -345,12 +382,11 @@ export function PromptEditor({
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!viewing) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [viewing]);
|
||||
const closeViewer = useCallback(() => setViewing(null), []);
|
||||
// The composer's own "Upload or add files" opens this picker; what it picks
|
||||
// takes the same path as a paste or a drop.
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
@@ -454,6 +490,19 @@ export function PromptEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pickFiles && (
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="prompt-file-input"
|
||||
onChange={(e) => {
|
||||
attachImages(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
@@ -467,8 +516,28 @@ export function PromptEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
// A Files panel row is not a file: say a drop is welcome.
|
||||
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const att = droppedAttachment(e.dataTransfer);
|
||||
if (att) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, [att]));
|
||||
return;
|
||||
}
|
||||
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
@@ -476,7 +545,7 @@ export function PromptEditor({
|
||||
spellCheck={false}
|
||||
className={
|
||||
bare
|
||||
? "block w-full resize-none bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
}
|
||||
/>
|
||||
@@ -545,20 +614,7 @@ export function PromptEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{viewing && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={viewing.name}
|
||||
onClick={() => setViewing(null)}
|
||||
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
>
|
||||
<img
|
||||
src={viewing.url}
|
||||
alt={viewing.name}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -673,7 +729,10 @@ export function CwdAutocomplete({
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
||||
<div
|
||||
data-cwd-suggestions
|
||||
className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
|
||||
) : (
|
||||
@@ -856,9 +915,11 @@ export function ModelMenu({
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Below the chip unless the viewport has more room above it.
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
|
||||
// Below the chip unless its pane (or the viewport) has more room above it.
|
||||
if (ref.current) {
|
||||
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
|
||||
setUp(below < 360 && above > below);
|
||||
}
|
||||
const away = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
|
||||
@@ -236,6 +236,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
||||
fontWeight: 500,
|
||||
fontWeightBold: 700,
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
});
|
||||
@@ -556,7 +559,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
||||
// `onData` drops input while the socket is connecting or reconnecting,
|
||||
// so a caller waits or says the paste failed instead.
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN
|
||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||
(data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file ChatAddMenu.test.tsx
|
||||
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||
* search with folders, attached as `@path`), the workspace's Context files
|
||||
* (files only, none missing) and this browser's Recent uploads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ChatAddMenu } from "../ChatAddMenu";
|
||||
import { api } from "../../../lib/api";
|
||||
import { rememberUploads } from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||
lanes: {
|
||||
sources: vi.fn().mockResolvedValue({
|
||||
sources: [
|
||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||
{ id: 2, kind: "file", path: "/data/ctx/1/gone.md", name: "gone.md", missing: true },
|
||||
{ id: 3, kind: "folder", path: "/srv/docs", name: "docs", missing: false },
|
||||
],
|
||||
}),
|
||||
},
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({ items: [] }),
|
||||
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderMenu(laneId: number | null = 7) {
|
||||
const onAttach = vi.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatAddMenu
|
||||
cwd="/repo"
|
||||
laneId={laneId}
|
||||
skills={false}
|
||||
onUpload={() => {}}
|
||||
onInsert={() => {}}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
return onAttach;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ChatAddMenu — existing resources", () => {
|
||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-project"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-project-src/"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/repo", "", true);
|
||||
expect(onAttach).toHaveBeenCalledWith([{ path: "@src/", name: "src/" }]);
|
||||
});
|
||||
|
||||
it("offers the workspace's context files that are still there, files only", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-context"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-context-brief.md"));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(7);
|
||||
expect(screen.queryByTestId("chat-add-context-gone.md")).toBeNull();
|
||||
expect(onAttach).toHaveBeenCalledWith([{ path: "@/data/ctx/1/brief.md", name: "brief.md" }]);
|
||||
});
|
||||
|
||||
it("has no Context files without a workspace", () => {
|
||||
renderMenu(null);
|
||||
expect(screen.queryByTestId("chat-add-context")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists recent uploads newest first and attaches one as it was sent", () => {
|
||||
rememberUploads([{ path: "/tmp/d/old.pdf", name: "old.pdf", image: false }]);
|
||||
rememberUploads([{ path: "/tmp/d/0123456789abcdef.png", name: "shot.png", image: true }]);
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.mouseEnter(screen.getByTestId("chat-add-recent"));
|
||||
const rows = screen.getAllByTestId(/^chat-add-recent-/);
|
||||
expect(rows.map((r) => r.textContent)).toEqual(["shot.png", "old.pdf"]);
|
||||
fireEvent.click(rows[0]!);
|
||||
expect(onAttach).toHaveBeenCalledWith([
|
||||
{
|
||||
path: "/tmp/d/0123456789abcdef.png",
|
||||
name: "shot.png",
|
||||
url: "/api/run/drop/0123456789abcdef.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file ChatFilePreview.test.tsx
|
||||
* @description Where a Chat pane's file preview reads from: a file under the
|
||||
* pane's folder through the lane's git file read, a memory file under
|
||||
* `~/.claude` through the config pages' read, anything else by path only; and
|
||||
* the list dialog previewing the file picked in it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: {
|
||||
gitFile: vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/a.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["export const a = 1;"],
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
ccConfig: {
|
||||
file: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
||||
|
||||
describe("FilePreview", () => {
|
||||
it("reads a workspace file through the lane", async () => {
|
||||
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
});
|
||||
|
||||
it("reads a memory file through the config read", async () => {
|
||||
const mem = "/h/.claude/projects/-w/memory/launch.md";
|
||||
render(<FilePreview path={mem} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||
});
|
||||
|
||||
it("shows only the path of a file it may not read", () => {
|
||||
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileListModal", () => {
|
||||
it("previews the file picked in the list", async () => {
|
||||
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
|
||||
expect(displayName(drop)).toBe("minutes.xlsx");
|
||||
render(
|
||||
<FileListModal
|
||||
title="Uploads"
|
||||
paths={[drop, "/w/src/a.ts"]}
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file ChatOutline.test.tsx
|
||||
* @description The Chat pane's activity panel renders a session's outline the
|
||||
* way Claude Desktop does: progress with done/current items, outputs by name
|
||||
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
||||
* uploads, files read and memory open their list, skills link to the config.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ChatOutline } from "../ChatOutline";
|
||||
|
||||
const outline = {
|
||||
progress: [
|
||||
{ id: "1", subject: "Parse plan", status: "completed" },
|
||||
{ id: "2", subject: "Build report", status: "in_progress" },
|
||||
],
|
||||
outputs: ["/w/out/Progress report.xlsx"],
|
||||
files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"],
|
||||
uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"],
|
||||
memory: ["/h/.claude/projects/-w/memory/release.md"],
|
||||
skills: ["xlsx"],
|
||||
mcp: ["vercel_vercel"],
|
||||
};
|
||||
|
||||
describe("ChatOutline", () => {
|
||||
it("shows progress, outputs and what the session used", () => {
|
||||
const onClose = vi.fn();
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenList = vi.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={outline}
|
||||
onClose={onClose}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenList={onOpenList}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
||||
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||
|
||||
// An upload is named without the drop dir's random prefix.
|
||||
fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("uploads");
|
||||
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
||||
fireEvent.click(screen.getByTitle("Memory: release"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("memory");
|
||||
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
|
||||
"/settings/agents/claude-code?tab=skills"
|
||||
);
|
||||
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides progress and the used section when there is none", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={{
|
||||
...outline,
|
||||
progress: [],
|
||||
files: [],
|
||||
uploads: [],
|
||||
memory: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}}
|
||||
onClose={() => {}}
|
||||
onOpenFile={() => {}}
|
||||
onOpenList={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByText("Progress")).toBeNull();
|
||||
expect(screen.queryByText("Used in this session")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,13 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { __resetChatUi } from "../../../lib/chatUi";
|
||||
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
@@ -33,7 +33,10 @@ vi.mock("../PaneChat", () => ({
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
||||
me: {
|
||||
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
|
||||
savePrefs: vi.fn(),
|
||||
},
|
||||
lanes: {
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
@@ -171,7 +174,41 @@ describe("LaneConsolePane", () => {
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
||||
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
|
||||
__resetChatUi();
|
||||
});
|
||||
|
||||
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
|
||||
vi.mocked(api.me.prefs).mockResolvedValueOnce({
|
||||
prefs: { "agenthub.chatUi": "1" },
|
||||
onboardedAt: null,
|
||||
});
|
||||
__resetChatUi();
|
||||
const live = {
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: "s-1",
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
tool: "claude" as const,
|
||||
};
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
|
||||
// Seen once, the session keeps chat: turning the default off leaves it alone.
|
||||
await waitFor(() =>
|
||||
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
|
||||
);
|
||||
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
|
||||
await act(() => saveChatUi(false));
|
||||
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
|
||||
// Its own toggle switches it.
|
||||
act(() => setSessionChat("s-1", false));
|
||||
expect(screen.queryByTestId("pane-chat")).toBeNull();
|
||||
__resetChatUi();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @file PaneChat.test.tsx
|
||||
* @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins
|
||||
* → a plugin's skills, each inserted as `/<name>`), the Model/Effort chip typed
|
||||
* into the pane as `/model` / `/effort`, and the permission mode read off the
|
||||
* pane and stepped by the server — none of them for a Codex pane; and the
|
||||
* activity panel, collapsed into a file button with the output count, whose
|
||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat } from "../PaneChat";
|
||||
import { api } from "../../../lib/api";
|
||||
import { interruptRun, submitToRun } from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
|
||||
<div data-testid="conversation">
|
||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
agents: { list: r({ agents: [] }) },
|
||||
aiTools: {
|
||||
models: r({
|
||||
flag: true,
|
||||
models: [
|
||||
{ id: "opus", label: "Opus" },
|
||||
{ id: "sonnet", label: "Sonnet" },
|
||||
],
|
||||
defaultModel: null,
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
permissionMode: r({ mode: "acceptEdits" }),
|
||||
setPermissionMode: r({ mode: "plan" }),
|
||||
},
|
||||
sessions: {
|
||||
outline: r({
|
||||
progress: [],
|
||||
outputs: ["/w/notes.md", "/w/report.xlsx"],
|
||||
files: [],
|
||||
uploads: [],
|
||||
memory: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
}),
|
||||
},
|
||||
lanes: {
|
||||
gitFile: r({
|
||||
available: true,
|
||||
path: "notes.md",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["# Notes", "", "hello"],
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({ items: [] }),
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "morning", frontmatter: { description: "Plan the day" } }],
|
||||
}),
|
||||
plugins: r({
|
||||
plugins: [
|
||||
{
|
||||
name: "doox",
|
||||
enabled: true,
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [{ name: "plan-consolidation", description: "Merge plans" }],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off",
|
||||
enabled: false,
|
||||
contributes: { items: { skills: [{ name: "x" }], commands: [] } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const RUN = {
|
||||
id: "agenthub-lane-1",
|
||||
tool: "claude" as "claude" | "codex",
|
||||
model: "opus",
|
||||
effort: "high" as const,
|
||||
permissionMode: "auto" as const,
|
||||
};
|
||||
|
||||
function renderChat(run: Partial<typeof RUN> = {}) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={{ ...RUN, ...run }} sessionId="s1" cwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.mocked(submitToRun).mockClear());
|
||||
|
||||
describe("PaneChat — the + menu", () => {
|
||||
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
||||
renderChat();
|
||||
const box = screen.getByPlaceholderText("Write a message…") as HTMLTextAreaElement;
|
||||
fireEvent.change(box, { target: { value: "today" } });
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-skills"));
|
||||
fireEvent.click(await screen.findByTestId("chat-add-skill-morning"));
|
||||
expect(box.value).toBe("/morning today");
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-plugins"));
|
||||
expect(screen.queryByTestId("chat-add-plugin-off")).toBeNull(); // disabled plugin
|
||||
fireEvent.click(await screen.findByTestId("chat-add-plugin-doox"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-skill-doox:plan-consolidation"));
|
||||
expect(box.value).toBe("/doox:plan-consolidation /morning today");
|
||||
});
|
||||
|
||||
it("Upload opens the prompt box's file picker", () => {
|
||||
renderChat();
|
||||
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
||||
const clicked = vi.spyOn(input, "click");
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
fireEvent.click(screen.getByTestId("chat-add-upload"));
|
||||
expect(clicked).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — Stop", () => {
|
||||
it("replaces Send while the main agent works and the box is empty, and presses Esc", async () => {
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [{ type: "main", status: "working" }],
|
||||
} as never);
|
||||
renderChat();
|
||||
fireEvent.click(await screen.findByTestId("pane-chat-stop"));
|
||||
expect(interruptRun).toHaveBeenCalledWith("agenthub-lane-1");
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
expect(screen.getByTestId("pane-chat-send")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops Stop once the turn ended even when no live update says so", async () => {
|
||||
// A missed agent_updated (another dashboard on the same database got the
|
||||
// hook, or the socket dropped): the poll while working catches it.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [{ type: "main", status: "working" }],
|
||||
} as never);
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Send for a session at rest", async () => {
|
||||
// With session_id the server answers EVERY agent of the session (its
|
||||
// status filter does not apply): the main agent is there, waiting.
|
||||
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||
agents: [{ type: "main", status: "waiting" }],
|
||||
} as never);
|
||||
renderChat();
|
||||
await waitFor(() => expect(api.agents.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — model, effort and permission mode", () => {
|
||||
it("types /model into the pane", async () => {
|
||||
renderChat();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
|
||||
});
|
||||
|
||||
it("shows the mode the pane shows, and asks the server to step it", async () => {
|
||||
renderChat();
|
||||
const chip = byId("pane-chat-mode");
|
||||
await waitFor(() => expect(chip()).toHaveTextContent("Accept edits"));
|
||||
fireEvent.click(chip().querySelector("button")!);
|
||||
fireEvent.click(await screen.findByRole("option", { name: /Plan/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.run.setPermissionMode).toHaveBeenCalledWith("agenthub-lane-1", "plan")
|
||||
);
|
||||
await waitFor(() => expect(chip()).toHaveTextContent("Plan"));
|
||||
});
|
||||
|
||||
it("a Codex pane gets the + menu's Upload only — no skills, model or mode", () => {
|
||||
renderChat({ tool: "codex" });
|
||||
expect(screen.queryByTestId("pane-chat-mode")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Opus/ })).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("chat-add"));
|
||||
expect(screen.getByTestId("chat-add-upload")).toBeTruthy();
|
||||
expect(screen.queryByTestId("chat-add-skills")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const byId = (id: string) => () => screen.getByTestId(id);
|
||||
|
||||
describe("PaneChat — the activity panel", () => {
|
||||
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
||||
localStorage.removeItem("agenthub.chat.outlineOpen");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
const show = screen.getByTestId("chat-outline-show");
|
||||
await waitFor(() => expect(show.textContent).toBe("2"));
|
||||
|
||||
fireEvent.click(show);
|
||||
fireEvent.click(screen.getByRole("button", { name: "MD: notes" }));
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
await screen.findByTestId("markdown-preview");
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(7, "notes.md");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — an edited file", () => {
|
||||
it("opens in the Changes panel when it is in the pane's folder, else in its own preview", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const listen = (e: Event) => seen.push((e as CustomEvent).detail);
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("edited-in"));
|
||||
expect(seen).toEqual([{ laneId: 7, path: "src/a.ts" }]);
|
||||
expect(screen.queryByTestId("chat-file-preview")).toBeNull();
|
||||
// Outside the folder the Changes panel cannot show it: the chat's preview does.
|
||||
fireEvent.click(screen.getByTestId("edited-out"));
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(await screen.findByTestId("chat-file-preview")).toBeInTheDocument();
|
||||
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@
|
||||
* @description The run-form pieces that outlived the pane's run form:
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||
* chip, a composer
|
||||
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
* same list as one chip: aliases up front, pinned versions under More models,
|
||||
@@ -13,17 +16,19 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
ModelMenu,
|
||||
ModelPicker,
|
||||
PASTE_AS_FILE_CHARS,
|
||||
PromptEditor,
|
||||
promptWithImages,
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptEditor — a composer grows with its text", () => {
|
||||
it("takes the height its text needs, capped by max-h, and only when bare", () => {
|
||||
const tall = vi
|
||||
.spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
|
||||
.mockReturnValue(180);
|
||||
const props = {
|
||||
placeholder: "Ask anything…",
|
||||
slash: false,
|
||||
fileCwd: "/Users/tester",
|
||||
images: [],
|
||||
onImagesChange: () => {},
|
||||
onChange: () => {},
|
||||
onSubmit: () => {},
|
||||
};
|
||||
const view = render(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
|
||||
expect(box().style.height).toBe("180px");
|
||||
expect(box().className).toContain("max-h-[40vh]");
|
||||
tall.mockReturnValue(60);
|
||||
view.rerender(<PromptEditor {...props} value="a" bare />);
|
||||
expect(box().style.height).toBe("60px");
|
||||
view.unmount();
|
||||
// The framed editor keeps its fixed rows.
|
||||
render(<PromptEditor {...props} value={"a\nb\nc"} />);
|
||||
expect(box().style.height).toBe("");
|
||||
tall.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PromptEditor — newlines and images", () => {
|
||||
it("Alt+Enter inserts a newline and does not send", () => {
|
||||
const spies = renderEditor("ab");
|
||||
@@ -202,7 +235,8 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||
|
||||
fireEvent.click(thumb);
|
||||
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
|
||||
// On the page, not in the composer's layer: nothing stacked over a pane covers it.
|
||||
expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
@@ -212,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||
});
|
||||
|
||||
it("a composer's Upload opens a picker whose files attach like a paste", async () => {
|
||||
const pick: { current: (() => void) | null } = { current: null };
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
pickFiles={pick}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
||||
const clicked = vi.spyOn(input, "click");
|
||||
pick.current?.();
|
||||
expect(clicked).toHaveBeenCalled();
|
||||
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||
fireEvent.change(input, { target: { files: [doc] } });
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||
function Harness() {
|
||||
@@ -237,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||
// Remembered for the + menu's Recent uploads.
|
||||
expect(recentUploads()).toContainEqual({
|
||||
path: "/tmp/agenthub-drops-1/b-spec.pdf",
|
||||
name: "spec.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const data: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (data[k] = v),
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(data);
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
// What a Files panel row's onDragStart puts on the drag.
|
||||
dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/");
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
expect(screen.getByText("lib/")).toBeTruthy();
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||
fireEvent.paste(box(), {
|
||||
clipboardData: { files: [], getData: () => "a short note" },
|
||||
});
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => {
|
||||
renderEditor("");
|
||||
const long = "x".repeat(PASTE_AS_FILE_CHARS + 1);
|
||||
fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } });
|
||||
await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1));
|
||||
const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File;
|
||||
expect(file.name).toBe("pasted-text.txt");
|
||||
expect(file.type).toBe("text/plain");
|
||||
expect(file.size).toBe(long.length); // the whole paste, nothing cut
|
||||
expect(box()).toHaveValue(""); // not typed into the box
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelPicker", () => {
|
||||
|
||||
Reference in New Issue
Block a user