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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user