/** * @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 slot (0–9, `POST /api/lanes/:id/start`) and * then opens it (`&run=`) — the one place a session starts WITH a * prompt (the session view's tab bar `+` only starts a bare CLI or a shell, * `ConsoleTabs.tsx`). Inside * the box, like Claude Desktop's: AI (the session's own tool, the workspace's * by default) and one Model chip (`ModelMenu`: the tool's own list or the * proxy's models, plus Effort); under it the quieter choices — AI Proxy * (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval, * a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it * owns and has not archived (`?lane=&archived=0`, with each one's state dot, * **Show more** past the first page, and the sidebar row's Rename/Archive in * its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane, * any other its conversation (`&session=`, `SessionReplay`), 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 { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; import { confirmDialog } from "../../lib/confirm"; import { useDialog } from "../../hooks/useDialog"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ArchiveRestore, ShieldAlert, FileText, Folder, GitBranch, MoreHorizontal, Pencil, ArrowUp, Plus, X, } from "lucide-react"; import { api, type CodexApproval, type CodexSandbox, type EffortLevel, type LaneMemoryFile, type LaneSource, type LaneSourcesView, type ModelChoice, 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 { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup"; import { AiToolIcon } from "../AiToolIcon"; import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker"; import { Select } from "../Select"; import { MarkdownPreview } from "../MarkdownPreview"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu"; import { workspaceName } from "./WorkspaceRow"; import { SessionMenu } from "./SessionMenu"; import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu"; import { liveRunFor, SessionFold, sessionDot, sessionDotLabel, sessionWorktreeName, useSessionPage, } from "./WorkspaceSessionList"; export { freeSlot, laneOfRun, paneRunId, startInFreeTab, startInFreeTabDetailed, } from "./startSession"; import { startInFreeTab } from "./startSession"; import { PathPickButton } from "../PathPickButton"; 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`; } /** Recents' page size; Show more adds another. */ const RECENT_PAGE = 30; 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(); // The NEW session's AI tool — its own choice, the workspace's by default. const [tool, setTool] = useState(lane.tool); const entry = aiTools.byId(tool); const claude = tool === "claude"; const codex = tool === "codex"; // The half-typed prompt survives leaving the page or the workspace. const draftKey = `agenthub.workspace.draft.${lane.id}`; const [prompt, setPrompt] = useState(() => { try { return localStorage.getItem(draftKey) ?? ""; } catch { return ""; } }); useEffect(() => { try { if (prompt) localStorage.setItem(draftKey, prompt); else localStorage.removeItem(draftKey); } catch { /* storage unavailable: the draft just does not survive */ } }, [draftKey, prompt]); const [images, setImages] = useState([]); const [proxy, setProxy] = useSessionProxy(); const proxies = useAiProxies(); // Claude and Codex, also before the catalog has loaded (as ProxyPicker). const proxyCapable = entry?.proxyCapable ?? (claude || codex); // A profile picked for a tool that can use one; its models replace the tool's own. const profile = proxyCapable ? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null) : null; const proxyModels: ModelChoice[] | undefined = profile ? [ ...new Set( [profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter( (m): m is string => !!m ) ), ].map((id) => ({ id, label: id })) : undefined; // The tool's default model (also when the catalog arrives after mount); on // a proxy, the proxy's own default. A new tool or proxy drops the pick. const defaultModel = profile ? "" : (entry?.defaultModel ?? ""); const [model, setModel] = useState(defaultModel); useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]); // Claude and Codex always take a model and an effort; a catalog tool a // model only when its file names the flag, an effort never. const hasModel = claude || codex || !!entry?.modelFlag; const [mode, setMode] = useState("auto"); const [sandbox, setSandbox] = useState("workspace-write"); const [approval, setApproval] = useState("on-request"); // Per session, never remembered. const [bypass, setBypass] = useState(false); const canBypass = !claude && !!entry?.bypass; const [effort, setEffort] = useState(""); const hasEffort = claude || codex; // 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 [starting, setStarting] = useState(false); const startingRef = useRef(false); // An image still uploading: its path is not in the prompt yet. const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [liveRuns, setLiveRuns] = useState([]); const [sessions, setSessions] = useState(null); const [sessionTotal, setSessionTotal] = useState(0); const [recentLimit, setRecentLimit] = useState(RECENT_PAGE); // A Recents row being renamed (its session id) and the draft. const [renamingId, setRenamingId] = useState(null); const [nameDraft, setNameDraft] = useState(""); const [view, setView] = useState(null); const [editing, setEditing] = useState(null); const [memory, setMemory] = useState(null); const [memoryOpen, setMemoryOpen] = useState(false); const [memoryTab, setMemoryTab] = useState(0); 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); const [ctxDrag, setCtxDrag] = useState(false); const addFiles = async (picked: File[]) => { for (const f of picked) { if (!(await run(() => api.lanes.addFile(lane.id, f)))) break; } }; const memoryPanel = useRef(null); useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false)); const recentCtx = useContextMenu(); const sourceCtx = useContextMenu(); // 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: recentLimit }) .then((r) => { if (!live) return; setSessions(r.sessions); setSessionTotal(r.total ?? r.sessions.length); }) .catch(() => live && setSessions([])); return () => { live = false; }; }, [lane.id, runKey, version, recentLimit]); 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 text = promptWithImages(prompt, images); const start = async () => { // Enter reaches here past the disabled button: the same guards again. // A ref, not only the state: two Enters in one tick both read `starting` false. if (startingRef.current || uploading || !text) return; startingRef.current = true; setStarting(true); setError(null); try { const runId = await startInFreeTab(lane.id, { prompt: text, // Only a different tool is named: the workspace's own keeps starting // even after it was disabled in Settings. ...(tool !== lane.tool ? { tool } : {}), ...proxyArg(proxyCapable, proxy), ...(hasModel && model ? { model } : {}), ...(claude ? { permissionMode: mode } : {}), ...(codex ? { sandbox, approval } : {}), ...(canBypass && bypass ? { bypass: true } : {}), ...(hasEffort && effort ? { effort } : {}), ...(claude && inWorktree && project?.root ? { worktree: true } : {}), }); setPrompt(""); images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); setImages([]); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); } catch (err) { setError(message(err)); } finally { startingRef.current = false; setStarting(false); } }; // The sidebar row's Rename and Archive, for a Recents row. const patchSession = async (s: Session, body: { name?: string; archived?: boolean }) => { try { await api.sessions.patch(s.id, body); setVersion((v) => v + 1); } catch (err) { setError(message(err)); } }; const archiveSession = async (s: Session) => { const live = !!liveRunFor(lane, liveRuns, s.id); const wt = sessionWorktreeName(lane, s); const ok = await confirmDialog( wt && !live ? { title: t("tree.archiveWorktreeTitle"), message: t("tree.archiveWorktreeBody", { name: wt }), confirmLabel: t("tree.archiveWorktreeConfirm"), } : live ? { title: t("tree.archiveLiveTitle"), message: t("tree.archiveLiveBody"), confirmLabel: t("sessionMenu.archive"), destructive: false, } : { title: t("tree.archiveSession"), confirmLabel: t("sessionMenu.archive"), destructive: false, } ); if (!ok) return; if (wt && !live) { try { await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => { if ((err as { code?: string }).code !== "ENOWORKTREE") throw err; }); } catch (err) { setError(message(err)); return; } } await patchSession(s, { archived: true }); }; const openSession = (s: Session) => { const r = liveRunFor(lane, liveRuns, s.id); navigate( `/workspace?lane=${lane.id}&${r ? `run=${encodeURIComponent(r.id)}` : `session=${encodeURIComponent(s.id)}`}` ); }; // The tools a session may start on: installed and enabled, plus the // workspace's own whatever its state. const toolOptions = [ ...new Set([ lane.tool, ...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id), ]), ]; const danger = claude && mode === "bypassPermissions" ? t("run:hint.permissionWarning") : canBypass && bypass ? t("run:hint.bypassWarning", { tool: aiTools.label(tool) }) : codex && sandbox === "danger-full-access" ? t("run:hint.sandboxWarning") : null; 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 ? (

setMenuAt(menuPoint(e))} className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary" > {name}

) : ( setTitle(e.target.value)} onBlur={() => setTitle(null)} onKeyDown={async (e) => { if (e.key === "Escape") setTitle(null); if (!isSubmitEnter(e)) 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} images={images} onImagesChange={setImages} onBusyChange={setUploading} bare />
variant="ghost" label={t("home.ai")} value={tool} onChange={(v) => { setTool(v); setBypass(false); }} options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id), icon: ( ), }))} />
{hasModel && ( )}
{/* The session's quieter choices, under the box like Claude Desktop's. */}
{claude && ( variant="ghost" label={t("run:fields.permissionMode")} value={mode} onChange={setMode} options={( [ ["auto", "Auto"], ["acceptEdits", "AcceptEdits"], ["default", "Default"], ["plan", "Plan"], ["bypassPermissions", "Bypass"], ] as const ).map(([value, key]) => ({ value, label: t(`home.permission${key}`), hint: t(`run:fields.permission${key}`), }))} /> )} {codex && ( <> variant="ghost" label={t("run:fields.sandbox")} value={sandbox} onChange={setSandbox} options={[ { value: "read-only", label: t("run:fields.sandboxReadOnly") }, { value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") }, { value: "danger-full-access", label: t("run:fields.sandboxFullAccess") }, ]} /> variant="ghost" label={t("run:fields.approval")} value={approval} onChange={setApproval} options={[ { value: "on-request", label: t("run:fields.approvalOnRequest") }, { value: "never", label: t("run:fields.approvalNever") }, ]} /> )} {claude && project?.root && (
variant="ghost" label={t("home.worktreeHint")} value={inWorktree ? "worktree" : "local"} onChange={(v) => setInWorktree(v === "worktree")} options={[ { value: "local", label: t("home.local"), hint: t("home.localHint"), icon: , }, { value: "worktree", label: t("home.worktree"), hint: t("home.worktreeHint"), icon: , }, ]} />
)} {canBypass && ( )}
{danger && (

{danger}

)} {error && (

{error}

)}

{t("home.recents")}

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

{t("home.noRecents")}

)}
    {(sessions ?? []).map((s) => { const live = !!liveRunFor(lane, liveRuns, s.id); if (renamingId === s.id) { return (
  • setNameDraft(e.target.value)} onBlur={() => setRenamingId(null)} onKeyDown={(e) => { if (e.key === "Escape") setRenamingId(null); if (!isSubmitEnter(e)) return; setRenamingId(null); const name = nameDraft.trim(); if (name && name !== s.name) void patchSession(s, { name }); }} className="w-full rounded border border-accent bg-surface-1 px-2 py-1 text-sm text-fg-primary" />
  • ); } return (
  • ); })}
{sessions !== null && sessionTotal > sessions.length && ( )}
t("tree.archived", { count })} actionLabel={t("tree.unarchive")} actionIcon={} onAction={(s) => api.sessions.patch(s.id, { archived: false })} onDone={() => setVersion((v) => v + 1)} laneId={lane.id} />