feat(ui): sidebar lists workspaces with their sessions, Claude Desktop style

The sidebar's project tree becomes a flat workspace list (WorkspaceTree):
one row per workspace in the stored order, each expanding to its sessions.
A session that still has a live pane opens /workspace?lane=<id>&run=<runId>,
which only attaches pane 0 to that tmux session; any other session opens
its read-only transcript. Nothing in the sidebar starts or resumes a run.
This commit is contained in:
2026-10-01 22:23:01 +07:00
parent 744e9c6cda
commit 2e051e23b6
16 changed files with 616 additions and 538 deletions
@@ -90,6 +90,9 @@ export interface LaneConsolePaneProps {
* `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;
/** A live run to show in this pane (a session picked in the sidebar). It
* attaches to that tmux session only — nothing is started or resumed. */
attachRequest?: { runId: string; nonce: string } | null;
}
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
@@ -114,6 +117,7 @@ export function LaneConsolePane({
hiddenRunIds,
onHandleChange,
resumeRequest,
attachRequest,
}: LaneConsolePaneProps) {
const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes");
@@ -216,6 +220,29 @@ export function LaneConsolePane({
refreshList();
}, [laneId, slot, refreshList]);
// Runs after the lane-switch effect above, so a sidebar pick that changes
// lane AND run at once lands on the picked run, not the lane's own.
useEffect(() => {
if (!attachRequest) return;
let live = true;
autoAttachedForLane.current = laneId;
api.run
.get(attachRequest.runId)
.then((h) => live && setHandle(h))
.catch(
(err: unknown) =>
live &&
setError(
t("errors.attachFailed", { message: err instanceof Error ? err.message : "unknown" })
)
);
return () => {
live = false;
};
// Only a new request (nonce) attaches; a lane switch never replays one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
// run starts, which is an extra prompt nobody asked for. The prompt is the
@@ -526,4 +526,34 @@ describe("LaneConsolePane", () => {
await screen.findByTestId("terminal-view");
expect(api.lanes.action).toHaveBeenCalledTimes(1);
});
it("attaches to a run picked in the sidebar without starting or resuming anything", async () => {
vi.mocked(api.run.get).mockResolvedValue({
id: "agenthub-lane-1-2",
laneId: 1,
slot: 2,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-9",
startedAt: null,
promptPreview: null,
tool: "claude",
});
render(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
attachRequest={{ runId: "agenthub-lane-1-2", nonce: "k1" }}
/>
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.lanes.action).not.toHaveBeenCalled();
expect(api.run.start).not.toHaveBeenCalled();
});
});