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
+15 -1
View File
@@ -44,7 +44,7 @@
* ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useSearchParams } from "react-router-dom";
import { useLocation, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Plus,
@@ -231,6 +231,7 @@ function ConsoleArea({
onLaneIdChange,
showToggle = true,
resumeRequest,
attachRequest,
}: {
lanes: Lane[];
selectedLaneId: number | null;
@@ -248,6 +249,8 @@ function ConsoleArea({
showToggle?: boolean;
/** A session to resume in slot 0, from the Sessions tab. */
resumeRequest: { sessionId: string; model: string | null; nonce: number } | null;
/** A live run to attach slot 0 to, from the sidebar (`?run=`). */
attachRequest: { runId: string; nonce: string } | null;
}) {
// Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave
@@ -292,6 +295,7 @@ function ConsoleArea({
defaultCwd={defaultCwd}
onHasActiveRunChange={onHasActiveRunChange}
resumeRequest={resumeRequest}
attachRequest={attachRequest}
/>
) : (
<div
@@ -323,6 +327,7 @@ function ConsoleArea({
wsConnected={wsConnected}
defaultCwd={defaultCwd}
resumeRequest={i === 0 ? resumeRequest : null}
attachRequest={i === 0 ? attachRequest : null}
/>
</div>
))}
@@ -342,6 +347,14 @@ export function Workspace() {
// notification click), falling back to the last one shown, then the first.
const { lanes, loaded: lanesLoaded, refresh: refreshLanes } = useLanes();
const laneParam = searchParams.get("lane");
// `?run=` — a live session picked in the sidebar; the location key makes a
// second pick of the same session a new request.
const location = useLocation();
const runParam = searchParams.get("run");
const attachRequest = useMemo(
() => (runParam ? { runId: runParam, nonce: location.key } : null),
[runParam, location.key]
);
const paramId = laneParam === null ? null : Number(laneParam);
const fallbackId = (() => {
if (!lanesLoaded) return null;
@@ -948,6 +961,7 @@ export function Workspace() {
onLaneIdChange={setSelectedLaneId}
showToggle={!currentLane}
resumeRequest={resumeRequest}
attachRequest={attachRequest}
/>
</div>
</section>