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