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:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar (`GET /api/sessions?cwd=&tool=`, newest first). A session with a LIVE
|
||||
* pane (a `GET /api/run` handle carrying its session id) opens that tmux pane
|
||||
* on the Workspace page (`?lane=<id>&run=<runId>`) — attaching, never starting
|
||||
* or resuming anything; any other session opens its read-only transcript
|
||||
* (`/sessions/:id`), where Resume is a separate, explicit action, because a
|
||||
* resume re-reads the whole transcript into context.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
|
||||
export const SESSION_LIMIT = 20;
|
||||
|
||||
export function WorkspaceSessionList({
|
||||
lane,
|
||||
liveRuns,
|
||||
query,
|
||||
selectedRunId,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Every live dashboard pane, from `GET /api/run`. */
|
||||
liveRuns: RunHandle[];
|
||||
/** The sidebar search; filters by session name. */
|
||||
query: string;
|
||||
/** The `run` the Workspace page is attached to, to mark its row. */
|
||||
selectedRunId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
|
||||
// The lane's own session/status move on every hook, which is when a new
|
||||
// session can appear — re-read then, not on a timer.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.sessions
|
||||
.list({ cwd: lane.cwd, tool: lane.tool, limit: SESSION_LIMIT })
|
||||
.then((r) => live && setSessions(r.sessions))
|
||||
.catch(() => live && setSessions([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.cwd, lane.tool, lane.session_id, lane.status]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
||||
);
|
||||
const runFor = (s: Session) =>
|
||||
liveRuns.find((r) => r.sessionId === s.id && r.status === "running") ?? null;
|
||||
|
||||
if (sessions !== null && shown.length === 0) {
|
||||
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
|
||||
}
|
||||
return (
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
{shown.map((s) => {
|
||||
const run = runFor(s);
|
||||
const selected = !!run && run.id === selectedRunId;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
: `/sessions/${s.id}`
|
||||
)
|
||||
}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
run ? "" : `\n${t("tree.transcriptHint")}`
|
||||
}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
run ? "bg-status-success" : "border border-fg-muted"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user