feat(workspace): AI tool per session, one place to start a session, ended sessions open like Claude Desktop
- A session picks its own AI tool: `tool` on POST /api/lanes/:id/start (a catalog id, known + enabled, else 400 EBADTOOL/EDISABLED); a resume keeps the tool its session ran on (`sessions.tool`). The workspace's `lanes.tool` is now only the default. - The workspace home's prompt box is the one place a session starts. Its fields run in order: AI → AI Router (Native or a profile) → Model (the tool's own list, or the router's models) → Permission (Claude mode / Codex sandbox+approval / catalog bypass) → Effort. - The pane's run form (RunSetup) is gone: New run, a single pane's exit and an empty split pane's New session all go to the home. RunSetup.tsx keeps PromptEditor, CwdAutocomplete and ModelPicker (new `choices` prop). - The tool icon moved from the sidebar's workspace row to each session row (right edge, `session.tool`); the split-pane header shows the run's tool. - An ended session opens in its workspace (`&session=`, SessionReplay.tsx): its conversation read-only plus a prompt box; only a SENT message resumes it (resumeSessionId + prompt, first free slot). The console is only hidden behind it. - Analyst button beside Files/Changes in the right panel opens /sessions/:id for the opened session. - Gemini CLI ships its own icon (client/public/ai-tools/gemini.svg, from zonalogo.com). - RouterPicker always shows (Native disabled for tools without router support); useAiRouters() exported. - Tests: server start-with-tool cases; SessionReplay, home field order/tool/ router models, empty pane, Analyst; dropped the cwd-field suites (Run.defaultCwd, Workspace.laneCwd). Docs: CLAUDE.md rules, API.md, LANES.md, README, ARCHITECTURE, INSTALL.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file SessionReplay.test.tsx
|
||||
* @description An ended session opened in its workspace: its conversation and
|
||||
* a prompt box; opening it starts nothing, and only a SENT message resumes it
|
||||
* (`resumeSessionId` + the prompt, in the first free pane slot), after which
|
||||
* the pane opens.
|
||||
* @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 type { Lane } from "../../../lib/types";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
ConversationView: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="conversation">{sessionId}</div>
|
||||
),
|
||||
}));
|
||||
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)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../WorkspaceHome", () => ({
|
||||
freeSlot: (_id: number, runs: RunHandle[]) => (runs.length ? 1 : 0),
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
sessions: {
|
||||
get: vi.fn(async () => ({ session: { id: "s-old", tool: "claude", cwd: "/r" } })),
|
||||
},
|
||||
lanes: { action: vi.fn(async () => ({ run_id: "agenthub-lane-1-1" })) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { SessionReplay } from "../SessionReplay";
|
||||
|
||||
const lane = { id: 1, cwd: "/r", tool: "claude" } as unknown as Lane;
|
||||
const busy = [{ id: "agenthub-lane-1", laneId: 1, slot: 0, status: "running" }] as RunHandle[];
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("SessionReplay", () => {
|
||||
it("shows the conversation and resumes nothing until a message is sent", async () => {
|
||||
const onResumed = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
|
||||
expect(screen.getByTestId("conversation")).toHaveTextContent("s-old");
|
||||
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old"));
|
||||
expect(screen.getByTestId("session-replay-send")).toBeDisabled();
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(screen.getByLabelText("Message to continue this session…"), "carry on");
|
||||
await user.click(screen.getByTestId("session-replay-send"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
resumeSessionId: "s-old",
|
||||
prompt: "carry on",
|
||||
});
|
||||
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1"));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @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
|
||||
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
||||
* default), with its fields in order — AI, AI Router, Model (the router's
|
||||
* models on a router), Permission, Effort —, 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
|
||||
@@ -28,8 +30,21 @@ vi.mock("../../run/RunSetup", () => ({
|
||||
}) => (
|
||||
<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)} />
|
||||
ModelPicker: ({
|
||||
value,
|
||||
onChange,
|
||||
choices,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
choices?: { id: string }[];
|
||||
}) => (
|
||||
<input
|
||||
aria-label="model"
|
||||
data-choices={choices ? choices.map((c) => c.id).join(",") : "tool"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -89,7 +104,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
},
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
||||
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -193,6 +208,76 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const card = screen.getByTestId("workspace-home-start").closest(".card") as HTMLElement;
|
||||
const labels = [...card.querySelectorAll("span.uppercase")].map((x) => x.textContent);
|
||||
expect(labels).toEqual(["AI", "AI Router", "Model", "Permission mode", "Thinking effort"]);
|
||||
});
|
||||
|
||||
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
|
||||
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
|
||||
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
||||
].map((x) => ({ installed: true, enabled: true, ...x })),
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const ai = screen.getByTestId("workspace-home-tool");
|
||||
await user.click(await within(ai).findByText("Claude Code"));
|
||||
// Not installed: not offered.
|
||||
expect(screen.queryByText("Cursor CLI")).toBeNull();
|
||||
await user.click(screen.getByText("Codex"));
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "port it");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
prompt: "port it",
|
||||
tool: "codex",
|
||||
sandbox: "workspace-write",
|
||||
approval: "on-request",
|
||||
});
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({ tools: [], default: "claude" } as never);
|
||||
__resetAiTools();
|
||||
});
|
||||
|
||||
it("offers the router's models on a router, and sends the router", async () => {
|
||||
localStorage.setItem("agenthub.session.router", "glm");
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ name: "native", label: "Native", builtin: true, models: {} },
|
||||
{
|
||||
name: "glm",
|
||||
label: "GLM",
|
||||
builtin: false,
|
||||
model: "glm-4.6",
|
||||
models: { opus: "glm-4.6", sonnet: "glm-4.5", haiku: "glm-4.5-air" },
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("model")).toHaveAttribute(
|
||||
"data-choices",
|
||||
"glm-4.6,glm-4.5,glm-4.5-air"
|
||||
)
|
||||
);
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "go");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ router: "glm" })
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
@@ -235,7 +320,7 @@ describe("WorkspaceHome", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
||||
});
|
||||
|
||||
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
|
||||
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
const live = await screen.findByTestId("workspace-recent-s-live");
|
||||
@@ -245,7 +330,7 @@ describe("WorkspaceHome", () => {
|
||||
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(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&session=s-old");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -361,7 +446,7 @@ describe("WorkspaceHome across workspaces", () => {
|
||||
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" }],
|
||||
tools: [{ id: "claude", label: "Claude", launcher: "claude", defaultModel: "sonnet" }],
|
||||
default: "claude",
|
||||
} as never);
|
||||
renderHome();
|
||||
|
||||
Reference in New Issue
Block a user