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

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

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
This commit is contained in:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
@@ -3,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");