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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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,8 +117,15 @@ 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) => {
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -477,7 +477,7 @@ 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(() =>
|
||||
@@ -485,6 +485,7 @@ describe("pty-run", () => {
|
||||
)
|
||||
);
|
||||
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("gemini", { bypass: false }), [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -299,6 +299,7 @@ function setClaudeHome(newPath) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encodeCwd,
|
||||
getClaudeHome,
|
||||
getProjectsDir,
|
||||
getDataDir,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user