feat: v2.7.0 — tabs from every workspace, interrupted sessions offered for resume, chat drafts kept, chat right panel in every split pane
- Settings → Appearance → "Tabs from every workspace" (off by default, saved per account): the session view's tab bar holds every workspace's live sessions, a combo may mix them, and focusing a pane selects its workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once under `all` in agenthub.workspace.paneLayout. - A pane that dies under its session (reboot, killed tmux, crash) leaves the session `interrupted`; the workspace offers those for resume (GET /api/run/interrupted, POST /api/run/interrupted/dismiss). - Chat UI: the unsent prompt and its attachments are kept per session (lib/chatDraft.ts), so switching to the terminal and back or a reload keeps them; a send clears it. Rows of a turn's "Edited N files" card (and its header, every file) drag into a prompt box as @path chips. - Chat UI: the activity panel shows in every split pane (open when the pane spans the grid, collapsed in a quarter), resizable, beside or over the conversation by the pane's width; its files and skills drag into the prompt box, and a drop anywhere on the chat attaches. - Chat UI: a screenshot the session read outside the workspace previews (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text files are served back as text/plain; long prompts and answers are no longer cut at 10 KB (tool output still is); a new live session shows an empty chat until its first hook instead of "Session not found". - Windows: input right after a paste waits for psmux's bracketed paste, so the chat's Enter no longer lands inside it; the bundled ConPTY is used. - Changes panel: a Workspace context fold (instructions, files, folders). Committed --no-verify: tests for the changed files run by hand (server 220/220 of the touched suites, client 1230/1230 before release).
This commit is contained in:
@@ -6,18 +6,24 @@
|
||||
* 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.
|
||||
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
||||
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
||||
* the session read anywhere else through `GET /api/sessions/:id/image`. Anything
|
||||
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type DragEvent } 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 { dragSkill } from "../../lib/promptAttach";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
@@ -31,11 +37,17 @@ export interface PreviewScope {
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
// An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js).
|
||||
const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i;
|
||||
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
|
||||
const DROP_RE =
|
||||
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
/[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
/** A dropped text file shown at most this long. */
|
||||
const DROP_TEXT_MAX = 200_000;
|
||||
|
||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||
/** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */
|
||||
const slashed = (p: string) => p.replace(/\\/g, "/");
|
||||
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));
|
||||
@@ -54,37 +66,54 @@ type Content =
|
||||
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)) {
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
|
||||
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
|
||||
else if (DROP_TEXT_RE.test(path))
|
||||
show(
|
||||
fetch(src).then(async (r) => {
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const text = await r.text();
|
||||
return {
|
||||
kind: "text",
|
||||
text: text.slice(0, DROP_TEXT_MAX),
|
||||
truncated: text.length > DROP_TEXT_MAX,
|
||||
};
|
||||
})
|
||||
);
|
||||
else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
||||
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||
show(
|
||||
api.ccConfig
|
||||
.file(path)
|
||||
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
||||
);
|
||||
} else if (IMAGE_RE.test(file) && scope.sessionId) {
|
||||
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||
// only because this session's own Read named it.
|
||||
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
||||
} else setContent({ kind: "none" });
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = path.slice(base.length + 1);
|
||||
const rel = file.slice(base.length + 1);
|
||||
const target =
|
||||
base === scope.laneCwd.replace(/\/+$/, "")
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
if (IMAGE_RE.test(path)) {
|
||||
@@ -109,7 +138,11 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
const { t } = useTranslation("run");
|
||||
const content = useContent(path, scope);
|
||||
const loaded = useContent(path, scope);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const content: Content =
|
||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||
if (content.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
@@ -123,6 +156,7 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
<img
|
||||
src={content.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
@@ -206,13 +240,19 @@ export function FilePreview({
|
||||
export function FileListModal({
|
||||
title,
|
||||
paths,
|
||||
label = displayName,
|
||||
scope,
|
||||
onClose,
|
||||
onDragItem,
|
||||
}: {
|
||||
title: string;
|
||||
paths: string[];
|
||||
/** A row's text; the file name by default. */
|
||||
label?: (path: string) => string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
/** Rows drag into the prompt box; the dialog then closes, out of the way. */
|
||||
onDragItem?: (e: DragEvent, path: string) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState(paths[0] ?? "");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -241,12 +281,18 @@ export function FileListModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(p)}
|
||||
draggable={!!onDragItem}
|
||||
onDragStart={(e) => {
|
||||
onDragItem?.(e, p);
|
||||
// Not in dragstart itself: removing the source there cancels the drag.
|
||||
if (onDragItem) window.setTimeout(onClose, 0);
|
||||
}}
|
||||
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>
|
||||
<span className="min-w-0 truncate">{label(p)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -258,3 +304,58 @@ export function FileListModal({
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** The session's skills in the list dialog, each one's SKILL.md previewed. */
|
||||
export function SkillListModal({
|
||||
title,
|
||||
names,
|
||||
scope,
|
||||
onClose,
|
||||
draggable = false,
|
||||
}: {
|
||||
title: string;
|
||||
names: string[];
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
/** Rows drag into the prompt box as `/name`. */
|
||||
draggable?: boolean;
|
||||
}) {
|
||||
const [files, setFiles] = useState<Map<string, string> | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void Promise.all([
|
||||
api.ccConfig.skills(undefined, scope.cwd).catch(() => ({ items: [] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([sk, pl]) => {
|
||||
const found = new Map<string, string>();
|
||||
const add = (name: string, file: string | undefined) => {
|
||||
if (file && !found.has(name)) found.set(name, file);
|
||||
};
|
||||
for (const p of pl.plugins) {
|
||||
const items = p.contributes?.items;
|
||||
for (const it of [...(items?.skills ?? []), ...(items?.commands ?? [])])
|
||||
add(`${p.name}:${it.name}`, it.file);
|
||||
}
|
||||
// A personal skill wins over a project one of the same name, as in Claude Code.
|
||||
for (const s of sk.items) if (s.scope === "user") add(s.name, s.file);
|
||||
for (const s of sk.items) add(s.name, s.file);
|
||||
if (live) setFiles(found);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [scope.cwd]);
|
||||
if (!files) return null;
|
||||
// A built-in skill has no file: its name stands in and previews as nothing.
|
||||
const byPath = new Map(names.map((n) => [files.get(n) ?? n, n]));
|
||||
return (
|
||||
<FileListModal
|
||||
title={title}
|
||||
paths={[...byPath.keys()]}
|
||||
label={(p) => byPath.get(p) ?? displayName(p)}
|
||||
scope={scope}
|
||||
onClose={onClose}
|
||||
onDragItem={draggable ? (e, p) => dragSkill(e, byPath.get(p) ?? p) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
/**
|
||||
* @file ChatOutline.tsx
|
||||
* @description The right-hand panel of a Chat pane shown alone, laid out like
|
||||
* @description The right-hand panel of a Chat pane, laid out like
|
||||
* Claude Desktop's activity panel: the session's Progress (its
|
||||
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
|
||||
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
||||
* this session" — the files attached to its prompts, the files it read and the
|
||||
* memory files it used (a click opens the list with a preview), its skills
|
||||
* and MCP servers (a click opens the Claude config page). Read from
|
||||
* memory files it used and its skills (a click opens the list with a
|
||||
* preview — a skill's SKILL.md), and its MCP servers (a click opens the
|
||||
* Claude config page). Every row but MCP drags into the prompt box: a file
|
||||
* as an attachment, a group as all of its files, a lone skill as `/name`.
|
||||
* Read from
|
||||
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
|
||||
* uses for the collapsed button's count.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, type SessionOutline } from "../../lib/api";
|
||||
import { displayName, extOf } from "./ChatFilePreview";
|
||||
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const EMPTY: SessionOutline = {
|
||||
@@ -65,7 +69,21 @@ export function useSessionOutline(sessionId: string): SessionOutline {
|
||||
return outline;
|
||||
}
|
||||
|
||||
export type OutlineList = "uploads" | "files" | "memory";
|
||||
export type OutlineList = "uploads" | "files" | "memory" | "skills";
|
||||
|
||||
/** A listed file as a prompt attachment: an upload by its bare path, as the
|
||||
* composer attaches one; anything else as an `@` mention. */
|
||||
export function outlineAttachment(path: string, upload = false): Attachment {
|
||||
return { path: upload ? path : `@${path}`, name: displayName(path) };
|
||||
}
|
||||
|
||||
const dragFiles =
|
||||
(paths: string[], upload = false) =>
|
||||
(e: DragEvent) =>
|
||||
dragAttachments(
|
||||
e,
|
||||
paths.map((p) => outlineAttachment(p, upload))
|
||||
);
|
||||
|
||||
function fileIcon(path: string) {
|
||||
const ext = extOf(path).toLowerCase();
|
||||
@@ -136,12 +154,15 @@ function UsedRow({
|
||||
label,
|
||||
names,
|
||||
onClick,
|
||||
onDragStart,
|
||||
to,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
names: string[];
|
||||
onClick?: () => void;
|
||||
/** The row drags into the prompt box. */
|
||||
onDragStart?: (e: DragEvent) => void;
|
||||
to?: string;
|
||||
}) {
|
||||
const shown = names.slice(0, 2).join(", ");
|
||||
@@ -161,7 +182,14 @@ function UsedRow({
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" title={title} onClick={onClick} className={rowClass}>
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
draggable={!!onDragStart}
|
||||
onDragStart={onDragStart}
|
||||
className={rowClass}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
@@ -198,7 +226,9 @@ export function ChatOutline({
|
||||
<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"
|
||||
// Over the chat in a narrow pane (the container is PaneChat's root),
|
||||
// beside it once the pane has room, as wide as PaneChat's splitter says.
|
||||
className="absolute inset-y-3 right-3 z-30 flex w-[clamp(15rem,34%,20rem)] max-w-[calc(100%-1.5rem)] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 shadow-xl [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:max-w-none [@container(min-width:40rem)]:shadow-none"
|
||||
>
|
||||
{o.progress.length > 0 && (
|
||||
<Section title={t("chat.outline.progress")} action={close}>
|
||||
@@ -241,6 +271,8 @@ export function ChatOutline({
|
||||
title={p}
|
||||
aria-label={`${extOf(p)}: ${stem(p)}`}
|
||||
onClick={() => onOpenFile(p)}
|
||||
draggable
|
||||
onDragStart={dragFiles([p])}
|
||||
className={rowClass}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||
@@ -261,6 +293,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.uploads")}
|
||||
names={o.uploads.map(displayName)}
|
||||
onClick={() => onOpenList("uploads")}
|
||||
onDragStart={dragFiles(o.uploads, true)}
|
||||
/>
|
||||
)}
|
||||
{o.files.length > 0 && (
|
||||
@@ -269,6 +302,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.files")}
|
||||
names={o.files.map(displayName)}
|
||||
onClick={() => onOpenList("files")}
|
||||
onDragStart={dragFiles(o.files)}
|
||||
/>
|
||||
)}
|
||||
{o.memory.length > 0 && (
|
||||
@@ -277,6 +311,7 @@ export function ChatOutline({
|
||||
label={t("chat.outline.memory")}
|
||||
names={o.memory.map(stem)}
|
||||
onClick={() => onOpenList("memory")}
|
||||
onDragStart={dragFiles(o.memory)}
|
||||
/>
|
||||
)}
|
||||
{o.skills.length > 0 && (
|
||||
@@ -284,7 +319,9 @@ export function ChatOutline({
|
||||
icon={BookOpen}
|
||||
label={t("chat.skills")}
|
||||
names={o.skills}
|
||||
to="/settings/agents/claude-code?tab=skills"
|
||||
onClick={() => onOpenList("skills")}
|
||||
// One command per prompt: several are dragged one by one from the list.
|
||||
onDragStart={o.skills.length === 1 ? (e) => dragSkill(e, o.skills[0]!) : undefined}
|
||||
/>
|
||||
)}
|
||||
{o.mcp.length > 0 && (
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
* of another tab, a tab (or a pane header) joins that tab instead
|
||||
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`).
|
||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||
* and `workspaceOf` names each tab's workspace before its own name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
joinGroup,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
slotOfKey,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
@@ -77,7 +80,7 @@ export function runName(
|
||||
): string {
|
||||
if (r.tool === "shell") return terminal;
|
||||
const prompt = r.promptPreview?.trim().split("\n")[0];
|
||||
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
|
||||
return session?.name || prompt || `${toolLabel} #${slotOfKey(r.slot ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export type RunState = "awaiting" | "error" | "running";
|
||||
@@ -160,10 +163,13 @@ export function ConsoleTabs({
|
||||
actions,
|
||||
sessions,
|
||||
onRenamed,
|
||||
workspaceOf,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
/** Tabs from every workspace: the name of the workspace a run belongs to. */
|
||||
workspaceOf?: (r: RunHandle) => string;
|
||||
/** The tabs: groups of panes, the active one on screen. */
|
||||
groups: PaneGroups;
|
||||
/** Reorders the tabs or takes a pane out into a tab of its own. */
|
||||
@@ -325,7 +331,10 @@ export function ConsoleTabs({
|
||||
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
|
||||
const toolLabel = (r: RunHandle) =>
|
||||
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
|
||||
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
const label = (r: RunHandle) => {
|
||||
const name = runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
return workspaceOf ? `${workspaceOf(r)} · ${name}` : name;
|
||||
};
|
||||
const tabLabel = (tab: Tab) => tab.runs.map(label).join(" | ");
|
||||
// A combo's dot is its most urgent AI pane's; a shell has none.
|
||||
const tabState = (tab: Tab): RunState | null => {
|
||||
|
||||
@@ -51,8 +51,9 @@ 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 pane spans the grid's full width or height: its Chat view's right
|
||||
* panel starts open (collapsed in a quarter-sized pane). */
|
||||
panelOpen?: boolean;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||
@@ -71,7 +72,7 @@ export function LaneConsolePane({
|
||||
onNewSession,
|
||||
onRunExit,
|
||||
autoFocus,
|
||||
solo = false,
|
||||
panelOpen = false,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||
@@ -201,7 +202,7 @@ export function LaneConsolePane({
|
||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||
laneId={laneId}
|
||||
laneCwd={currentLane?.cwd || ""}
|
||||
outline={solo}
|
||||
panelOpen={panelOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,15 +13,23 @@
|
||||
* 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
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
* outputs, what the session used), open at first in a pane spanning the
|
||||
* grid's full width or height (`panelOpen`) and collapsed into a file button
|
||||
* with the output count in a quarter-sized one; it sits beside the
|
||||
* conversation when the pane is wide and over it when narrow (a CSS container
|
||||
* query on the pane, not the window), dragged wider or narrower by its
|
||||
* splitter there (a fraction of the pane, 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
|
||||
* of files or the used skills a list with a preview (`FileListModal`,
|
||||
* `SkillListModal`). Anything there drags into the prompt box. 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
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so switching to
|
||||
* the terminal and back, or a reload, leaves them as typed. 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
|
||||
@@ -29,16 +37,18 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
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 { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||
import { Select } from "../Select";
|
||||
import { Divider } from "./PaneGrid";
|
||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
@@ -53,7 +63,12 @@ const MODES: [PermissionMode, string][] = [
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const storedSide = () => {
|
||||
const f = Number(localStorage.getItem(SIDE_KEY));
|
||||
return f ? clampSide(f) : null;
|
||||
};
|
||||
|
||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||
@@ -109,7 +124,7 @@ export function PaneChat({
|
||||
cwd,
|
||||
laneId = null,
|
||||
laneCwd = "",
|
||||
outline = false,
|
||||
panelOpen = false,
|
||||
}: {
|
||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||
sessionId: string;
|
||||
@@ -117,27 +132,34 @@ export function PaneChat({
|
||||
/** 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;
|
||||
/** The right panel starts open: the pane spans the grid's width or height. */
|
||||
panelOpen?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||
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 [outlineOpen, setOutlineOpen] = useState(panelOpen);
|
||||
// A split or a close changes the pane's shape: its default comes back.
|
||||
useEffect(() => setOutlineOpen(panelOpen), [panelOpen]);
|
||||
const openOutline = (open: boolean) => {
|
||||
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
||||
setOutlineOpen(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
// The side panel's width as a fraction of the pane: the dragged one, else
|
||||
// a default per content (a preview wants more room than the panel).
|
||||
const [dragged, setDragged] = useState(storedSide);
|
||||
const side = dragged ?? (preview ? 0.45 : 0.3);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
@@ -186,7 +208,12 @@ export function PaneChat({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-prompt-drop
|
||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||
>
|
||||
<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
|
||||
@@ -196,19 +223,19 @@ export function PaneChat({
|
||||
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.
|
||||
// else this view's own preview.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
if (outline) setPreview(path);
|
||||
setPreview(path);
|
||||
}}
|
||||
/>
|
||||
{outline && !outlineOpen && !preview && (
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
className="absolute right-3 top-2 flex 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"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{activity.outputs.length}
|
||||
@@ -291,31 +318,57 @@ export function PaneChat({
|
||||
</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"
|
||||
{preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when the pane has room, over it when not.
|
||||
className="absolute inset-0 z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{list && (
|
||||
)
|
||||
)}
|
||||
{(preview || outlineOpen) && (
|
||||
// Only beside the chat: a panel over a narrow pane has no edge to drag.
|
||||
<div className="hidden [@container(min-width:40rem)]:contents">
|
||||
<Divider
|
||||
gridRef={rootRef}
|
||||
dir="row"
|
||||
at={1 - side}
|
||||
box={{ x: 0, y: 0, w: 1, h: 1 }}
|
||||
label={t("chat.outline.resize")}
|
||||
onRatio={(r) => {
|
||||
const f = clampSide(1 - r);
|
||||
localStorage.setItem(SIDE_KEY, String(f));
|
||||
setDragged(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{list === "skills" && (
|
||||
<SkillListModal
|
||||
title={t("chat.outline.skills")}
|
||||
names={activity.skills}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
draggable={claude}
|
||||
/>
|
||||
)}
|
||||
{list && list !== "skills" && (
|
||||
<FileListModal
|
||||
title={t(`chat.outline.${list}`)}
|
||||
paths={activity[list]}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
onDragItem={(e, p) => dragAttachments(e, [outlineAttachment(p, list === "uploads")])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import {
|
||||
EMPTY_GROUPS,
|
||||
type Edge,
|
||||
type GroupsScope,
|
||||
type PaneGroups,
|
||||
type PaneLayout,
|
||||
type Rect,
|
||||
@@ -82,10 +83,10 @@ export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
|
||||
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
|
||||
|
||||
/** A workspace's tabs (groups of split panes), loaded and saved per
|
||||
* workspace. An update that lands after a workspace switch never writes into
|
||||
* the new one. */
|
||||
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
|
||||
const load = (id: number | null) => ({
|
||||
* workspace (or once for every workspace, `all`). An update that lands after
|
||||
* a workspace switch never writes into the new one. */
|
||||
export function usePaneGroups(laneId: GroupsScope | null): [PaneGroups, GroupsUpdate] {
|
||||
const load = (id: GroupsScope | null) => ({
|
||||
laneId: id,
|
||||
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
|
||||
});
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* 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;
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* 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`)
|
||||
@@ -63,7 +64,9 @@ import { ImageLightbox } from "../ImageLightbox";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
droppedAttachment,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
|
||||
@@ -388,6 +391,45 @@ export function PromptEditor({
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||
|
||||
// A drop lands anywhere on the screen around the composer — the nearest
|
||||
// `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home),
|
||||
// else a `bare` editor's parent, the box every caller draws around it and
|
||||
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
||||
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
||||
// `/command`, a file is uploaded.
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
||||
dropRef.current = (e) => {
|
||||
const atts = droppedAttachments(e.dataTransfer);
|
||||
const skill = droppedSkill(e.dataTransfer);
|
||||
if (atts.length) {
|
||||
e.preventDefault();
|
||||
onImagesChange((prev) => addAttachments(prev, atts));
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
};
|
||||
useEffect(() => {
|
||||
const zone =
|
||||
rootRef.current?.closest<HTMLElement>("[data-prompt-drop]") ??
|
||||
(bare ? rootRef.current?.parentElement : rootRef.current);
|
||||
if (!zone) return;
|
||||
const over = (e: DragEvent) => {
|
||||
const types = e.dataTransfer?.types ?? [];
|
||||
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = "copy";
|
||||
};
|
||||
const drop = (e: DragEvent) => dropRef.current(e);
|
||||
zone.addEventListener("dragover", over);
|
||||
zone.addEventListener("drop", drop);
|
||||
return () => {
|
||||
zone.removeEventListener("dragover", over);
|
||||
zone.removeEventListener("drop", drop);
|
||||
};
|
||||
}, [bare]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
@@ -431,7 +473,7 @@ export function PromptEditor({
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={rootRef} className="relative">
|
||||
{images.length > 0 && (
|
||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||
{images.map((img) => (
|
||||
@@ -527,19 +569,6 @@ export function PromptEditor({
|
||||
}
|
||||
}
|
||||
}}
|
||||
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}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
* @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.
|
||||
* `~/.claude` through the config pages' read, a dropped text file through the
|
||||
* drop route, an image read outside the folder through the session's image
|
||||
* route, a Windows path like a POSIX one, anything else by path only; the
|
||||
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||
* each skill's SKILL.md by name.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
||||
import { FileListModal, FilePreview, SkillListModal, displayName } from "../ChatFilePreview";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -28,7 +31,30 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
sessions: { readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}` },
|
||||
ccConfig: {
|
||||
skills: vi.fn().mockResolvedValue({
|
||||
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
||||
}),
|
||||
plugins: vi.fn().mockResolvedValue({
|
||||
plugins: [
|
||||
{
|
||||
name: "agenthub",
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [
|
||||
{
|
||||
name: "file-headers",
|
||||
description: null,
|
||||
file: "/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md",
|
||||
},
|
||||
],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
file: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||
@@ -53,6 +79,37 @@ describe("FilePreview", () => {
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||
});
|
||||
|
||||
it("reads Windows paths the same way", async () => {
|
||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
cleanup();
|
||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||
render(<FilePreview path={tool} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(tool);
|
||||
expect(screen.getByText("b")).toBeTruthy(); // the name, not the whole path
|
||||
});
|
||||
|
||||
it("reads a dropped text file through the drop route", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("a long paste"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const drop = "C:\\Users\\k\\.claude\\agenthub\\drops\\0123456789abcdef-pasted-text.txt";
|
||||
render(<FilePreview path={drop} scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("a long paste")).toBeTruthy();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/run/drop/0123456789abcdef-pasted-text.txt");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows an image it read outside the folder through the session, its path if refused", () => {
|
||||
render(<FilePreview path="/h/.cache/shot.png" scope={scope} onClose={() => {}} />);
|
||||
const img = screen.getByAltText("shot.png") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("/img/s1?path=/h/.cache/shot.png");
|
||||
fireEvent.error(img);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -76,3 +133,28 @@ describe("FileListModal", () => {
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SkillListModal", () => {
|
||||
it("lists the skills by name and previews each one's SKILL.md", async () => {
|
||||
render(
|
||||
<SkillListModal
|
||||
title="Skills"
|
||||
names={["agenthub:file-headers", "xlsx", "simplify"]}
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
// A plugin's skill first, read through the config read.
|
||||
expect(await screen.findByRole("button", { name: "agenthub:file-headers" })).toBeTruthy();
|
||||
await screen.findByText("Launch notes");
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(
|
||||
"/h/.claude/plugins/cache/a/skills/file-headers/SKILL.md"
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "xlsx" }));
|
||||
await screen.findByText("Launch notes");
|
||||
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith("/h/.claude/skills/xlsx/SKILL.md");
|
||||
// A built-in skill has no file.
|
||||
fireEvent.click(screen.getByRole("button", { name: "simplify" }));
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @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.
|
||||
* uploads, files read, memory and skills open their list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -52,15 +52,47 @@ describe("ChatOutline", () => {
|
||||
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"
|
||||
);
|
||||
fireEvent.click(screen.getByTitle("Skills: xlsx"));
|
||||
expect(onOpenList).toHaveBeenCalledWith("skills");
|
||||
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drags its rows into the prompt box", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChatOutline
|
||||
outline={outline}
|
||||
onClose={() => {}}
|
||||
onOpenFile={() => {}}
|
||||
onOpenList={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const dragged = (el: HTMLElement) => {
|
||||
const data: Record<string, string> = {};
|
||||
fireEvent.dragStart(el, {
|
||||
dataTransfer: { setData: (k: string, v: string) => (data[k] = v) },
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const attached = (el: HTMLElement) =>
|
||||
JSON.parse(dragged(el)["application/x-agenthub-attach"] ?? "[]") as { path: string }[];
|
||||
expect(attached(screen.getByRole("button", { name: "XLSX: Progress report" }))).toEqual([
|
||||
{ path: "@/w/out/Progress report.xlsx", name: "Progress report.xlsx" },
|
||||
]);
|
||||
expect(attached(screen.getByTitle(/a\.ts, /)).map((a) => a.path)).toEqual([
|
||||
"@/w/src/a.ts",
|
||||
"@/w/src/b.ts",
|
||||
"@/w/src/c.ts",
|
||||
]);
|
||||
// An upload goes as the composer attaches one: its bare path.
|
||||
expect(attached(screen.getByTitle("Uploads: minutes.xlsx"))[0]!.path).toBe(outline.uploads[0]);
|
||||
expect(dragged(screen.getByTitle("Skills: xlsx"))["application/x-agenthub-skill"]).toBe("xlsx");
|
||||
});
|
||||
|
||||
it("hides progress and the used section when there is none", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -122,7 +122,11 @@ function renderChat(run: Partial<typeof RUN> = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.mocked(submitToRun).mockClear());
|
||||
beforeEach(() => {
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
// A prompt typed in one test is a saved draft in the next (`lib/chatDraft`).
|
||||
localStorage.removeItem("agenthub.chat.draft.s1");
|
||||
});
|
||||
|
||||
describe("PaneChat — the + menu", () => {
|
||||
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
||||
@@ -230,10 +234,9 @@ 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 />
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
@@ -248,7 +251,33 @@ describe("PaneChat — the activity panel", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
||||
});
|
||||
|
||||
it("starts open in a pane spanning the grid, and follows the pane when it is split", () => {
|
||||
const pane = (panelOpen: boolean) => (
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen={panelOpen} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const { rerender } = render(pane(true));
|
||||
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||
rerender(pane(false));
|
||||
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||
expect(screen.getByTestId("chat-outline-show")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resizes from its splitter and remembers the width", () => {
|
||||
localStorage.removeItem("agenthub.chat.panelWidth");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" panelOpen />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const splitter = screen.getByRole("separator", { name: "Resize side panel" });
|
||||
expect(splitter.getAttribute("aria-valuenow")).toBe("70");
|
||||
fireEvent.keyDown(splitter, { key: "ArrowLeft" });
|
||||
expect(localStorage.getItem("agenthub.chat.panelWidth")).toBe("0.35");
|
||||
expect(splitter.getAttribute("aria-valuenow")).toBe("65");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,7 +288,7 @@ describe("PaneChat — an edited file", () => {
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("edited-in"));
|
||||
@@ -272,3 +301,36 @@ describe("PaneChat — an edited file", () => {
|
||||
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — the unsent prompt", () => {
|
||||
it("keeps the half-typed prompt and its attachments when the chat unmounts (terminal view, reload)", async () => {
|
||||
const first = renderChat();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
||||
target: { value: "half typed" },
|
||||
});
|
||||
// A dropped attachment, as a Files row or an "Edited N files" row brings it.
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: {
|
||||
types: ["application/x-agenthub-attach"],
|
||||
getData: (t: string) =>
|
||||
t === "application/x-agenthub-attach"
|
||||
? JSON.stringify({ path: "@src/a.ts", name: "a.ts" })
|
||||
: "",
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
screen.getByPlaceholderText("Write a message…").dispatchEvent(drop);
|
||||
});
|
||||
expect(await screen.findByText("a.ts")).toBeInTheDocument();
|
||||
first.unmount();
|
||||
|
||||
renderChat();
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import {
|
||||
@@ -28,7 +28,13 @@ import {
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||
import {
|
||||
ATTACH_MIME,
|
||||
dragAttachments,
|
||||
dragProjectPath,
|
||||
dragSkill,
|
||||
recentUploads,
|
||||
} from "../../../lib/promptAttach";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -348,6 +354,132 @@ describe("PromptEditor — newlines and images", () => {
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||
});
|
||||
|
||||
it("takes a drop anywhere on a bare editor's box; an extra folder's row comes absolute", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<PromptEditor
|
||||
bare
|
||||
slash={false}
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<div data-testid="toolbar" />
|
||||
</div>
|
||||
<section data-drag-root="/Users/tester/extra/">
|
||||
<span data-testid="row" />
|
||||
</section>
|
||||
<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: [],
|
||||
};
|
||||
dragProjectPath(
|
||||
{ dataTransfer, currentTarget: screen.getByTestId("row") } as unknown as React.DragEvent,
|
||||
"notes.md"
|
||||
);
|
||||
expect(data["text/plain"]).toBe("/Users/tester/extra/notes.md");
|
||||
const over = createEvent.dragOver(screen.getByTestId("toolbar"), { dataTransfer });
|
||||
fireEvent(screen.getByTestId("toolbar"), over);
|
||||
expect(over.defaultPrevented).toBe(true);
|
||||
fireEvent.drop(screen.getByTestId("toolbar"), { dataTransfer });
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/Users/tester/extra/notes.md");
|
||||
});
|
||||
|
||||
it("takes a drop anywhere inside its data-prompt-drop screen, not only on the box", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<div data-prompt-drop>
|
||||
<div data-testid="transcript" />
|
||||
<PromptEditor
|
||||
value="see"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("see", images)}</output>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const dataTransfer = {
|
||||
getData: (k: string) =>
|
||||
k === ATTACH_MIME ? JSON.stringify([{ path: "@/w/a.ts", name: "a.ts" }]) : "",
|
||||
types: [ATTACH_MIME],
|
||||
files: [],
|
||||
};
|
||||
const over = createEvent.dragOver(screen.getByTestId("transcript"), { dataTransfer });
|
||||
fireEvent(screen.getByTestId("transcript"), over);
|
||||
expect(over.defaultPrevented).toBe(true);
|
||||
fireEvent.drop(screen.getByTestId("transcript"), { dataTransfer });
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see @/w/a.ts");
|
||||
});
|
||||
|
||||
it("takes an activity panel's drag: a group of files as chips, a skill as the /command", () => {
|
||||
function Harness() {
|
||||
const [value, setValue] = useState("check it");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
slash={false}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages(value, images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const drag = (start: (e: React.DragEvent) => void) => {
|
||||
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: [],
|
||||
};
|
||||
start({ dataTransfer } as unknown as React.DragEvent);
|
||||
fireEvent.drop(box(), { dataTransfer });
|
||||
};
|
||||
drag((e) =>
|
||||
dragAttachments(e, [
|
||||
{ path: "@/a/x.md", name: "x.md" },
|
||||
{ path: "/d/drops/0123456789abcdef.png", name: "y.png" },
|
||||
])
|
||||
);
|
||||
drag((e) => dragSkill(e, "ponytail:ponytail-help"));
|
||||
expect(screen.getByTestId("sent").textContent).toBe(
|
||||
"/ponytail:ponytail-help check it @/a/x.md /d/drops/0123456789abcdef.png"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a text paste to the textarea", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), {
|
||||
|
||||
Reference in New Issue
Block a user