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:
@@ -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)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user