/** * @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: the `+` menu (`ChatAddMenu`: upload, or * attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's * by default; its last row, **Open terminal**, is no AI — it starts a plain * shell tab at once, like the tab bar's New terminal) 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 (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel). * @author Nguyễn Ngọc Trí Vĩ */ import { Checkbox } from "../Checkbox"; import { isSubmitEnter } from "../../lib/keys"; import { confirmDialog } from "../../lib/confirm"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ArchiveRestore, ShieldAlert, Folder, GitBranch, MoreHorizontal, ArrowUp, SquareTerminal, } from "lucide-react"; import { api, type CodexApproval, type CodexSandbox, type EffortLevel, 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 { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup"; import { ChatAddMenu } from "../run/ChatAddMenu"; import { addAttachments } from "../../lib/promptAttach"; import { AiToolIcon } from "../AiToolIcon"; import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker"; import { Select } from "../Select"; import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu"; import { workspaceName } from "./WorkspaceRow"; import { SessionMenu } from "./SessionMenu"; import { 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 { WorkspaceContext } from "./WorkspaceContext"; import { readDefaultMode } from "../../lib/sessionDefaults"; /** 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. */ /** The AI menu row that opens a terminal; no catalog id can collide (ids are slugs). */ const OPEN_TERMINAL = "__terminal__"; export function WorkspaceHome(props: HomeProps) { return ; } function HomeBody({ lane, onMenuAction, onRenamed, onSourcesChange, sourcesVersion = 0, }: { 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; /** Called with the sources after a folder or file is added or removed. */ onSourcesChange?: (view: LaneSourcesView) => void; /** Bumped when the folder list changed elsewhere (the Changes panel's * Close repository): the Folder list re-reads. */ sourcesVersion?: number; }) { const { t } = useTranslation("lanes"); const navigate = useNavigate(); const aiTools = useAiTools(); // 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 pickFiles = useRef<(() => void) | null>(null); 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; // The account's own default (first-run setup) — never bypass. const [mode, setMode] = useState(readDefaultMode); 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 [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null); const [title, setTitle] = useState(null); const recentCtx = 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}` ); // 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]); // The AI menu's Open terminal: a plain shell in the first free slot, opened // at once — the same start the session view's tab bar `+` makes. const openTerminal = async () => { if (startingRef.current) return; startingRef.current = true; setStarting(true); setError(null); try { const runId = await startInFreeTab(lane.id, { shell: true }); navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); } catch (err) { setError(message(err)); } finally { startingRef.current = false; setStarting(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(""); // Here, not only through the effect: the navigate below unmounts this // home in the same batch, so the effect never sees the empty prompt. try { localStorage.removeItem(draftKey); } catch { /* storage unavailable: nothing was kept */ } 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 name = workspaceName(lane); 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} lane={lane.id} images={images} onImagesChange={setImages} onBusyChange={setUploading} pickFiles={pickFiles} bare />
pickFiles.current?.()} onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)} onAttach={(items) => setImages((prev) => addAttachments(prev, items))} />
variant="ghost" label={t("home.ai")} value={tool} onChange={(v) => { // Not a tool: an action. The AI chosen for the prompt stays. if (v === OPEN_TERMINAL) return void openTerminal(); setTool(v); setBypass(false); }} options={[ ...toolOptions.map((id) => ({ value: id, label: aiTools.label(id), icon: ( ), })), { value: OPEN_TERMINAL, label: t("home.openTerminal"), icon: , divider: true, }, ]} />
{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 && ( )} {recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && ( )}
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} />
{recentCtx.menu && ( { const s = recentCtx.menu!.item; setNameDraft(s.name || ""); setRenamingId(s.id); }} onArchive={() => void archiveSession(recentCtx.menu!.item)} /> )} {menuAt && ( setMenuAt(null)} onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))} /> )}
); }