feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces

The Workspace page's lane strip moves into the sidebar as a project tree:
workspaces grouped by git repository (new GET /api/projects, cached in
memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline
rename, drag/Alt+arrow ordering and a right-click row menu. The selected
workspace is the URL's ?lane=, one shared lane list feeds the tree, the new
bottom status bar and the page. The Changes panel gains an activity bar
(Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
@@ -86,6 +86,10 @@ export interface LaneConsolePaneProps {
hiddenRunIds?: string[];
/** Reports the run this pane shows (null on the setup form). */
onHandleChange?: (runId: string | null) => void;
/** A session to resume in this pane, from the Workspace's Sessions tab. A new
* `nonce` is a new request; it goes through the same path as a resume from
* the run history, live-run fallback included. */
resumeRequest?: { sessionId: string; model: string | null; nonce: number } | null;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
@@ -109,6 +113,7 @@ export function LaneConsolePane({
slot = 0,
hiddenRunIds,
onHandleChange,
resumeRequest,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -455,6 +460,23 @@ export function LaneConsolePane({
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
);
const resumeRef = useRef(onResumeFromHistory);
resumeRef.current = onResumeFromHistory;
useEffect(() => {
const lane = latest.current.lanes.find((l) => l.id === laneId);
if (!resumeRequest || !lane) return;
void resumeRef.current({
session_id: resumeRequest.sessionId,
cwd: lane.cwd,
tool: lane.tool,
model: resumeRequest.model,
permission_mode: null,
effort: null,
} as DashboardRunHistoryItem);
// Only a new request (nonce) resumes; a lane switch never replays one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resumeRequest?.nonce]);
const onViewFromHistory = useCallback(
(item: DashboardRunHistoryItem) => {
if (item.session_id) void onResumeFromHistory(item);
@@ -483,4 +483,47 @@ describe("LaneConsolePane", () => {
expect(screen.queryByText("shown")).toBeNull();
});
});
it("resumes a session handed over by the Sessions tab, once per request", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ run_id: "agenthub-lane-1" });
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "agenthub-lane-1",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: "s-1",
sessionId: null,
startedAt: null,
promptPreview: null,
});
const request = { sessionId: "s-1", model: "claude-opus-5-5", nonce: 1 };
const { rerender } = render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} resumeRequest={request} />
</MemoryRouter>
);
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({
slot: 0,
prompt: "",
resumeSessionId: "s-1",
model: "claude-opus-5-5",
})
)
);
// A re-render with the same request is not a second resume.
rerender(
<MemoryRouter>
<LaneConsolePane {...baseProps()} resumeRequest={{ ...request }} />
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.lanes.action).toHaveBeenCalledTimes(1);
});
});