feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+73 -3
View File
@@ -25,7 +25,10 @@
* 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
* one, up to four panes). A pane drag held over another tab for `SPRING_MS` shows that tab
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
* any of its panes' edges; near either end of an overflowing bar a drag
* scrolls it. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name. Settings
@@ -57,7 +60,14 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import {
PANE_DRAG_TYPE,
RunIcon,
paneDragSlot,
setDragChip,
startPaneDrag,
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
@@ -155,6 +165,11 @@ interface Tab {
const URGENCY: RunState[] = ["awaiting", "error", "running"];
/** How long a pane drag rests on a tab before that tab is shown. */
export const SPRING_MS = 600;
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
const EDGE_SCROLL_PX = 40;
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
@@ -256,8 +271,59 @@ export function ConsoleTabs({
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;
};
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
const stopSpring = () => {
if (spring.current) clearTimeout(spring.current.timer);
spring.current = null;
};
useEffect(() => stopSpring, []);
const hoveredIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left && x <= b.right;
});
};
const springAt = (e: DragEvent) => {
const slot = paneDragSlot();
const i = hoveredIndexAt(e.clientX);
const tab = tabs[i];
// Only a pane drag, onto a tab not on screen that does not hold it.
const target =
slot !== null &&
tab &&
!settings?.active &&
tab.index !== groups.active &&
!tab.slots.includes(slot)
? i
: null;
if ((spring.current?.at ?? null) === target) return;
stopSpring();
if (target === null || !tab) return;
const index = tab.index;
spring.current = {
at: target,
timer: setTimeout(() => {
spring.current = null;
if (paneDragSlot() === null) return;
onGroupsChange((g) =>
g.active === index || !g.groups[index] ? g : { ...g, active: index }
);
}, SPRING_MS),
};
};
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
const edgeScroll = (x: number) => {
const list = listRef.current;
if (!list || list.scrollWidth <= list.clientWidth) return;
const b = list.getBoundingClientRect();
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
};
const endDrag = () => {
dragged.current = [];
stopSpring();
setDropAt(null);
setJoinAt(null);
};
@@ -266,6 +332,8 @@ export function ConsoleTabs({
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
edgeScroll(e.clientX);
springAt(e);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
@@ -273,6 +341,7 @@ export function ConsoleTabs({
},
onDragLeave: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
stopSpring();
setDropAt(null);
setJoinAt(null);
},
@@ -504,10 +573,11 @@ export function ConsoleTabs({
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
if (single) startPaneDrag(e, single.slot ?? 0, label(single));
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
setDragChip(e, tabLabel(tab));
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
+62 -9
View File
@@ -14,7 +14,13 @@
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). While a
* drag is on, the pane it carries is dimmed, the browser's ghost is a small
* chip with its name (`setDragChip`) instead of a whole header, and the drop
* preview names what the drop will do (`dropAction`). The dragged pane's
* header stays mounted even once the tab bar has switched tabs under the
* drag (`ConsoleTabs` spring-loads a hovered tab) — removing the drag's
* source node would lose its `dragend`. A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
@@ -69,11 +75,31 @@ export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
let dragSlot: number | null = null;
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
export function startPaneDrag(e: React.DragEvent, slot: number): void {
/** The slot a pane drag carries, or null (no pane drag is on). */
export const paneDragSlot = (): number | null => dragSlot;
/** The drag's ghost: a small chip naming what moves, not a snapshot of a
* whole pane header or tab. Drawn off screen for the browser to copy. */
export function setDragChip(e: React.DragEvent, label: string): void {
const dt = e.dataTransfer;
if (!label || typeof dt?.setDragImage !== "function") return;
const chip = document.createElement("div");
chip.textContent = label;
chip.className =
"fixed -top-96 left-0 max-w-[16rem] truncate rounded-md border border-accent/60 bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg-primary shadow-lg";
document.body.appendChild(chip);
dt.setDragImage(chip, 12, 12);
// The browser copies it during dragstart; it may go right after.
window.setTimeout(() => chip.remove(), 0);
}
/** Starts dragging a pane or a tab (`slot`) onto the grid; `label` names it
* on the drag's chip. */
export function startPaneDrag(e: React.DragEvent, slot: number, label?: string): void {
dragSlot = slot;
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
if (label) setDragChip(e, label);
}
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
@@ -140,6 +166,14 @@ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** What a drop does, as an i18n key (`lanes` namespace): the center swaps a
* pane already shown, or shows one from another tab in the target's place
* (the target gets a tab of its own); an edge places it on that side. */
export function dropAction(l: PaneLayout, src: number, zone: Zone): string {
if (zone !== "center") return `panes.drop.${zone}`;
return leaves(l.root).includes(src) ? "panes.drop.swap" : "panes.drop.replace";
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
* an outside click or Escape. */
function PaneMenu({
@@ -398,6 +432,8 @@ export function PaneGrid({
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
// The slot a pane drag carries (a header here, or a tab from the bar).
const [dragging, setDragging] = useState<number | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -416,6 +452,7 @@ export function PaneGrid({
const end = () => {
dragSlot = null;
setDrop(null);
setDragging(null);
};
window.addEventListener("dragend", end);
window.addEventListener("drop", end);
@@ -479,6 +516,7 @@ export function PaneGrid({
if (!isPaneDrag(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragSlot !== dragging) setDragging(dragSlot);
const z = zoneAt(e);
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
@@ -495,6 +533,7 @@ export function PaneGrid({
const z = zoneAt(e);
dragSlot = null;
setDrop(null);
setDragging(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
@@ -515,6 +554,7 @@ export function PaneGrid({
const r = rectOf.get(slot);
const run = runOf(slot);
const focused = layout.focus === slot;
const moving = dragging === slot;
return (
<div
key={key}
@@ -522,17 +562,23 @@ export function PaneGrid({
data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
data-menu-bounds
className={r ? "absolute flex flex-col" : "hidden"}
data-dragging={moving ? "true" : undefined}
className={r ? `absolute flex flex-col${moving ? " opacity-40" : ""}` : "hidden"}
style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)}
// Tabbing into a pane focuses it as a click does.
onFocusCapture={() => r && focus(slot)}
>
{multi && r && (
{/* The dragged pane's header stays while the drag lasts, even
hidden in another tab: it is the drag's source node. */}
{((multi && r) || moving) && (
<div
data-testid={`pane-header-${slot}`}
draggable
onDragStart={(e) => startPaneDrag(e, slot)}
onDragStart={(e) => {
startPaneDrag(e, slot, run ? label(run) : t("panes.empty"));
setDragging(slot);
}}
onContextMenu={(e) => paneCtx.open(e, slot)}
title={t("panes.drag")}
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
@@ -667,12 +713,19 @@ export function PaneGrid({
}
/>
))}
{preview && (
{preview && drop && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
className="pointer-events-none absolute z-20 flex items-center justify-center rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(preview)}
/>
>
<span
data-testid="pane-drop-label"
className="max-w-[90%] truncate rounded-md bg-accent-solid px-2.5 py-1 text-xs font-medium text-white shadow-lg"
>
{t(dropAction(layout, drop.src, drop.zone))}
</span>
</div>
)}
</div>
);
@@ -0,0 +1,126 @@
/**
* @file ConsoleTabs.test.tsx
* @description The tab bar's spring-loaded tabs: a pane drag resting on
* another tab shows it after `SPRING_MS`, so the pane can be dropped on that
* tab's panes; moving off before then, or resting on the tab already holding
* the pane, shows nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, split, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs, SPRING_MS } from "../ConsoleTabs";
import { PANE_DRAG_TYPE, paneDragSlot } from "../PaneGrid";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const run = (slot: number): RunHandle => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
tool: "shell",
});
// Tab 0: pane 0 alone (on screen); tab 1: panes 1 and 2.
const GROUPS: PaneGroups = {
groups: [
{ root: pane(0), focus: 0 },
{ root: split("row", pane(1), pane(2)), focus: 1 },
],
active: 0,
};
function setup() {
let groups = GROUPS;
const onGroupsChange = vi.fn((fn: (g: PaneGroups) => PaneGroups) => {
groups = fn(groups);
});
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[run(0), run(1), run(2)]}
groups={GROUPS}
onGroupsChange={onGroupsChange}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
// jsdom lays nothing out: tab i spans x 100i … 100i+100.
for (const el of document.querySelectorAll<HTMLElement>("[data-tab-index]")) {
const i = Number(el.dataset.tabIndex);
el.getBoundingClientRect = () =>
({
left: i * 100,
right: i * 100 + 100,
width: 100,
top: 0,
bottom: 40,
height: 40,
}) as DOMRect;
}
return { onGroupsChange, groups: () => groups };
}
const dataTransfer = () => ({
types: [PANE_DRAG_TYPE],
setData: vi.fn(),
getData: () => "",
effectAllowed: "",
dropEffect: "",
});
function drag(type: "dragStart" | "dragOver", el: Element, clientX = 0) {
const ev = createEvent[type](el, { dataTransfer: dataTransfer() });
Object.defineProperty(ev, "clientX", { value: clientX });
fireEvent(el, ev);
}
const tabEl = (key: string) => screen.getByTestId(`console-tab-${key}`).parentElement as Element;
describe("ConsoleTabs spring-loaded tabs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("a pane drag resting on another tab shows that tab", () => {
const { onGroupsChange, groups } = setup();
drag("dragStart", tabEl("0"));
expect(paneDragSlot()).toBe(0);
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS - 1));
expect(onGroupsChange).not.toHaveBeenCalled();
act(() => void vi.advanceTimersByTime(1));
expect(groups().active).toBe(1);
});
it("moving off the tab first, or resting on the pane's own tab, shows nothing", () => {
const { onGroupsChange } = setup();
drag("dragStart", tabEl("0"));
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS / 2));
drag("dragOver", tabEl("0"), 50);
act(() => void vi.advanceTimersByTime(SPRING_MS * 2));
expect(onGroupsChange).not.toHaveBeenCalled();
});
});
@@ -2,12 +2,13 @@
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* current box, and nothing for a drop that changes nothing; and the label
* naming what a drop does (`dropAction`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { dropAction, dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
@@ -34,3 +35,15 @@ describe("dropPreview", () => {
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
describe("dropAction", () => {
it("an edge places the pane on that side", () => {
expect(dropAction(AB, 0, "left")).toBe("panes.drop.left");
expect(dropAction(AB, 2, "bottom")).toBe("panes.drop.bottom");
});
it("the center swaps a shown pane, or shows one from another tab in its place", () => {
expect(dropAction(AB, 0, "center")).toBe("panes.drop.swap");
expect(dropAction(AB, 2, "center")).toBe("panes.drop.replace");
});
});