/** * @file WorkspaceHome — what `/workspace?lane=` opens on, shaped like a * Claude Desktop project page: the name, a prompt box that starts a NEW * session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and * then opens it (`&run=`), the workspace's Recents — the sessions it * owns and has not archived (`?lane=&archived=0`, with each one's state dot), * then the archived ones folded with Unarchive; a live session opens its pane, * any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and * Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see * server/lib/lane-sources.js). Folder suggestions are the repo's untracked * worktrees from `GET /api/projects`, filtered by Settings → Worktrees. * @author Nguyễn Ngọc Trí Vĩ */ import { Checkbox } from "../Checkbox"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ArchiveRestore, FileText, Folder, GitBranch, MoreHorizontal, Pencil, Play, Plus, X, } from "lucide-react"; import { api, type LaneMemoryFile, type LaneSourcesView, type PermissionMode, type RunHandle, } from "../../lib/api"; import type { Lane, Project, Session } from "../../lib/types"; import { fmtCost, timeAgo } from "../../lib/format"; import { useAiTools } from "../../hooks/useAiTools"; import { useProjectTreePrefs } from "../../lib/projectTreePrefs"; import { ModelPicker, PromptEditor } from "../run/RunSetup"; import { RouterPicker, routerArg, useSessionRouter } from "../run/RouterPicker"; import { Select } from "../Select"; import { MarkdownPreview } from "../MarkdownPreview"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu"; import { workspaceName } from "./WorkspaceRow"; import { liveRunFor, SessionFold, sessionDot, sessionWorktreeName, useSessionPage, } from "./WorkspaceSessionList"; /** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */ export function paneRunId(laneId: number, slot: number): string { return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`; } /** The first pane slot with no live run, or null when all four are busy. */ export function freeSlot(laneId: number, runs: RunHandle[]): number | null { const busy = new Set( runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0) ); return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null; } function fmtBytes(n: number | null): string { if (!n) return ""; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } const message = (err: unknown) => (err instanceof Error ? err.message : String(err)); type HomeProps = Parameters[0]; /** Keyed by workspace: every piece of state below (a half-typed prompt, the * model, an open folder form, a late upload's answer) belongs to ONE * workspace, and the page swaps `lane` without unmounting. */ export function WorkspaceHome(props: HomeProps) { return ; } function HomeBody({ lane, onMenuAction, onRenamed, }: { lane: Lane; /** Row-menu items the page carries out (its modals); rename is done here. */ onMenuAction: (action: WorkspaceMenuAction) => void; /** Called after a rename, so the page re-reads the lane list. */ onRenamed?: () => void; }) { const { t } = useTranslation("lanes"); const navigate = useNavigate(); const aiTools = useAiTools(); const prefs = useProjectTreePrefs(); const claude = lane.tool === "claude"; const [prompt, setPrompt] = useState(""); // The tool's default model, also when the catalog arrives after mount. const defaultModel = aiTools.byId(lane.tool)?.defaultModel ?? ""; const [model, setModel] = useState(defaultModel); useEffect(() => setModel(defaultModel), [defaultModel]); const [mode, setMode] = useState("auto"); // Claude Desktop's worktree toggle: the new session gets a git worktree of // its own (`claude --worktree`), made and cleaned up by Claude Code. const [inWorktree, setInWorktree] = useState(false); const [router, setRouter] = useSessionRouter(); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); const [liveRuns, setLiveRuns] = useState([]); const [sessions, setSessions] = useState(null); const [view, setView] = useState(null); const [editing, setEditing] = useState(null); const [memory, setMemory] = useState(null); const [memoryOpen, setMemoryOpen] = useState(false); const [folderOpen, setFolderOpen] = useState(false); const [folderPath, setFolderPath] = useState(""); const [suggestions, setSuggestions] = useState([]); const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null); const [title, setTitle] = useState(null); const fileInput = useRef(null); // A new pane, a pane gone, a session id learned — all move these lane fields. const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`; // Bumped after a session is unarchived, so the lists re-read. const [version, setVersion] = useState(0); useEffect(() => { let live = true; api.run .list() .then((r) => live && setLiveRuns(r.items)) .catch(() => undefined); api.sessions .list({ lane: lane.id, archived: false, limit: 30 }) .then((r) => live && setSessions(r.sessions)) .catch(() => live && setSessions([])); return () => { live = false; }; }, [lane.id, runKey, version]); const archived = useSessionPage( { lane: lane.id, archived: true, limit: 30 }, `${runKey}:${version}` ); useEffect(() => { let live = true; setView(null); setMemory(null); setEditing(null); setError(null); api.lanes .sources(lane.id) .then((v) => live && setView(v)) .catch((err) => live && setError(message(err))); api.lanes .memory(lane.id) .then((r) => live && setMemory(r.files)) .catch(() => live && setMemory([])); return () => { live = false; }; }, [lane.id]); // The repository this workspace belongs to (Settings → Projects used to // show this): read once per visit, from the server's cached project list. const [project, setProject] = useState(null); useEffect(() => { let live = true; api.projects .list() .then(({ projects }) => { if (live) setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null); }) .catch(() => undefined); return () => { live = false; }; }, [lane.id]); // Suggestions are read when the folder form opens, not on every visit. useEffect(() => { if (!folderOpen) return; let live = true; api.projects .list() .then(({ projects }) => { if (!live) return; const p = projects.find((x) => x.workspaces.includes(lane.id)); const own = new Set((view?.sources ?? []).map((s) => s.path)); setSuggestions( !p || !prefs.showUntracked ? [] : p.worktrees .filter((w) => w.workspace_id === null && !own.has(w.path)) .filter((w) => !(w.source && prefs.hideSources.includes(w.source))) .map((w) => w.path) ); }) .catch(() => undefined); return () => { live = false; }; }, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]); const run = async (fn: () => Promise) => { setError(null); try { setView(await fn()); return true; } catch (err) { setError(message(err)); return false; } }; const start = async () => { const slot = freeSlot(lane.id, liveRuns); if (slot === null) { setError(t("home.allBusy")); return; } setStarting(true); setError(null); try { const r = await api.lanes.action(lane.id, "start", { slot, prompt: prompt.trim(), ...(model ? { model } : {}), ...(claude ? { permissionMode: mode } : {}), ...(claude && inWorktree && project?.root ? { worktree: true } : {}), ...routerArg(aiTools.byId(lane.tool)?.routerCapable, router), }); setPrompt(""); const runId = r.run_id ?? paneRunId(lane.id, slot); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); } catch (err) { setError(message(err)); } finally { setStarting(false); } }; const openSession = (s: Session) => { const r = liveRunFor(lane, liveRuns, s.id); navigate( r ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}` : `/sessions/${s.id}` ); }; const files = (view?.sources ?? []).filter((s) => s.kind === "file"); const folders = (view?.sources ?? []).filter((s) => s.kind === "folder"); const name = workspaceName(lane); const card = "border-b border-border px-4 py-4 last:border-b-0"; const iconBtn = "rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40"; return (

{t("home.breadcrumb")} / {name}

{title === null ? (

{name}

) : ( setTitle(e.target.value)} onBlur={() => setTitle(null)} onKeyDown={async (e) => { if (e.key === "Escape") setTitle(null); if (e.key !== "Enter") return; const next = title.trim(); setTitle(null); if (next === (lane.title || "")) return; try { await api.lanes.update(lane.id, { title: next }); onRenamed?.(); } catch (err) { setError(message(err)); } }} className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-1 text-2xl font-semibold text-fg-primary" /> )}
{project?.root && (

{project.root} {project.worktrees.find((w) => w.main)?.branch && ( {project.worktrees.find((w) => w.main)?.branch} )} {t("home.worktreeCount", { count: project.worktrees.length })}

)}
void start()} placeholder={t("home.placeholder")} rows={3} slash={claude} fileCwd={lane.cwd} />
{claude && (
value={mode} onChange={setMode} options={[ { value: "auto", label: t("run:fields.permissionAuto") }, { value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") }, { value: "default", label: t("run:fields.permissionDefault") }, { value: "plan", label: t("run:fields.permissionPlan") }, { value: "bypassPermissions", label: t("run:fields.permissionBypass") }, ]} />
)} {claude && project?.root && ( )}
{error && (

{error}

)}

{t("home.recents")}

{sessions !== null && sessions.length === 0 && (

{t("home.noRecents")}

)}
    {(sessions ?? []).map((s) => { const live = !!liveRunFor(lane, liveRuns, s.id); return (
  • ); })}
t("tree.archived", { count })} actionLabel={t("tree.unarchive")} actionIcon={} onAction={(s) => api.sessions.patch(s.id, { archived: false })} onDone={() => setVersion((v) => v + 1)} />