/** * @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&pinned_first=1`, newest first — * pinned ones on top in pin order, then the ones with a live pane, then the * ended ones; a pin is `PATCH {pinned}`, issue #80) — 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, its hover buttons pin/unpin and archive 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** while it is open — a closed one * leaves no row; Ctrl/Cmd+click opens * it in a split in the tab on screen) — 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, then a **Show less** that goes back to `SESSION_LIMIT`. 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. `readOnly` (another user's * workspace, for an analyst): no live tabs, no rename/archive/menu, and a row * opens the session's analysis page (`/sessions/`) — never a pane or a resume. * @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, Pin, PinOff, SquareTerminal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import { api, type RunHandle } from "../../lib/api"; 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 { useContextMenu } from "../ContextMenu"; import { SessionMenu } from "./SessionMenu"; export const SESSION_LIMIT = 6; /** * 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, readOnly = 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; /** A workspace this user may only read. */ readOnly?: 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}`; const own = useSessionPage( { lane: lane.id, archived: false, pinnedFirst: true, 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 runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id); // Pinned sessions first, then live ones, then ended ones — each group in // the server's order (pin order, else newest first; the sort is stable). const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2); const shown = (sessions ?? []) .filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)) .sort((a, b) => rank(a) - rank(b)); // 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 || readOnly ? [] : [ ...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; pinned?: 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}

); if (sessions !== null && shown.length === 0 && fresh.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 (
  • {!readOnly && ( )} {!readOnly && ( )}
  • ); })}
{more > 0 && !q && ( )} {more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && ( )} {failure} {confirmArchive} {ctx.menu && ( )} {sessionCtx.menu && ( startRename(sessionCtx.menu!.item)} onArchive={() => void archive(sessionCtx.menu!.item)} onPin={() => void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at }) } /> )} ); }