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
+302
View File
@@ -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>
);
}