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
+242 -106
View File
@@ -7,13 +7,30 @@
* an admin — the account changes: add a user, change a role, disable/enable,
* reset a password, give a workspace. Every change asks for the admin's OWN
* password again (the server refuses without it); an analyst only reads.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
import {
AlertCircle,
DollarSign,
Eye,
FolderInput,
FolderOpen,
KeyRound,
Lock,
LockOpen,
MessagesSquare,
RefreshCw,
ScrollText,
Shield,
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import type { Lane } from "../lib/types";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
@@ -22,7 +39,8 @@ import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin";
import { useAuth } from "../components/AuthGate";
import { ConfirmModal } from "../components/ConfirmModal";
import { SettingsSection } from "../components/settings/SettingsRow";
import { StatCard } from "../components/StatCard";
import { TableRowSkeleton } from "../components/Skeleton";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
@@ -67,12 +85,22 @@ export function Users() {
// Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "developer") return <Navigate to="/" replace />;
const sum = (pick: (u: ServerUserUsage) => number) =>
(users ?? []).reduce((n, u) => n + pick(u), 0);
const active = (users ?? []).filter((u) => !u.disabled).length;
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="animate-fade-in space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
<UsersIcon className="w-4.5 h-4.5 text-accent" />
</div>
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
@@ -97,115 +125,206 @@ export function Users() {
</div>
</div>
<SettingsSection title={t("title")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("name")}</th>
<th className="py-2 pr-3 font-medium">{t("role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => {
const self = u.id === me.id;
return (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
</td>
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
{t(`roles.${u.role}`)}
</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
{isAdmin && (
<td className="py-1 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingsSection>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("title")}
value={users?.length ?? 0}
icon={UsersIcon}
trend={users ? t("activeCount", { n: active }) : undefined}
loading={!users && !error}
/>
<StatCard
label={t("workspaces")}
value={sum((u) => u.workspaces)}
icon={FolderOpen}
loading={!users && !error}
/>
<StatCard
label={t("sessions")}
value={sum((u) => u.sessions)}
icon={MessagesSquare}
loading={!users && !error}
/>
<StatCard
label={t("cost30d")}
value={fmtCost(sum((u) => u.last30d.cost))}
icon={DollarSign}
trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined}
raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })}
loading={!users && !error}
/>
</div>
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
{!isAdmin && (
<p className="flex items-center gap-1.5 text-xs text-fg-muted">
<Eye className="w-3.5 h-3.5" /> {t("readOnly")}
</p>
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[760px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{!users &&
!error &&
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 7 : 6}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
/>
))}
{users?.map((u) => {
const self = u.id === me.id;
return (
<tr
key={u.id}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
<div className="flex items-center gap-3 min-w-0">
<span
aria-hidden
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
>
{u.username.slice(0, 2)}
</span>
<span className="truncate text-sm font-medium text-fg-primary">
{u.username}
</span>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
</div>
</td>
<td className="px-5 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.workspaces}
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction
label={t("changeRole")}
disabled={self}
onClick={() => setDialog({ kind: "role", user: u })}
>
<Shield className="h-4 w-4" />
</RowAction>
<RowAction
label={t("resetPassword")}
onClick={() => setDialog({ kind: "password", user: u })}
>
<KeyRound className="h-4 w-4" />
</RowAction>
<RowAction
label={t("assignWorkspace")}
onClick={() => setDialog({ kind: "workspace", user: u })}
>
<FolderInput className="h-4 w-4" />
</RowAction>
<RowAction
label={u.disabled ? t("enable") : t("disable")}
disabled={self}
onClick={() =>
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
}
>
{u.disabled ? (
<LockOpen className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</RowAction>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
<section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" />
<div>
<h2 className="text-sm font-semibold text-fg-primary">{t("auditTitle")}</h2>
<p className="text-xs text-fg-muted">{t("auditDescription")}</p>
</div>
</div>
{audit?.length === 0 && (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("auditEmpty")}</p>
)}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
<ul
className="divide-y divide-border text-xs max-h-[28rem] overflow-y-auto"
data-testid="audit-list"
>
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
<li
key={a.id}
className="flex items-center gap-3 px-5 py-2 hover:bg-surface-4/40 transition-colors"
>
<span className="w-36 flex-shrink-0 tabular-nums text-fg-muted">
{formatDateTime(a.at)}
</span>
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
<span className="font-medium text-fg-primary">{a.reader}</span>
<span className="mx-1.5 text-fg-muted">→</span>
{a.owner}
</span>
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
{a.method}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted" title={a.path}>
{a.path}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
</section>
{dialog && (
<AccountDialog
@@ -223,6 +342,23 @@ export function Users() {
);
}
const ROLE_BADGE: Record<Role, string> = {
admin: "bg-accent/15 text-accent border-accent/20",
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
developer: "bg-surface-4 text-fg-secondary border-border",
};
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
return (
<td className="px-5 py-3 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
</td>
);
}
function RowAction({
label,
disabled,