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

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

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
This commit is contained in:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
-171
View File
@@ -1,171 +0,0 @@
/**
* @file ProjectSettings — Settings → Projects → <project>: a read-only card of
* the repository (root, main branch, worktree count), its workspaces linked to
* the Workspace page, this project's own override of the worktree sources the
* sidebar hides (`projectHideSources` in `lib/projectTreePrefs.ts`), and
* "Forget all workspaces", which runs the same `remove` as the row menu for
* each one — preflight, then `POST /api/lanes/:id/remove` with `confirm` and
* the preflight facts as `expect`. Forgetting drops AgentHub's record only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../lib/api";
import type { Lane, Project } from "../lib/types";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { ConfirmModal } from "../components/ConfirmModal";
import { expectFor } from "../components/lanes/DestructiveLaneModal";
import {
SettingsRow,
SettingsSection,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import { SourceSwitches } from "../components/settings/WorktreeVisibilitySettings";
export function ProjectSettings({
project,
lanes,
onForgotten,
}: {
project: Project & { root: string };
lanes: Lane[];
/** Called after the forget loop ends, so the caller can re-read lanes. */
onForgotten: () => void;
}) {
const { t } = useTranslation("settings");
const prefs = useProjectTreePrefs();
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const own = lanes.filter((l) => project.workspaces.includes(l.id));
const main = project.worktrees.find((w) => w.main);
const override = prefs.projectHideSources[project.root];
const setOverride = (hidden: string[] | null) => {
const next = { ...prefs.projectHideSources };
if (hidden) next[project.root] = hidden;
else delete next[project.root];
writeProjectTreePrefs({ projectHideSources: next });
};
const forgetAll = async () => {
setBusy(true);
setError(null);
try {
for (const lane of own) {
try {
const preflight = await api.lanes.preflight(lane.id, "remove");
await api.lanes.action(lane.id, "remove", {
confirm: true,
expect: expectFor(preflight),
});
} catch (err) {
setError(
t("project.forgetFailed", {
lane: lane.title || lane.cwd,
message: err instanceof Error ? err.message : String(err),
})
);
break;
}
}
} finally {
setBusy(false);
setConfirming(false);
onForgotten();
}
};
return (
<div className="space-y-4">
<SettingsSection title={t("project.facts")}>
<SettingsRow
label={t("project.root")}
control={
<span className="break-all font-mono text-xs text-fg-primary">{project.root}</span>
}
/>
<SettingsRow
label={t("project.mainBranch")}
control={<span className="font-mono text-xs text-fg-primary">{main?.branch ?? "—"}</span>}
/>
<SettingsRow
label={t("project.worktreeCount")}
control={<span className="text-xs text-fg-primary">{project.worktrees.length}</span>}
/>
</SettingsSection>
<SettingsSection title={t("project.workspaces")}>
{own.length === 0 ? (
<p className="py-2 text-xs text-fg-muted">{t("project.noWorkspaces")}</p>
) : (
<ul data-testid="project-workspaces">
{own.map((l) => (
<li key={l.id} className="border-b border-border/60 py-2 last:border-b-0">
<Link
to={`/workspace?lane=${l.id}`}
className="text-sm text-accent hover:underline"
>
{l.title || l.cwd}
</Link>
<span className="ml-2 font-mono text-[11px] text-fg-muted">#{l.id}</span>
</li>
))}
</ul>
)}
</SettingsSection>
<SettingsSection title={t("project.sources")} description={t("project.sourcesDesc")}>
<SettingsSwitchRow
label={t("project.override")}
checked={!!override}
onChange={(on) => setOverride(on ? [...prefs.hideSources] : null)}
/>
<SourceSwitches
hidden={override ?? prefs.hideSources}
disabled={!override}
onChange={setOverride}
/>
</SettingsSection>
<SettingsSection title={t("project.forget")} description={t("project.forgetDesc")}>
<div className="py-2">
<button
type="button"
disabled={own.length === 0 || busy}
onClick={() => setConfirming(true)}
className="rounded-md border border-status-danger/40 px-3 py-1.5 text-xs text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
>
{t("project.forgetAll")}
</button>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</div>
</SettingsSection>
<ConfirmModal
open={confirming}
title={t("project.forgetTitle", { count: own.length })}
message={t("project.forgetBody")}
confirmLabel={t("project.forgetConfirm")}
cancelLabel={t("common:cancel")}
busy={busy}
onConfirm={() => void forgetAll()}
onCancel={() => setConfirming(false)}
>
<ul className="max-h-48 overflow-y-auto text-xs text-fg-secondary">
{own.map((l) => (
<li key={l.id} className="font-mono">
#{l.id} {l.title || l.cwd}
</li>
))}
</ul>
</ConfirmModal>
</div>
);
}
+5 -44
View File
@@ -1,13 +1,13 @@
/**
* @file SettingsHub.tsx
* @description The Settings page: `/settings/<section>`, a left nav grouped
* General / Agents / Routing / Git / Projects (a `<select>` below `md`), a
* General / Agents / Routing / Git (a `<select>` below `md`), a
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
* focuses it, Enter opens the first match, Escape clears), and one page per
* section. A tool's own settings live under `agents/<id>` — Claude Code and
* Codex have their own pages, every other installed, enabled catalog tool the
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
* disabled. Each project in `GET /api/projects` gets `projects/<encoded root>`.
* disabled. A workspace's own settings live on its home, not here.
* The old `?tool=<id>` query and `#ai-tools` hash redirect to the path form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,7 +20,6 @@ import {
Bot,
Boxes,
Coins,
FolderGit2,
GitBranch,
KeyRound,
Orbit,
@@ -37,18 +36,14 @@ import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { ProjectSettings } from "./ProjectSettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { api } from "../lib/api";
import type { Project } from "../lib/types";
import { rankSettings, SETTINGS_INDEX, type SettingsEntry } from "../lib/settingsSearch";
const GROUPS = ["general", "agents", "routing", "git", "projects"] as const;
const GROUPS = ["general", "agents", "routing", "git"] as const;
type Group = (typeof GROUPS)[number];
interface Section {
@@ -76,22 +71,8 @@ export function SettingsHub() {
const location = useLocation();
const navigate = useNavigate();
const aiTools = useAiTools();
const { lanes, refresh: refreshLanes } = useLanes();
const [projects, setProjects] = useState<Project[] | null>(null);
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const laneKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
let live = true;
api.projects
.list()
.then((r) => live && setProjects(r.projects))
.catch(() => live && setProjects([]));
return () => {
live = false;
};
}, [laneKey]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -215,26 +196,6 @@ export function SettingsHub() {
...s("worktrees"),
render: () => <WorktreeVisibilitySettings />,
},
...(projects ?? []).flatMap((p) =>
p.root === null
? []
: [
{
id: `projects/${encodeURIComponent(p.root)}`,
group: "projects" as const,
Icon: FolderGit2,
title: p.name,
description: t("settingsNav.section.project.description"),
render: () => (
<ProjectSettings
project={{ ...p, root: p.root as string }}
lanes={lanes}
onForgotten={() => void refreshLanes()}
/>
),
},
]
),
];
const entries: SettingsEntry[] = sections.flatMap((sec) => [
@@ -267,8 +228,8 @@ export function SettingsHub() {
}
const active = sections.find((x) => x.id === rest);
if (!active) {
// A catalog tool or project page exists only once its list has loaded.
if (!aiTools.loaded || projects === null) return null;
// A catalog tool page exists only once the tool list has loaded.
if (!aiTools.loaded) return null;
return <Navigate to="/settings/general" replace />;
}
+71 -4
View File
@@ -47,6 +47,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import { useLocation, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Plus,
Square,
Columns2,
@@ -79,6 +80,8 @@ import LaneCard, {
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceSessionsTab } from "../components/lanes/WorkspaceSessionsTab";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
@@ -373,6 +376,8 @@ export function Workspace() {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
// A run belongs to its lane: another lane opens on its home.
if (next.get("lane") !== String(id)) next.delete("run");
if (id === null) next.delete("lane");
else next.set("lane", String(id));
return next;
@@ -394,6 +399,10 @@ export function Workspace() {
if (paramId === null) setSelectedLaneId(selectedLaneId);
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
const [laneActionError, setLaneActionError] = useState<string | null>(null);
const [destructive, setDestructive] = useState<{
lane: Lane;
action: "reset" | "remove";
} | null>(null);
const [addLaneOpen, setAddLaneOpen] = useState(false);
// Fields the Add-lane modal opens with when it was opened from a lane's git
// bar ("new lane here"): one provisioning path for every lane, prefilled.
@@ -664,8 +673,12 @@ export function Workspace() {
// so the form is fully reachable on short windows. A full-width Changes panel
// hides that form anyway, and without a locked height it shrinks to its
// content — the editor inside it collapsed to a few lines (no live run).
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it.
const home = !!currentLane && !runParam;
const viewportLocked =
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
setLaneActionError(null);
@@ -767,7 +780,26 @@ export function Workspace() {
it rather than under it. It collapses to a rail instead of
unmounting nothing — there is no live subscription inside it, so
unmounting is safe and keeps a closed panel from polling. */}
<div className="flex min-h-0 flex-1 gap-4">
{home && currentLane && (
<WorkspaceHome
lane={currentLane}
onRenamed={() => void refreshLanes()}
onMenuAction={(action) => {
if (action === "new-here") {
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
setAddLaneOpen(true);
} else if (action === "reset" || action === "remove") {
setDestructive({ lane: currentLane, action });
} else if (action === "started") {
setSearchParams({
lane: String(currentLane.id),
run: `agenthub-lane-${currentLane.id}`,
});
}
}}
/>
)}
<div className={`flex min-h-0 flex-1 gap-4${home ? " hidden" : ""}`}>
<section
data-testid="lane-detail"
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}${
@@ -790,8 +822,18 @@ export function Workspace() {
? tLanes("statusDead")
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
</span>
{/* Renaming lives on the sidebar row (double-click); here the
name is only read. */}
{/* Back to the workspace's home; renaming lives on the sidebar
row and the home's title, so here the name is only read. */}
<button
type="button"
data-testid="workspace-back-home"
onClick={() => setSearchParams({ lane: String(currentLane.id) })}
title={tLanes("home.back")}
aria-label={tLanes("home.back")}
className="shrink-0 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
>
<ArrowLeft className="h-4 w-4" />
</button>
<span
title={currentLane.cwd}
className="min-w-0 truncate text-sm font-semibold text-fg-primary"
@@ -1086,6 +1128,31 @@ export function Workspace() {
}}
/>
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
action={destructive.action}
open
onClose={() => setDestructive(null)}
onConfirm={async (body) => {
const { lane, action } = destructive;
setDestructive(null);
setLaneActionError(null);
try {
await api.lanes.action(lane.id, action, {
confirm: true,
expect: body.expect,
...(body.force === true ? { force: true } : {}),
});
await refreshLanes();
if (action === "remove") setSearchParams({});
} catch (err) {
setLaneActionError(err instanceof Error ? err.message : String(err));
}
}}
/>
)}
{laneActionError && (
<p
role="alert"
@@ -1,99 +0,0 @@
/**
* @file ProjectSettings.test.tsx
* @description A project's settings page: its facts and workspaces, the
* per-project source override written under `projectHideSources`, and
* "Forget all workspaces" running preflight + `remove` (with the preflight
* facts as `expect`) for each of the project's workspaces only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { Lane, Project } from "../../lib/types";
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
preflight: vi.fn(async (id: number) => ({
action: "remove",
head: `h${id}`,
dirty: 0,
untracked: 0,
unpushed: 0,
})),
action: vi.fn(async () => ({ ok: true })),
},
},
};
});
import { api } from "../../lib/api";
import { ProjectSettings } from "../ProjectSettings";
const lane = (id: number, cwd: string) => ({ id, cwd, title: "" }) as unknown as Lane;
const project: Project & { root: string } = {
root: "/r",
name: "r",
workspaces: [1, 2],
worktrees: [{ path: "/r", branch: "main", main: true, workspace_id: 1 }],
};
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
function renderPage(onForgotten = vi.fn()) {
render(
<MemoryRouter>
<ProjectSettings
project={project}
lanes={[lane(1, "/r"), lane(2, "/r/sub"), lane(3, "/elsewhere")]}
onForgotten={onForgotten}
/>
</MemoryRouter>
);
return onForgotten;
}
describe("ProjectSettings", () => {
it("shows the project's facts and only its own workspaces", () => {
renderPage();
expect(screen.getByText("main")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "/r/sub" })).toHaveAttribute(
"href",
"/workspace?lane=2"
);
expect(screen.queryByText("/elsewhere")).toBeNull();
});
it("writes a per-project source override", async () => {
const user = userEvent.setup();
renderPage();
expect(screen.getByRole("switch", { name: "Claude Code worktrees" })).toBeDisabled();
await user.click(screen.getByRole("switch", { name: "Use this project's own setting" }));
await user.click(screen.getByRole("switch", { name: "Claude Code worktrees" }));
const stored = JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!);
expect(stored.projectHideSources).toEqual({ "/r": ["claude"] });
expect(stored.hideSources).toEqual([]);
});
it("forgets every workspace of the project after a confirm", async () => {
const user = userEvent.setup();
const onForgotten = renderPage();
await user.click(screen.getByRole("button", { name: "Forget all workspaces…" }));
await user.click(screen.getByRole("button", { name: "Forget all" }));
await waitFor(() => expect(onForgotten).toHaveBeenCalled());
expect(api.lanes.action).toHaveBeenCalledTimes(2);
expect(api.lanes.action).toHaveBeenCalledWith(1, "remove", {
confirm: true,
expect: { head: "h1", dirty: 0, untracked: 0, unpushed: 0 },
});
expect(api.lanes.action).toHaveBeenCalledWith(2, "remove", expect.anything());
});
});
@@ -3,7 +3,7 @@
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, a project gets its page, the mobile
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -14,7 +14,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
import type { Project } from "../../lib/types";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => <div data-testid={`settings-${section}`} />,
@@ -43,7 +42,6 @@ const tool = (id: string, over: Partial<AiTool> = {}) =>
...over,
}) as AiTool;
let view: AiToolsView;
let projects: Project[];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -51,7 +49,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
...actual,
api: {
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
projects: { list: vi.fn(async () => ({ projects })) },
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
},
};
@@ -97,10 +94,6 @@ beforeEach(() => {
tool("aider", { installed: false }),
],
};
projects = [
{ root: "/r/app", name: "app", workspaces: [], worktrees: [] },
{ root: null, name: "Folders", workspaces: [], worktrees: [] },
];
});
describe("SettingsHub", () => {
@@ -146,13 +139,6 @@ describe("SettingsHub", () => {
expect(links).not.toContain("Aider");
});
it("lists a project under Projects and opens its page", async () => {
renderAt(`/settings/projects/${encodeURIComponent("/r/app")}`);
expect(await screen.findByRole("heading", { level: 1, name: "app" })).toBeInTheDocument();
expect(screen.getByText("/r/app")).toBeInTheDocument();
expect(within(nav()).queryByText("Folders")).toBeNull();
});
it("navigates from the mobile select", async () => {
renderAt("/settings/general");
await screen.findByTestId("settings-general");
@@ -53,6 +53,13 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -53,6 +53,13 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
};
});
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -67,6 +67,13 @@ const h = vi.hoisted(() => {
const routerApply = h.routerApply;
let lanesToReturn: LaneFixture[] = [];
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
@@ -50,6 +50,13 @@ let lanesToReturn: LaneFixture[] = [
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
let nextRunId = 1;
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
}));
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
@@ -694,3 +701,25 @@ describe("split terminal view", () => {
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
});
});
describe("Workspace — home and session view", () => {
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
await renderWorkspace();
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
});
it("opens the session view for ?run=, and the back button returns home", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
});
});