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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user