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:
2026-10-10 15:10:13 +07:00
parent f0dc1445fc
commit f6f1bf4a76
61 changed files with 2621 additions and 731 deletions
+123 -22
View File
@@ -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}
/>
);
}