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:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
@@ -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
);
}