feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)

- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
  /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
  REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
  group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
  parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
  picker on the server's desktop — loopback only and no forwarding header
  (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
  killed when the request closes, a folder field falls back to the in-app
  browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
  counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
  (supersededBy), doctor offers fixes behind two confirmations; plugin-open
  treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
  /api/workspaces mount.
This commit is contained in:
2026-10-06 11:31:26 +07:00
parent c067abc68f
commit 6c605849d8
104 changed files with 3478 additions and 1396 deletions
+19 -14
View File
@@ -8,8 +8,10 @@
* remount it and drop what it drew. With more than one pane each gets a slim
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or HIDE the pane (its session keeps running as a tab — so
* its icon is not the tab's stop X). A header names the pane's session and
* right or down, or take the pane OUT of this tab (its session keeps running
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
* 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
* right-click on a header offers the swap, a split with a new terminal and
@@ -33,8 +35,9 @@ import {
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
EMPTY_LAYOUT,
EMPTY_GROUPS,
type Edge,
type PaneGroups,
type PaneLayout,
type Rect,
type SplitDir,
@@ -42,9 +45,9 @@ import {
closeInLayout,
layoutRects,
leaves,
loadLayout,
loadGroups,
moveInLayout,
saveLayout,
saveGroups,
setRatio,
swapInLayout,
} from "../../lib/paneLayout";
@@ -72,13 +75,15 @@ export function RunIcon({ run, label }: { run: RunHandle; label: string }) {
}
export type LayoutUpdate = (fn: (l: PaneLayout) => PaneLayout) => void;
export type GroupsUpdate = (fn: (g: PaneGroups) => PaneGroups) => void;
/** A workspace's pane layout, loaded and saved per workspace. A layout update
* that lands after a workspace switch never writes into the new one. */
export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate] {
/** A workspace's tabs (groups of split panes), loaded and saved per
* workspace. An update that lands after a workspace switch never writes into
* the new one. */
export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate] {
const load = (id: number | null) => ({
laneId: id,
layout: id === null ? EMPTY_LAYOUT : loadLayout(id),
groups: id === null ? EMPTY_GROUPS : loadGroups(id),
});
const [state, setState] = useState(() => load(laneId));
let current = state;
@@ -87,18 +92,18 @@ export function usePaneLayout(laneId: number | null): [PaneLayout, LayoutUpdate]
setState(current);
}
useEffect(() => {
if (state.laneId !== null) saveLayout(state.laneId, state.layout);
if (state.laneId !== null) saveGroups(state.laneId, state.groups);
}, [state]);
const update = useCallback<LayoutUpdate>(
const update = useCallback<GroupsUpdate>(
(fn) =>
setState((s) => {
if (s.laneId !== laneId) return s;
const layout = fn(s.layout);
return layout === s.layout ? s : { ...s, layout };
const groups = fn(s.groups);
return groups === s.groups ? s : { ...s, groups };
}),
[laneId]
);
return [current.layout, update];
return [current.groups, update];
}
type Zone = Edge | "center";