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:
2026-10-02 15:29:38 +07:00
parent c507d37baf
commit 4802684d2f
34 changed files with 1137 additions and 2504 deletions
@@ -0,0 +1,116 @@
/**
* @file SessionReplay — an ENDED session opened in its workspace
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
* an old one: its conversation, read-only (`ConversationView`), and a prompt
* box under it. Opening it resumes nothing; only SENDING a message does —
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the
* first free pane slot, after which `onResumed` opens the pane (`&run=`). The
* session's tool and folder come from its row (`GET /api/sessions/:id`), so a
* resume runs on the tool the session ran on (the server enforces that too).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Send } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor } from "../run/RunSetup";
import { freeSlot, paneRunId } from "./WorkspaceHome";
export function SessionReplay({
lane,
sessionId,
liveRuns,
onResumed,
}: {
lane: Lane;
sessionId: string;
/** Every live dashboard pane, to pick a free slot. */
liveRuns: RunHandle[];
/** Opens the pane the resume started in. */
onResumed: (runId: string) => void;
}) {
const { t } = useTranslation("lanes");
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
setSession(null);
setPrompt("");
setError(null);
api.sessions
.get(sessionId)
.then((r) => live && setSession(r.session))
.catch(() => undefined);
return () => {
live = false;
};
}, [sessionId]);
const send = async () => {
const text = prompt.trim();
if (!text || busy) return;
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
setError(t("home.allBusy"));
return;
}
setBusy(true);
setError(null);
try {
const r = await api.lanes.action(lane.id, "start", {
slot,
resumeSessionId: sessionId,
prompt: text,
});
setPrompt("");
onResumed(r.run_id ?? paneRunId(lane.id, slot));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} />
</div>
<div className="rounded-xl border border-border bg-surface-1 p-2">
<PromptEditor
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
placeholder={t("replay.placeholder")}
rows={2}
slash={(session?.tool ?? "claude") === "claude"}
fileCwd={session?.cwd || lane.cwd}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
<button
type="button"
data-testid="session-replay-send"
onClick={() => void send()}
disabled={busy || !prompt.trim()}
className="btn-primary ml-auto text-xs disabled:opacity-50"
>
<Send className="h-3.5 w-3.5" />
{t("replay.send")}
</button>
</div>
{error && (
<p role="alert" className="mt-1 text-xs text-status-danger">
{error}
</p>
)}
</div>
</div>
);
}
+173 -19
View File
@@ -2,10 +2,14 @@
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
* then opens it (`&run=<runId>`), the workspace's Recents — the sessions it
* then opens it (`&run=<runId>`) — the ONE place a session is started, its
* fields in order: AI (the session's own tool, the workspace's by default),
* AI Router (Native or a profile), Model (the tool's own list, or the
* router's models), Permission (Claude's mode, Codex's sandbox/approval, a
* catalog tool's bypass) and Effort — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
* then the archived ones folded with Unarchive; a live session opens its pane,
* any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
@@ -13,11 +17,12 @@
*/
import { Checkbox } from "../Checkbox";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ArchiveRestore,
ShieldAlert,
FileText,
Folder,
GitBranch,
@@ -29,8 +34,13 @@ import {
} from "lucide-react";
import {
api,
RUN_EFFORT_CHOICES,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
type RunHandle,
} from "../../lib/api";
@@ -39,7 +49,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup";
import { RouterPicker, routerArg, useSessionRouter } from "../run/RouterPicker";
import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
@@ -74,6 +85,18 @@ function fmtBytes(n: number | null): string {
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** One labelled field of the prompt box's row. */
function Field({ label, width, children }: { label: string; width: string; children: ReactNode }) {
return (
<div className={width}>
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</span>
{children}
</div>
);
}
type HomeProps = Parameters<typeof HomeBody>[0];
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
@@ -98,17 +121,48 @@ function HomeBody({
const navigate = useNavigate();
const aiTools = useAiTools();
const prefs = useProjectTreePrefs();
const claude = lane.tool === "claude";
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
const claude = tool === "claude";
const codex = tool === "codex";
const [prompt, setPrompt] = useState("");
// The tool's default model, also when the catalog arrives after mount.
const defaultModel = aiTools.byId(lane.tool)?.defaultModel ?? "";
const [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
const routerCapable = entry?.routerCapable ?? (claude || codex);
// A profile picked for a tool that can use one; its models replace the tool's own.
const profile = routerCapable
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
: null;
const routerModels: ModelChoice[] | undefined = profile
? [
...new Set(
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
Boolean
)
),
].map((id) => ({ id, label: id }))
: undefined;
// The tool's default model (also when the catalog arrives after mount); on
// a router, the router's own default. A new tool or router drops the pick.
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
const [model, setModel] = useState(defaultModel);
useEffect(() => setModel(defaultModel), [defaultModel]);
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
const [mode, setMode] = useState<PermissionMode>("auto");
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [bypass, setBypass] = useState(false);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
// Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false);
const [router, setRouter] = useSessionRouter();
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
@@ -230,10 +284,16 @@ function HomeBody({
const r = await api.lanes.action(lane.id, "start", {
slot,
prompt: prompt.trim(),
...(model ? { model } : {}),
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
...routerArg(routerCapable, router),
...(hasModel && model ? { model } : {}),
...(claude ? { permissionMode: mode } : {}),
...(codex ? { sandbox, approval } : {}),
...(canBypass && bypass ? { bypass: true } : {}),
...(hasEffort && effort ? { effort } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
...routerArg(aiTools.byId(lane.tool)?.routerCapable, router),
});
setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot);
@@ -248,10 +308,27 @@ function HomeBody({
const openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id);
navigate(
r ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}` : `/sessions/${s.id}`
`/workspace?lane=${lane.id}&${r ? `run=${encodeURIComponent(r.id)}` : `session=${encodeURIComponent(s.id)}`}`
);
};
// The tools a session may start on: installed and enabled, plus the
// workspace's own whatever its state.
const toolOptions = [
...new Set([
lane.tool,
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
]),
];
const danger =
claude && mode === "bypassPermissions"
? t("run:hint.permissionWarning")
: canBypass && bypass
? t("run:hint.bypassWarning", { tool: aiTools.label(tool) })
: codex && sandbox === "danger-full-access"
? t("run:hint.sandboxWarning")
: null;
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const name = workspaceName(lane);
@@ -340,13 +417,35 @@ function HomeBody({
slash={claude}
fileCwd={lane.cwd}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<div className="w-56">
<ModelPicker value={model} onChange={setModel} tool={lane.tool} />
</div>
<RouterPicker tool={lane.tool} value={router} onChange={setRouter} />
<div className="mt-2 flex flex-wrap items-end gap-2">
<Field label={t("home.ai")} width="w-44">
<div data-testid="workspace-home-tool" className="flex items-center gap-1.5">
<AiToolIcon
tool={{ id: tool, label: aiTools.label(tool) }}
className="h-6 w-6 shrink-0"
/>
<div className="min-w-0 flex-1">
<Select<string>
value={tool}
onChange={(v) => {
setTool(v);
setBypass(false);
}}
options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id) }))}
/>
</div>
</div>
</Field>
<Field label={t("router.label")} width="w-44">
<RouterPicker tool={tool} routers={routers} value={router} onChange={setRouter} />
</Field>
{hasModel && (
<Field label={t("run:fields.model")} width="w-56">
<ModelPicker value={model} onChange={setModel} tool={tool} choices={routerModels} />
</Field>
)}
{claude && (
<div className="w-44">
<Field label={t("run:fields.permissionMode")} width="w-44">
<Select<PermissionMode>
value={mode}
onChange={setMode}
@@ -358,7 +457,56 @@ function HomeBody({
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
]}
/>
</div>
</Field>
)}
{codex && (
<>
<Field label={t("run:fields.sandbox")} width="w-40">
<Select<CodexSandbox>
value={sandbox}
onChange={setSandbox}
options={[
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
]}
/>
</Field>
<Field label={t("run:fields.approval")} width="w-32">
<Select<CodexApproval>
value={approval}
onChange={setApproval}
options={[
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
{ value: "never", label: t("run:fields.approvalNever") },
]}
/>
</Field>
</>
)}
{hasEffort && (
<Field label={t("run:fields.effort")} width="w-40">
<Select<EffortLevel>
value={effort}
onChange={setEffort}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{canBypass && (
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
)}
{claude && project?.root && (
<Checkbox
@@ -381,6 +529,12 @@ function HomeBody({
</button>
</div>
</div>
{danger && (
<p className="mt-2 flex items-start gap-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{danger}
</p>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
+1 -8
View File
@@ -2,7 +2,7 @@
* @file WorkspaceRow — one workspace (lane) in the sidebar's workspace list, one
* line like a Claude Desktop project: a chevron that expands its sessions, the
* status dot, the name (click opens the workspace, double-click renames,
* `PATCH /api/lanes/:id {title}`), the tool's icon, and on hover `+` (a new
* `PATCH /api/lanes/:id {title}`) and on hover `+` (a new
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
@@ -14,8 +14,6 @@ import { useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
export function workspaceDot(lane: Lane): string {
@@ -61,7 +59,6 @@ export function WorkspaceRow({
onRenameEnd: (title: string | null) => void;
}) {
const { t } = useTranslation("lanes");
const tools = useAiTools();
const [draft, setDraft] = useState("");
const [dropTarget, setDropTarget] = useState(false);
const name = workspaceName(lane);
@@ -153,10 +150,6 @@ export function WorkspaceRow({
>
<span className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="truncate text-[13px] text-fg-primary">{name}</span>
<AiToolIcon
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</button>
)}
{!renaming && (
@@ -3,15 +3,16 @@
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error); double-click
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page
* (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
* split pane) — attaching, never starting or resuming anything;
* any other session opens its read-only transcript (`/sessions/:id`), where
* Resume is a separate, explicit action, because a resume re-reads the whole
* transcript into context.
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +24,8 @@ import { api, type RunHandle } from "../../lib/api";
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
export const SESSION_LIMIT = 20;
@@ -164,6 +167,7 @@ export function WorkspaceSessionList({
liveRuns,
query,
selectedRunId,
selectedSessionId = null,
onMatches,
hidden = false,
}: {
@@ -174,6 +178,8 @@ export function WorkspaceSessionList({
query: string;
/** The `run` the Workspace page is attached to, to mark its row. */
selectedRunId: string | null;
/** The ended session the Workspace page is showing (`session`), likewise. */
selectedSessionId?: string | null;
/** How many sessions match `query`, once they are loaded — lets the
* sidebar search find a workspace by a session's name. */
onMatches?: (count: number) => void;
@@ -182,6 +188,7 @@ export function WorkspaceSessionList({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const tools = useAiTools();
// Bumped after a rename or an archive, so the list re-reads.
const [version, setVersion] = useState(0);
const [renaming, setRenaming] = useState<string | null>(null);
@@ -276,7 +283,7 @@ export function WorkspaceSessionList({
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
{shown.map((s) => {
const run = runFor(s);
const selected = !!run && run.id === selectedRunId;
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) {
return (
<li key={s.id} className="py-0.5 pl-7 pr-2">
@@ -314,7 +321,7 @@ export function WorkspaceSessionList({
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
e.ctrlKey || e.metaKey ? "&pane=1" : ""
}`
: `/sessions/${s.id}`
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`
)
}
onDoubleClick={() => {
@@ -326,7 +333,7 @@ export function WorkspaceSessionList({
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
selected
? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
@@ -344,6 +351,10 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
/>
</button>
<button
type="button"
@@ -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();