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

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

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
This commit is contained in:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
+3 -2
View File
@@ -184,6 +184,7 @@ client/
│ │ ├── EmptyState.tsx
│ │ ├── Sidebar.tsx
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), row menu
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: prompt box (new session in a free pane slot), Recents, Instructions / Memory / Context / Folder
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
│ │ ├── Layout.tsx
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
@@ -252,7 +253,7 @@ graph TB
Detail[SessionDetail<br/>agent hierarchy + timeline]
Feed[ActivityFeed<br/>streaming event log]
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktrees<br/>Projects: one page per repo]
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
App --> Layout
Layout --> Dashboard & Sessions & Detail & Feed & Settings
@@ -473,7 +474,7 @@ graph TB
CcConfig["/cc-config (redirect)"]
Lanes["/lanes (redirect)"]
Run["/run (redirect)"]
Workspace["/workspace?lane=<id>"]
Workspace["/workspace?lane=<id> (home) · &run=<runId> (session)"]
Settings["/settings (tabs: dashboard | claude-code | ...)"]
NF["/* (NotFound)"]
@@ -0,0 +1,639 @@
/**
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`), the workspace's Recents (a live session
* opens its pane, any other its transcript — nothing is resumed), and on the
* right its context: Instructions, Memory (read-only), Context files and
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { FileText, Folder, MoreHorizontal, Pencil, Play, Plus, X } from "lucide-react";
import {
api,
type LaneMemoryFile,
type LaneSourcesView,
type PermissionMode,
type RunHandle,
} from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { liveRunFor } from "./WorkspaceSessionList";
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
export function paneRunId(laneId: number, slot: number): string {
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
}
/** The first pane slot with no live run, or null when all four are busy. */
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
const busy = new Set(
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
);
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
}
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
type HomeProps = Parameters<typeof HomeBody>[0];
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
* model, an open folder form, a late upload's answer) belongs to ONE
* workspace, and the page swaps `lane` without unmounting. */
export function WorkspaceHome(props: HomeProps) {
return <HomeBody key={props.lane.id} {...props} />;
}
function HomeBody({
lane,
onMenuAction,
onRenamed,
}: {
lane: Lane;
/** Row-menu items the page carries out (its modals); rename is done here. */
onMenuAction: (action: WorkspaceMenuAction) => void;
/** Called after a rename, so the page re-reads the lane list. */
onRenamed?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
const claude = lane.tool === "claude";
const [prompt, setPrompt] = useState("");
// The tool's default model, also when the catalog arrives after mount.
const defaultModel = aiTools.byId(lane.tool)?.defaultModel ?? "";
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel]);
const [mode, setMode] = useState<PermissionMode>("auto");
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [sessions, setSessions] = useState<Session[] | null>(null);
const [view, setView] = useState<LaneSourcesView | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
useEffect(() => {
let live = true;
api.run
.list()
.then((r) => live && setLiveRuns(r.items))
.catch(() => undefined);
api.sessions
.list({ cwd: lane.cwd, tool: lane.tool, limit: 30 })
.then((r) => live && setSessions(r.sessions))
.catch(() => live && setSessions([]));
return () => {
live = false;
};
}, [lane.id, lane.cwd, lane.tool, runKey]);
useEffect(() => {
let live = true;
setView(null);
setMemory(null);
setEditing(null);
setError(null);
api.lanes
.sources(lane.id)
.then((v) => live && setView(v))
.catch((err) => live && setError(message(err)));
api.lanes
.memory(lane.id)
.then((r) => live && setMemory(r.files))
.catch(() => live && setMemory([]));
return () => {
live = false;
};
}, [lane.id]);
// Suggestions are read when the folder form opens, not on every visit.
useEffect(() => {
if (!folderOpen) return;
let live = true;
api.projects
.list()
.then(({ projects }) => {
if (!live) return;
const p = projects.find((x) => x.workspaces.includes(lane.id));
const own = new Set((view?.sources ?? []).map((s) => s.path));
setSuggestions(
!p || !prefs.showUntracked
? []
: p.worktrees
.filter((w) => w.workspace_id === null && !own.has(w.path))
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
.map((w) => w.path)
);
})
.catch(() => undefined);
return () => {
live = false;
};
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
const run = async (fn: () => Promise<LaneSourcesView>) => {
setError(null);
try {
setView(await fn());
return true;
} catch (err) {
setError(message(err));
return false;
}
};
const start = async () => {
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
setStarting(true);
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", {
slot,
prompt: prompt.trim(),
...(model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
});
setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
setError(message(err));
} finally {
setStarting(false);
}
};
const openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id);
navigate(
r ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}` : `/sessions/${s.id}`
);
};
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const name = workspaceName(lane);
const card = "border-b border-border px-4 py-4 last:border-b-0";
const iconBtn =
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
return (
<div
data-testid="workspace-home"
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
>
<div className="min-w-0 flex-1 basis-[28rem]">
<p className="text-xs text-fg-muted">
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
</p>
<div className="mt-3 flex items-center gap-2">
{title === null ? (
<h1
title={lane.cwd}
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
>
{name}
</h1>
) : (
<input
data-testid="workspace-home-rename"
aria-label={t("renameLabel")}
autoFocus
value={title}
maxLength={200}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => setTitle(null)}
onKeyDown={async (e) => {
if (e.key === "Escape") setTitle(null);
if (e.key !== "Enter") return;
const next = title.trim();
setTitle(null);
if (next === (lane.title || "")) return;
try {
await api.lanes.update(lane.id, { title: next });
onRenamed?.();
} catch (err) {
setError(message(err));
}
}}
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-1 text-2xl font-semibold text-fg-primary"
/>
)}
<button
type="button"
data-testid="workspace-home-menu"
aria-label={t("row.actions")}
title={t("row.actions")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setMenuAt({ x: r.left - 200, y: r.bottom + 4 });
}}
className={iconBtn}
>
<MoreHorizontal className="h-5 w-5" />
</button>
</div>
<div className="card mt-5 p-3">
<PromptEditor
value={prompt}
onChange={setPrompt}
onSubmit={() => void start()}
placeholder={t("home.placeholder")}
rows={3}
slash={claude}
fileCwd={lane.cwd}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<div className="w-56">
<ModelPicker value={model} onChange={setModel} tool={lane.tool} />
</div>
{claude && (
<div className="w-44">
<Select<PermissionMode>
value={mode}
onChange={setMode}
options={[
{ value: "auto", label: t("run:fields.permissionAuto") },
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
{ value: "default", label: t("run:fields.permissionDefault") },
{ value: "plan", label: t("run:fields.permissionPlan") },
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
]}
/>
</div>
)}
<button
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting}
className="btn-primary ml-auto text-xs disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
{t("home.start")}
</button>
</div>
</div>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
<h2 className="mt-8 text-sm font-medium text-fg-muted">{t("home.recents")}</h2>
{sessions !== null && sessions.length === 0 && (
<p className="mt-2 text-sm text-fg-muted">{t("home.noRecents")}</p>
)}
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
{(sessions ?? []).map((s) => {
const live = !!liveRunFor(lane, liveRuns, s.id);
return (
<li key={s.id}>
<button
type="button"
data-testid={`workspace-recent-${s.id}`}
data-live={live ? "true" : undefined}
onClick={() => openSession(s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
live ? "bg-status-success" : "border border-fg-muted"
}`}
/>
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
{s.name || s.id.slice(0, 8)}
</span>
<span className="shrink-0 text-xs text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)}
</span>
</button>
</li>
);
})}
</ul>
</div>
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
{view && !view.supported && (
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
</p>
)}
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
{editing === null && (
<button
type="button"
aria-label={t("home.edit")}
title={t("home.edit")}
onClick={() => setEditing(view?.instructions ?? "")}
disabled={!view}
className={iconBtn}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
{editing === null ? (
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
{view?.instructions || t("home.instructionsEmpty")}
</p>
) : (
<div className="mt-2 space-y-2">
<textarea
data-testid="workspace-instructions"
aria-label={t("home.instructions")}
autoFocus
value={editing}
maxLength={view?.maxInstructions}
onChange={(e) => setEditing(e.target.value)}
rows={6}
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(null)}
className="btn-ghost text-xs"
>
{t("home.cancel")}
</button>
<button
type="button"
onClick={async () => {
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
setEditing(null);
}}
className="btn-primary text-xs"
>
{t("home.save")}
</button>
</div>
</div>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
<button
type="button"
onClick={() => setMemoryOpen(true)}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
{t("home.viewMemory")}
</button>
</div>
<p className="mt-1 text-xs text-fg-muted">
{memory?.length
? t("home.memoryCount", { count: memory.length })
: t("home.memoryEmpty")}
</p>
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
<button
type="button"
aria-label={t("home.addFile")}
title={t("home.addFile")}
onClick={() => fileInput.current?.click()}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
<input
ref={fileInput}
data-testid="workspace-file-input"
type="file"
multiple
hidden
onChange={async (e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
}}
/>
</div>
{files.length === 0 ? (
<button
type="button"
onClick={() => fileInput.current?.click()}
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
>
{t("home.contextEmpty")}
</button>
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
</span>
<span className="shrink-0 text-fg-muted">
{f.missing ? t("home.missing") : fmtBytes(f.size)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
)}
</section>
<section className={card}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
<button
type="button"
aria-label={t("home.addFolder")}
title={t("home.addFolder")}
onClick={() => setFolderOpen((v) => !v)}
disabled={!view}
className={iconBtn}
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<ul className="mt-2 space-y-1.5">
<li className="flex items-start gap-2 text-xs">
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span className="min-w-0">
<span title={lane.cwd} className="block truncate text-fg-secondary">
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
</span>
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
</span>
</li>
{folders.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
{f.missing && (
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
)}
</span>
<button
type="button"
aria-label={`${t("home.remove")} ${f.name}`}
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
>
<X className="h-3 w-3" />
</button>
</li>
))}
</ul>
{folderOpen && (
<form
className="mt-3 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
setFolderPath("");
setFolderOpen(false);
}
}}
>
<div className="flex gap-2">
<input
data-testid="workspace-folder-input"
aria-label={t("home.folderPath")}
placeholder={t("home.folderPath")}
autoFocus
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
{t("home.add")}
</button>
</div>
{suggestions.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("home.suggested")}
</p>
<ul className="mt-1 space-y-0.5">
{suggestions.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => setFolderPath(p)}
title={p}
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
>
{p}
</button>
</li>
))}
</ul>
</div>
)}
</form>
)}
</section>
</aside>
{memoryOpen && memory && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={() => setMemoryOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
<button
type="button"
autoFocus
aria-label={t("home.close")}
onClick={() => setMemoryOpen(false)}
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
{memory.map((f) => (
<section key={f.path} className="mb-5">
<p title={f.path} className="mb-1 font-mono text-xs text-fg-muted">
{f.label}
</p>
<MarkdownPreview text={f.content} />
</section>
))}
</div>
</div>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
/>
)}
</div>
);
}
@@ -18,6 +18,19 @@ import { timeAgo } from "../../lib/format";
export const SESSION_LIMIT = 20;
/**
* The live pane holding a session: the run the hooks bound it to
* (`dashboard_runs.session_id`), else — a pane started before the hooks
* reported it — the lane's own pane when the lane's bound session is this one.
*/
export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): RunHandle | null {
const live = runs.filter((r) => r.laneId === lane.id && r.status === "running");
return (
live.find((r) => r.sessionId === sessionId) ??
(lane.session_id === sessionId ? (live.find((r) => r.id === lane.run_id) ?? null) : null)
);
}
export function WorkspaceSessionList({
lane,
liveRuns,
@@ -53,8 +66,7 @@ export function WorkspaceSessionList({
const shown = (sessions ?? []).filter(
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
);
const runFor = (s: Session) =>
liveRuns.find((r) => r.sessionId === s.id && r.status === "running") ?? null;
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
if (sessions !== null && shown.length === 0) {
return <p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>;
@@ -0,0 +1,299 @@
/**
* @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free
* pane slot and opens it, all-busy is refused, Recents open a live pane or the
* transcript (never a resume), instructions save, context files upload and
* remove, a folder is added with the repo's untracked worktrees suggested
* (minus hidden sources), an unsupported tool is said so, and memory opens
* read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
vi.mock("../../run/RunSetup", () => ({
PromptEditor: ({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (s: string) => void;
placeholder: string;
}) => (
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
),
ModelPicker: ({ value, onChange }: { value: string; onChange: (s: string) => void }) => (
<input aria-label="model" value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
let view: LaneSourcesView;
let runs: RunHandle[];
const sessions = [
{ id: "s-live", name: "Live one", started_at: "2026-09-30T10:00:00Z" },
{ id: "s-old", name: "Old one", started_at: "2026-09-29T10:00:00Z" },
] as Session[];
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
sources: vi.fn(async () => view),
setInstructions: vi.fn(async (_id: number, instructions: string) => ({
...view,
instructions,
})),
addFolder: vi.fn(async () => view),
addFile: vi.fn(async () => view),
removeSource: vi.fn(async () => view),
memory: vi.fn(async () => ({
files: [{ label: "CLAUDE.md", path: "/r/CLAUDE.md", content: "# Rules here" }],
})),
action: vi.fn(async () => ({ ok: true, run_id: "agenthub-lane-1-1" })),
update: vi.fn(async () => ({})),
routerList: vi.fn(async () => ({ routers: [] })),
},
run: { list: vi.fn(async () => ({ items: runs })) },
sessions: { list: vi.fn(async () => ({ sessions })) },
projects: {
list: vi.fn(async () => ({
projects: [
{
root: "/r",
name: "r",
workspaces: [1],
worktrees: [
{ path: "/r", branch: "main", main: true, workspace_id: 1 },
{
path: "/r/.claude/worktrees/x",
branch: "x",
main: false,
workspace_id: null,
source: "claude",
},
{ path: "/wt/feat", branch: "feat", main: false, workspace_id: null },
],
},
],
})),
},
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
},
};
});
import { api } from "../../../lib/api";
import { WorkspaceHome, freeSlot, paneRunId } from "../WorkspaceHome";
import { __resetAiTools } from "../../../hooks/useAiTools";
const lane = (extra: Partial<Lane> = {}) =>
({
id: 1,
title: "SG_MiniApp",
cwd: "/r",
tool: "claude",
kind: "adopted",
status: "idle",
run_id: null,
session_id: null,
...extra,
}) as unknown as Lane;
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderHome(l = lane()) {
return render(
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
<Routes>
<Route
path="*"
element={
<>
<WorkspaceHome lane={l} onMenuAction={() => undefined} />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
view = {
instructions: "",
supported: true,
maxFileBytes: 10 * 1024 * 1024,
maxInstructions: 16000,
sources: [],
};
runs = [
{
id: "agenthub-lane-1",
laneId: 1,
slot: 0,
status: "running",
sessionId: "s-live",
} as RunHandle,
];
});
describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0);
expect(freeSlot(1, runs)).toBe(1);
const all = [0, 1, 2, 3].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(2, all)).toBe(0);
expect(paneRunId(4, 0)).toBe("agenthub-lane-4");
expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2");
});
});
describe("WorkspaceHome", () => {
it("starts a new session in the first free pane and opens it", async () => {
const user = userEvent.setup();
renderHome();
await waitFor(() => expect(api.run.list).toHaveBeenCalled());
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
await user.click(screen.getByTestId("workspace-home-start"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: 1,
prompt: "fix the login",
permissionMode: "auto",
});
await waitFor(() =>
expect(screen.getByTestId("where").textContent).toBe(
"/workspace?lane=1&run=agenthub-lane-1-1"
)
);
});
it("refuses to start when all four panes are busy", async () => {
runs = [0, 1, 2, 3].map(
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
);
const user = userEvent.setup();
renderHome();
await screen.findByTestId("workspace-recent-s-old");
await user.click(screen.getByTestId("workspace-home-start"));
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
const user = userEvent.setup();
renderHome();
const live = await screen.findByTestId("workspace-recent-s-live");
await waitFor(() => expect(live).toHaveAttribute("data-live", "true"));
await user.click(live);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
await user.click(screen.getByTestId("workspace-recent-s-old"));
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("saves instructions", async () => {
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByRole("button", { name: "Edit instructions" }));
await user.type(screen.getByTestId("workspace-instructions"), "Answer in Vietnamese");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(api.lanes.setInstructions).toHaveBeenCalledWith(1, "Answer in Vietnamese");
expect(await screen.findByText("Answer in Vietnamese")).toBeInTheDocument();
});
it("uploads a context file and removes one", async () => {
view.sources = [
{
id: 7,
kind: "file",
path: "/d/spec.pdf",
name: "spec.pdf",
size: 2048,
added_at: "",
missing: false,
},
];
const user = userEvent.setup();
renderHome();
expect(await screen.findByText("spec.pdf")).toBeInTheDocument();
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("workspace-file-input"), file);
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
});
it("adds a folder, suggesting untracked worktrees minus hidden sources", async () => {
localStorage.setItem(
"agenthub.sidebar.projects",
JSON.stringify({ hideSources: ["claude"], showUntracked: true })
);
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByRole("button", { name: "Add folder" }));
await user.click(await screen.findByRole("button", { name: "/wt/feat" }));
expect(screen.queryByRole("button", { name: "/r/.claude/worktrees/x" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Add" }));
expect(api.lanes.addFolder).toHaveBeenCalledWith(1, "/wt/feat");
});
it("says when the workspace's tool cannot take sources", async () => {
view.supported = false;
renderHome(lane({ tool: "gemini" as Lane["tool"] }));
expect(await screen.findByText(/cannot take instructions/)).toBeInTheDocument();
});
it("shows memory read-only in a dialog", async () => {
const user = userEvent.setup();
renderHome();
expect(await screen.findByText("1 file the agent reads")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View memory" }));
const dialog = screen.getByRole("dialog", { name: "Memory" });
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
expect(within(dialog).queryByRole("textbox")).toBeNull();
});
});
describe("WorkspaceHome across workspaces", () => {
it("drops the previous workspace's prompt and model when another opens", async () => {
const user = userEvent.setup();
const ui = (l: Lane) => (
<MemoryRouter>
<WorkspaceHome lane={l} onMenuAction={() => undefined} />
</MemoryRouter>
);
const { rerender } = render(ui(lane()));
await user.type(screen.getByLabelText("How can I help you today?"), "half typed");
await user.type(screen.getByLabelText("model"), "opus");
rerender(ui(lane({ id: 2, tool: "codex", cwd: "/other" })));
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
expect(screen.getByLabelText("model")).toHaveValue("");
});
it("starts on the tool's default model once the catalog arrives", async () => {
__resetAiTools();
vi.mocked(api.aiTools.list).mockResolvedValueOnce({
tools: [{ id: "claude", label: "Claude", defaultModel: "sonnet" }],
default: "claude",
} as never);
renderHome();
await waitFor(() => expect(screen.getByLabelText("model")).toHaveValue("sonnet"));
});
});
@@ -221,9 +221,13 @@ export function LaneConsolePane({
}, [laneId, slot, refreshList]);
// Runs after the lane-switch effect above, so a sidebar pick that changes
// lane AND run at once lands on the picked run, not the lane's own.
// lane AND run at once lands on the picked run, not the lane's own. Keyed on
// `laneId` too: on a cold load the pane mounts before the lane list does,
// and the switch effect then resets the pane to its own slot once `laneId`
// arrives — the request must land after that. Only this lane's own panes.
useEffect(() => {
if (!attachRequest) return;
if (!attachRequest || laneId === null) return;
if (!new RegExp(`^agenthub-lane-${laneId}(-[1-3])?$`).test(attachRequest.runId)) return;
let live = true;
autoAttachedForLane.current = laneId;
api.run
@@ -239,9 +243,10 @@ export function LaneConsolePane({
return () => {
live = false;
};
// Only a new request (nonce) attaches; a lane switch never replays one.
// A new request (nonce), or the lane it names arriving, attaches; a switch
// to another lane drops `?run=` (Workspace), so it never replays one.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attachRequest?.nonce]);
}, [attachRequest?.nonce, laneId]);
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
// slash command (or the codex stage-checklist prose) is sent as soon as the
+1 -1
View File
@@ -142,7 +142,7 @@ interface PromptEditorProps {
fileCwd: string;
}
function PromptEditor({
export function PromptEditor({
value,
onChange,
onSubmit,
@@ -556,4 +556,50 @@ describe("LaneConsolePane", () => {
expect(api.lanes.action).not.toHaveBeenCalled();
expect(api.run.start).not.toHaveBeenCalled();
});
it("attaches once the lane arrives on a cold load, and ignores another lane's run", async () => {
vi.mocked(api.run.get).mockResolvedValue({
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: "s-9",
startedAt: null,
promptPreview: null,
tool: "claude",
});
const req = { runId: "agenthub-lane-1-1", nonce: "k2" };
const { rerender } = render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} laneId={null} attachRequest={req} />
</MemoryRouter>
);
// The lane list lands only after the pane mounted (and after any early
// attach would have resolved) — the switch effect must not win.
await new Promise((r) => setTimeout(r, 20));
rerender(
<MemoryRouter>
<LaneConsolePane {...baseProps()} attachRequest={req} />
</MemoryRouter>
);
await screen.findByTestId("terminal-view");
expect(api.run.get).toHaveBeenCalledWith("agenthub-lane-1-1");
vi.mocked(api.run.get).mockClear();
rerender(
<MemoryRouter>
<LaneConsolePane
{...baseProps()}
attachRequest={{ runId: "agenthub-lane-12", nonce: "k3" }}
/>
</MemoryRouter>
);
await new Promise((r) => setTimeout(r, 10));
expect(api.run.get).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,8 @@
/**
* @file WorktreeVisibilitySettings — Settings → Git → Worktrees: whether the
* sidebar's project tree lists a repo's untracked worktrees, and which sources
* of them (`.claude/worktrees/*`, AgentHub-managed) it hides. Writes the tree's
* own localStorage prefs (`lib/projectTreePrefs.ts`), so the sidebar follows at
* once; a project's own override lives on its Settings → Projects page.
* @file WorktreeVisibilitySettings — Settings → Git → Worktrees: whether a
* workspace home's Add folder suggests the repo's untracked worktrees, and
* which sources of them (`.claude/worktrees/*`, AgentHub-managed) it leaves
* out. Writes the localStorage prefs in `lib/projectTreePrefs.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -2,8 +2,7 @@
* @file WorktreeVisibilitySettings.test.tsx
* @description Settings → Worktrees writes the sidebar tree's own prefs key
* (`agenthub.sidebar.projects`), disables the source rows while untracked
* worktrees are hidden, and a project's override (`hiddenSourcesFor`) replaces
* the global list for that project only.
* worktrees are not suggested.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -11,7 +10,6 @@ import { beforeEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WorktreeVisibilitySettings } from "../WorktreeVisibilitySettings";
import { hiddenSourcesFor, readProjectTreePrefs } from "../../../lib/projectTreePrefs";
const stored = () => JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
@@ -29,15 +27,4 @@ describe("WorktreeVisibilitySettings", () => {
expect(stored().showUntracked).toBe(false);
expect(screen.getByRole("switch", { name: "AgentHub-managed worktrees" })).toBeDisabled();
});
it("lets a project's own list replace the global one", () => {
localStorage.setItem(
"agenthub.sidebar.projects",
JSON.stringify({ hideSources: ["claude"], projectHideSources: { "/r": [] } })
);
const prefs = readProjectTreePrefs();
expect(hiddenSourcesFor(prefs, "/r")).toEqual([]);
expect(hiddenSourcesFor(prefs, "/other")).toEqual(["claude"]);
expect(hiddenSourcesFor(prefs, null)).toEqual(["claude"]);
});
});
@@ -145,6 +145,34 @@ describe("useNotifications", () => {
vi.restoreAllMocks();
});
it("links a notification to the pane that holds the session, in any slot", async () => {
fetchMock.mockImplementation(async (url: string) => ({
ok: true,
json: async () =>
url === "/api/push/send"
? { ok: true, native: true, pushed: 1 }
: url === "/api/run"
? {
items: [
{ id: "agenthub-lane-4-2", laneId: 4, status: "running", sessionId: "s-7" },
],
}
: { lanes: [], counts: {} },
}));
enable();
renderHook(() => useNotifications());
eventBus.publish({
type: "new_event",
timestamp: new Date().toISOString(),
data: { event_type: "Stop", summary: "Done", session_id: "s-7" } as DashboardEvent,
});
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
const push = fetchMock.mock.calls.find((c) => c[0] === "/api/push/send")!;
expect(JSON.parse((push[1] as RequestInit).body as string).url).toBe(
"/workspace?lane=4&run=agenthub-lane-4-2"
);
});
it("stays silent while notifications are disabled", async () => {
renderHook(() => useNotifications());
+25 -7
View File
@@ -84,8 +84,19 @@ export function useNotifications() {
.then((r) => (lanesRef.current = r.lanes))
.catch(() => {});
function laneUrlForSession(sessionId: string): string | undefined {
const lane = lanesRef.current.find((l) => l.session_id === sessionId);
// Straight into the pane that holds the session (any slot, from the
// hooks' `run_id` binding) — the agent waiting on the user is there, not
// on the workspace's home; the home when no pane holds it.
async function laneUrlForSession(sessionId: string): Promise<string | undefined> {
const runs = await api.run
.list()
.then((r) => r.items ?? [])
.catch(() => []);
const run = runs.find((r) => r.sessionId === sessionId && r.status === "running");
if (run && run.laneId !== null) {
return `/workspace?lane=${run.laneId}&run=${encodeURIComponent(run.id)}`;
}
const lane = (lanesRef.current ?? []).find((l) => l.session_id === sessionId);
return lane ? `/workspace?lane=${lane.id}` : undefined;
}
@@ -106,11 +117,18 @@ export function useNotifications() {
if (!prefs.enabled) return;
// A notification that was shown (pushed, native or local) plays its
// type's sound; one that reached nobody plays nothing.
const show = (type: NotifType, title: string, body: string, url?: string) =>
void notify(title, body, url).then((r) => {
if (r.via === "native" || r.via === "push" || r.via === "local")
playSound(soundFor(prefs, type));
});
const show = (
type: NotifType,
title: string,
body: string,
url?: Promise<string | undefined>
) =>
void Promise.resolve(url)
.then((u) => notify(title, body, u))
.then((r) => {
if (r.via === "native" || r.via === "push" || r.via === "local")
playSound(soundFor(prefs, type));
});
switch (msg.type) {
case "session_created": {
+31 -1
View File
@@ -334,5 +334,35 @@
"tree.search": "Search workspaces and sessions",
"tree.newSession": "New session",
"tree.noSessions": "No sessions yet",
"tree.transcriptHint": "No live pane — opens the transcript"
"tree.transcriptHint": "No live pane — opens the transcript",
"home.breadcrumb": "Workspaces",
"home.placeholder": "How can I help you today?",
"home.start": "Start",
"home.allBusy": "All four panes of this workspace are busy — stop one before starting another session.",
"home.recents": "Recents",
"home.noRecents": "No sessions yet — start one above.",
"home.instructions": "Instructions",
"home.instructionsEmpty": "Add instructions to tailor how the agent works in this workspace. They are appended to every new session's system prompt.",
"home.edit": "Edit instructions",
"home.save": "Save",
"home.cancel": "Cancel",
"home.memory": "Memory",
"home.viewMemory": "View memory",
"home.memoryCount_one": "{{count}} file the agent reads",
"home.memoryCount_other": "{{count}} files the agent reads",
"home.memoryEmpty": "Memory shows here once the folder has a CLAUDE.md or AGENTS.md, or Claude saves memory for it.",
"home.context": "Context",
"home.contextEmpty": "Add PDFs, documents, or other text to reference in this workspace.",
"home.addFile": "Add files",
"home.folder": "Folder",
"home.ownFolder": "Workspace folder",
"home.addFolder": "Add folder",
"home.folderPath": "Absolute folder path",
"home.add": "Add",
"home.suggested": "Untracked worktrees",
"home.remove": "Remove",
"home.missing": "missing",
"home.unsupported": "{{tool}} cannot take instructions, context files or extra folders: they are kept, but its sessions start without them.",
"home.back": "Back to the workspace home",
"home.close": "Close"
}
+5 -28
View File
@@ -649,8 +649,7 @@
"general": "General",
"agents": "Agents",
"routing": "Routing",
"git": "Git",
"projects": "Projects"
"git": "Git"
},
"section": {
"general": {
@@ -694,10 +693,7 @@
},
"worktrees": {
"title": "Worktrees",
"description": "Which git worktrees the sidebar lists under each project."
},
"project": {
"description": "This project's workspaces and worktree settings."
"description": "Which untracked git worktrees a workspace's Add folder suggests."
}
}
},
@@ -706,32 +702,13 @@
"noMatch": "No setting matches “{{query}}”."
},
"worktrees": {
"title": "Untracked worktrees",
"description": "A worktree with no workspace on it is listed under its project with an Adopt button. Saved in this browser only.",
"title": "Worktree suggestions",
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
"showUntracked": "Show untracked worktrees",
"showUntrackedDesc": "List a repository's worktrees that have no workspace yet.",
"showUntrackedDesc": "Suggest a repository's worktrees that have no workspace yet.",
"claude": "Claude Code worktrees",
"claudeDesc": "Worktrees under <repo>/.claude/worktrees/, made by Claude Code's own agents.",
"agenthub": "AgentHub-managed worktrees",
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
},
"project": {
"notFound": "No such project.",
"facts": "Project",
"root": "Root",
"mainBranch": "Main branch",
"worktreeCount": "Worktrees",
"workspaces": "Workspaces",
"noWorkspaces": "No workspace in this project yet.",
"sources": "Worktree sources",
"sourcesDesc": "Override the Worktrees page for this project only.",
"override": "Use this project's own setting",
"forget": "Forget workspaces",
"forgetDesc": "Remove every workspace of this project from AgentHub. Working trees, branches and data on disk are left alone.",
"forgetAll": "Forget all workspaces…",
"forgetTitle": "Forget {{count}} workspaces?",
"forgetBody": "AgentHub drops its record of each one and stops its runs. Nothing on disk is deleted.",
"forgetConfirm": "Forget all",
"forgetFailed": "Could not forget {{lane}}: {{message}}"
}
}
+31 -1
View File
@@ -334,5 +334,35 @@
"tree.search": "Tìm workspace và session",
"tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session",
"tree.transcriptHint": "Không còn pane — mở transcript"
"tree.transcriptHint": "Không còn pane — mở transcript",
"home.breadcrumb": "Không gian làm việc",
"home.placeholder": "Tôi có thể giúp gì cho bạn hôm nay?",
"home.start": "Bắt đầu",
"home.allBusy": "Cả bốn pane của workspace này đang bận — hãy dừng một pane trước khi mở session mới.",
"home.recents": "Gần đây",
"home.noRecents": "Chưa có session nào — bắt đầu ở ô phía trên.",
"home.instructions": "Hướng dẫn",
"home.instructionsEmpty": "Thêm hướng dẫn để điều chỉnh cách agent làm việc trong workspace này. Hướng dẫn được nối vào system prompt của mỗi session mới.",
"home.edit": "Sửa hướng dẫn",
"home.save": "Lưu",
"home.cancel": "Huỷ",
"home.memory": "Bộ nhớ",
"home.viewMemory": "Xem bộ nhớ",
"home.memoryCount_one": "{{count}} file agent đọc",
"home.memoryCount_other": "{{count}} file agent đọc",
"home.memoryEmpty": "Bộ nhớ hiện ở đây khi thư mục có CLAUDE.md hoặc AGENTS.md, hoặc Claude lưu bộ nhớ cho nó.",
"home.context": "Ngữ cảnh",
"home.contextEmpty": "Thêm PDF, tài liệu hoặc văn bản để tham chiếu trong workspace này.",
"home.addFile": "Thêm file",
"home.folder": "Thư mục",
"home.ownFolder": "Thư mục của workspace",
"home.addFolder": "Thêm thư mục",
"home.folderPath": "Đường dẫn tuyệt đối của thư mục",
"home.add": "Thêm",
"home.suggested": "Worktree chưa theo dõi",
"home.remove": "Gỡ",
"home.missing": "không còn",
"home.unsupported": "{{tool}} không nhận được hướng dẫn, file ngữ cảnh hay thư mục bổ sung: chúng vẫn được giữ, nhưng session của nó khởi động mà không có chúng.",
"home.back": "Quay lại trang workspace",
"home.close": "Đóng"
}
+5 -28
View File
@@ -649,8 +649,7 @@
"general": "Chung",
"agents": "Tác tử",
"routing": "Định tuyến",
"git": "Git",
"projects": "Dự án"
"git": "Git"
},
"section": {
"general": {
@@ -694,10 +693,7 @@
},
"worktrees": {
"title": "Worktree",
"description": "Những git worktree mà sidebar liệt kê dưới mỗi dự án."
},
"project": {
"description": "Các workspace và cài đặt worktree của dự án này."
"description": "Những git worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace."
}
}
},
@@ -706,32 +702,13 @@
"noMatch": "Không có cài đặt nào khớp “{{query}}”."
},
"worktrees": {
"title": "Worktree chưa theo dõi",
"description": "Worktree chưa có workspace được liệt kê dưới dự án của nó kèm nút Nhận. Chỉ lưu trong trình duyệt này.",
"title": "Gợi ý worktree",
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
"showUntracked": "Hiện worktree chưa theo dõi",
"showUntrackedDesc": "Liệt kê các worktree của repo chưa có workspace.",
"showUntrackedDesc": "Gợi ý các worktree của repo chưa có workspace.",
"claude": "Worktree của Claude Code",
"claudeDesc": "Worktree dưới <repo>/.claude/worktrees/, do agent của Claude Code tạo.",
"agenthub": "Worktree do AgentHub quản lý",
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
},
"project": {
"notFound": "Không có dự án này.",
"facts": "Dự án",
"root": "Thư mục gốc",
"mainBranch": "Nhánh chính",
"worktreeCount": "Worktree",
"workspaces": "Workspace",
"noWorkspaces": "Dự án này chưa có workspace.",
"sources": "Nguồn worktree",
"sourcesDesc": "Ghi đè trang Worktree cho riêng dự án này.",
"override": "Dùng cài đặt riêng của dự án",
"forget": "Quên workspace",
"forgetDesc": "Gỡ mọi workspace của dự án này khỏi AgentHub. Thư mục làm việc, nhánh và dữ liệu trên đĩa được giữ nguyên.",
"forgetAll": "Quên tất cả workspace…",
"forgetTitle": "Quên {{count}} workspace?",
"forgetBody": "AgentHub xoá bản ghi của từng workspace và dừng các lượt chạy. Không có gì trên đĩa bị xoá.",
"forgetConfirm": "Quên tất cả",
"forgetFailed": "Không quên được {{lane}}: {{message}}"
}
}
+63
View File
@@ -2739,6 +2739,37 @@ export const api = {
* @param patch Subset of lane fields to update.
* @returns `{ lane }` — the updated {@link Lane}.
*/
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
method: "POST",
body: JSON.stringify({ path }),
}),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) =>
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
method: "POST",
body: file,
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
},
}),
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
/**
@@ -3879,6 +3910,38 @@ export interface RunHandle {
tool: LaneTool;
}
/** One extra folder or uploaded context file of a workspace. */
export interface LaneSource {
id: number;
kind: "folder" | "file";
path: string;
name: string;
size: number | null;
added_at: string;
/** The folder or file is gone from disk; it is skipped at spawn. */
missing: boolean;
}
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
supported: boolean;
maxFileBytes: number;
maxInstructions: number;
sources: LaneSource[];
}
/** One read-only memory file from GET /api/lanes/:id/memory. */
export interface LaneMemoryFile {
label: string;
path: string;
size: number;
mtime: string;
truncated: boolean;
content: string;
}
/** Response shape of GET /api/run. */
export interface RunListResponse {
items: RunHandle[];
+4 -17
View File
@@ -1,11 +1,10 @@
/**
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
* expanded to their sessions (by lane id), whether a repo's untracked
* worktrees are offered, and which
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
* LANES_ROOT) are hidden — globally, or per project root, where a project's own
* list (Settings → Projects) replaces the global one. A `useSyncExternalStore` reader that caches nothing
* expanded to their sessions (by lane id), and — Settings → Worktrees —
* whether a workspace home's Add folder suggests the repo's untracked
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
* but the parsed blob for the current raw string, so the sidebar and Settings
* can never disagree. Nothing here reaches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -20,15 +19,12 @@ export interface ProjectTreePrefs {
expanded: Record<string, boolean>;
showUntracked: boolean;
hideSources: string[];
/** Per project root: replaces `hideSources` for that project. */
projectHideSources: Record<string, string[]>;
}
const DEFAULTS: ProjectTreePrefs = {
expanded: {},
showUntracked: true,
hideSources: [],
projectHideSources: {},
};
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
@@ -51,10 +47,6 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
projectHideSources:
parsed.projectHideSources && typeof parsed.projectHideSources === "object"
? parsed.projectHideSources
: {},
};
} catch {
value = DEFAULTS;
@@ -72,11 +64,6 @@ export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
listeners.forEach((l) => l());
}
/** The sources hidden for one project: its own override, else the global list. */
export function hiddenSourcesFor(prefs: ProjectTreePrefs, root: string | null): string[] {
return (root !== null && prefs.projectHideSources[root]) || prefs.hideSources;
}
function subscribe(l: () => void) {
listeners.add(l);
return () => listeners.delete(l);
-171
View File
@@ -1,171 +0,0 @@
/**
* @file ProjectSettings — Settings → Projects → <project>: a read-only card of
* the repository (root, main branch, worktree count), its workspaces linked to
* the Workspace page, this project's own override of the worktree sources the
* sidebar hides (`projectHideSources` in `lib/projectTreePrefs.ts`), and
* "Forget all workspaces", which runs the same `remove` as the row menu for
* each one — preflight, then `POST /api/lanes/:id/remove` with `confirm` and
* the preflight facts as `expect`. Forgetting drops AgentHub's record only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../lib/api";
import type { Lane, Project } from "../lib/types";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { ConfirmModal } from "../components/ConfirmModal";
import { expectFor } from "../components/lanes/DestructiveLaneModal";
import {
SettingsRow,
SettingsSection,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import { SourceSwitches } from "../components/settings/WorktreeVisibilitySettings";
export function ProjectSettings({
project,
lanes,
onForgotten,
}: {
project: Project & { root: string };
lanes: Lane[];
/** Called after the forget loop ends, so the caller can re-read lanes. */
onForgotten: () => void;
}) {
const { t } = useTranslation("settings");
const prefs = useProjectTreePrefs();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const own = lanes.filter((l) => project.workspaces.includes(l.id));
const main = project.worktrees.find((w) => w.main);
const override = prefs.projectHideSources[project.root];
const setOverride = (hidden: string[] | null) => {
const next = { ...prefs.projectHideSources };
if (hidden) next[project.root] = hidden;
else delete next[project.root];
writeProjectTreePrefs({ projectHideSources: next });
};
const forgetAll = async () => {
setBusy(true);
setError(null);
try {
for (const lane of own) {
try {
const preflight = await api.lanes.preflight(lane.id, "remove");
await api.lanes.action(lane.id, "remove", {
confirm: true,
expect: expectFor(preflight),
});
} catch (err) {
setError(
t("project.forgetFailed", {
lane: lane.title || lane.cwd,
message: err instanceof Error ? err.message : String(err),
})
);
break;
}
}
} finally {
setBusy(false);
setConfirming(false);
onForgotten();
}
};
return (
<div className="space-y-4">
<SettingsSection title={t("project.facts")}>
<SettingsRow
label={t("project.root")}
control={
<span className="break-all font-mono text-xs text-fg-primary">{project.root}</span>
}
/>
<SettingsRow
label={t("project.mainBranch")}
control={<span className="font-mono text-xs text-fg-primary">{main?.branch ?? "—"}</span>}
/>
<SettingsRow
label={t("project.worktreeCount")}
control={<span className="text-xs text-fg-primary">{project.worktrees.length}</span>}
/>
</SettingsSection>
<SettingsSection title={t("project.workspaces")}>
{own.length === 0 ? (
<p className="py-2 text-xs text-fg-muted">{t("project.noWorkspaces")}</p>
) : (
<ul data-testid="project-workspaces">
{own.map((l) => (
<li key={l.id} className="border-b border-border/60 py-2 last:border-b-0">
<Link
to={`/workspace?lane=${l.id}`}
className="text-sm text-accent hover:underline"
>
{l.title || l.cwd}
</Link>
<span className="ml-2 font-mono text-[11px] text-fg-muted">#{l.id}</span>
</li>
))}
</ul>
)}
</SettingsSection>
<SettingsSection title={t("project.sources")} description={t("project.sourcesDesc")}>
<SettingsSwitchRow
label={t("project.override")}
checked={!!override}
onChange={(on) => setOverride(on ? [...prefs.hideSources] : null)}
/>
<SourceSwitches
hidden={override ?? prefs.hideSources}
disabled={!override}
onChange={setOverride}
/>
</SettingsSection>
<SettingsSection title={t("project.forget")} description={t("project.forgetDesc")}>
<div className="py-2">
<button
type="button"
disabled={own.length === 0 || busy}
onClick={() => setConfirming(true)}
className="rounded-md border border-status-danger/40 px-3 py-1.5 text-xs text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
>
{t("project.forgetAll")}
</button>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</div>
</SettingsSection>
<ConfirmModal
open={confirming}
title={t("project.forgetTitle", { count: own.length })}
message={t("project.forgetBody")}
confirmLabel={t("project.forgetConfirm")}
cancelLabel={t("common:cancel")}
busy={busy}
onConfirm={() => void forgetAll()}
onCancel={() => setConfirming(false)}
>
<ul className="max-h-48 overflow-y-auto text-xs text-fg-secondary">
{own.map((l) => (
<li key={l.id} className="font-mono">
#{l.id} {l.title || l.cwd}
</li>
))}
</ul>
</ConfirmModal>
</div>
);
}
+5 -44
View File
@@ -1,13 +1,13 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Git / Projects (a `<select>` below `md`), a
* General / Agents / Routing / Git (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it, Enter opens the first match, Escape clears), and one page per
* section. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. Each project in `GET /api/projects` gets `projects/<encoded root>`.
* disabled. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,7 +20,6 @@ import {
Bot,
Boxes,
Coins,
FolderGit2,
GitBranch,
KeyRound,
Orbit,
@@ -37,18 +36,14 @@ import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { ProjectSettings } from "./ProjectSettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { api } from "../lib/api";
import type { Project } from "../lib/types";
import { rankSettings, SETTINGS_INDEX, type SettingsEntry } from "../lib/settingsSearch";
const GROUPS = ["general", "agents", "routing", "git", "projects"] as const;
const GROUPS = ["general", "agents", "routing", "git"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -76,22 +71,8 @@ export function SettingsHub() {
const location = useLocation();
const navigate = useNavigate();
const aiTools = useAiTools();
const { lanes, refresh: refreshLanes } = useLanes();
const [projects, setProjects] = useState<Project[] | null>(null);
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const laneKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
let live = true;
api.projects
.list()
.then((r) => live && setProjects(r.projects))
.catch(() => live && setProjects([]));
return () => {
live = false;
};
}, [laneKey]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -215,26 +196,6 @@ export function SettingsHub() {
...s("worktrees"),
render: () => <WorktreeVisibilitySettings />,
},
...(projects ?? []).flatMap((p) =>
p.root === null
? []
: [
{
id: `projects/${encodeURIComponent(p.root)}`,
group: "projects" as const,
Icon: FolderGit2,
title: p.name,
description: t("settingsNav.section.project.description"),
render: () => (
<ProjectSettings
project={{ ...p, root: p.root as string }}
lanes={lanes}
onForgotten={() => void refreshLanes()}
/>
),
},
]
),
];
const entries: SettingsEntry[] = sections.flatMap((sec) => [
@@ -267,8 +228,8 @@ export function SettingsHub() {
}
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool or project page exists only once its list has loaded.
if (!aiTools.loaded || projects === null) return null;
// A catalog tool page exists only once the tool list has loaded.
if (!aiTools.loaded) return null;
return <Navigate to="/settings/general" replace />;
}
+71 -4
View File
@@ -47,6 +47,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import { useLocation, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Plus,
Square,
Columns2,
@@ -79,6 +80,8 @@ import LaneCard, {
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
@@ -373,6 +376,8 @@ export function Workspace() {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
// A run belongs to its lane: another lane opens on its home.
if (next.get("lane") !== String(id)) next.delete("run");
if (id === null) next.delete("lane");
else next.set("lane", String(id));
return next;
@@ -394,6 +399,10 @@ export function Workspace() {
if (paramId === null) setSelectedLaneId(selectedLaneId);
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [destructive, setDestructive] = useState<{
lane: Lane;
action: "reset" | "remove";
} | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
// Fields the Add-lane modal opens with when it was opened from a lane's git
// bar ("new lane here"): one provisioning path for every lane, prefilled.
@@ -664,8 +673,12 @@ export function Workspace() {
// so the form is fully reachable on short windows. A full-width Changes panel
// hides that form anyway, and without a locked height it shrinks to its
// content — the editor inside it collapsed to a few lines (no live run).
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam;
const viewportLocked =
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -767,7 +780,26 @@ export function Workspace() {
it rather than under it. It collapses to a rail instead of
unmounting nothing — there is no live subscription inside it, so
unmounting is safe and keeps a closed panel from polling. */}
<div className="flex min-h-0 flex-1 gap-4">
{home && currentLane && (
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
} else if (action === "started") {
setSearchParams({
lane: String(currentLane.id),
run: `agenthub-lane-${currentLane.id}`,
});
}
}}
/>
)}
<div className={`flex min-h-0 flex-1 gap-4${home ? " hidden" : ""}`}>
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}${
@@ -790,8 +822,18 @@ export function Workspace() {
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
{/* Renaming lives on the sidebar row (double-click); here the
name is only read. */}
{/* Back to the workspace's home; renaming lives on the sidebar
row and the home's title, so here the name is only read. */}
<button
type="button"
data-testid="workspace-back-home"
onClick={() => setSearchParams({ lane: String(currentLane.id) })}
title={tLanes("home.back")}
aria-label={tLanes("home.back")}
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<ArrowLeft className="h-4 w-4" />
</button>
<span
title={currentLane.cwd}
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
@@ -1086,6 +1128,31 @@ export function Workspace() {
}}
/>
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={async (body) => {
const { lane, action } = destructive;
setDestructive(null);
setLaneActionError(null);
try {
await api.lanes.action(lane.id, action, {
confirm: true,
expect: body.expect,
...(body.force === true ? { force: true } : {}),
});
await refreshLanes();
if (action === "remove") setSearchParams({});
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : String(err));
}
}}
/>
)}
{laneActionError && (
<p
role="alert"
@@ -1,99 +0,0 @@
/**
* @file ProjectSettings.test.tsx
* @description A project's settings page: its facts and workspaces, the
* per-project source override written under `projectHideSources`, and
* "Forget all workspaces" running preflight + `remove` (with the preflight
* facts as `expect`) for each of the project's workspaces only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane, Project } from "../../lib/types";
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
preflight: vi.fn(async (id: number) => ({
action: "remove",
head: `h${id}`,
dirty: 0,
untracked: 0,
unpushed: 0,
})),
action: vi.fn(async () => ({ ok: true })),
},
},
};
});
import { api } from "../../lib/api";
import { ProjectSettings } from "../ProjectSettings";
const lane = (id: number, cwd: string) => ({ id, cwd, title: "" }) as unknown as Lane;
const project: Project & { root: string } = {
root: "/r",
name: "r",
workspaces: [1, 2],
worktrees: [{ path: "/r", branch: "main", main: true, workspace_id: 1 }],
};
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
function renderPage(onForgotten = vi.fn()) {
render(
<MemoryRouter>
<ProjectSettings
project={project}
lanes={[lane(1, "/r"), lane(2, "/r/sub"), lane(3, "/elsewhere")]}
onForgotten={onForgotten}
/>
</MemoryRouter>
);
return onForgotten;
}
describe("ProjectSettings", () => {
it("shows the project's facts and only its own workspaces", () => {
renderPage();
expect(screen.getByText("main")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "/r/sub" })).toHaveAttribute(
"href",
"/workspace?lane=2"
);
expect(screen.queryByText("/elsewhere")).toBeNull();
});
it("writes a per-project source override", async () => {
const user = userEvent.setup();
renderPage();
expect(screen.getByRole("switch", { name: "Claude Code worktrees" })).toBeDisabled();
await user.click(screen.getByRole("switch", { name: "Use this project's own setting" }));
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
const stored = JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
expect(stored.projectHideSources).toEqual({ "/r": ["claude"] });
expect(stored.hideSources).toEqual([]);
});
it("forgets every workspace of the project after a confirm", async () => {
const user = userEvent.setup();
const onForgotten = renderPage();
await user.click(screen.getByRole("button", { name: "Forget all workspaces…" }));
await user.click(screen.getByRole("button", { name: "Forget all" }));
await waitFor(() => expect(onForgotten).toHaveBeenCalled());
expect(api.lanes.action).toHaveBeenCalledTimes(2);
expect(api.lanes.action).toHaveBeenCalledWith(1, "remove", {
confirm: true,
expect: { head: "h1", dirty: 0, untracked: 0, unpushed: 0 },
});
expect(api.lanes.action).toHaveBeenCalledWith(2, "remove", expect.anything());
});
});
@@ -3,7 +3,7 @@
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, a project gets its page, the mobile
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -14,7 +14,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
import type { Project } from "../../lib/types";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
@@ -43,7 +42,6 @@ const tool = (id: string, over: Partial<AiTool> = {}) =>
...over,
}) as AiTool;
let view: AiToolsView;
let projects: Project[];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -51,7 +49,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
...actual,
api: {
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
projects: { list: vi.fn(async () => ({ projects })) },
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
},
};
@@ -97,10 +94,6 @@ beforeEach(() => {
tool("aider", { installed: false }),
],
};
projects = [
{ root: "/r/app", name: "app", workspaces: [], worktrees: [] },
{ root: null, name: "Folders", workspaces: [], worktrees: [] },
];
});
describe("SettingsHub", () => {
@@ -146,13 +139,6 @@ describe("SettingsHub", () => {
expect(links).not.toContain("Aider");
});
it("lists a project under Projects and opens its page", async () => {
renderAt(`/settings/projects/${encodeURIComponent("/r/app")}`);
expect(await screen.findByRole("heading", { level: 1, name: "app" })).toBeInTheDocument();
expect(screen.getByText("/r/app")).toBeInTheDocument();
expect(within(nav()).queryByText("Folders")).toBeNull();
});
it("navigates from the mobile select", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
@@ -53,6 +53,13 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -53,6 +53,13 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -67,6 +67,13 @@ const h = vi.hoisted(() => {
const routerApply = h.routerApply;
let lanesToReturn: LaneFixture[] = [];
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
@@ -50,6 +50,13 @@ let lanesToReturn: LaneFixture[] = [
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
let nextRunId = 1;
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
@@ -694,3 +701,25 @@ describe("split terminal view", () => {
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
});
describe("Workspace — home and session view", () => {
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
await renderWorkspace();
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
});
it("opens the session view for ?run=, and the back button returns home", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
});
});