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