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
+3 -1
View File
File diff suppressed because one or more lines are too long
+9 -3
View File
@@ -56,10 +56,16 @@ Internal build — all rights reserved.
**Configure**
- **Workspaces like Claude Desktop projects.** The sidebar lists every
workspace with its sessions under it (a live one opens its tmux pane, any
other its transcript — never a resume). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files and extra Folders, which Claude and Codex
sessions start with.
- **Settings as one page per section** (`/settings/<section>`): a left nav
grouped General / Agents / Routing / Git / Projects, a search box (Ctrl+F)
over every section, a page per installed AI tool, per-vendor model pricing,
which untracked worktrees the sidebar lists, and a page per project.
grouped General / Agents / Routing / Git, a search box (Ctrl+F) over every
section, a page per installed AI tool, per-vendor model pricing, and which
untracked worktrees a workspace's Add folder suggests.
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
file, memory file, marketplace, keybinding and statusline Claude Code knows
+8 -3
View File
@@ -49,10 +49,15 @@ Internal build — all rights reserved.
**Cấu hình**
- **Workspace như project của Claude Desktop.** Sidebar liệt kê mọi workspace
kèm các session của nó (session còn sống mở đúng tmux pane, session khác mở
transcript — không bao giờ resume). Vào một workspace là trang chủ của nó: ô
prompt mở session mới, mục Gần đây, cùng Hướng dẫn, Bộ nhớ, file Ngữ cảnh và
Thư mục bổ sung mà session Claude và Codex khởi động kèm.
- **Settings mỗi mục một trang** (`/settings/<section>`): thanh điều hướng bên
trái chia nhóm Chung / Tác tử / Định tuyến / Git / Dự án, ô tìm kiếm (Ctrl+F)
trên mọi mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung
cấp, worktree chưa theo dõi nào được hiện trên sidebar, và một trang cho mỗi dự án.
trái chia nhóm Chung / Tác tử / Định tuyến / Git, ô tìm kiếm (Ctrl+F) trên mọi
mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung cấp, và
những worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace.
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
settings file, memory file, marketplace, keybinding và statusline mà Claude
+3 -2
View File
@@ -184,6 +184,7 @@ client/
│ │ ├── EmptyState.tsx
│ │ ├── Sidebar.tsx
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), row menu
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: prompt box (new session in a free pane slot), Recents, Instructions / Memory / Context / Folder
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
│ │ ├── Layout.tsx
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
@@ -252,7 +253,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktrees<br/>Projects: one page per repo]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings
@@ -473,7 +474,7 @@ graph TB
CcConfig["/cc-config (redirect)"]
Lanes["/lanes (redirect)"]
Run["/run (redirect)"]
Workspace["/workspace?lane=<id>"]
Workspace["/workspace?lane=<id> (home) · &run=<runId> (session)"]
Settings["/settings (tabs: dashboard | claude-code | ...)"]
NF["/* (NotFound)"]
@@ -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>
);
}
@@ -18,6 +18,19 @@ import { timeAgo } from "../../lib/format";
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)
);
}
export function WorkspaceSessionList({
lane,
liveRuns,
@@ -53,8 +66,7 @@ export function WorkspaceSessionList({
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
);
const runFor = (s: Session) =>
liveRuns.find((r) => r.sessionId === s.id && r.status === "running") ?? null;
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
if (sessions !== null && shown.length === 0) {
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
@@ -0,0 +1,299 @@
/**
* @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free
* pane slot and opens it, all-busy is refused, Recents open a live pane or the
* transcript (never a resume), instructions save, context files upload and
* remove, a folder is added with the repo's untracked worktrees suggested
* (minus hidden sources), an unsupported tool is said so, and memory opens
* read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
vi.mock("../../run/RunSetup", () => ({
PromptEditor: ({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (s: string) => void;
placeholder: string;
}) => (
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
),
ModelPicker: ({ value, onChange }: { value: string; onChange: (s: string) => void }) => (
<input aria-label="model" value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
let view: LaneSourcesView;
let runs: RunHandle[];
const sessions = [
{ id: "s-live", name: "Live one", started_at: "2026-09-30T10:00:00Z" },
{ id: "s-old", name: "Old one", started_at: "2026-09-29T10:00:00Z" },
] as Session[];
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
sources: vi.fn(async () => view),
setInstructions: vi.fn(async (_id: number, instructions: string) => ({
...view,
instructions,
})),
addFolder: vi.fn(async () => view),
addFile: vi.fn(async () => view),
removeSource: vi.fn(async () => view),
memory: vi.fn(async () => ({
files: [{ label: "CLAUDE.md", path: "/r/CLAUDE.md", content: "# Rules here" }],
})),
action: vi.fn(async () => ({ ok: true, run_id: "agenthub-lane-1-1" })),
update: vi.fn(async () => ({})),
routerList: vi.fn(async () => ({ routers: [] })),
},
run: { list: vi.fn(async () => ({ items: runs })) },
sessions: { list: vi.fn(async () => ({ sessions })) },
projects: {
list: vi.fn(async () => ({
projects: [
{
root: "/r",
name: "r",
workspaces: [1],
worktrees: [
{ path: "/r", branch: "main", main: true, workspace_id: 1 },
{
path: "/r/.claude/worktrees/x",
branch: "x",
main: false,
workspace_id: null,
source: "claude",
},
{ path: "/wt/feat", branch: "feat", main: false, workspace_id: null },
],
},
],
})),
},
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
},
};
});
import { api } from "../../../lib/api";
import { WorkspaceHome, freeSlot, paneRunId } from "../WorkspaceHome";
import { __resetAiTools } from "../../../hooks/useAiTools";
const lane = (extra: Partial<Lane> = {}) =>
({
id: 1,
title: "SG_MiniApp",
cwd: "/r",
tool: "claude",
kind: "adopted",
status: "idle",
run_id: null,
session_id: null,
...extra,
}) as unknown as Lane;
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderHome(l = lane()) {
return render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Routes>
<Route
path="*"
element={
<>
<WorkspaceHome lane={l} onMenuAction={() => undefined} />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
view = {
instructions: "",
supported: true,
maxFileBytes: 10 * 1024 * 1024,
maxInstructions: 16000,
sources: [],
};
runs = [
{
id: "agenthub-lane-1",
laneId: 1,
slot: 0,
status: "running",
sessionId: "s-live",
} as RunHandle,
];
});
describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0);
expect(freeSlot(1, runs)).toBe(1);
const all = [0, 1, 2, 3].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(2, all)).toBe(0);
expect(paneRunId(4, 0)).toBe("agenthub-lane-4");
expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2");
});
});
describe("WorkspaceHome", () => {
it("starts a new session in the first free pane and opens it", async () => {
const user = userEvent.setup();
renderHome();
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
prompt: "fix the login",
permissionMode: "auto",
});
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=1&run=agenthub-lane-1-1"
)
);
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
await user.click(screen.getByTestId("workspace-home-start"));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
const user = userEvent.setup();
renderHome();
const live = await screen.findByTestId("workspace-recent-s-live");
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
await user.click(live);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
await user.click(screen.getByTestId("workspace-recent-s-old"));
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("saves instructions", async () => {
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByRole("button", { name: "Edit instructions" }));
await user.type(screen.getByTestId("workspace-instructions"), "Answer in Vietnamese");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(api.lanes.setInstructions).toHaveBeenCalledWith(1, "Answer in Vietnamese");
expect(await screen.findByText("Answer in Vietnamese")).toBeInTheDocument();
});
it("uploads a context file and removes one", async () => {
view.sources = [
{
id: 7,
kind: "file",
path: "/d/spec.pdf",
name: "spec.pdf",
size: 2048,
added_at: "",
missing: false,
},
];
const user = userEvent.setup();
renderHome();
expect(await screen.findByText("spec.pdf")).toBeInTheDocument();
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("workspace-file-input"), file);
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
});
it("adds a folder, suggesting untracked worktrees minus hidden sources", async () => {
localStorage.setItem(
"agenthub.sidebar.projects",
JSON.stringify({ hideSources: ["claude"], showUntracked: true })
);
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByRole("button", { name: "Add folder" }));
await user.click(await screen.findByRole("button", { name: "/wt/feat" }));
expect(screen.queryByRole("button", { name: "/r/.claude/worktrees/x" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Add" }));
expect(api.lanes.addFolder).toHaveBeenCalledWith(1, "/wt/feat");
});
it("says when the workspace's tool cannot take sources", async () => {
view.supported = false;
renderHome(lane({ tool: "gemini" as Lane["tool"] }));
expect(await screen.findByText(/cannot take instructions/)).toBeInTheDocument();
});
it("shows memory read-only in a dialog", async () => {
const user = userEvent.setup();
renderHome();
expect(await screen.findByText("1 file the agent reads")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View memory" }));
const dialog = screen.getByRole("dialog", { name: "Memory" });
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
expect(within(dialog).queryByRole("textbox")).toBeNull();
});
});
describe("WorkspaceHome across workspaces", () => {
it("drops the previous workspace's prompt and model when another opens", async () => {
const user = userEvent.setup();
const ui = (l: Lane) => (
<MemoryRouter>
<WorkspaceHome lane={l} onMenuAction={() => undefined} />
</MemoryRouter>
);
const { rerender } = render(ui(lane()));
await user.type(screen.getByLabelText("How can I help you today?"), "half typed");
await user.type(screen.getByLabelText("model"), "opus");
rerender(ui(lane({ id: 2, tool: "codex", cwd: "/other" })));
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
expect(screen.getByLabelText("model")).toHaveValue("");
});
it("starts on the tool's default model once the catalog arrives", async () => {
__resetAiTools();
vi.mocked(api.aiTools.list).mockResolvedValueOnce({
tools: [{ id: "claude", label: "Claude", defaultModel: "sonnet" }],
default: "claude",
} as never);
renderHome();
await waitFor(() => expect(screen.getByLabelText("model")).toHaveValue("sonnet"));
});
});
@@ -221,9 +221,13 @@ export function LaneConsolePane({
}, [laneId, slot, refreshList]);
// Runs after the lane-switch effect above, so a sidebar pick that changes
// lane AND run at once lands on the picked run, not the lane's own.
// lane AND run at once lands on the picked run, not the lane's own. Keyed on
// `laneId` too: on a cold load the pane mounts before the lane list does,
// and the switch effect then resets the pane to its own slot once `laneId`
// arrives — the request must land after that. Only this lane's own panes.
useEffect(() => {
if (!attachRequest) return;
if (!attachRequest || laneId === null) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
let live = true;
autoAttachedForLane.current = laneId;
api.run
@@ -239,9 +243,10 @@ export function LaneConsolePane({
return () => {
live = false;
};
// Only a new request (nonce) attaches; a lane switch never replays one.
// A new request (nonce), or the lane it names arriving, attaches; a switch
// to another lane drops `?run=` (Workspace), so it never replays one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce]);
}, [attachRequest?.nonce, laneId]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
+1 -1
View File
@@ -142,7 +142,7 @@ interface PromptEditorProps {
fileCwd: string;
}
function PromptEditor({
export function PromptEditor({
value,
onChange,
onSubmit,
@@ -556,4 +556,50 @@ describe("LaneConsolePane", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
expect(api.run.start).not.toHaveBeenCalled();
});
it("attaches once the lane arrives on a cold load, and ignores another lane's run", async () => {
vi.mocked(api.run.get).mockResolvedValue({
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-9",
startedAt: null,
promptPreview: null,
tool: "claude",
});
const req = { runId: "agenthub-lane-1-1", nonce: "k2" };
const { rerender } = render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} laneId={null} attachRequest={req} />
</MemoryRouter>
);
// The lane list lands only after the pane mounted (and after any early
// attach would have resolved) — the switch effect must not win.
await new Promise((r) => setTimeout(r, 20));
rerender(
<MemoryRouter>
<LaneConsolePane {...baseProps()} attachRequest={req} />
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-1");
vi.mocked(api.run.get).mockClear();
rerender(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
attachRequest={{ runId: "agenthub-lane-12", nonce: "k3" }}
/>
</MemoryRouter>
);
await new Promise((r) => setTimeout(r, 10));
expect(api.run.get).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,8 @@
/**
* @file WorktreeVisibilitySettings — Settings → Git → Worktrees: whether the
* sidebar's project tree lists a repo's untracked worktrees, and which sources
* of them (`.claude/worktrees/*`, AgentHub-managed) it hides. Writes the tree's
* own localStorage prefs (`lib/projectTreePrefs.ts`), so the sidebar follows at
* once; a project's own override lives on its Settings → Projects page.
* @file WorktreeVisibilitySettings — Settings → Git → Worktrees: whether a
* workspace home's Add folder suggests the repo's untracked worktrees, and
* which sources of them (`.claude/worktrees/*`, AgentHub-managed) it leaves
* out. Writes the localStorage prefs in `lib/projectTreePrefs.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -2,8 +2,7 @@
* @file WorktreeVisibilitySettings.test.tsx
* @description Settings → Worktrees writes the sidebar tree's own prefs key
* (`agenthub.sidebar.projects`), disables the source rows while untracked
* worktrees are hidden, and a project's override (`hiddenSourcesFor`) replaces
* the global list for that project only.
* worktrees are not suggested.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -11,7 +10,6 @@ import { beforeEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WorktreeVisibilitySettings } from "../WorktreeVisibilitySettings";
import { hiddenSourcesFor, readProjectTreePrefs } from "../../../lib/projectTreePrefs";
const stored = () => JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
@@ -29,15 +27,4 @@ describe("WorktreeVisibilitySettings", () => {
expect(stored().showUntracked).toBe(false);
expect(screen.getByRole("switch", { name: "AgentHub-managed worktrees" })).toBeDisabled();
});
it("lets a project's own list replace the global one", () => {
localStorage.setItem(
"agenthub.sidebar.projects",
JSON.stringify({ hideSources: ["claude"], projectHideSources: { "/r": [] } })
);
const prefs = readProjectTreePrefs();
expect(hiddenSourcesFor(prefs, "/r")).toEqual([]);
expect(hiddenSourcesFor(prefs, "/other")).toEqual(["claude"]);
expect(hiddenSourcesFor(prefs, null)).toEqual(["claude"]);
});
});
@@ -145,6 +145,34 @@ describe("useNotifications", () => {
vi.restoreAllMocks();
});
it("links a notification to the pane that holds the session, in any slot", async () => {
fetchMock.mockImplementation(async (url: string) => ({
ok: true,
json: async () =>
url === "/api/push/send"
? { ok: true, native: true, pushed: 1 }
: url === "/api/run"
? {
items: [
{ id: "agenthub-lane-4-2", laneId: 4, status: "running", sessionId: "s-7" },
],
}
: { lanes: [], counts: {} },
}));
enable();
renderHook(() => useNotifications());
eventBus.publish({
type: "new_event",
timestamp: new Date().toISOString(),
data: { event_type: "Stop", summary: "Done", session_id: "s-7" } as DashboardEvent,
});
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
const push = fetchMock.mock.calls.find((c) => c[0] === "/api/push/send")!;
expect(JSON.parse((push[1] as RequestInit).body as string).url).toBe(
"/workspace?lane=4&run=agenthub-lane-4-2"
);
});
it("stays silent while notifications are disabled", async () => {
renderHook(() => useNotifications());
+25 -7
View File
@@ -84,8 +84,19 @@ export function useNotifications() {
.then((r) => (lanesRef.current = r.lanes))
.catch(() => {});
function laneUrlForSession(sessionId: string): string | undefined {
const lane = lanesRef.current.find((l) => l.session_id === sessionId);
// Straight into the pane that holds the session (any slot, from the
// hooks' `run_id` binding) — the agent waiting on the user is there, not
// on the workspace's home; the home when no pane holds it.
async function laneUrlForSession(sessionId: string): Promise<string | undefined> {
const runs = await api.run
.list()
.then((r) => r.items ?? [])
.catch(() => []);
const run = runs.find((r) => r.sessionId === sessionId && r.status === "running");
if (run && run.laneId !== null) {
return `/workspace?lane=${run.laneId}&run=${encodeURIComponent(run.id)}`;
}
const lane = (lanesRef.current ?? []).find((l) => l.session_id === sessionId);
return lane ? `/workspace?lane=${lane.id}` : undefined;
}
@@ -106,11 +117,18 @@ export function useNotifications() {
if (!prefs.enabled) return;
// A notification that was shown (pushed, native or local) plays its
// type's sound; one that reached nobody plays nothing.
const show = (type: NotifType, title: string, body: string, url?: string) =>
void notify(title, body, url).then((r) => {
if (r.via === "native" || r.via === "push" || r.via === "local")
playSound(soundFor(prefs, type));
});
const show = (
type: NotifType,
title: string,
body: string,
url?: Promise<string | undefined>
) =>
void Promise.resolve(url)
.then((u) => notify(title, body, u))
.then((r) => {
if (r.via === "native" || r.via === "push" || r.via === "local")
playSound(soundFor(prefs, type));
});
switch (msg.type) {
case "session_created": {
+31 -1
View File
@@ -334,5 +334,35 @@
"tree.search": "Search workspaces and sessions",
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.transcriptHint": "No live pane — opens the transcript"
"tree.transcriptHint": "No live pane — opens the transcript",
"home.breadcrumb": "Workspaces",
"home.placeholder": "How can I help you today?",
"home.start": "Start",
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
"home.recents": "Recents",
"home.noRecents": "No sessions yet — start one above.",
"home.instructions": "Instructions",
"home.instructionsEmpty": "Add instructions to tailor how the agent works in this workspace. They are appended to every new session's system prompt.",
"home.edit": "Edit instructions",
"home.save": "Save",
"home.cancel": "Cancel",
"home.memory": "Memory",
"home.viewMemory": "View memory",
"home.memoryCount_one": "{{count}} file the agent reads",
"home.memoryCount_other": "{{count}} files the agent reads",
"home.memoryEmpty": "Memory shows here once the folder has a CLAUDE.md or AGENTS.md, or Claude saves memory for it.",
"home.context": "Context",
"home.contextEmpty": "Add PDFs, documents, or other text to reference in this workspace.",
"home.addFile": "Add files",
"home.folder": "Folder",
"home.ownFolder": "Workspace folder",
"home.addFolder": "Add folder",
"home.folderPath": "Absolute folder path",
"home.add": "Add",
"home.suggested": "Untracked worktrees",
"home.remove": "Remove",
"home.missing": "missing",
"home.unsupported": "{{tool}} cannot take instructions, context files or extra folders: they are kept, but its sessions start without them.",
"home.back": "Back to the workspace home",
"home.close": "Close"
}
+5 -28
View File
@@ -649,8 +649,7 @@
"general": "General",
"agents": "Agents",
"routing": "Routing",
"git": "Git",
"projects": "Projects"
"git": "Git"
},
"section": {
"general": {
@@ -694,10 +693,7 @@
},
"worktrees": {
"title": "Worktrees",
"description": "Which git worktrees the sidebar lists under each project."
},
"project": {
"description": "This project's workspaces and worktree settings."
"description": "Which untracked git worktrees a workspace's Add folder suggests."
}
}
},
@@ -706,32 +702,13 @@
"noMatch": "No setting matches “{{query}}”."
},
"worktrees": {
"title": "Untracked worktrees",
"description": "A worktree with no workspace on it is listed under its project with an Adopt button. Saved in this browser only.",
"title": "Worktree suggestions",
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
"showUntracked": "Show untracked worktrees",
"showUntrackedDesc": "List a repository's worktrees that have no workspace yet.",
"showUntrackedDesc": "Suggest a repository's worktrees that have no workspace yet.",
"claude": "Claude Code worktrees",
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
"agenthub": "AgentHub-managed worktrees",
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
},
"project": {
"notFound": "No such project.",
"facts": "Project",
"root": "Root",
"mainBranch": "Main branch",
"worktreeCount": "Worktrees",
"workspaces": "Workspaces",
"noWorkspaces": "No workspace in this project yet.",
"sources": "Worktree sources",
"sourcesDesc": "Override the Worktrees page for this project only.",
"override": "Use this project's own setting",
"forget": "Forget workspaces",
"forgetDesc": "Remove every workspace of this project from AgentHub. Working trees, branches and data on disk are left alone.",
"forgetAll": "Forget all workspaces…",
"forgetTitle": "Forget {{count}} workspaces?",
"forgetBody": "AgentHub drops its record of each one and stops its runs. Nothing on disk is deleted.",
"forgetConfirm": "Forget all",
"forgetFailed": "Could not forget {{lane}}: {{message}}"
}
}
+31 -1
View File
@@ -334,5 +334,35 @@
"tree.search": "Tìm workspace và session",
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.transcriptHint": "Không còn pane — mở transcript"
"tree.transcriptHint": "Không còn pane — mở transcript",
"home.breadcrumb": "Không gian làm việc",
"home.placeholder": "Tôi có thể giúp gì cho bạn hôm nay?",
"home.start": "Bắt đầu",
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
"home.recents": "Gần đây",
"home.noRecents": "Chưa có session nào — bắt đầu ở ô phía trên.",
"home.instructions": "Hướng dẫn",
"home.instructionsEmpty": "Thêm hướng dẫn để điều chỉnh cách agent làm việc trong workspace này. Hướng dẫn được nối vào system prompt của mỗi session mới.",
"home.edit": "Sửa hướng dẫn",
"home.save": "Lưu",
"home.cancel": "Huỷ",
"home.memory": "Bộ nhớ",
"home.viewMemory": "Xem bộ nhớ",
"home.memoryCount_one": "{{count}} file agent đọc",
"home.memoryCount_other": "{{count}} file agent đọc",
"home.memoryEmpty": "Bộ nhớ hiện ở đây khi thư mục có CLAUDE.md hoặc AGENTS.md, hoặc Claude lưu bộ nhớ cho nó.",
"home.context": "Ngữ cảnh",
"home.contextEmpty": "Thêm PDF, tài liệu hoặc văn bản để tham chiếu trong workspace này.",
"home.addFile": "Thêm file",
"home.folder": "Thư mục",
"home.ownFolder": "Thư mục của workspace",
"home.addFolder": "Thêm thư mục",
"home.folderPath": "Đường dẫn tuyệt đối của thư mục",
"home.add": "Thêm",
"home.suggested": "Worktree chưa theo dõi",
"home.remove": "Gỡ",
"home.missing": "không còn",
"home.unsupported": "{{tool}} không nhận được hướng dẫn, file ngữ cảnh hay thư mục bổ sung: chúng vẫn được giữ, nhưng session của nó khởi động mà không có chúng.",
"home.back": "Quay lại trang workspace",
"home.close": "Đóng"
}
+5 -28
View File
@@ -649,8 +649,7 @@
"general": "Chung",
"agents": "Tác tử",
"routing": "Định tuyến",
"git": "Git",
"projects": "Dự án"
"git": "Git"
},
"section": {
"general": {
@@ -694,10 +693,7 @@
},
"worktrees": {
"title": "Worktree",
"description": "Những git worktree mà sidebar liệt kê dưới mỗi dự án."
},
"project": {
"description": "Các workspace và cài đặt worktree của dự án này."
"description": "Những git worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace."
}
}
},
@@ -706,32 +702,13 @@
"noMatch": "Không có cài đặt nào khớp “{{query}}”."
},
"worktrees": {
"title": "Worktree chưa theo dõi",
"description": "Worktree chưa có workspace được liệt kê dưới dự án của nó kèm nút Nhận. Chỉ lưu trong trình duyệt này.",
"title": "Gợi ý worktree",
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
"showUntracked": "Hiện worktree chưa theo dõi",
"showUntrackedDesc": "Liệt kê các worktree của repo chưa có workspace.",
"showUntrackedDesc": "Gợi ý các worktree của repo chưa có workspace.",
"claude": "Worktree của Claude Code",
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
"agenthub": "Worktree do AgentHub quản lý",
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
},
"project": {
"notFound": "Không có dự án này.",
"facts": "Dự án",
"root": "Thư mục gốc",
"mainBranch": "Nhánh chính",
"worktreeCount": "Worktree",
"workspaces": "Workspace",
"noWorkspaces": "Dự án này chưa có workspace.",
"sources": "Nguồn worktree",
"sourcesDesc": "Ghi đè trang Worktree cho riêng dự án này.",
"override": "Dùng cài đặt riêng của dự án",
"forget": "Quên workspace",
"forgetDesc": "Gỡ mọi workspace của dự án này khỏi AgentHub. Thư mục làm việc, nhánh và dữ liệu trên đĩa được giữ nguyên.",
"forgetAll": "Quên tất cả workspace…",
"forgetTitle": "Quên {{count}} workspace?",
"forgetBody": "AgentHub xoá bản ghi của từng workspace và dừng các lượt chạy. Không có gì trên đĩa bị xoá.",
"forgetConfirm": "Quên tất cả",
"forgetFailed": "Không quên được {{lane}}: {{message}}"
}
}
+63
View File
@@ -2739,6 +2739,37 @@ export const api = {
* @param patch Subset of lane fields to update.
* @returns `{ lane }` — the updated {@link Lane}.
*/
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
method: "POST",
body: JSON.stringify({ path }),
}),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) =>
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
method: "POST",
body: file,
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
},
}),
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
/**
@@ -3879,6 +3910,38 @@ export interface RunHandle {
tool: LaneTool;
}
/** One extra folder or uploaded context file of a workspace. */
export interface LaneSource {
id: number;
kind: "folder" | "file";
path: string;
name: string;
size: number | null;
added_at: string;
/** The folder or file is gone from disk; it is skipped at spawn. */
missing: boolean;
}
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
supported: boolean;
maxFileBytes: number;
maxInstructions: number;
sources: LaneSource[];
}
/** One read-only memory file from GET /api/lanes/:id/memory. */
export interface LaneMemoryFile {
label: string;
path: string;
size: number;
mtime: string;
truncated: boolean;
content: string;
}
/** Response shape of GET /api/run. */
export interface RunListResponse {
items: RunHandle[];
+4 -17
View File
@@ -1,11 +1,10 @@
/**
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
* expanded to their sessions (by lane id), whether a repo's untracked
* worktrees are offered, and which
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
* LANES_ROOT) are hidden — globally, or per project root, where a project's own
* list (Settings → Projects) replaces the global one. A `useSyncExternalStore` reader that caches nothing
* expanded to their sessions (by lane id), and — Settings → Worktrees —
* whether a workspace home's Add folder suggests the repo's untracked
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
* but the parsed blob for the current raw string, so the sidebar and Settings
* can never disagree. Nothing here reaches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -20,15 +19,12 @@ export interface ProjectTreePrefs {
expanded: Record<string, boolean>;
showUntracked: boolean;
hideSources: string[];
/** Per project root: replaces `hideSources` for that project. */
projectHideSources: Record<string, string[]>;
}
const DEFAULTS: ProjectTreePrefs = {
expanded: {},
showUntracked: true,
hideSources: [],
projectHideSources: {},
};
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
@@ -51,10 +47,6 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
projectHideSources:
parsed.projectHideSources && typeof parsed.projectHideSources === "object"
? parsed.projectHideSources
: {},
};
} catch {
value = DEFAULTS;
@@ -72,11 +64,6 @@ export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
listeners.forEach((l) => l());
}
/** The sources hidden for one project: its own override, else the global list. */
export function hiddenSourcesFor(prefs: ProjectTreePrefs, root: string | null): string[] {
return (root !== null && prefs.projectHideSources[root]) || prefs.hideSources;
}
function subscribe(l: () => void) {
listeners.add(l);
return () => listeners.delete(l);
-171
View File
@@ -1,171 +0,0 @@
/**
* @file ProjectSettings — Settings → Projects → <project>: a read-only card of
* the repository (root, main branch, worktree count), its workspaces linked to
* the Workspace page, this project's own override of the worktree sources the
* sidebar hides (`projectHideSources` in `lib/projectTreePrefs.ts`), and
* "Forget all workspaces", which runs the same `remove` as the row menu for
* each one — preflight, then `POST /api/lanes/:id/remove` with `confirm` and
* the preflight facts as `expect`. Forgetting drops AgentHub's record only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../lib/api";
import type { Lane, Project } from "../lib/types";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { ConfirmModal } from "../components/ConfirmModal";
import { expectFor } from "../components/lanes/DestructiveLaneModal";
import {
SettingsRow,
SettingsSection,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import { SourceSwitches } from "../components/settings/WorktreeVisibilitySettings";
export function ProjectSettings({
project,
lanes,
onForgotten,
}: {
project: Project & { root: string };
lanes: Lane[];
/** Called after the forget loop ends, so the caller can re-read lanes. */
onForgotten: () => void;
}) {
const { t } = useTranslation("settings");
const prefs = useProjectTreePrefs();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const own = lanes.filter((l) => project.workspaces.includes(l.id));
const main = project.worktrees.find((w) => w.main);
const override = prefs.projectHideSources[project.root];
const setOverride = (hidden: string[] | null) => {
const next = { ...prefs.projectHideSources };
if (hidden) next[project.root] = hidden;
else delete next[project.root];
writeProjectTreePrefs({ projectHideSources: next });
};
const forgetAll = async () => {
setBusy(true);
setError(null);
try {
for (const lane of own) {
try {
const preflight = await api.lanes.preflight(lane.id, "remove");
await api.lanes.action(lane.id, "remove", {
confirm: true,
expect: expectFor(preflight),
});
} catch (err) {
setError(
t("project.forgetFailed", {
lane: lane.title || lane.cwd,
message: err instanceof Error ? err.message : String(err),
})
);
break;
}
}
} finally {
setBusy(false);
setConfirming(false);
onForgotten();
}
};
return (
<div className="space-y-4">
<SettingsSection title={t("project.facts")}>
<SettingsRow
label={t("project.root")}
control={
<span className="break-all font-mono text-xs text-fg-primary">{project.root}</span>
}
/>
<SettingsRow
label={t("project.mainBranch")}
control={<span className="font-mono text-xs text-fg-primary">{main?.branch ?? "—"}</span>}
/>
<SettingsRow
label={t("project.worktreeCount")}
control={<span className="text-xs text-fg-primary">{project.worktrees.length}</span>}
/>
</SettingsSection>
<SettingsSection title={t("project.workspaces")}>
{own.length === 0 ? (
<p className="py-2 text-xs text-fg-muted">{t("project.noWorkspaces")}</p>
) : (
<ul data-testid="project-workspaces">
{own.map((l) => (
<li key={l.id} className="border-b border-border/60 py-2 last:border-b-0">
<Link
to={`/workspace?lane=${l.id}`}
className="text-sm text-accent hover:underline"
>
{l.title || l.cwd}
</Link>
<span className="ml-2 font-mono text-[11px] text-fg-muted">#{l.id}</span>
</li>
))}
</ul>
)}
</SettingsSection>
<SettingsSection title={t("project.sources")} description={t("project.sourcesDesc")}>
<SettingsSwitchRow
label={t("project.override")}
checked={!!override}
onChange={(on) => setOverride(on ? [...prefs.hideSources] : null)}
/>
<SourceSwitches
hidden={override ?? prefs.hideSources}
disabled={!override}
onChange={setOverride}
/>
</SettingsSection>
<SettingsSection title={t("project.forget")} description={t("project.forgetDesc")}>
<div className="py-2">
<button
type="button"
disabled={own.length === 0 || busy}
onClick={() => setConfirming(true)}
className="rounded-md border border-status-danger/40 px-3 py-1.5 text-xs text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
>
{t("project.forgetAll")}
</button>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</div>
</SettingsSection>
<ConfirmModal
open={confirming}
title={t("project.forgetTitle", { count: own.length })}
message={t("project.forgetBody")}
confirmLabel={t("project.forgetConfirm")}
cancelLabel={t("common:cancel")}
busy={busy}
onConfirm={() => void forgetAll()}
onCancel={() => setConfirming(false)}
>
<ul className="max-h-48 overflow-y-auto text-xs text-fg-secondary">
{own.map((l) => (
<li key={l.id} className="font-mono">
#{l.id} {l.title || l.cwd}
</li>
))}
</ul>
</ConfirmModal>
</div>
);
}
+5 -44
View File
@@ -1,13 +1,13 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Git / Projects (a `<select>` below `md`), a
* General / Agents / Routing / Git (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it, Enter opens the first match, Escape clears), and one page per
* section. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. Each project in `GET /api/projects` gets `projects/<encoded root>`.
* disabled. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,7 +20,6 @@ import {
Bot,
Boxes,
Coins,
FolderGit2,
GitBranch,
KeyRound,
Orbit,
@@ -37,18 +36,14 @@ import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { ProjectSettings } from "./ProjectSettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { api } from "../lib/api";
import type { Project } from "../lib/types";
import { rankSettings, SETTINGS_INDEX, type SettingsEntry } from "../lib/settingsSearch";
const GROUPS = ["general", "agents", "routing", "git", "projects"] as const;
const GROUPS = ["general", "agents", "routing", "git"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -76,22 +71,8 @@ export function SettingsHub() {
const location = useLocation();
const navigate = useNavigate();
const aiTools = useAiTools();
const { lanes, refresh: refreshLanes } = useLanes();
const [projects, setProjects] = useState<Project[] | null>(null);
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const laneKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
let live = true;
api.projects
.list()
.then((r) => live && setProjects(r.projects))
.catch(() => live && setProjects([]));
return () => {
live = false;
};
}, [laneKey]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -215,26 +196,6 @@ export function SettingsHub() {
...s("worktrees"),
render: () => <WorktreeVisibilitySettings />,
},
...(projects ?? []).flatMap((p) =>
p.root === null
? []
: [
{
id: `projects/${encodeURIComponent(p.root)}`,
group: "projects" as const,
Icon: FolderGit2,
title: p.name,
description: t("settingsNav.section.project.description"),
render: () => (
<ProjectSettings
project={{ ...p, root: p.root as string }}
lanes={lanes}
onForgotten={() => void refreshLanes()}
/>
),
},
]
),
];
const entries: SettingsEntry[] = sections.flatMap((sec) => [
@@ -267,8 +228,8 @@ export function SettingsHub() {
}
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool or project page exists only once its list has loaded.
if (!aiTools.loaded || projects === null) return null;
// A catalog tool page exists only once the tool list has loaded.
if (!aiTools.loaded) return null;
return <Navigate to="/settings/general" replace />;
}
+71 -4
View File
@@ -47,6 +47,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import { useLocation, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Plus,
Square,
Columns2,
@@ -79,6 +80,8 @@ import LaneCard, {
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
@@ -373,6 +376,8 @@ export function Workspace() {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
// A run belongs to its lane: another lane opens on its home.
if (next.get("lane") !== String(id)) next.delete("run");
if (id === null) next.delete("lane");
else next.set("lane", String(id));
return next;
@@ -394,6 +399,10 @@ export function Workspace() {
if (paramId === null) setSelectedLaneId(selectedLaneId);
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [destructive, setDestructive] = useState<{
lane: Lane;
action: "reset" | "remove";
} | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
// Fields the Add-lane modal opens with when it was opened from a lane's git
// bar ("new lane here"): one provisioning path for every lane, prefilled.
@@ -664,8 +673,12 @@ export function Workspace() {
// so the form is fully reachable on short windows. A full-width Changes panel
// hides that form anyway, and without a locked height it shrinks to its
// content — the editor inside it collapsed to a few lines (no live run).
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam;
const viewportLocked =
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -767,7 +780,26 @@ export function Workspace() {
it rather than under it. It collapses to a rail instead of
unmounting nothing — there is no live subscription inside it, so
unmounting is safe and keeps a closed panel from polling. */}
<div className="flex min-h-0 flex-1 gap-4">
{home && currentLane && (
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
} else if (action === "started") {
setSearchParams({
lane: String(currentLane.id),
run: `agenthub-lane-${currentLane.id}`,
});
}
}}
/>
)}
<div className={`flex min-h-0 flex-1 gap-4${home ? " hidden" : ""}`}>
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}${
@@ -790,8 +822,18 @@ export function Workspace() {
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
{/* Renaming lives on the sidebar row (double-click); here the
name is only read. */}
{/* Back to the workspace's home; renaming lives on the sidebar
row and the home's title, so here the name is only read. */}
<button
type="button"
data-testid="workspace-back-home"
onClick={() => setSearchParams({ lane: String(currentLane.id) })}
title={tLanes("home.back")}
aria-label={tLanes("home.back")}
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<ArrowLeft className="h-4 w-4" />
</button>
<span
title={currentLane.cwd}
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
@@ -1086,6 +1128,31 @@ export function Workspace() {
}}
/>
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={async (body) => {
const { lane, action } = destructive;
setDestructive(null);
setLaneActionError(null);
try {
await api.lanes.action(lane.id, action, {
confirm: true,
expect: body.expect,
...(body.force === true ? { force: true } : {}),
});
await refreshLanes();
if (action === "remove") setSearchParams({});
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : String(err));
}
}}
/>
)}
{laneActionError && (
<p
role="alert"
@@ -1,99 +0,0 @@
/**
* @file ProjectSettings.test.tsx
* @description A project's settings page: its facts and workspaces, the
* per-project source override written under `projectHideSources`, and
* "Forget all workspaces" running preflight + `remove` (with the preflight
* facts as `expect`) for each of the project's workspaces only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane, Project } from "../../lib/types";
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
preflight: vi.fn(async (id: number) => ({
action: "remove",
head: `h${id}`,
dirty: 0,
untracked: 0,
unpushed: 0,
})),
action: vi.fn(async () => ({ ok: true })),
},
},
};
});
import { api } from "../../lib/api";
import { ProjectSettings } from "../ProjectSettings";
const lane = (id: number, cwd: string) => ({ id, cwd, title: "" }) as unknown as Lane;
const project: Project & { root: string } = {
root: "/r",
name: "r",
workspaces: [1, 2],
worktrees: [{ path: "/r", branch: "main", main: true, workspace_id: 1 }],
};
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
function renderPage(onForgotten = vi.fn()) {
render(
<MemoryRouter>
<ProjectSettings
project={project}
lanes={[lane(1, "/r"), lane(2, "/r/sub"), lane(3, "/elsewhere")]}
onForgotten={onForgotten}
/>
</MemoryRouter>
);
return onForgotten;
}
describe("ProjectSettings", () => {
it("shows the project's facts and only its own workspaces", () => {
renderPage();
expect(screen.getByText("main")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "/r/sub" })).toHaveAttribute(
"href",
"/workspace?lane=2"
);
expect(screen.queryByText("/elsewhere")).toBeNull();
});
it("writes a per-project source override", async () => {
const user = userEvent.setup();
renderPage();
expect(screen.getByRole("switch", { name: "Claude Code worktrees" })).toBeDisabled();
await user.click(screen.getByRole("switch", { name: "Use this project's own setting" }));
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
const stored = JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
expect(stored.projectHideSources).toEqual({ "/r": ["claude"] });
expect(stored.hideSources).toEqual([]);
});
it("forgets every workspace of the project after a confirm", async () => {
const user = userEvent.setup();
const onForgotten = renderPage();
await user.click(screen.getByRole("button", { name: "Forget all workspaces…" }));
await user.click(screen.getByRole("button", { name: "Forget all" }));
await waitFor(() => expect(onForgotten).toHaveBeenCalled());
expect(api.lanes.action).toHaveBeenCalledTimes(2);
expect(api.lanes.action).toHaveBeenCalledWith(1, "remove", {
confirm: true,
expect: { head: "h1", dirty: 0, untracked: 0, unpushed: 0 },
});
expect(api.lanes.action).toHaveBeenCalledWith(2, "remove", expect.anything());
});
});
@@ -3,7 +3,7 @@
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, a project gets its page, the mobile
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -14,7 +14,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
import type { Project } from "../../lib/types";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
@@ -43,7 +42,6 @@ const tool = (id: string, over: Partial<AiTool> = {}) =>
...over,
}) as AiTool;
let view: AiToolsView;
let projects: Project[];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -51,7 +49,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
...actual,
api: {
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
projects: { list: vi.fn(async () => ({ projects })) },
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
},
};
@@ -97,10 +94,6 @@ beforeEach(() => {
tool("aider", { installed: false }),
],
};
projects = [
{ root: "/r/app", name: "app", workspaces: [], worktrees: [] },
{ root: null, name: "Folders", workspaces: [], worktrees: [] },
];
});
describe("SettingsHub", () => {
@@ -146,13 +139,6 @@ describe("SettingsHub", () => {
expect(links).not.toContain("Aider");
});
it("lists a project under Projects and opens its page", async () => {
renderAt(`/settings/projects/${encodeURIComponent("/r/app")}`);
expect(await screen.findByRole("heading", { level: 1, name: "app" })).toBeInTheDocument();
expect(screen.getByText("/r/app")).toBeInTheDocument();
expect(within(nav()).queryByText("Folders")).toBeNull();
});
it("navigates from the mobile select", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
@@ -53,6 +53,13 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -53,6 +53,13 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -67,6 +67,13 @@ const h = vi.hoisted(() => {
const routerApply = h.routerApply;
let lanesToReturn: LaneFixture[] = [];
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
@@ -50,6 +50,13 @@ let lanesToReturn: LaneFixture[] = [
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
let nextRunId = 1;
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
@@ -694,3 +701,25 @@ describe("split terminal view", () => {
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
});
describe("Workspace — home and session view", () => {
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
await renderWorkspace();
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
});
it("opens the session view for ?run=, and the back button returns home", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
});
});
+36 -2
View File
@@ -1120,6 +1120,40 @@ POST /api/locks/:name/release
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
#### Workspace context: instructions, folders, context files, memory
```http
GET /api/lanes/:id/sources
PUT /api/lanes/:id/instructions {"instructions": "…" | null}
POST /api/lanes/:id/sources/folder {"path": "/abs/dir"}
POST /api/lanes/:id/sources/file raw body, Content-Type: application/octet-stream, X-File-Name: <URI-encoded name>
DELETE /api/lanes/:id/sources/:sourceId
GET /api/lanes/:id/memory
```
What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes/lane-sources.js`). `GET …/sources` and every write answer the same view:
```jsonc
{
"instructions": "Always answer in Vietnamese.",
"supported": true, // the workspace's tool can take them (claude, codex)
"maxFileBytes": 10485760,
"maxInstructions": 16000,
"sources": [
{ "id": 3, "kind": "folder", "path": "/home/u/shared", "name": "shared", "size": null, "added_at": "…", "missing": false },
{ "id": 4, "kind": "file", "path": "<data dir>/workspace-context/12/spec.pdf", "name": "spec.pdf", "size": 20480, "added_at": "…", "missing": false }
]
}
```
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
#### Projects (workspaces grouped by repository)
```http
@@ -1127,7 +1161,7 @@ GET /api/projects
POST /api/projects/refresh
```
Every workspace grouped under the git repository it belongs to, for the sidebar's project tree. A project's `root` is the repo's MAIN worktree top level (`git rev-parse --git-common-dir`, realpath); workspaces outside any repo form the `root: null` pseudo-project, listed last. `worktrees` is `git worktree list` for that repo (a worktree whose directory is gone is left out); `workspace_id` is the lane sitting exactly on that path, else `null` — an **untracked worktree** the UI offers to adopt. A lane on a subdirectory of a worktree is in `workspaces` but marks no worktree. `source` is `"claude"` under `<root>/.claude/worktrees/`, `"agenthub"` under `LANES_ROOT`, else absent.
Every workspace grouped under the git repository it belongs to; a workspace home's **Add folder** suggests the untracked worktrees of its own repo from it. A project's `root` is the repo's MAIN worktree top level (`git rev-parse --git-common-dir`, realpath); workspaces outside any repo form the `root: null` pseudo-project, listed last. `worktrees` is `git worktree list` for that repo (a worktree whose directory is gone is left out); `workspace_id` is the lane sitting exactly on that path, else `null` — an **untracked worktree** the UI offers to adopt. A lane on a subdirectory of a worktree is in `workspaces` but marks no worktree. `source` is `"claude"` under `<root>/.claude/worktrees/`, `"agenthub"` under `LANES_ROOT`, else absent.
```jsonc
{
@@ -1146,7 +1180,7 @@ Every workspace grouped under the git repository it belongs to, for the sidebar'
}
```
Both git reads are cached in memory — a directory's root for the life of the server, a repo's worktree list for 30 s — and the lane ↔ worktree match is recomputed on every call, so a new or removed workspace needs no invalidation. This route is read on demand (the client re-reads it when the set of workspaces changes), never on the polled `GET /api/lanes`. `POST /api/projects/refresh` drops both caches first and answers the same shape; it is same-origin only (`403 EBADORIGIN`).
Both git reads are cached in memory — a directory's root for the life of the server, a repo's worktree list for 30 s — and the lane ↔ worktree match is recomputed on every call, so a new or removed workspace needs no invalidation. This route is read on demand (a workspace home's Add folder suggestions), never on the polled `GET /api/lanes`. `POST /api/projects/refresh` drops both caches first and answers the same shape; it is same-origin only (`403 EBADORIGIN`).
### Sessions
+17
View File
@@ -532,6 +532,23 @@ CREATE TABLE antigravity_conversations (
);
```
### lane_sources (and `lanes.instructions`)
A workspace's own context, as on its home page (`server/lib/lane-sources.js`). `lanes.instructions` (TEXT, nullable, ≤ 16 000 characters) is appended to every new pane's system prompt; `lane_sources` holds its extra **folders** (the user's directories, never deleted by AgentHub) and uploaded context **files** (AgentHub's own copies under `<data dir>/workspace-context/<lane id>/`, deleted with the row and with the workspace). Both are read at spawn from these rows only (`launchExtras`), never from a request. Both are additive migrations; the rows cascade when the lane row is deleted.
```sql
CREATE TABLE lane_sources (
id INTEGER PRIMARY KEY,
lane_id INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('folder', 'file')),
path TEXT NOT NULL, -- real path of the folder, or AgentHub's copy of the file
name TEXT NOT NULL, -- folder basename / the stored file name
size INTEGER, -- bytes, files only
added_at TEXT NOT NULL
);
CREATE INDEX idx_lane_sources_lane ON lane_sources(lane_id);
```
---
## Indexes
+4 -3
View File
@@ -391,10 +391,11 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, between the nav and the footer (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has search (workspace names and paths, plus the names of the sessions listed) and `+` (Add workspace). In the collapsed rail the list is a single icon that opens the sidebar.
- **A workspace's sessions** — the chevron expands the row to its sessions (`WorkspaceSessionList.tsx`: `GET /api/sessions?cwd=&tool=`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
- **A workspace's sessions** — the chevron expands the row to its sessions (`WorkspaceSessionList.tsx`: `GET /api/sessions?cwd=&tool=`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context.
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Untracked worktrees** are no longer listed in the sidebar; the settings that hid them (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`, Settings → Git → **Worktrees** and a project's own page) are kept for the workspace page's folder suggestions.
- **A project's settings page** — Settings → Projects → <repo> shows the repository's root, main branch and worktree count, links each of its workspaces, holds the per-project worktree override above, and offers **Forget all workspaces…**: after one confirm listing them it runs the same `remove` as the row menu for each (preflight, then `POST /api/lanes/:id/remove` with those facts as `expect`) and stops at the first refusal. Forgetting drops AgentHub's records only — nothing on disk is deleted.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's sessions, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes / Sessions panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
- **No workspaces yet** — the page shows a single banner (`lane-empty`): icon, one line of what a workspace is, the CLI equivalent (`agenthub lanes add --cwd $(pwd)`) and an **Add workspace** affordance. The whole banner IS the button — clicking anywhere on it opens the Add-workspace modal. The banner stays compact on purpose: the run setup form below it is what needs the height.
- **Reordering** — drag a row onto another, or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub lanes` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
+3
View File
@@ -60,6 +60,9 @@ process.stdin.on("end", () => {
hook_type: hookType,
data: parsedData,
...(tool && { tool }),
// Set in every pane the dashboard starts (server/lib/pty-run.js): names
// the pane this session runs in. Absent in a user's own terminal.
...(process.env.AGENTHUB_RUN_ID && { run_id: process.env.AGENTHUB_RUN_ID }),
});
const contentLength = Buffer.byteLength(payload);
+360
View File
@@ -0,0 +1,360 @@
/**
* @file Tests for a workspace's own context (server/lib/lane-sources.js and
* routes/lane-sources.js): folder validation (exists, a directory, not the
* workspace's own folder, not the OS home, no duplicate), uploaded files stored
* under AgentHub's data dir with a name it chooses, removal deleting only
* AgentHub's own copies, instructions capped and cleared, the read-only
* memory, what a pane is started with (`launchExtras` → claude/codex argv,
* nothing for other tools), cleanup on delete, and same-origin writes only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-sources-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const lanesLib = require("../lib/lanes");
const src = require("../lib/lane-sources");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const { encodeCwd } = require("../lib/claude-home");
const ws = path.join(ROOT, "ws");
const extra = path.join(ROOT, "extra");
let lane;
let codexLane;
let otherLane;
let server;
let base;
function request(method, url, { body, headers = {} } = {}) {
return new Promise((resolve, reject) => {
const data = Buffer.isBuffer(body) ? body : body ? Buffer.from(JSON.stringify(body)) : null;
const req = http.request(
`${base}${url}`,
{
method,
headers: {
...(data && !Buffer.isBuffer(body) ? { "content-type": "application/json" } : {}),
...headers,
},
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () =>
resolve({ status: res.statusCode, body: text ? JSON.parse(text) : null })
);
}
);
req.on("error", reject);
if (data) req.write(data);
req.end();
});
}
before(async () => {
for (const d of [ws, extra, process.env.CLAUDE_HOME]) fs.mkdirSync(d, { recursive: true });
lane = lanesLib.createLane({ cwd: ws, kind: "adopted", tool: "claude" });
codexLane = lanesLib.createLane({ cwd: extra, kind: "adopted", tool: "codex" });
otherLane = lanesLib.createLane({ cwd: path.join(ROOT), kind: "adopted", tool: "claude" });
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("lane-sources folders", () => {
it("adds an existing folder by its real path, once", () => {
const row = src.addFolder(lane.id, extra + "/");
assert.equal(row.path, extra);
assert.equal(row.name, "extra");
assert.throws(
() => src.addFolder(lane.id, extra),
(e) => e.code === "EDUP"
);
});
it("refuses the workspace's own folder, a missing one, a file, a relative path and the home", () => {
assert.throws(
() => src.addFolder(lane.id, ws),
(e) => e.code === "EDUP"
);
assert.throws(
() => src.addFolder(lane.id, path.join(ROOT, "nope")),
(e) => e.code === "EBADPATH"
);
const f = path.join(ROOT, "a-file");
fs.writeFileSync(f, "x");
assert.throws(
() => src.addFolder(lane.id, f),
(e) => e.code === "EBADPATH"
);
assert.throws(
() => src.addFolder(lane.id, "relative/dir"),
(e) => e.code === "EBADPATH"
);
assert.throws(
() => src.addFolder(lane.id, os.homedir()),
(e) => e.code === "EBADHOME"
);
assert.throws(
() => src.addFolder(lane.id, "/"),
(e) => e.code === "EBADHOME"
);
});
});
describe("lane-sources files", () => {
it("stores an upload under the data dir with a safe name it chooses", () => {
const row = src.addFile(lane.id, "../../etc/pass wd.md", Buffer.from("# spec"));
assert.equal(row.name, "pass wd.md");
assert.equal(path.dirname(row.path), src.contextDir(lane.id));
assert.ok(row.path.startsWith(path.join(process.env.DASHBOARD_DATA_DIR, "workspace-context")));
assert.equal(fs.readFileSync(row.path, "utf8"), "# spec");
const again = src.addFile(lane.id, "pass wd.md", Buffer.from("two"));
assert.equal(again.name, "pass wd (2).md");
});
it("refuses an empty or oversized file", () => {
assert.throws(
() => src.addFile(lane.id, "e.txt", Buffer.alloc(0)),
(e) => e.code === "EEMPTY"
);
assert.throws(
() => src.addFile(lane.id, "big.bin", Buffer.alloc(src.MAX_FILE_BYTES + 1)),
(e) => e.code === "ETOOBIG"
);
});
it("removing a file deletes AgentHub's copy; removing a folder leaves the folder", () => {
const file = src.addFile(otherLane.id, "x.txt", Buffer.from("x"));
const folder = src.addFolder(otherLane.id, extra);
src.removeSource(otherLane.id, file.id);
src.removeSource(otherLane.id, folder.id);
assert.equal(fs.existsSync(file.path), false);
assert.equal(fs.existsSync(extra), true);
assert.deepEqual(src.listSources(otherLane.id), []);
assert.throws(
() => src.removeSource(otherLane.id, file.id),
(e) => e.code === "ENOSOURCE"
);
});
it("will not remove another workspace's source", () => {
const [own] = src.listSources(lane.id);
assert.throws(
() => src.removeSource(otherLane.id, own.id),
(e) => e.code === "ENOSOURCE"
);
});
});
describe("lane-sources instructions and launch", () => {
it("caps instructions and clears them on blank", () => {
assert.throws(
() => src.setInstructions(lane.id, "x".repeat(src.MAX_INSTRUCTIONS + 1)),
(e) => e.code === "ETOOBIG"
);
assert.throws(
() => src.setInstructions(lane.id, 5),
(e) => e.code === "EBADFIELD"
);
src.setInstructions(lane.id, " ");
assert.equal(lanesLib.getLane(lane.id).instructions, null);
src.setInstructions(lane.id, "- always answer in Vietnamese");
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
});
it("starts a claude pane with --add-dir first and the instructions from a file", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
return "";
});
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
tmux.__reset();
assert.equal(argv[0], "claude");
assert.equal(argv[1], "--add-dir");
assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]);
assert.equal(argv[4], "--permission-mode");
const flag = argv.find((a) => a.startsWith("--append-system-prompt-file="));
const file = flag.slice("--append-system-prompt-file=".length);
assert.ok(file.startsWith(process.env.DASHBOARD_DATA_DIR));
const text = fs.readFileSync(file, "utf8");
assert.match(text, /^- always answer in Vietnamese/);
assert.match(text, /pass wd\.md/);
// A prompt that looks like flags stays one positional, never `claude -c`.
assert.equal(argv.at(-1), " -c fix it");
assert.ok(!argv.includes("-c"));
});
it("starts a codex pane with repeated --add-dir and developer_instructions", () => {
src.setInstructions(codexLane.id, 'say "hi"\nthen stop');
src.addFolder(codexLane.id, ws);
const { addDirs, systemPrompt } = src.launchExtras(codexLane.id, "codex");
assert.deepEqual(addDirs, [ws]);
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
return "";
});
pty.spawnRun({ laneId: codexLane.id, cwd: extra, tool: "codex", initialPrompt: "go" });
tmux.__reset();
assert.deepEqual(argv.slice(argv.indexOf("--add-dir"), argv.indexOf("--add-dir") + 2), [
"--add-dir",
ws,
]);
assert.ok(argv.includes(`developer_instructions=${JSON.stringify(systemPrompt)}`));
assert.equal(argv.at(-1), "go");
});
it("hands Codex a pointer to the file once the instructions are too long for tmux", () => {
src.setInstructions(codexLane.id, "dài ".repeat(3000));
const { systemPrompt, systemPromptFile } = src.launchExtras(codexLane.id, "codex");
assert.ok(Buffer.byteLength(systemPrompt) > src.INLINE_PROMPT_BYTES);
const inline = src.codexInstructions(systemPrompt, systemPromptFile);
assert.ok(inline.includes(systemPromptFile));
assert.ok(Buffer.byteLength(inline) < 1024);
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
src.setInstructions(codexLane.id, null);
});
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
addDirs: [],
systemPrompt: null,
systemPromptFile: null,
});
const gone = path.join(ROOT, "gone");
fs.mkdirSync(gone);
src.addFolder(otherLane.id, gone);
fs.rmdirSync(gone);
assert.deepEqual(src.launchExtras(otherLane.id, "claude").addDirs, []);
});
});
describe("lane-sources review fixes", () => {
it("leaves no file on disk when an upload is refused for the source limit", () => {
const d = path.join(ROOT, "limit-ws");
fs.mkdirSync(d);
const l = lanesLib.createLane({ cwd: d, kind: "adopted", tool: "claude" });
for (let i = 0; i < 50; i++) src.addFile(l.id, `f${i}.txt`, Buffer.from("x"));
assert.throws(
() => src.addFile(l.id, "one-too-many.txt", Buffer.from("x")),
(e) => e.code === "ETOOMANY"
);
assert.equal(fs.readdirSync(src.contextDir(l.id)).length, 50);
lanesLib.deleteLane(l.id);
});
it("measures Codex's inline budget on the escaped TOML string, and escapes DEL", () => {
const quotes = '"'.repeat(src.INLINE_PROMPT_BYTES - 100);
assert.ok(src.codexInstructions(quotes, "/p.md").includes("/p.md"));
assert.equal(src.tomlString('a\x7fb"'), '"a\\u007fb\\""');
});
it("does not serve instructions in the lane list payload", () => {
src.setInstructions(lane.id, "secret-ish text");
assert.equal("instructions" in lanesLib.lanePayload(lanesLib.getLane(lane.id)), false);
src.setInstructions(lane.id, "- always answer in Vietnamese");
});
it("still starts a pane when the prompt file cannot be written", () => {
const real = fs.writeFileSync;
fs.writeFileSync = () => {
throw Object.assign(new Error("read-only"), { code: "EROFS" });
};
try {
const got = src.launchExtras(lane.id, "claude");
assert.equal(got.systemPromptFile, null);
// Codex can still take a short text inline; Claude needs the file.
assert.equal(src.codexInstructions(got.systemPrompt, null), got.systemPrompt);
assert.equal(src.codexInstructions("x".repeat(src.INLINE_PROMPT_BYTES + 1), null), null);
} finally {
fs.writeFileSync = real;
}
});
});
describe("lane-sources memory and cleanup", () => {
it("reads the folder's CLAUDE.md and Claude's auto memory, read-only", () => {
fs.writeFileSync(path.join(ws, "CLAUDE.md"), "# rules");
const mem = path.join(process.env.CLAUDE_HOME, "projects", encodeCwd(ws), "memory");
fs.mkdirSync(mem, { recursive: true });
fs.writeFileSync(path.join(mem, "MEMORY.md"), "- a fact");
const files = src.memoryFiles(lane.id);
assert.deepEqual(
files.map((f) => f.label),
["CLAUDE.md", "memory/MEMORY.md"]
);
assert.equal(files[1].content, "- a fact");
});
it("deleting the workspace drops its rows and AgentHub's copies, not its folders", () => {
const tmp = lanesLib.createLane({ cwd: path.join(ROOT, "claude"), kind: "adopted" });
const f = src.addFile(tmp.id, "a.txt", Buffer.from("a"));
src.addFolder(tmp.id, extra);
lanesLib.deleteLane(tmp.id);
assert.equal(fs.existsSync(path.dirname(f.path)), false);
assert.equal(fs.existsSync(extra), true);
assert.throws(
() => src.listSources(tmp.id),
(e) => e.code === "ENOLANE"
);
});
});
describe("lane-sources routes", () => {
it("lists, uploads, and refuses a cross-origin write", async () => {
const list = await request("GET", `/api/lanes/${lane.id}/sources`);
assert.equal(list.status, 200);
assert.equal(list.body.supported, true);
assert.equal(list.body.instructions, "- always answer in Vietnamese");
const up = await request("POST", `/api/lanes/${lane.id}/sources/file`, {
body: Buffer.from("notes"),
headers: {
"content-type": "application/octet-stream",
"x-file-name": encodeURIComponent("ghi chú.txt"),
},
});
assert.equal(up.status, 201);
assert.ok(up.body.sources.some((s) => s.name === "ghi chú.txt"));
const evil = await request("PUT", `/api/lanes/${lane.id}/instructions`, {
body: { instructions: "exfiltrate" },
headers: { origin: "https://evil.example" },
});
assert.equal(evil.status, 403);
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
const missing = await request("GET", `/api/lanes/99999/sources`);
assert.equal(missing.status, 404);
const bad = await request("POST", `/api/lanes/${lane.id}/sources/folder`, {
body: { path: "/definitely/not/here" },
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADPATH");
const noKey = await request("PUT", `/api/lanes/${lane.id}/instructions`, { body: {} });
assert.equal(noKey.status, 400);
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
});
});
+5 -4
View File
@@ -477,20 +477,21 @@ describe("pty-run", () => {
}
});
it("builds each prompt mode, with fixed args first", () => {
it("builds each prompt mode, with fixed args first, a dash-led prompt never a flag", () => {
const argvFor = (tool, extra = {}) =>
paneArgv(
captureNewSession(() =>
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
)
);
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", "-x y"]);
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("gemini", { bypass: false }), [
at("gemini"),
"--prompt-interactive",
"-x y",
" -x y",
]);
});
+143
View File
@@ -0,0 +1,143 @@
/**
* @file Tests that a dashboard pane learns which session it holds: every pane
* is started with `AGENTHUB_RUN_ID` in its tmux env, and a hook carrying that
* `run_id` binds the hook's session to the run (`dashboard_runs.session_id`),
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
* name, or names no run, changes nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "run-bind-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp } = require("../index");
const dashboardRuns = require("../lib/dashboard-runs");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
let server;
let base;
function postHook(body) {
return new Promise((resolve, reject) => {
const data = Buffer.from(JSON.stringify(body));
const req = http.request(
`${base}/api/hooks/event`,
{ method: "POST", headers: { "content-type": "application/json" } },
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
req.on("error", reject);
req.end(data);
});
}
before(async () => {
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
describe("run ↔ session binding", () => {
it("starts every pane with AGENTHUB_RUN_ID naming that pane", () => {
let argv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args;
return "";
});
pty.spawnRun({ laneId: 77, slot: 2, cwd: ROOT });
tmux.__reset();
const env = argv.filter((a, i) => argv[i - 1] === "-e");
assert.ok(env.includes("AGENTHUB_RUN_ID=agenthub-lane-77-2"), env.join(" "));
});
it("binds a hook's session to the run it names, and follows a new session", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-5-1",
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
const hook = (session_id, run_id) =>
postHook({ hook_type: "SessionStart", data: { session_id, cwd: ROOT }, run_id });
assert.equal(await hook("sess-aaaa-1111", "agenthub-lane-5-1"), 200);
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-aaaa-1111");
// `/clear` in the pane: a new session that is the pane's now.
await postHook({
hook_type: "SessionStart",
data: { session_id: "sess-bbbb-2222", cwd: ROOT, source: "clear" },
run_id: "agenthub-lane-5-1",
});
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
it("keeps the pane's session when a nested CLI inside it starts its own", async () => {
// A `claude -p` the agent runs through Bash inherits AGENTHUB_RUN_ID.
for (const hook_type of ["SessionStart", "UserPromptSubmit", "Stop"]) {
await postHook({
hook_type,
data: { session_id: "sess-nest-9999", cwd: ROOT, source: "startup" },
run_id: "agenthub-lane-5-1",
});
}
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
it("follows any new session on a non-claude pane, whose hooks carry no reliable source", async () => {
dashboardRuns.recordRun({
id: "agenthub-lane-6-1",
mode: "interactive",
cwd: ROOT,
status: "running",
startedAt: Date.now(),
});
const hook = (session_id) =>
postHook({
hook_type: "UserPromptSubmit",
tool: "codex",
data: { session_id, cwd: ROOT, prompt: "hi" },
run_id: "agenthub-lane-6-1",
});
await hook("codex-thread-aaaa");
assert.equal(dashboardRuns.getRun("agenthub-lane-6-1").session_id, "codex-thread-aaaa");
await hook("codex-thread-bbbb");
assert.equal(dashboardRuns.getRun("agenthub-lane-6-1").session_id, "codex-thread-bbbb");
});
it("ignores a run_id that is not a dashboard pane, or names no run", async () => {
await postHook({
hook_type: "SessionStart",
data: { session_id: "sess-cccc-3333", cwd: ROOT },
run_id: "main",
});
await postHook({
hook_type: "SessionStart",
data: { session_id: "sess-cccc-3333", cwd: ROOT },
run_id: "agenthub-lane-999",
});
assert.equal(dashboardRuns.getRun("main"), null);
assert.equal(dashboardRuns.getRun("agenthub-lane-999"), null);
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
});
});
+24
View File
@@ -721,6 +721,30 @@ try {
db.prepare("ALTER TABLE lanes ADD COLUMN needs_action_session TEXT").run();
}
// Migrate: a workspace's own context, as on a Claude Desktop project page
// (server/lib/lane-sources.js). `instructions` is appended to every pane's
// system prompt; `lane_sources` holds extra folders (the user's, never
// deleted) and uploaded context files (AgentHub's own copies under
// <data dir>/workspace-context/<lane id>/). Both additive.
try {
db.prepare("SELECT instructions FROM lanes LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE lanes ADD COLUMN instructions TEXT").run();
}
db.exec(`
CREATE TABLE IF NOT EXISTS lane_sources (
id INTEGER PRIMARY KEY,
lane_id INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('folder', 'file')),
path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER,
added_at TEXT NOT NULL,
FOREIGN KEY (lane_id) REFERENCES lanes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_lane_sources_lane ON lane_sources(lane_id);
`);
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
// not read as "every pipeline has never run". Each lane's live `stages` blob
// becomes one run; each archived feature becomes one more. That is all the
+3
View File
@@ -89,6 +89,7 @@ const remoteSourcesRouter = require("./routes/remote-sources");
const metricsRouter = require("./routes/metrics");
const lanesRouter = require("./routes/lanes");
const projectsRouter = require("./routes/projects");
const laneSourcesRouter = require("./routes/lane-sources");
const locksRouter = require("./routes/locks");
const APP_VERSION = (() => {
@@ -128,6 +129,8 @@ function createApp() {
app.use("/api/git-credentials", gitCredentialsRouter);
app.use("/api/ai-tools", aiToolsRouter);
app.use("/api/run", runRouter);
// Before the lanes router, whose `/:id/:action` would otherwise see these.
app.use("/api/lanes", laneSourcesRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/projects", projectsRouter);
app.use("/api/locks", locksRouter);
+1
View File
@@ -299,6 +299,7 @@ function setClaudeHome(newPath) {
}
module.exports = {
encodeCwd,
getClaudeHome,
getProjectsDir,
getDataDir,
+322
View File
@@ -0,0 +1,322 @@
/**
* @file A workspace's own context, like a Claude Desktop project's: its
* Instructions (`lanes.instructions`), extra Folders and uploaded Context files
* (`lane_sources`), its read-only Memory, and what all of it adds to a pane at
* spawn (`launchExtras`). Everything a spawn reads comes from these rows, never
* from a request. An extra folder must exist, be a directory, not be the
* workspace's own folder and not be or contain the OS home; an uploaded file
* is stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
* name it chooses, so a request never names a path on disk. Only Claude
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
* `-c developer_instructions=`) can take them; every other tool is reported
* unsupported and its panes get nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { db } = require("../db");
const { getDataDir, getClaudeHome, encodeCwd } = require("./claude-home");
const { validateHomeDir } = require("./settings-env");
const SUPPORTED_TOOLS = new Set(["claude", "codex"]);
const MAX_INSTRUCTIONS = 16_000;
const MAX_FILE_BYTES = 10 * 1024 * 1024;
const MAX_SOURCES = 50;
const MAX_MEMORY_BYTES = 256 * 1024;
// The whole `tmux new-session … -- argv` must fit in one tmux message (16 KB
// on tmux 3.4, `command too long` past it), with PATH and the env beside it.
// Claude reads the prompt from a file instead; Codex has no file form for
// developer instructions, so past this it gets a pointer to the file.
const INLINE_PROMPT_BYTES = 6 * 1024;
const fail = (code, message) => Object.assign(new Error(message), { code });
function laneRow(laneId) {
const lane = db.prepare("SELECT id, cwd, tool, instructions FROM lanes WHERE id = ?").get(laneId);
if (!lane) throw fail("ENOLANE", "workspace not found");
return lane;
}
function contextRoot() {
return path.join(getDataDir(), "workspace-context");
}
function contextDir(laneId) {
return path.join(contextRoot(), String(Number(laneId)));
}
function supports(tool) {
return SUPPORTED_TOOLS.has(tool || "claude");
}
function listSources(laneId) {
laneRow(laneId);
return db
.prepare(
"SELECT id, kind, path, name, size, added_at FROM lane_sources WHERE lane_id = ? ORDER BY id ASC"
)
.all(laneId)
.map((r) => ({ ...r, missing: !fs.existsSync(r.path) }));
}
function assertRoom(laneId) {
const count = db
.prepare("SELECT count(*) AS c FROM lane_sources WHERE lane_id = ?")
.get(laneId).c;
if (count >= MAX_SOURCES)
throw fail("ETOOMANY", `a workspace holds at most ${MAX_SOURCES} sources`);
}
function insertSource(laneId, kind, p, name, size) {
assertRoom(laneId);
const info = db
.prepare(
"INSERT INTO lane_sources (lane_id, kind, path, name, size, added_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.run(laneId, kind, p, name, size ?? null, new Date().toISOString());
return db.prepare("SELECT * FROM lane_sources WHERE id = ?").get(info.lastInsertRowid);
}
function addFolder(laneId, input) {
const lane = laneRow(laneId);
if (typeof input !== "string" || !input.trim()) throw fail("EBADPATH", "path is required");
let resolved;
try {
resolved = fs.realpathSync(validateHomeDir("a workspace folder", input));
} catch (err) {
if (err.code === "EBADHOME") throw err;
throw fail("EBADPATH", err.message);
}
let laneCwd = lane.cwd;
try {
laneCwd = fs.realpathSync(lane.cwd);
} catch {
/* a vanished workspace folder still has its recorded path */
}
if (resolved === laneCwd) throw fail("EDUP", "that is the workspace's own folder");
const dup = db
.prepare("SELECT 1 FROM lane_sources WHERE lane_id = ? AND kind = 'folder' AND path = ?")
.get(laneId, resolved);
if (dup) throw fail("EDUP", "that folder is already a source");
return insertSource(laneId, "folder", resolved, path.basename(resolved) || resolved, null);
}
/** A file name AgentHub can store: no directory part, no control or path characters. */
function safeName(raw) {
const base = path.basename(String(raw || "")).replace(/[^\p{L}\p{N}._ -]/gu, "_");
const trimmed = base
.replace(/^[.\s]+/, "")
.trim()
.slice(0, 120);
return trimmed || "file";
}
function addFile(laneId, rawName, buf) {
laneRow(laneId);
if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EEMPTY", "the file is empty");
if (buf.length > MAX_FILE_BYTES) {
throw fail("ETOOBIG", `a context file may be at most ${MAX_FILE_BYTES / 1024 / 1024} MB`);
}
// Before the write: a refused upload must leave nothing in the dir the
// agent is handed as --add-dir.
assertRoom(laneId);
const dir = contextDir(laneId);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const name = safeName(rawName);
const ext = path.extname(name);
const stem = name.slice(0, name.length - ext.length);
let file = path.join(dir, name);
for (let n = 2; fs.existsSync(file); n++) {
const tag = n <= 99 ? `(${n})` : crypto.randomBytes(4).toString("hex");
file = path.join(dir, `${stem} ${tag}${ext}`);
}
fs.writeFileSync(file, buf, { mode: 0o600, flag: "wx" });
return insertSource(laneId, "file", file, path.basename(file), buf.length);
}
/** True when `p` is inside AgentHub's own context store — the only files it may delete. */
function ownsFile(p) {
const root = contextRoot() + path.sep;
return path.resolve(p).startsWith(root);
}
function removeSource(laneId, sourceId) {
laneRow(laneId);
const row = db
.prepare("SELECT * FROM lane_sources WHERE id = ? AND lane_id = ?")
.get(Number(sourceId), laneId);
if (!row) throw fail("ENOSOURCE", "source not found");
// A folder is the user's; only AgentHub's own copy of an uploaded file goes.
if (row.kind === "file" && ownsFile(row.path)) fs.rmSync(row.path, { force: true });
db.prepare("DELETE FROM lane_sources WHERE id = ?").run(row.id);
}
/** Drops AgentHub's context copies of a deleted workspace (its rows cascade). */
function removeLaneContext(laneId) {
const dir = contextDir(laneId);
if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
}
function setInstructions(laneId, text) {
laneRow(laneId);
if (text !== null && typeof text !== "string") {
throw fail("EBADFIELD", "instructions must be a string or null");
}
const value = text === null || !text.trim() ? null : text.replace(/\r\n/g, "\n");
if (value && value.length > MAX_INSTRUCTIONS) {
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
}
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
}
/**
* What a pane of this workspace is started with, read from its rows: the
* extra folders and (when any file was uploaded) the context dir to allow, and
* the text to append to the system prompt — the instructions, then the list of
* context files so the agent knows they exist. Sources that vanished are
* skipped, not fatal: a deleted folder must not stop the workspace starting.
*/
function launchExtras(laneId, tool) {
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
if (!supports(tool)) return empty;
const lane = db.prepare("SELECT instructions FROM lanes WHERE id = ?").get(laneId);
if (!lane) return empty;
const rows = db
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
.all(laneId);
const isDir = (p) => {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
};
const folders = rows.filter((r) => r.kind === "folder" && isDir(r.path)).map((r) => r.path);
const files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
const addDirs = [...folders, ...(files.length ? [contextDir(laneId)] : [])];
const parts = [];
if (lane.instructions) parts.push(lane.instructions);
if (files.length) {
parts.push(
"Reference files for this workspace — read them when they are relevant:\n" +
files.map((f) => `- ${f}`).join("\n")
);
}
if (folders.length) {
parts.push("Additional folders you may use:\n" + folders.map((f) => `- ${f}`).join("\n"));
}
if (!parts.length) return { addDirs, systemPrompt: null, systemPromptFile: null };
const systemPrompt = parts.join("\n\n");
// Written at every spawn, so the pane reads what the rows say now.
// A data dir that cannot be written costs the pane its instructions (all of
// Claude's, Codex's past the inline budget), never its start — same as a
// vanished source.
try {
const dir = path.join(getDataDir(), "workspace-prompts");
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`);
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
return { addDirs, systemPrompt, systemPromptFile };
} catch (err) {
console.warn(`[lane-sources] workspace ${laneId}: prompt file not written:`, err.message);
return { addDirs, systemPrompt, systemPromptFile: null };
}
}
/** `text` as a TOML basic string: JSON's escapes are TOML's, except that TOML
* also refuses a raw DEL, which JSON.stringify leaves as is. */
function tomlString(text) {
return JSON.stringify(text).replace(/\x7f/g, "\\u007f");
}
/** The developer instructions Codex gets inline: the text itself while it is
* small, else one line pointing at the file (see INLINE_PROMPT_BYTES), or
* null when it is too long and no file was written. */
function codexInstructions(systemPrompt, systemPromptFile) {
// Measured as sent: escaping can double the text (quotes, newlines) or more.
if (Buffer.byteLength(tomlString(systemPrompt)) <= INLINE_PROMPT_BYTES) return systemPrompt;
if (!systemPromptFile) return null;
return `This workspace's instructions and reference files are in ${systemPromptFile} — read that file before anything else and follow it.`;
}
function readMemoryFile(label, file) {
try {
const st = fs.statSync(file);
if (!st.isFile()) return null;
const truncated = st.size > MAX_MEMORY_BYTES;
const fd = fs.openSync(file, "r");
try {
const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES));
fs.readSync(fd, buf, 0, buf.length, 0);
return {
label,
path: file,
size: st.size,
mtime: st.mtime.toISOString(),
truncated,
content: buf.toString("utf8"),
};
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
}
/**
* The memory a workspace's agent already reads, read-only: the folder's
* CLAUDE.md / AGENTS.md family, and (Claude) its auto-memory directory
* `<CLAUDE_HOME>/projects/<encoded cwd>/memory/`. Every path is derived from
* the lane row; nothing is written.
*/
function memoryFiles(laneId) {
const lane = laneRow(laneId);
const out = [];
const add = (label, file) => {
const f = readMemoryFile(label, file);
if (f) out.push(f);
};
add("CLAUDE.md", path.join(lane.cwd, "CLAUDE.md"));
add(".claude/CLAUDE.md", path.join(lane.cwd, ".claude", "CLAUDE.md"));
add("CLAUDE.local.md", path.join(lane.cwd, "CLAUDE.local.md"));
add("AGENTS.md", path.join(lane.cwd, "AGENTS.md"));
if ((lane.tool || "claude") === "claude") {
const dir = path.join(getClaudeHome(), "projects", encodeCwd(lane.cwd), "memory");
let names = [];
try {
names = fs
.readdirSync(dir)
.filter((n) => n.endsWith(".md"))
.sort();
} catch {
/* no auto memory yet */
}
for (const n of names.slice(0, 50)) add(`memory/${n}`, path.join(dir, n));
}
return out;
}
module.exports = {
SUPPORTED_TOOLS,
MAX_FILE_BYTES,
MAX_INSTRUCTIONS,
supports,
contextDir,
listSources,
addFolder,
addFile,
removeSource,
removeLaneContext,
setInstructions,
launchExtras,
codexInstructions,
tomlString,
INLINE_PROMPT_BYTES,
memoryFiles,
safeName,
};
+19 -2
View File
@@ -357,7 +357,20 @@ function setProvisioningFacts(id, facts = {}) {
}
function deleteLane(id) {
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
const gone = db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
// Its `lane_sources` rows cascade; AgentHub's own copies of its uploaded
// context files go with them. A folder source is the user's and stays.
// Lazy: lane-sources.js reads the db directly and must not load at import.
// The row is already gone: a copy that will not delete is logged, not a
// failed remove.
if (gone) {
try {
require("./lane-sources").removeLaneContext(id);
} catch (err) {
console.warn(`[lanes] workspace ${id}: context files not removed:`, err.message);
}
}
return gone;
}
/** Slots currently held by a lane, ascending. Input to the slot allocator. */
@@ -755,8 +768,12 @@ function lanePayload(lane, ageSec = null) {
const inferredIdx = inferred ? phaseIdx(pipeline, inferred) : -1;
const view = inferredIdx === -1 ? lane : { ...lane, stage: inferred };
const nodes = nodeStates(pipeline, view);
// Up to 16 KB per lane, on every list and broadcast; the home reads it from
// `GET /api/lanes/:id/sources` instead.
const row = { ...lane };
delete row.instructions;
return {
...lane,
...row,
pipeline_name: pipeline.name,
pipeline_skill: pipeline.skill || "",
pipeline_nodes:
+56 -5
View File
@@ -91,9 +91,34 @@ function makeErr(code, message) {
* interactive afterward (unlike `-p`, which forces print-and-exit and closes
* stdin).
*/
function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
/**
* A prompt as an argv element no CLI can read as an option: one that starts
* with "-" (`-c fix it`) would otherwise be parsed as flags — `claude -c`
* continues the last conversation, `-p` turns the pane into print mode. A
* leading space keeps it a positional and changes nothing the model reads.
*/
function promptArg(p) {
return p.startsWith("-") ? ` ${p}` : p;
}
function buildClaudeArgv({
model,
permissionMode,
effort,
resumeSessionId,
initialPrompt,
addDirs = [],
systemPromptFile = null,
}) {
const argv = ["claude"];
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
// argument up to the next option, so it goes first, where `--permission-mode`
// always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs);
argv.push("--permission-mode", permissionMode || "auto");
// From a file: the instructions can be long, and the whole argv has to fit
// in one tmux message. `=` form, so the path is never read as an option.
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
// it on: `claude --help` — "Enable bypassing all permission checks as an
// option, without it being enabled by default." Without it the cycle is only
@@ -104,7 +129,7 @@ function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initi
if (model) argv.push("--model", model);
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
if (resumeSessionId) argv.push("--resume", resumeSessionId);
if (initialPrompt) argv.push(initialPrompt);
if (initialPrompt) argv.push(promptArg(initialPrompt));
return argv;
}
@@ -124,6 +149,9 @@ function buildCodexArgv({
bypass,
initialPrompt,
routerProfile,
addDirs = [],
systemPrompt = null,
systemPromptFile = null,
}) {
const argv = ["codex"];
// Before the subcommand and before the prompt: `-p` layers
@@ -141,7 +169,15 @@ function buildCodexArgv({
if (approval) argv.push("-a", approval);
}
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
if (initialPrompt) argv.push(initialPrompt);
for (const dir of addDirs) argv.push("--add-dir", dir);
// `-c` parses its value as TOML; a JSON string literal is a valid TOML basic
// string, so any text — quotes, newlines — arrives as one string.
if (systemPrompt) {
const { codexInstructions, tomlString } = require("./lane-sources");
const text = codexInstructions(systemPrompt, systemPromptFile);
if (text) argv.push("-c", `developer_instructions=${tomlString(text)}`);
}
if (initialPrompt) argv.push(promptArg(initialPrompt));
return argv;
}
@@ -159,9 +195,10 @@ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt
if (resumeLast && entry.resume) argv.push(...entry.resume);
if (bypass && entry.bypass) argv.push(...entry.bypass);
const p = entry.prompt;
if (initialPrompt && p.mode === "flag") argv.push(p.flag, initialPrompt);
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
if (initialPrompt && p.mode === "argv")
argv.push(...(p.separator ? [p.separator] : []), initialPrompt);
// A `--` separator already ends the options; without one, guard the prompt.
argv.push(...(p.separator ? [p.separator, initialPrompt] : [promptArg(initialPrompt)]));
return argv;
}
@@ -274,8 +311,18 @@ function spawnRun(args) {
const id = runIdForLane(laneId, slot);
const startedAt = Date.now();
// The workspace's own folders, context files and instructions — read from
// its rows here, the one place both spawn paths pass through, never from a
// request (server/lib/lane-sources.js).
const { addDirs, systemPrompt, systemPromptFile } = require("./lane-sources").launchExtras(
laneId,
tool
);
const argv = buildArgv(
{
addDirs,
systemPrompt,
systemPromptFile,
tool,
model,
permissionMode,
@@ -336,6 +383,10 @@ function spawnRun(args) {
// node`) dies there without the `node` the user's shell would have found.
const env = {
AGENTHUB_LANE_ID: String(laneId),
// Which pane this is: the CLI's hooks inherit it, so the dashboard learns
// which session each pane holds (routes/hooks.js → bindRunSession) — the
// sidebar opens a session's own pane by it.
AGENTHUB_RUN_ID: id,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...desktopEnv(),
// A codex pane must read the same home the dashboard reads — its rollout
+25
View File
@@ -16,6 +16,7 @@ const { ingestWorkflowsForSession } = require("../lib/workflow-ingest");
// `liveness.probeLiveCwds` and the watchdog picks the stub up at call time.
const liveness = require("../lib/session-liveness");
const lanesLib = require("../lib/lanes");
const dashboardRuns = require("../lib/dashboard-runs");
const { broadcastLane } = require("./lanes");
const { detect } = require("../lib/stage-detect");
const { getPipeline } = require("../lib/pipelines");
@@ -1293,6 +1294,29 @@ function antigravityHookInput(hookType, data) {
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
/**
* Record which session a dashboard pane holds. The pane's CLI inherits
* `AGENTHUB_RUN_ID` (pty-run.js) and the hook handler forwards it as `run_id`;
* without this a run's `session_id` stays whatever it was started with (null
* for a new session), and the sidebar could not tell that a session is live in
* a pane. Follows `/clear` too, which starts a new session in the same pane.
* Only a dashboard pane name is accepted, and only a run that exists. Once
* bound, only a SessionStart that is not a fresh `startup` (a `/clear`, a
* `/resume`) moves it: a `claude -p` the agent runs through Bash inherits
* AGENTHUB_RUN_ID too, and its hooks must not take the pane over. That rule
* is Claude's only: for Codex and agy it is not verified that a new session in
* the pane and a nested one can be told apart, so their panes follow every one.
*/
function bindRunSession(runId, sessionId, hookType, source, tool) {
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) return;
if (typeof sessionId !== "string" || !sessionId) return;
const row = dashboardRuns.getRun(runId);
if (!row || row.session_id === sessionId) return;
const reopened = hookType === "SessionStart" && source !== "startup";
if (tool === "claude" && row.session_id && !reopened) return;
dashboardRuns.patchRun({ id: runId, sessionId });
}
router.post("/event", (req, res) => {
let { hook_type, data } = req.body;
if (!hook_type || !data) {
@@ -1334,6 +1358,7 @@ router.post("/event", (req, res) => {
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
touchLaneFromHook(hook_type, data, tool);
bindRunSession(req.body.run_id, data.session_id, hook_type, data.source, tool);
res.json({ ok: true, event: result });
+142
View File
@@ -0,0 +1,142 @@
/**
* @file Routes for a workspace's own context (server/lib/lane-sources.js),
* mounted at /api/lanes before the main lanes router:
* `GET /:id/sources` (instructions, folders, context files, whether the tool
* can take them), `PUT /:id/instructions`, `POST /:id/sources/folder {path}`,
* `POST /:id/sources/file` (raw body, `X-File-Name` header, ≤ 10 MB),
* `DELETE /:id/sources/:sourceId`, and the read-only `GET /:id/memory`. Every
* write is same-origin only: instructions and sources reach an agent's system
* prompt and file access, so a page on another origin may not set them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const express = require("express");
const sources = require("../lib/lane-sources");
const lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const STATUS = { ENOLANE: 404, ENOSOURCE: 404, ETOOBIG: 413, EBADORIGIN: 403 };
function sendError(res, err) {
if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) {
return res
.status(STATUS[err.code] || 400)
.json({ error: { code: err.code, message: err.message } });
}
return res
.status(500)
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
}
const laneId = (req) => Number.parseInt(req.params.id, 10);
/** Re-broadcast the lane row, like every other lane write does. Lazy: the
* lanes router requires a lot, and this file loads before it. */
function broadcast(id) {
try {
require("./lanes").broadcastLane(id);
} catch {
/* a broadcast is best-effort */
}
}
function view(id) {
const lane = lanesLib.getLane(id);
if (!lane) throw Object.assign(new Error("workspace not found"), { code: "ENOLANE" });
return {
instructions: lane.instructions || "",
supported: sources.supports(lane.tool),
maxFileBytes: sources.MAX_FILE_BYTES,
maxInstructions: sources.MAX_INSTRUCTIONS,
sources: sources.listSources(id),
};
}
router.get("/:id/sources", (req, res) => {
try {
res.json(view(laneId(req)));
} catch (err) {
sendError(res, err);
}
});
router.put("/:id/instructions", sameOriginGuard, (req, res) => {
try {
const id = laneId(req);
const body = req.body || {};
if (!("instructions" in body)) {
throw Object.assign(new Error("instructions is required (null clears)"), {
code: "EBADFIELD",
});
}
sources.setInstructions(id, body.instructions);
broadcast(id);
res.json(view(id));
} catch (err) {
sendError(res, err);
}
});
router.post("/:id/sources/folder", sameOriginGuard, (req, res) => {
try {
const id = laneId(req);
sources.addFolder(id, (req.body || {}).path);
res.status(201).json(view(id));
} catch (err) {
sendError(res, err);
}
});
router.post(
"/:id/sources/file",
sameOriginGuard,
express.raw({ type: "application/octet-stream", limit: sources.MAX_FILE_BYTES }),
(req, res) => {
try {
const id = laneId(req);
let name = String(req.headers["x-file-name"] || "");
try {
name = decodeURIComponent(name);
} catch {
/* a header that is not URI-encoded is used as sent */
}
sources.addFile(id, name, Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0));
res.status(201).json(view(id));
} catch (err) {
sendError(res, err);
}
}
);
router.delete("/:id/sources/:sourceId", sameOriginGuard, (req, res) => {
try {
const id = laneId(req);
sources.removeSource(id, req.params.sourceId);
res.json(view(id));
} catch (err) {
sendError(res, err);
}
});
router.get("/:id/memory", (req, res) => {
try {
res.json({ files: sources.memoryFiles(laneId(req)) });
} catch (err) {
sendError(res, err);
}
});
// A body over the cap is refused by the parser before the handler runs.
router.use((err, _req, res, next) => {
if (err && err.type === "entity.too.large") {
return res.status(413).json({
error: { code: "ETOOBIG", message: "a context file may be at most 10 MB" },
});
}
return next(err);
});
module.exports = router;