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:
@@ -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