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
@@ -6,7 +6,8 @@
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a
* whole markdown file can also be read rendered (Preview)
* whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
* Workspace right panel's Files tab; the other four are its
@@ -131,6 +132,33 @@ function Delta({
);
}
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
function ImageView({ src, path }: { src: string; path: string }) {
const { t } = useTranslation("lanes");
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
return (
<div
data-testid="lane-file-image"
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
>
{failed ? (
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
) : (
<img
src={src}
alt={path}
onError={() => setFailed(true)}
className="max-h-full max-w-full object-contain"
/>
)}
</div>
);
}
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
* key naming it, so the reader is never left with a button that does nothing. */
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
@@ -482,7 +510,7 @@ export function LaneChangesPanel({
(text: string) => {
const runId = pasteToLane(laneId, text);
if (runId) {
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0);
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
}
return runId !== null;
@@ -495,6 +523,8 @@ export function LaneChangesPanel({
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
// An image opens as a picture, never as text or a binary diff.
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
@@ -1683,7 +1713,7 @@ export function LaneChangesPanel({
}}
/>
) : mode === "file" ? (
current || (whole && text) ? (
current || (whole && text) || openImage ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1 flex items-baseline gap-1.5">
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
@@ -1714,6 +1744,7 @@ export function LaneChangesPanel({
only the diff, which has no whole text to click, keeps
an Edit button. */}
{range !== "commit" &&
!openImage &&
(draft === null && whole && text && !text.sha ? (
<span
data-testid="lane-file-noedit"
@@ -1766,7 +1797,16 @@ export function LaneChangesPanel({
</>
))}
</div>
{whole && draft !== null ? (
{openImage ? (
<ImageView
path={openImage}
src={api.lanes.gitRawUrl(
target,
openImage,
range === "commit" && sha ? sha : "work"
)}
/>
) : whole && draft !== null ? (
<textarea
ref={editorRef}
data-testid="lane-file-editor"