/** * @file WorkspaceSessionList — the sessions under an expanded workspace in the * sidebar: the ones the workspace OWNS and has not archived * (`GET /api/sessions?lane=&archived=0`, newest first) — never the other * sessions that ran in its folder. A row's dot * shows its state (`sessionDot`: live pane, needs you, error) and its right * edge the AI tool the session ran on (`sessions.tool`); double-click * renames it and its hover button archives it (`PATCH {name}` / `{archived}`, * which deletes nothing); a right-click opens the same actions and more * (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle * carrying its session id) opens that tmux pane on the Workspace page * (`?lane=&run=`, its tab in the session view; a live tab whose * session has no row yet — a bare tab before its first prompt — is listed as * **New session** from the run itself, and a live Terminal tab (`tool: "shell"`, * which never has a session) as **Terminal** — and under the sessions the last * `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose * click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or * right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens * it in a split beside the focused pane) — attaching, never starting or * resuming anything; * any other session opens in its workspace too (`?lane=&session=`, * `SessionReplay`): its conversation with a prompt box, resumed only when a * message is SENT, because a resume re-reads the whole transcript into context. * The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that * loads every one. A * live tab whose session was archived is not brought back as a New session, * and archiving a live one asks first. F2 renames the focused row too; each * dot carries its meaning as screen-reader text. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState, type ReactNode } from "react"; import { confirmDialog } from "../../lib/confirm"; import { isSubmitEnter } from "../../lib/keys"; import { Archive, GitBranch, SquareTerminal, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import { api, type RunHandle, type RunHistoryItem } from "../../lib/api"; import { toast } from "../../lib/toast"; import { startInFreeTab } from "./startSession"; import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types"; import { timeAgo } from "../../lib/format"; import { ConfirmModal } from "../ConfirmModal"; import { AiToolIcon } from "../AiToolIcon"; import { useAiTools } from "../../hooks/useAiTools"; import { useThrottledValue } from "../../hooks/useThrottledValue"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { SessionMenu } from "./SessionMenu"; export const SESSION_LIMIT = 6; /** Closed Terminal tabs listed under a workspace's sessions. */ export const CLOSED_TERMINALS = 3; /** * The live pane holding a session: the run the hooks bound it to * (`dashboard_runs.session_id`), else — a pane started before the hooks * reported it — the lane's own pane when the lane's bound session is this one. */ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): RunHandle | null { const live = runs.filter((r) => r.laneId === lane.id && r.status === "running"); return ( live.find((r) => r.sessionId === sessionId) ?? (lane.session_id === sessionId ? (live.find((r) => r.id === lane.run_id) ?? null) : null) ); } /** The name of the git worktree a session ran in (`claude --worktree` puts it * at `/.claude/worktrees/`), or null for one in the folder. */ export function sessionWorktreeName(lane: Lane, s: Session): string | null { // Either separator: a Windows cwd is `D:\repo\.claude\worktrees\`. const strip = (p: string) => p.replace(/[\\/]+$/, ""); if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null; return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null; } /** A session row's dot: waiting for the user, failed, held by a live pane, or * at rest (a ring, which reads on every theme). */ export function sessionDot(session: Session, live: boolean): string { if (isSessionAwaitingInput(session)) return "bg-status-warning"; if (session.status === "error") return "bg-status-danger"; if (live) return "bg-status-success"; return "border border-fg-muted"; } /** What the dot means, for a screen reader (`tree.state.*`). */ export function sessionDotLabel(session: Session, live: boolean): string { if (isSessionAwaitingInput(session)) return "awaiting"; if (session.status === "error") return "error"; return live ? "live" : "idle"; } export type SessionPage = { sessions: Session[]; total: number }; /** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */ export function useSessionPage( params: Parameters[0], refreshKey: string ): SessionPage | null { const [page, setPage] = useState(null); const key = JSON.stringify(params); useEffect(() => { let live = true; api.sessions .list(JSON.parse(key)) .then((r) => live && setPage({ sessions: r.sessions, total: r.total ?? r.sessions.length })) .catch(() => live && setPage(null)); return () => { live = false; }; }, [key, refreshKey]); return page; } /** A folded list of sessions under a one-line toggle, each with one action * (Unarchive); `onDone` re-reads the lists after it. */ export function SessionFold({ testId, page, label, hint, actionLabel, actionIcon, onAction, onDone, indent = "", laneId, }: { testId: string; page: SessionPage | null; label: (count: number) => string; hint?: string; actionLabel: string; actionIcon: ReactNode; onAction: (s: Session) => Promise; onDone: () => void; /** Left padding of the rows, to line up with the list above. */ indent?: string; /** The rows' workspace: given, a right-click opens the session menu. */ laneId?: number; }) { const [open, setOpen] = useState(false); const [error, setError] = useState(null); const ctx = useContextMenu(); if (!page || page.total === 0) return null; const act = async (s: Session) => { setError(null); try { await onAction(s); onDone(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }; return (
{open && (
    {page.sessions.map((s) => (
  • ctx.open(e, s)} > {s.name || s.id.slice(0, 8)}
  • ))}
)} {error && (

{error}

)} {ctx.menu && laneId !== undefined && ( void act(ctx.menu!.item)} /> )}
); } export function WorkspaceSessionList({ lane, liveRuns, query, selectedRunId, selectedSessionId = null, onMatches, hidden = false, }: { 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; /** The ended session the Workspace page is showing (`session`), likewise. */ selectedSessionId?: string | null; /** How many sessions match `query`, once they are loaded — lets the * sidebar search find a workspace by a session's name. */ onMatches?: (count: number) => void; /** Mounted only to load and count, during a search that hides the row. */ hidden?: boolean; }) { const { t } = useTranslation("lanes"); const navigate = useNavigate(); const tools = useAiTools(); // Bumped after a rename or an archive, so the list re-reads. const [version, setVersion] = useState(0); const [renaming, setRenaming] = useState(null); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); // A worktree session asked to be archived: Claude Desktop removes its // worktree too, so that is confirmed first. const [archiving, setArchiving] = useState(null); const [busy, setBusy] = useState(false); const ctx = useContextMenu(); const sessionCtx = useContextMenu(); // Rows shown; See all raises it to the whole list. const [limit, setLimit] = useState(SESSION_LIMIT); // This workspace's live AI tabs. A tab started bare (no prompt) has its // session id from the start, but no session row until a hook creates one. const laneRuns = liveRuns.filter( (r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell" ); const liveShells = liveRuns.filter( (r) => r.laneId === lane.id && r.status === "running" && r.tool === "shell" ); // The lane's own session/status/needs-you move on every hook, which is when // a session can appear or change state, and a tab opening or getting its // session is too — re-read then, not on a timer. Throttled to one re-read per // 3 s: two sessions in one workspace flip these on every hook. A rename or an // archive (`version`) re-reads at once. const hookKey = useThrottledValue( `${lane.session_id}:${lane.status}:${lane.needs_action}:${laneRuns .map((r) => `${r.id}=${r.sessionId ?? ""}`) .join(",")}`, 3000 ); const refreshKey = `${hookKey}:${version}`; // A Terminal tab closing is when one joins the history. const shellKey = liveShells.map((r) => r.id).join(","); const [closedShells, setClosedShells] = useState([]); useEffect(() => { if (hidden) return; let live = true; api.run // Live ones come back too (isLive): ask for enough to fill the list anyway. .history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 }) .then( (r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS)) ) .catch(() => undefined); return () => { live = false; }; }, [lane.id, shellKey, hidden]); const reopenShell = async (split: boolean) => { try { const runId = await startInFreeTab(lane.id, { shell: true }); navigate( `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`, split ? { state: { split: true } } : undefined ); } catch (err) { toast.error(err); } }; const closedCtx = useContextMenu(); const forgetShell = async (r: RunHistoryItem) => { try { await api.run.forgetHistory(r.id); setClosedShells((all) => all.filter((x) => x.id !== r.id)); } catch (err) { toast.error(err); } }; const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey); const sessions = own?.sessions ?? (own === null ? null : []); const more = own ? Math.max(0, own.total - own.sessions.length) : 0; const q = query.trim().toLowerCase(); const shown = (sessions ?? []).filter( (s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q) ); const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id); // A live tab whose session has no row yet: shown as a New session at the // top, like Claude Desktop, until its first hook brings the real row. Not // during a search — it has no name to match. // A tab whose session HAS a row the list does not show is archived (or // past the page): looked up once, so archiving a live session never brings // it back as a New session. const orphanIds = sessions === null ? "" : laneRuns .filter((r) => r.sessionId && !sessions.some((s) => s.id === r.sessionId)) .map((r) => r.sessionId) .join(","); const [hasRow, setHasRow] = useState>(new Set()); useEffect(() => { if (!orphanIds) return; let live = true; void Promise.all( orphanIds.split(",").map((id) => api.sessions // The row only: a session's events are its whole history. .get(id, { events: false }) .then(() => id) .catch(() => null) ) ).then((found) => live && setHasRow(new Set(found.filter((x): x is string => !!x)))); return () => { live = false; }; }, [orphanIds]); const fresh = sessions === null || q ? [] : [ ...laneRuns.filter( (r) => !r.sessionId || (!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId)) ), // Live Terminal tabs: no session row ever, so listed from the run. ...liveShells, ]; const count = sessions === null ? null : shown.length; useEffect(() => { if (count !== null) onMatches?.(count); }, [count, onMatches]); const patch = async (s: Session, body: { name?: string; archived?: boolean }) => { setError(null); try { await api.sessions.patch(s.id, body); setVersion((v) => v + 1); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }; // Archive: a worktree session with no live pane loses its worktree first // (never forced — git refuses unsaved work, which then stops the archive); // one Claude already cleaned up is just archived. const startRename = (s: Session) => { setDraft(s.name || ""); setRenaming(s.id); }; const archive = async (s: Session) => { if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s); // A live one keeps running in its tab; archiving only hides its row. if ( runFor(s) && !(await confirmDialog({ title: t("tree.archiveLiveTitle"), message: t("tree.archiveLiveBody"), confirmLabel: t("sessionMenu.archive"), destructive: false, })) ) return; await patch(s, { archived: true }); }; const archiveWithWorktree = async (s: Session) => { setBusy(true); setError(null); try { await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => { if ((err as { code?: string }).code !== "ENOWORKTREE") throw err; }); await patch(s, { archived: true }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); setArchiving(null); } }; const confirmArchive = ( archiving && void archiveWithWorktree(archiving)} onCancel={() => setArchiving(null)} /> ); if (hidden) return null; const failure = error && (

{error}

); // Not during a search, like the live ones: they have no name to match. const closed = q ? [] : closedShells; if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) { return ( <>

{t("tree.noSessions")}

{failure} {confirmArchive} ); } return ( <>
    {fresh.map((r) => (
  • ))} {shown.map((s) => { const run = runFor(s); const selected = run ? run.id === selectedRunId : s.id === selectedSessionId; if (renaming === s.id) { return (
  • setDraft(e.target.value)} // Blur CANCELS — Enter is the only save, like a workspace row. onBlur={() => setRenaming(null)} onKeyDown={(e) => { if (e.key === "Escape") setRenaming(null); if (!isSubmitEnter(e)) return; setRenaming(null); const name = draft.trim(); if (name && name !== s.name) void patch(s, { name }); }} className="w-full rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[12px] text-fg-primary" />
  • ); } return (
  • ); })} {closed.map((r) => (
  • ))}
{more > 0 && !q && ( )} {failure} {confirmArchive} {ctx.menu && ( )} {closedCtx.menu && ( { closedCtx.close(); void reopenShell(false); }} > {t("tree.reopenTerminal")} { closedCtx.close(); void reopenShell(true); }} > {t("sessionMenu.openSplit")} { const item = closedCtx.menu!.item; closedCtx.close(); void forgetShell(item); }} > {t("tree.forgetTerminal")} )} {sessionCtx.menu && ( startRename(sessionCtx.menu!.item)} onArchive={() => void archive(sessionCtx.menu!.item)} /> )} ); }