feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins

Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+65 -10
View File
@@ -22,7 +22,10 @@
* a `+` item) brings it into the tab on screen, split to the right, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. A right-click on a tab
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -51,7 +54,15 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import {
type PaneGroups,
MAX_PANES,
detachSlot,
groupOf,
joinGroup,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -201,6 +212,10 @@ export function ConsoleTabs({
// point among the drawn tabs, from the pointer against their middles.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
// The drawn tab a drop would join (the pointer on its middle half), and the
// slots a drag from this bar carries — unknown for a pane header (one slot).
const [joinAt, setJoinAt] = useState<number | null>(null);
const dragged = useRef<number[]>([]);
const isBarDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types;
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
@@ -215,31 +230,70 @@ export function ConsoleTabs({
x
);
};
const joinIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
const i = els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left + b.width / 4 && x <= b.right - b.width / 4;
});
const tab = tabs[i];
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
};
const endDrag = () => {
dragged.current = [];
setDropAt(null);
setJoinAt(null);
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientX);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setDropAt(null);
setJoinAt(null);
},
onDragEnd: endDrag,
onDrop: (e: DragEvent) => {
const join = joinIndexAt(e.clientX);
endDrag();
if (!isBarDrag(e)) return;
e.preventDefault();
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
const target = join === null ? undefined : tabs[join];
// A pane header dropped on its OWN tab is a detach, not a join: a pane
// drag carries no slots during dragover, so joinIndexAt cannot skip it.
const ownTab =
tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index;
if (target && !ownTab) {
onGroupsChange((g) =>
joinGroup(
g,
tab !== ""
? leaves(g.groups[Number(tab)]?.root ?? null)
: raw !== ""
? [Number(raw)]
: [],
target.index
)
);
return;
}
// The drawn position, as an index into the groups.
const drawn = dropIndexAt(e.clientX);
const at = tabs[drawn]?.index ?? groups.groups.length;
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
if (tab !== "") {
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
return;
}
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
if (raw === "") return;
const slot = Number(raw);
onGroupsChange((g) => {
@@ -426,6 +480,7 @@ export function ConsoleTabs({
data-slots={tab.key}
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
@@ -439,7 +494,7 @@ export function ConsoleTabs({
active
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
} ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`}
>
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
<span
+38 -26
View File
@@ -6,8 +6,8 @@
* box (`WorkspaceHome.tsx`):
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and a pasted or dropped image attached by its saved path
* (`POST /api/run/drop-image`);
* newlines, and pasted or dropped files (images as thumbnails, any other
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Proxy's models), plus Custom…;
@@ -25,6 +25,7 @@ import {
Check,
ChevronDown,
ChevronRight,
FileText,
FolderOpen,
GitBranch,
Home,
@@ -153,8 +154,8 @@ interface PromptEditorProps {
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string;
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
* them with the text through `promptWithImages`. */
/** Files pasted or dropped so far (images shown as thumbnails); the caller
* sends them with the text through `promptWithImages`. */
images: PromptImage[];
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */
@@ -164,11 +165,12 @@ interface PromptEditorProps {
onBusyChange?: (busy: boolean) => void;
}
/** An attached image: its path on the server's machine and a local preview. */
export type PromptImage = { path: string; url: string; name: string };
/** An attached file: its path on the server's machine, and a local preview
* `url` when it is an image. */
export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each image's saved path — which is how
* the CLI is handed an image. */
/** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
@@ -309,9 +311,9 @@ export function PromptEditor({
});
};
// A pasted or dropped image is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail; its path is
// added to the prompt only when it is sent (promptWithImages).
// A pasted or dropped file is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
// its path is added to the prompt only when it is sent (promptWithImages).
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
@@ -320,15 +322,15 @@ export function PromptEditor({
busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => {
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropImage(f)))
Promise.all(picked.map((f) => api.run.dropFile(f)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
path: saved[i]!.path,
url: URL.createObjectURL(f),
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
onImagesChange((prev) => [...prev, ...added]);
@@ -337,7 +339,7 @@ export function PromptEditor({
return true;
};
const removeImage = (img: PromptImage) => {
URL.revokeObjectURL(img.url);
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
};
useEffect(() => {
@@ -394,17 +396,27 @@ export function PromptEditor({
{images.length > 0 && (
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
{images.map((img) => (
<div key={img.url} className="group relative">
<button
type="button"
aria-label={img.name}
title={img.name}
onClick={() => setViewing(img)}
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
</button>
<div key={img.path} className="group relative">
{img.url ? (
<button
type="button"
aria-label={img.name}
title={img.name}
onClick={() => setViewing(img)}
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
</button>
) : (
<span
title={img.path}
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{img.name}</span>
</span>
)}
<button
type="button"
aria-label={t("drop.remove")}
+10 -11
View File
@@ -14,9 +14,10 @@
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
* and its PATH pasted into the pane — what a local terminal does on a drop,
* and how `claude` attaches an image. The pane also registers with
* Files (images or any other) dropped on it are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -46,7 +47,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
type DropState = "hint" | "uploading" | "failed";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -183,13 +184,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return flash("imagesOnly");
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees one pasted path rather than typed keystrokes.
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
@@ -650,9 +651,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "imagesOnly"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
ccConfig: {
commands: r({
items: [
@@ -195,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
const img = new File(["x"], "shot.png", { type: "image/png" });
fireEvent.paste(box(), { clipboardData: { files: [img] } });
const thumb = await screen.findByRole("button", { name: "shot.png" });
expect(api.run.dropImage).toHaveBeenCalledWith(img);
expect(api.run.dropFile).toHaveBeenCalledWith(img);
// The path is not typed into the prompt; it goes with it on send.
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
@@ -204,16 +204,43 @@ describe("PromptEditor — newlines and images", () => {
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
fireEvent.click(screen.getByRole("button", { name: "Remove attachment" }));
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
expect(screen.getByTestId("sent").textContent).toBe("see");
});
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" });
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
expect(await screen.findByText("spec.pdf")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } });
expect(api.run.dropImage).not.toHaveBeenCalled();
expect(api.run.dropFile).not.toHaveBeenCalled();
});
});
@@ -21,9 +21,9 @@ const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropImageMock = vi.fn();
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -421,29 +421,36 @@ describe("TerminalView", () => {
});
it("pastes the server-side path of a dropped image into the pane", async () => {
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
const target = screen.getByTestId("terminal-view");
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
expect(dropImageMock).toHaveBeenCalledWith(img);
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img);
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("uploads nothing for a dropped non-image and says why", async () => {
dropImageMock.mockReset();
it("pastes every path of several dropped files, images or not", async () => {
dropFileMock
.mockReset()
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
const img = new File(["x"], "b.png", { type: "image/png" });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [txt] },
dataTransfer: { types: ["Files"], files: [txt, img] },
});
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
expect(dropImageMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith(
"/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png "
)
);
});
});