feat(ui): a Claude-Desktop-style home for every workspace

/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt
box whose Start opens a new session in the first free pane slot, Recents (a
live session attaches its pane, any other opens its transcript, nothing
resumes), and Instructions / Memory / Context / Folder. Instructions live in
lanes.instructions, extra folders and uploaded files in the new lane_sources
table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads
them from the lane's rows and hands Claude --add-dir + an appended system
prompt file, Codex --add-dir + developer_instructions. Memory is read-only.

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
This commit is contained in:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
@@ -0,0 +1,639 @@
/**
* @file WorkspaceHome — what `/workspace?lane=<id>` 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=<runId>`), the workspace's Recents (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ĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { FileText, Folder, MoreHorizontal, Pencil, Play, Plus, X } from "lucide-react";
import {
api,
type LaneMemoryFile,
type LaneSourcesView,
type PermissionMode,
type RunHandle,
} from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { liveRunFor } 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<typeof HomeBody>[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 <HomeBody key={props.lane.id} {...props} />;
}
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<PermissionMode>("auto");
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [sessions, setSessions] = useState<Session[] | null>(null);
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(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}`;
useEffect(() => {
let live = true;
api.run
.list()
.then((r) => live && setLiveRuns(r.items))
.catch(() => undefined);
api.sessions
.list({ cwd: lane.cwd, tool: lane.tool, limit: 30 })
.then((r) => live && setSessions(r.sessions))
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.id, lane.cwd, lane.tool, runKey]);
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]);
// 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<LaneSourcesView>) => {
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 } : {}),
});
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 (
<div
data-testid="workspace-home"
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
>
<div className="min-w-0 flex-1 basis-[28rem]">
<p className="text-xs text-fg-muted">
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
</p>
<div className="mt-3 flex items-center gap-2">
{title === null ? (
<h1
title={lane.cwd}
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
>
{name}
</h1>
) : (
<input
data-testid="workspace-home-rename"
aria-label={t("renameLabel")}
autoFocus
value={title}
maxLength={200}
onChange={(e) => 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"
/>
)}
<button
type="button"
data-testid="workspace-home-menu"
aria-label={t("row.actions")}
title={t("row.actions")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuAt({ x: r.left - 200, y: r.bottom + 4 });
}}
className={iconBtn}
>
<MoreHorizontal className="h-5 w-5" />
</button>
</div>
<div className="card mt-5 p-3">
<PromptEditor
value={prompt}
onChange={setPrompt}
onSubmit={() => void start()}
placeholder={t("home.placeholder")}
rows={3}
slash={claude}
fileCwd={lane.cwd}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<div className="w-56">
<ModelPicker value={model} onChange={setModel} tool={lane.tool} />
</div>
{claude && (
<div className="w-44">
<Select<PermissionMode>
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") },
]}
/>
</div>
)}
<button
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting}
className="btn-primary ml-auto text-xs disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
{t("home.start")}
</button>
</div>
</div>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
<h2 className="mt-8 text-sm font-medium text-fg-muted">{t("home.recents")}</h2>
{sessions !== null && sessions.length === 0 && (
<p className="mt-2 text-sm text-fg-muted">{t("home.noRecents")}</p>
)}
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
{(sessions ?? []).map((s) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
return (
<li key={s.id}>
<button
type="button"
data-testid={`workspace-recent-${s.id}`}
data-live={live ? "true" : undefined}
onClick={() => openSession(s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
live ? "bg-status-success" : "border border-fg-muted"
}`}
/>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
<span className="shrink-0 text-xs text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)}
</span>
</button>
</li>
);
})}
</ul>
</div>
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
<button
type="button"
onClick={() => setMemoryOpen(true)}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={async (e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
}}
/>
</div>
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li className="flex items-start gap-2 text-xs">
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
{memory.map((f) => (
<section key={f.path} className="mb-5">
<p title={f.path} className="mb-1 font-mono text-xs text-fg-muted">
{f.label}
</p>
<MarkdownPreview text={f.content} />
</section>
))}
</div>
</div>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
/>
)}
</div>
);
}