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,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