feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)

Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
+166 -105
View File
@@ -15,8 +15,9 @@
* a tab click replaces the history entry (Back leaves the session view, not
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
* splits the focused one right with a new terminal, Alt+W hides it. The
* Changes panel resizes by its button's presets or by dragging (or arrow keys
* on) its splitter; an unsaved edit in it is asked about before the panel
* Changes panel has Full screen (Restore at full screen) and Collapse
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
* closes, switches tab, or moves to another workspace or session (it stays on
* the old one until saved or discarded). Errors are toasts.
*
@@ -54,17 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Plus,
Columns2,
Layers,
PanelLeftClose,
PanelRightClose,
Maximize2,
ChartColumn,
} from "lucide-react";
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
import { api } from "../lib/api";
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
import type {
CwdSuggestion,
LaneSource,
LaneSourcesView,
RunHandle,
RunListResponse,
} from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
@@ -109,6 +108,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
@@ -118,39 +118,37 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the last width the user dragged the panel to — what
* it reopens at after the step control closes it. */
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
const SELECTED_KEY = "agenthub.workspace.selected";
/** The three widths the Changes panel cycles through, then closed. Reading a
* diff and watching a console want opposite amounts of room, and which one is
* "work" changes several times an hour — hence ONE control that steps
* 30/70 → 50/50 → full → closed, rather than a width button beside a
* collapse button that both live in the panel's tightest corner. */
const CHANGES_SIZES = ["compact", "half", "full"] as const;
/** The Changes panel's two preset widths. Its header has two controls, like a
* window's: Full screen (Restore once at full screen, back to the last
* dragged width) and Collapse (to the rail). */
const CHANGES_SIZES = ["compact", "full"] as const;
type ChangesPreset = (typeof CHANGES_SIZES)[number];
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
type ChangesSize = ChangesPreset | number;
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
compact: "w-[30%]",
half: "w-1/2",
compact: "w-[25%]",
// The console beside it is HIDDEN, not unmounted: a live run's terminal
// and a half-typed prompt must survive a round trip through full width.
full: "w-full",
};
/** The icon the one resize control shows AT each size: what the next click
* gives you — an even split, the full width, then closed. */
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
compact: Columns2,
half: Maximize2,
full: PanelRightClose,
};
/** The panel's share of the row, for the splitter. */
const changesRatio = (s: ChangesSize) =>
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
/** The preset a size counts as for the button: under ~45% is narrow. */
const changesStep = (s: ChangesSize): ChangesPreset =>
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1);
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
/** The width the user last dragged the panel to, else `compact`. */
function lastDraggedChanges(): ChangesSize {
try {
const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN);
return Number.isFinite(n) ? clampChanges(n) : "compact";
} catch {
return "compact";
}
}
/** The panel's own minimum (`min-w-[18rem]`), in px. */
const CHANGES_MIN_PX = 288;
@@ -502,6 +500,7 @@ function ConsoleArea({
onRunExit={() => ended(slot)}
// Only the focused pane takes the keyboard when it appears.
autoFocus={slot === focusSlot}
solo={shown.length === 1}
attachRequest={
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
}
@@ -646,17 +645,14 @@ export function Workspace() {
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
if (typeof changesSize === "number")
window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize));
} catch {
/* same as above: a forgotten width, not an error */
}
}, [changesSize]);
const changesFull = changesSize === "full";
const changesPreset = changesStep(changesSize);
const nextChangesSize =
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
const changesNextLabel = changesFull
? tLanes("changes.collapse")
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full");
const changesRowRef = useRef<HTMLDivElement>(null);
// The row's width: the panel never gets narrower than its 18rem minimum,
// so the splitter is drawn — and dragged — no further than that edge.
@@ -713,16 +709,49 @@ export function Workspace() {
? sessionParam
: undefined;
// The workspace's extra folders: each is its own repo the Changes panel can
// show instead (`?source=`), picked in its header. Read per workspace, and
// again from the home's Folder list whenever it adds or removes one.
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
null
);
const takeGitFolders = useCallback(
(laneId: number, view: LaneSourcesView) =>
setGitFolders({
laneId,
items: view.sources.filter((s) => s.kind === "folder" && !s.missing),
}),
[]
);
useEffect(() => {
if (selectedLaneId === null) return;
api.lanes.sources(selectedLaneId).then(
(v) => takeGitFolders(selectedLaneId, v),
() => setGitFolders({ laneId: selectedLaneId, items: [] })
);
}, [selectedLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
// A pick holds for its own workspace, while that folder is still listed.
const gitSource =
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
? gitSourcePick.id
: undefined;
// An unsaved whole-file edit in the Changes panel. While it is open the
// panel stays on the tree it was opened in — moving it (another workspace,
// session or worktree) would remount it and drop the edit — until the user
// says to discard it, or saves.
// session, worktree or folder) would remount it and drop the edit — until
// the user says to discard it, or saves.
const [changesDirty, setChangesDirty] = useState(false);
const wantPanel =
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
selectedLaneId === null
? null
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
const keyOf = (p: typeof wantPanel) =>
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
const wantPanelKey = keyOf(wantPanel);
const [panelAt, setPanelAt] = useState(wantPanel);
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
const panelKey = keyOf(panelAt);
const asking = useRef(false);
useEffect(() => {
if (wantPanelKey === panelKey) return;
@@ -740,6 +769,33 @@ export function Workspace() {
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
}, [wantPanelKey, panelKey, changesDirty]);
// A chat's "Edited N files" card asks for a file: open the panel on it, if
// the panel shows that workspace.
const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>(
null
);
const panelLaneRef = useRef<number | null>(null);
panelLaneRef.current = panelAt?.laneId ?? null;
useEffect(() => {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
if (laneId !== panelLaneRef.current) return;
// Its path is the workspace folder's: back from an extra folder first.
setGitSourcePick(null);
setChangesOpen(true);
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
};
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
}, []);
// Asked once: a panel reopened or moved to another tree must not replay it.
useEffect(() => {
if (!changesOpen) setChangesRequest(null);
}, [changesOpen]);
// Not on a folder pick: the request above makes one, to land in the panel
// it remounts; the picker drops the request itself.
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
useEffect(() => setChangesRequest(null), [panelTreeKey]);
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
const afterDrop = (fn: () => void) => {
if (!changesDirty) return fn();
@@ -958,41 +1014,9 @@ export function Workspace() {
: "space-y-5"
}
>
{/* Workspaces are picked in the sidebar's project tree; this page shows
the selected one. With none at all, one banner offers to create it. */}
{/* Workspaces are picked — and added, with the + — in the sidebar's
project tree; this page shows the selected one. */}
<div className="flex min-h-0 flex-1 flex-col gap-4">
{lanesLoaded && lanes.length === 0 && (
/* With no lanes the strip was a dashed tile beside a stray sentence,
which reads as a half-rendered row rather than a state. One banner
instead — and a compact one on purpose: the run setup form below
is what the user actually needs the height for. */
<button
type="button"
data-testid="lane-empty"
onClick={() => setAddLaneOpen(true)}
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
<Layers className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<span className="block text-sm font-medium text-fg-primary">
{tLanes("empty.title")}
</span>
<p className="mt-0.5 text-xs text-fg-muted">
{tLanes("empty.body")}{" "}
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
agenthub workspace add --cwd $(pwd)
</code>
</p>
</div>
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
<Plus className="h-4 w-4" />
{tLanes("add")}
</span>
</button>
)}
{/* The selected lane's pipeline — the thing you actually come to this
page to read, beside the list rather than stacked under it.
The section itself is ALWAYS rendered, even with no lane selected:
@@ -1011,6 +1035,7 @@ export function Workspace() {
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
@@ -1232,7 +1257,7 @@ export function Workspace() {
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
>
{/* Header: the title, Analyst, then the resize control — the
{/* Header: the title, Analyst, then Full screen and Collapse — the
same height as the console's tab bar, so the two read as
one header strip. Files and Changes are ONE panel now; its
own mode switcher moves between them. */}
@@ -1243,6 +1268,34 @@ export function Workspace() {
<span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")}
</span>
{/* Which repo the panel shows: the workspace's folder or
one of its extra folders. Only when it has any. */}
{folders.length > 0 && (
<select
data-testid="panel-git-folder"
value={gitSource ?? ""}
onChange={(e) => {
setChangesRequest(null);
setGitSourcePick(
e.target.value
? { laneId: currentLane.id, id: Number(e.target.value) }
: null
);
}}
aria-label={tLanes("panel.folder")}
title={tLanes("panel.folder")}
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
>
<option value="">
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
</option>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
@@ -1258,37 +1311,42 @@ export function Workspace() {
>
<ChartColumn className="h-4 w-4" />
</button>
{/* A window's two controls: Full screen (Restore at full
screen, to the last dragged width) and Collapse. */}
<button
type="button"
data-testid="lane-changes-resize"
onClick={() => {
// Past the widest step the next state is "gone", which
// is what the rail button reopens at `compact` — and
// what drops an unsaved edit, so that asks first.
if (changesFull) {
afterDrop(() => {
setChangesOpen(false);
setChangesSize("compact");
});
} else {
setChangesSize(nextChangesSize);
}
}}
// The control names the same region the rail button does,
// so a screen reader reads one panel that is open or
// closed rather than two unrelated buttons.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={changesNextLabel}
title={changesNextLabel}
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
aria-label={changesSizeLabel}
title={changesSizeLabel}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
{/* Icon-only, and it draws the width the click LANDS on,
not the current one — the label the tooltip carries. */}
{(() => {
const Next = CHANGES_NEXT_ICON[changesPreset];
return <Next className="h-4 w-4" />;
})()}
{changesFull ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
type="button"
data-testid="lane-changes-collapse"
// Closing drops an unsaved edit, so that asks first; it
// reopens from the rail at the last dragged width.
onClick={() =>
afterDrop(() => {
setChangesOpen(false);
setChangesSize(lastDraggedChanges());
})
}
// The same region the rail button names, so a screen
// reader reads one panel that is open or closed.
aria-expanded
aria-controls="lane-changes-panel"
aria-label={tLanes("changes.collapse")}
title={tLanes("changes.collapse")}
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
@@ -1300,9 +1358,11 @@ export function Workspace() {
key={panelKey}
laneId={panelAt.laneId}
session={panelAt.session}
source={panelAt.source}
full={changesFull}
wide={changesPreset !== "compact"}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={setChangesDirty}
openRequest={panelAt.source ? null : changesRequest}
// A sibling worktree forks from the lane's SOURCE repo when
// it has one (this lane is itself a worktree of it); an
// adopted lane is its own repo.
@@ -1341,6 +1401,7 @@ export function Workspace() {
<LaneGitRailCounts
laneId={panelAt.laneId}
session={panelAt.session}
source={panelAt.source}
onShowAll={() => setChangesOpen(true)}
/>
)}