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**
|
**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
|
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||||
grouped General / Agents / Routing / Git / Projects, a search box (Ctrl+F)
|
grouped General / Agents / Routing / Git, a search box (Ctrl+F) over every
|
||||||
over every section, a page per installed AI tool, per-vendor model pricing,
|
section, a page per installed AI tool, per-vendor model pricing, and which
|
||||||
which untracked worktrees the sidebar lists, and a page per project.
|
untracked worktrees a workspace's Add folder suggests.
|
||||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
||||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
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**
|
**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
|
- **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ái chia nhóm Chung / Tác tử / Định tuyến / Git, ô tìm kiếm (Ctrl+F) trên mọi
|
||||||
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
|
mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung cấp, và
|
||||||
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.
|
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
|
- **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,
|
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
|
||||||
settings file, memory file, marketplace, keybinding và statusline mà Claude
|
settings file, memory file, marketplace, keybinding và statusline mà Claude
|
||||||
|
|||||||
+3
-2
@@ -184,6 +184,7 @@ client/
|
|||||||
│ │ ├── EmptyState.tsx
|
│ │ ├── EmptyState.tsx
|
||||||
│ │ ├── Sidebar.tsx
|
│ │ ├── Sidebar.tsx
|
||||||
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), row menu
|
│ │ ├── 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
|
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
|
||||||
│ │ ├── Layout.tsx
|
│ │ ├── Layout.tsx
|
||||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||||
@@ -252,7 +253,7 @@ graph TB
|
|||||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||||
Feed[ActivityFeed<br/>streaming event log]
|
Feed[ActivityFeed<br/>streaming event log]
|
||||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
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
|
App --> Layout
|
||||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||||
@@ -473,7 +474,7 @@ graph TB
|
|||||||
CcConfig["/cc-config (redirect)"]
|
CcConfig["/cc-config (redirect)"]
|
||||||
Lanes["/lanes (redirect)"]
|
Lanes["/lanes (redirect)"]
|
||||||
Run["/run (redirect)"]
|
Run["/run (redirect)"]
|
||||||
Workspace["/workspace?lane=<id>"]
|
Workspace["/workspace?lane=<id> (home) · &run=<runId> (session)"]
|
||||||
Settings["/settings (tabs: dashboard | claude-code | ...)"]
|
Settings["/settings (tabs: dashboard | claude-code | ...)"]
|
||||||
NF["/* (NotFound)"]
|
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;
|
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({
|
export function WorkspaceSessionList({
|
||||||
lane,
|
lane,
|
||||||
liveRuns,
|
liveRuns,
|
||||||
@@ -53,8 +66,7 @@ export function WorkspaceSessionList({
|
|||||||
const shown = (sessions ?? []).filter(
|
const shown = (sessions ?? []).filter(
|
||||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
||||||
);
|
);
|
||||||
const runFor = (s: Session) =>
|
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||||
liveRuns.find((r) => r.sessionId === s.id && r.status === "running") ?? null;
|
|
||||||
|
|
||||||
if (sessions !== null && shown.length === 0) {
|
if (sessions !== null && shown.length === 0) {
|
||||||
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
|
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]);
|
}, [laneId, slot, refreshList]);
|
||||||
|
|
||||||
// Runs after the lane-switch effect above, so a sidebar pick that changes
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!attachRequest) return;
|
if (!attachRequest || laneId === null) return;
|
||||||
|
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
|
||||||
let live = true;
|
let live = true;
|
||||||
autoAttachedForLane.current = laneId;
|
autoAttachedForLane.current = laneId;
|
||||||
api.run
|
api.run
|
||||||
@@ -239,9 +243,10 @@ export function LaneConsolePane({
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
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
|
// 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
|
// 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
|
// slash command (or the codex stage-checklist prose) is sent as soon as the
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ interface PromptEditorProps {
|
|||||||
fileCwd: string;
|
fileCwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PromptEditor({
|
export function PromptEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|||||||
@@ -556,4 +556,50 @@ describe("LaneConsolePane", () => {
|
|||||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||||
expect(api.run.start).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
|
* @file WorktreeVisibilitySettings — Settings → Git → Worktrees: whether a
|
||||||
* sidebar's project tree lists a repo's untracked worktrees, and which sources
|
* workspace home's Add folder suggests the repo's untracked worktrees, and
|
||||||
* of them (`.claude/worktrees/*`, AgentHub-managed) it hides. Writes the tree's
|
* which sources of them (`.claude/worktrees/*`, AgentHub-managed) it leaves
|
||||||
* own localStorage prefs (`lib/projectTreePrefs.ts`), so the sidebar follows at
|
* out. Writes the localStorage prefs in `lib/projectTreePrefs.ts`.
|
||||||
* once; a project's own override lives on its Settings → Projects page.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
* @file WorktreeVisibilitySettings.test.tsx
|
* @file WorktreeVisibilitySettings.test.tsx
|
||||||
* @description Settings → Worktrees writes the sidebar tree's own prefs key
|
* @description Settings → Worktrees writes the sidebar tree's own prefs key
|
||||||
* (`agenthub.sidebar.projects`), disables the source rows while untracked
|
* (`agenthub.sidebar.projects`), disables the source rows while untracked
|
||||||
* worktrees are hidden, and a project's override (`hiddenSourcesFor`) replaces
|
* worktrees are not suggested.
|
||||||
* the global list for that project only.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { WorktreeVisibilitySettings } from "../WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../WorktreeVisibilitySettings";
|
||||||
import { hiddenSourcesFor, readProjectTreePrefs } from "../../../lib/projectTreePrefs";
|
|
||||||
|
|
||||||
const stored = () => JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
|
const stored = () => JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
|
||||||
|
|
||||||
@@ -29,15 +27,4 @@ describe("WorktreeVisibilitySettings", () => {
|
|||||||
expect(stored().showUntracked).toBe(false);
|
expect(stored().showUntracked).toBe(false);
|
||||||
expect(screen.getByRole("switch", { name: "AgentHub-managed worktrees" })).toBeDisabled();
|
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();
|
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 () => {
|
it("stays silent while notifications are disabled", async () => {
|
||||||
renderHook(() => useNotifications());
|
renderHook(() => useNotifications());
|
||||||
|
|
||||||
|
|||||||
@@ -84,8 +84,19 @@ export function useNotifications() {
|
|||||||
.then((r) => (lanesRef.current = r.lanes))
|
.then((r) => (lanesRef.current = r.lanes))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
function laneUrlForSession(sessionId: string): string | undefined {
|
// Straight into the pane that holds the session (any slot, from the
|
||||||
const lane = lanesRef.current.find((l) => l.session_id === sessionId);
|
// 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;
|
return lane ? `/workspace?lane=${lane.id}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +117,18 @@ export function useNotifications() {
|
|||||||
if (!prefs.enabled) return;
|
if (!prefs.enabled) return;
|
||||||
// A notification that was shown (pushed, native or local) plays its
|
// A notification that was shown (pushed, native or local) plays its
|
||||||
// type's sound; one that reached nobody plays nothing.
|
// type's sound; one that reached nobody plays nothing.
|
||||||
const show = (type: NotifType, title: string, body: string, url?: string) =>
|
const show = (
|
||||||
void notify(title, body, url).then((r) => {
|
type: NotifType,
|
||||||
if (r.via === "native" || r.via === "push" || r.via === "local")
|
title: string,
|
||||||
playSound(soundFor(prefs, type));
|
body: string,
|
||||||
});
|
url?: Promise<string | undefined>
|
||||||
|
) =>
|
||||||
|
void Promise.resolve(url)
|
||||||
|
.then((u) => notify(title, body, u))
|
||||||
|
.then((r) => {
|
||||||
|
if (r.via === "native" || r.via === "push" || r.via === "local")
|
||||||
|
playSound(soundFor(prefs, type));
|
||||||
|
});
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "session_created": {
|
case "session_created": {
|
||||||
|
|||||||
@@ -334,5 +334,35 @@
|
|||||||
"tree.search": "Search workspaces and sessions",
|
"tree.search": "Search workspaces and sessions",
|
||||||
"tree.newSession": "New session",
|
"tree.newSession": "New session",
|
||||||
"tree.noSessions": "No sessions yet",
|
"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",
|
"general": "General",
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"routing": "Routing",
|
"routing": "Routing",
|
||||||
"git": "Git",
|
"git": "Git"
|
||||||
"projects": "Projects"
|
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
"general": {
|
"general": {
|
||||||
@@ -694,10 +693,7 @@
|
|||||||
},
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Worktrees",
|
"title": "Worktrees",
|
||||||
"description": "Which git worktrees the sidebar lists under each project."
|
"description": "Which untracked git worktrees a workspace's Add folder suggests."
|
||||||
},
|
|
||||||
"project": {
|
|
||||||
"description": "This project's workspaces and worktree settings."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -706,32 +702,13 @@
|
|||||||
"noMatch": "No setting matches “{{query}}”."
|
"noMatch": "No setting matches “{{query}}”."
|
||||||
},
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Untracked worktrees",
|
"title": "Worktree suggestions",
|
||||||
"description": "A worktree with no workspace on it is listed under its project with an Adopt button. Saved in this browser only.",
|
"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",
|
"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",
|
"claude": "Claude Code worktrees",
|
||||||
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
|
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
|
||||||
"agenthub": "AgentHub-managed worktrees",
|
"agenthub": "AgentHub-managed worktrees",
|
||||||
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
|
"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.search": "Tìm workspace và session",
|
||||||
"tree.newSession": "Session mới",
|
"tree.newSession": "Session mới",
|
||||||
"tree.noSessions": "Chưa có session",
|
"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",
|
"general": "Chung",
|
||||||
"agents": "Tác tử",
|
"agents": "Tác tử",
|
||||||
"routing": "Định tuyến",
|
"routing": "Định tuyến",
|
||||||
"git": "Git",
|
"git": "Git"
|
||||||
"projects": "Dự án"
|
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
"general": {
|
"general": {
|
||||||
@@ -694,10 +693,7 @@
|
|||||||
},
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Worktree",
|
"title": "Worktree",
|
||||||
"description": "Những git worktree mà sidebar liệt kê dưới mỗi dự án."
|
"description": "Những git worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace."
|
||||||
},
|
|
||||||
"project": {
|
|
||||||
"description": "Các workspace và cài đặt worktree của dự án này."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -706,32 +702,13 @@
|
|||||||
"noMatch": "Không có cài đặt nào khớp “{{query}}”."
|
"noMatch": "Không có cài đặt nào khớp “{{query}}”."
|
||||||
},
|
},
|
||||||
"worktrees": {
|
"worktrees": {
|
||||||
"title": "Worktree chưa theo dõi",
|
"title": "Gợi ý worktree",
|
||||||
"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.",
|
"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",
|
"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",
|
"claude": "Worktree của Claude Code",
|
||||||
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
|
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
|
||||||
"agenthub": "Worktree do AgentHub quản lý",
|
"agenthub": "Worktree do AgentHub quản lý",
|
||||||
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
|
"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.
|
* @param patch Subset of lane fields to update.
|
||||||
* @returns `{ lane }` — the updated {@link Lane}.
|
* @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>) =>
|
update: (id: number, patch: Partial<Lane>) =>
|
||||||
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
/**
|
/**
|
||||||
@@ -3879,6 +3910,38 @@ export interface RunHandle {
|
|||||||
tool: LaneTool;
|
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. */
|
/** Response shape of GET /api/run. */
|
||||||
export interface RunListResponse {
|
export interface RunListResponse {
|
||||||
items: RunHandle[];
|
items: RunHandle[];
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
|
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
|
||||||
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
|
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
|
||||||
* expanded to their sessions (by lane id), whether a repo's untracked
|
* expanded to their sessions (by lane id), and — Settings → Worktrees —
|
||||||
* worktrees are offered, and which
|
* whether a workspace home's Add folder suggests the repo's untracked
|
||||||
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
|
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
|
||||||
* LANES_ROOT) are hidden — globally, or per project root, where a project's own
|
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
|
||||||
* list (Settings → Projects) replaces the global one. A `useSyncExternalStore` reader that caches nothing
|
|
||||||
* but the parsed blob for the current raw string, so the sidebar and Settings
|
* but the parsed blob for the current raw string, so the sidebar and Settings
|
||||||
* can never disagree. Nothing here reaches the server.
|
* can never disagree. Nothing here reaches the server.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -20,15 +19,12 @@ export interface ProjectTreePrefs {
|
|||||||
expanded: Record<string, boolean>;
|
expanded: Record<string, boolean>;
|
||||||
showUntracked: boolean;
|
showUntracked: boolean;
|
||||||
hideSources: string[];
|
hideSources: string[];
|
||||||
/** Per project root: replaces `hideSources` for that project. */
|
|
||||||
projectHideSources: Record<string, string[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: ProjectTreePrefs = {
|
const DEFAULTS: ProjectTreePrefs = {
|
||||||
expanded: {},
|
expanded: {},
|
||||||
showUntracked: true,
|
showUntracked: true,
|
||||||
hideSources: [],
|
hideSources: [],
|
||||||
projectHideSources: {},
|
|
||||||
};
|
};
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
|
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 : {},
|
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
|
||||||
showUntracked: parsed.showUntracked !== false,
|
showUntracked: parsed.showUntracked !== false,
|
||||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||||
projectHideSources:
|
|
||||||
parsed.projectHideSources && typeof parsed.projectHideSources === "object"
|
|
||||||
? parsed.projectHideSources
|
|
||||||
: {},
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
value = DEFAULTS;
|
value = DEFAULTS;
|
||||||
@@ -72,11 +64,6 @@ export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
|
|||||||
listeners.forEach((l) => l());
|
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) {
|
function subscribe(l: () => void) {
|
||||||
listeners.add(l);
|
listeners.add(l);
|
||||||
return () => listeners.delete(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
|
* @file SettingsHub.tsx
|
||||||
* @description The Settings page: `/settings/<section>`, a left nav grouped
|
* @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
|
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
|
||||||
* focuses it, Enter opens the first match, Escape clears), and one page per
|
* 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
|
* 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
|
* 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
|
* 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.
|
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
Coins,
|
Coins,
|
||||||
FolderGit2,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Orbit,
|
Orbit,
|
||||||
@@ -37,18 +36,14 @@ import { CodexConfig } from "./CodexConfig";
|
|||||||
import { AiToolConfig } from "./AiToolConfig";
|
import { AiToolConfig } from "./AiToolConfig";
|
||||||
import { AiRouterConfig } from "./AiRouterConfig";
|
import { AiRouterConfig } from "./AiRouterConfig";
|
||||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||||
import { ProjectSettings } from "./ProjectSettings";
|
|
||||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
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";
|
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];
|
type Group = (typeof GROUPS)[number];
|
||||||
|
|
||||||
interface Section {
|
interface Section {
|
||||||
@@ -76,22 +71,8 @@ export function SettingsHub() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const aiTools = useAiTools();
|
const aiTools = useAiTools();
|
||||||
const { lanes, refresh: refreshLanes } = useLanes();
|
|
||||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -215,26 +196,6 @@ export function SettingsHub() {
|
|||||||
...s("worktrees"),
|
...s("worktrees"),
|
||||||
render: () => <WorktreeVisibilitySettings />,
|
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) => [
|
const entries: SettingsEntry[] = sections.flatMap((sec) => [
|
||||||
@@ -267,8 +228,8 @@ export function SettingsHub() {
|
|||||||
}
|
}
|
||||||
const active = sections.find((x) => x.id === rest);
|
const active = sections.find((x) => x.id === rest);
|
||||||
if (!active) {
|
if (!active) {
|
||||||
// A catalog tool or project page exists only once its list has loaded.
|
// A catalog tool page exists only once the tool list has loaded.
|
||||||
if (!aiTools.loaded || projects === null) return null;
|
if (!aiTools.loaded) return null;
|
||||||
return <Navigate to="/settings/general" replace />;
|
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 { useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
Plus,
|
Plus,
|
||||||
Square,
|
Square,
|
||||||
Columns2,
|
Columns2,
|
||||||
@@ -79,6 +80,8 @@ import LaneCard, {
|
|||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||||
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
|
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 { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||||
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||||
|
|
||||||
@@ -373,6 +376,8 @@ export function Workspace() {
|
|||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
const next = new URLSearchParams(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");
|
if (id === null) next.delete("lane");
|
||||||
else next.set("lane", String(id));
|
else next.set("lane", String(id));
|
||||||
return next;
|
return next;
|
||||||
@@ -394,6 +399,10 @@ export function Workspace() {
|
|||||||
if (paramId === null) setSelectedLaneId(selectedLaneId);
|
if (paramId === null) setSelectedLaneId(selectedLaneId);
|
||||||
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
|
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
|
||||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||||
|
const [destructive, setDestructive] = useState<{
|
||||||
|
lane: Lane;
|
||||||
|
action: "reset" | "remove";
|
||||||
|
} | null>(null);
|
||||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||||
// Fields the Add-lane modal opens with when it was opened from a lane's git
|
// 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.
|
// 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
|
// 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
|
// 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).
|
// 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 =
|
const viewportLocked =
|
||||||
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
|
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
|
||||||
|
|
||||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||||
setLaneActionError(null);
|
setLaneActionError(null);
|
||||||
@@ -767,7 +780,26 @@ export function Workspace() {
|
|||||||
it rather than under it. It collapses to a rail instead of
|
it rather than under it. It collapses to a rail instead of
|
||||||
unmounting nothing — there is no live subscription inside it, so
|
unmounting nothing — there is no live subscription inside it, so
|
||||||
unmounting is safe and keeps a closed panel from polling. */}
|
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
|
<section
|
||||||
data-testid="lane-detail"
|
data-testid="lane-detail"
|
||||||
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}${
|
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("statusDead")
|
||||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||||
</span>
|
</span>
|
||||||
{/* Renaming lives on the sidebar row (double-click); here the
|
{/* Back to the workspace's home; renaming lives on the sidebar
|
||||||
name is only read. */}
|
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
|
<span
|
||||||
title={currentLane.cwd}
|
title={currentLane.cwd}
|
||||||
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
|
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 && (
|
{laneActionError && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
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`
|
* @description The Settings page shell: a path picks its section, `/settings`
|
||||||
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
|
* 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
|
* 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
|
* select navigates, and the search filters the nav, opens the first match on
|
||||||
* Enter, clears on Escape and is focused by Ctrl+F.
|
* Enter, clears on Escape and is focused by Ctrl+F.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import type { AiTool, AiToolsView } from "../../lib/api";
|
import type { AiTool, AiToolsView } from "../../lib/api";
|
||||||
import type { Project } from "../../lib/types";
|
|
||||||
|
|
||||||
vi.mock("../Settings", () => ({
|
vi.mock("../Settings", () => ({
|
||||||
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
|
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
|
||||||
@@ -43,7 +42,6 @@ const tool = (id: string, over: Partial<AiTool> = {}) =>
|
|||||||
...over,
|
...over,
|
||||||
}) as AiTool;
|
}) as AiTool;
|
||||||
let view: AiToolsView;
|
let view: AiToolsView;
|
||||||
let projects: Project[];
|
|
||||||
|
|
||||||
vi.mock("../../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -51,7 +49,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
api: {
|
api: {
|
||||||
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
||||||
projects: { list: vi.fn(async () => ({ projects })) },
|
|
||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -97,10 +94,6 @@ beforeEach(() => {
|
|||||||
tool("aider", { installed: false }),
|
tool("aider", { installed: false }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
projects = [
|
|
||||||
{ root: "/r/app", name: "app", workspaces: [], worktrees: [] },
|
|
||||||
{ root: null, name: "Folders", workspaces: [], worktrees: [] },
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SettingsHub", () => {
|
describe("SettingsHub", () => {
|
||||||
@@ -146,13 +139,6 @@ describe("SettingsHub", () => {
|
|||||||
expect(links).not.toContain("Aider");
|
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 () => {
|
it("navigates from the mobile select", async () => {
|
||||||
renderAt("/settings/general");
|
renderAt("/settings/general");
|
||||||
await screen.findByTestId("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) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
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) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ const h = vi.hoisted(() => {
|
|||||||
const routerApply = h.routerApply;
|
const routerApply = h.routerApply;
|
||||||
let lanesToReturn: LaneFixture[] = [];
|
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) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ let lanesToReturn: LaneFixture[] = [
|
|||||||
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
||||||
let nextRunId = 1;
|
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) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
const recordCall = (method: string, path: string) => {
|
const recordCall = (method: string, path: string) => {
|
||||||
@@ -694,3 +701,25 @@ describe("split terminal view", () => {
|
|||||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
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.
|
- **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.
|
- **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)
|
#### Projects (workspaces grouped by repository)
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -1127,7 +1161,7 @@ GET /api/projects
|
|||||||
POST /api/projects/refresh
|
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
|
```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
|
### 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
|
## 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.
|
- **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.
|
- **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").
|
- **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.
|
- **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.
|
||||||
- **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 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.
|
- **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.
|
- **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.
|
- **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,
|
hook_type: hookType,
|
||||||
data: parsedData,
|
data: parsedData,
|
||||||
...(tool && { tool }),
|
...(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);
|
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,20 +477,21 @@ describe("pty-run", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds each prompt mode, with fixed args first", () => {
|
it("builds each prompt mode, with fixed args first, a dash-led prompt never a flag", () => {
|
||||||
const argvFor = (tool, extra = {}) =>
|
const argvFor = (tool, extra = {}) =>
|
||||||
paneArgv(
|
paneArgv(
|
||||||
captureNewSession(() =>
|
captureNewSession(() =>
|
||||||
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
|
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", "-x y"]);
|
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
|
||||||
|
// Behind a `--` separator the prompt is already a positional, kept as typed.
|
||||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", "-x y"]);
|
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
|
||||||
assert.deepEqual(argvFor("gemini", { bypass: false }), [
|
assert.deepEqual(argvFor("gemini", { bypass: false }), [
|
||||||
at("gemini"),
|
at("gemini"),
|
||||||
"--prompt-interactive",
|
"--prompt-interactive",
|
||||||
"-x y",
|
" -x y",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
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
|
// 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
|
// 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
|
// 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 metricsRouter = require("./routes/metrics");
|
||||||
const lanesRouter = require("./routes/lanes");
|
const lanesRouter = require("./routes/lanes");
|
||||||
const projectsRouter = require("./routes/projects");
|
const projectsRouter = require("./routes/projects");
|
||||||
|
const laneSourcesRouter = require("./routes/lane-sources");
|
||||||
const locksRouter = require("./routes/locks");
|
const locksRouter = require("./routes/locks");
|
||||||
|
|
||||||
const APP_VERSION = (() => {
|
const APP_VERSION = (() => {
|
||||||
@@ -128,6 +129,8 @@ function createApp() {
|
|||||||
app.use("/api/git-credentials", gitCredentialsRouter);
|
app.use("/api/git-credentials", gitCredentialsRouter);
|
||||||
app.use("/api/ai-tools", aiToolsRouter);
|
app.use("/api/ai-tools", aiToolsRouter);
|
||||||
app.use("/api/run", runRouter);
|
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/lanes", lanesRouter);
|
||||||
app.use("/api/projects", projectsRouter);
|
app.use("/api/projects", projectsRouter);
|
||||||
app.use("/api/locks", locksRouter);
|
app.use("/api/locks", locksRouter);
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ function setClaudeHome(newPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
encodeCwd,
|
||||||
getClaudeHome,
|
getClaudeHome,
|
||||||
getProjectsDir,
|
getProjectsDir,
|
||||||
getDataDir,
|
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) {
|
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. */
|
/** 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 inferredIdx = inferred ? phaseIdx(pipeline, inferred) : -1;
|
||||||
const view = inferredIdx === -1 ? lane : { ...lane, stage: inferred };
|
const view = inferredIdx === -1 ? lane : { ...lane, stage: inferred };
|
||||||
const nodes = nodeStates(pipeline, view);
|
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 {
|
return {
|
||||||
...lane,
|
...row,
|
||||||
pipeline_name: pipeline.name,
|
pipeline_name: pipeline.name,
|
||||||
pipeline_skill: pipeline.skill || "",
|
pipeline_skill: pipeline.skill || "",
|
||||||
pipeline_nodes:
|
pipeline_nodes:
|
||||||
|
|||||||
+56
-5
@@ -91,9 +91,34 @@ function makeErr(code, message) {
|
|||||||
* interactive afterward (unlike `-p`, which forces print-and-exit and closes
|
* interactive afterward (unlike `-p`, which forces print-and-exit and closes
|
||||||
* stdin).
|
* 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"];
|
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");
|
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
|
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
|
||||||
// it on: `claude --help` — "Enable bypassing all permission checks as an
|
// it on: `claude --help` — "Enable bypassing all permission checks as an
|
||||||
// option, without it being enabled by default." Without it the cycle is only
|
// 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 (model) argv.push("--model", model);
|
||||||
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
||||||
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
||||||
if (initialPrompt) argv.push(initialPrompt);
|
if (initialPrompt) argv.push(promptArg(initialPrompt));
|
||||||
return argv;
|
return argv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +149,9 @@ function buildCodexArgv({
|
|||||||
bypass,
|
bypass,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
routerProfile,
|
routerProfile,
|
||||||
|
addDirs = [],
|
||||||
|
systemPrompt = null,
|
||||||
|
systemPromptFile = null,
|
||||||
}) {
|
}) {
|
||||||
const argv = ["codex"];
|
const argv = ["codex"];
|
||||||
// Before the subcommand and before the prompt: `-p` layers
|
// Before the subcommand and before the prompt: `-p` layers
|
||||||
@@ -141,7 +169,15 @@ function buildCodexArgv({
|
|||||||
if (approval) argv.push("-a", approval);
|
if (approval) argv.push("-a", approval);
|
||||||
}
|
}
|
||||||
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
|
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;
|
return argv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,9 +195,10 @@ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt
|
|||||||
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||||
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
||||||
const p = entry.prompt;
|
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")
|
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;
|
return argv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,8 +311,18 @@ function spawnRun(args) {
|
|||||||
|
|
||||||
const id = runIdForLane(laneId, slot);
|
const id = runIdForLane(laneId, slot);
|
||||||
const startedAt = Date.now();
|
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(
|
const argv = buildArgv(
|
||||||
{
|
{
|
||||||
|
addDirs,
|
||||||
|
systemPrompt,
|
||||||
|
systemPromptFile,
|
||||||
tool,
|
tool,
|
||||||
model,
|
model,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
@@ -336,6 +383,10 @@ function spawnRun(args) {
|
|||||||
// node`) dies there without the `node` the user's shell would have found.
|
// node`) dies there without the `node` the user's shell would have found.
|
||||||
const env = {
|
const env = {
|
||||||
AGENTHUB_LANE_ID: String(laneId),
|
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 } : {}),
|
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||||
...desktopEnv(),
|
...desktopEnv(),
|
||||||
// A codex pane must read the same home the dashboard reads — its rollout
|
// 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.
|
// `liveness.probeLiveCwds` and the watchdog picks the stub up at call time.
|
||||||
const liveness = require("../lib/session-liveness");
|
const liveness = require("../lib/session-liveness");
|
||||||
const lanesLib = require("../lib/lanes");
|
const lanesLib = require("../lib/lanes");
|
||||||
|
const dashboardRuns = require("../lib/dashboard-runs");
|
||||||
const { broadcastLane } = require("./lanes");
|
const { broadcastLane } = require("./lanes");
|
||||||
const { detect } = require("../lib/stage-detect");
|
const { detect } = require("../lib/stage-detect");
|
||||||
const { getPipeline } = require("../lib/pipelines");
|
const { getPipeline } = require("../lib/pipelines");
|
||||||
@@ -1293,6 +1294,29 @@ function antigravityHookInput(hookType, data) {
|
|||||||
|
|
||||||
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
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) => {
|
router.post("/event", (req, res) => {
|
||||||
let { hook_type, data } = req.body;
|
let { hook_type, data } = req.body;
|
||||||
if (!hook_type || !data) {
|
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.
|
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
|
||||||
touchLaneFromHook(hook_type, data, tool);
|
touchLaneFromHook(hook_type, data, tool);
|
||||||
|
bindRunSession(req.body.run_id, data.session_id, hook_type, data.source, tool);
|
||||||
|
|
||||||
res.json({ ok: true, event: result });
|
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