/** * @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 session with a LIVE pane (a `GET /api/run` handle * carrying its session id) opens that tmux pane on the Workspace page * (`?lane=&run=`, Ctrl/Cmd+click `&pane=1` to open it in a second * split 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. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState, type ReactNode } from "react"; import { Archive, GitBranch } 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"; export const SESSION_LIMIT = 20; /** * 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 { 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. */ 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"; } 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 = "", }: { 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; }) { const [open, setOpen] = useState(false); const [error, setError] = useState(null); 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) => (
  • {s.name || s.id.slice(0, 8)}
  • ))}
)} {error && (

{error}

)}
); } 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); // The lane's own session/status/needs-you move on every hook, which is when // a session can appear or change state — re-read then, not on a timer. const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}`; const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey); const sessions = own?.sessions ?? (own === null ? null : []); 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); 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 archive = (s: Session) => sessionWorktreeName(lane, s) && !runFor(s) ? setArchiving(s) : void 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) { return ( <>

{t("tree.noSessions")}

{failure} {confirmArchive} ); } return ( <>
    {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 (e.key !== "Enter") 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 (
  • ); })}
{failure} {confirmArchive} ); }