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
+1 -2
View File
@@ -1582,8 +1582,7 @@ two minutes) keeps the status, `ended_at` and `updated_at` its hooks gave it
(the poll still writes its usage, model and name); once the hooks stop — (the poll still writes its usage, model and name); once the hooks stop —
removed, or untrusted in Codex — the journal decides again, in `settleActive` removed, or untrusted in Codex — the journal decides again, in `settleActive`
too. The Workspace's external-session list too. The Workspace's external-session list
(attach, `claude --resume`) and the run form's resume picker offer (attach, `claude --resume`) offers `tool = 'claude'` rows only.
`tool = 'claude'` rows only.
**Codex hooks** (`server/lib/codex-hooks.js`, Settings → Codex → Overview → **Codex hooks** (`server/lib/codex-hooks.js`, Settings → Codex → Overview →
Codex hooks, `GET/POST/DELETE /api/settings/codex-hooks`) are installed only on Codex hooks, `GET/POST/DELETE /api/settings/codex-hooks`) are installed only on
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -131,7 +131,7 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
1. Fresh plugin install from a Terminal `claude` session: the dashboard answers on `http://localhost:4820`, `~/Library/LaunchAgents/io.smartgift.agenthub.plist` exists, and `/agenthub-doctor`'s launchd row says the LaunchAgent is current and running. 1. Fresh plugin install from a Terminal `claude` session: the dashboard answers on `http://localhost:4820`, `~/Library/LaunchAgents/io.smartgift.agenthub.plist` exists, and `/agenthub-doctor`'s launchd row says the LaunchAgent is current and running.
2. `tmux show-environment -g | grep -i claude` prints nothing. 2. `tmux show-environment -g | grep -i claude` prints nothing.
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map. 3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form. 4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the workspace home.
5. Changes panel: diff, stage and commit on a lane. 5. Changes panel: diff, stage and commit on a lane.
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name. 6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works. 7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
+2 -1
View File
@@ -58,7 +58,8 @@ Internal build — all rights reserved.
- **Workspaces like Claude Desktop projects.** The sidebar lists every - **Workspaces like Claude Desktop projects.** The sidebar lists every
workspace with its sessions under it (a live one opens its tmux pane, any workspace with its sessions under it (a live one opens its tmux pane, any
other its transcript — never a resume). A workspace opens on its home: a other its conversation with a prompt box — sending a message resumes it,
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
prompt box that starts a new session, Recents, and the workspace's own prompt box that starts a new session, Recents, and the workspace's own
Instructions, Memory, Context files and extra Folders, which Claude and Codex Instructions, Memory, Context files and extra Folders, which Claude and Codex
sessions start with. sessions start with.
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 250 250" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
<path d="M0,4.391C-0,1.982 1.982,0 4.391,0C4.391,0 19.609,-0 19.609,-0C22.018,-0 24,1.982 24,4.391C24,4.391 24,19.609 24,19.609C24,22.018 22.018,24 19.609,24C19.609,24 4.391,24 4.391,24C1.982,24 -0,22.018 -0,19.609C-0,19.609 0,4.391 0,4.391Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
<path d="M19.74,1.444C21.285,1.444 22.556,2.715 22.556,4.26L22.556,19.74C22.556,21.285 21.285,22.556 19.74,22.556L4.26,22.556C2.715,22.556 1.444,21.285 1.444,19.74L1.444,4.26C1.444,2.715 2.715,1.444 4.26,1.444L19.74,1.444ZM7.236,8.564L14.988,12.292L7.236,16.019L7.236,18.821L16.793,14.225L16.793,10.359L7.236,5.763L7.236,8.564Z" style="fill:rgb(30,30,46);"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-24,9.907,-9.907,-24,24,6.587)"><stop offset="0" style="stop-color:rgb(238,77,93);stop-opacity:1"/><stop offset="0.33" style="stop-color:rgb(179,129,221);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(32,124,254);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(32,124,254);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -14,6 +14,7 @@ import { api, type AiTool } from "../lib/api";
const BUNDLED: Record<string, string | [string, string]> = { const BUNDLED: Record<string, string | [string, string]> = {
claude: "/ai-tools/claude.svg", claude: "/ai-tools/claude.svg",
codex: "/ai-tools/codex.svg", codex: "/ai-tools/codex.svg",
gemini: "/ai-tools/gemini.svg",
openclaw: "/ai-tools/openclaw.svg", openclaw: "/ai-tools/openclaw.svg",
hermes: ["/ai-tools/hermes-black.svg", "/ai-tools/hermes-white.svg"], hermes: ["/ai-tools/hermes-black.svg", "/ai-tools/hermes-white.svg"],
}; };
+4 -3
View File
@@ -35,10 +35,10 @@ import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
/** The workspace (and attached run) the Workspace page shows, from its URL. */ /** The workspace (and attached run) the Workspace page shows, from its URL. */
function selectedFrom(pathname: string, search: string) { function selectedFrom(pathname: string, search: string) {
if (!pathname.startsWith("/workspace")) return { lane: null, run: null }; if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
const p = new URLSearchParams(search); const p = new URLSearchParams(search);
const v = p.get("lane"); const v = p.get("lane");
return { lane: v === null ? null : Number(v), run: p.get("run") }; return { lane: v === null ? null : Number(v), run: p.get("run"), session: p.get("session") };
} }
export function WorkspaceTree() { export function WorkspaceTree() {
@@ -240,7 +240,7 @@ export function WorkspaceTree() {
<WorkspaceRow <WorkspaceRow
lane={lane} lane={lane}
index={i} index={i}
selected={selected.lane === lane.id && !selected.run} selected={selected.lane === lane.id && !selected.run && !selected.session}
expanded={expanded(lane.id)} expanded={expanded(lane.id)}
renaming={renamingId === lane.id} renaming={renamingId === lane.id}
onSelect={() => { onSelect={() => {
@@ -262,6 +262,7 @@ export function WorkspaceTree() {
// A workspace found by its name shows all of its sessions. // A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query} query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null} selectedRunId={selected.lane === lane.id ? selected.run : null}
selectedSessionId={selected.lane === lane.id ? selected.session : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined} onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)} hidden={!visible(lane)}
/> />
@@ -230,7 +230,7 @@ describe("WorkspaceTree", () => {
expect(screen.getByTestId("workspace-session-dot-s-bad")).toHaveClass("bg-status-danger"); expect(screen.getByTestId("workspace-session-dot-s-bad")).toHaveClass("bg-status-danger");
}); });
it("opens a live session's pane and an ended session's transcript, resuming nothing", async () => { it("opens a live session's pane and an ended session's conversation, resuming nothing", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderTree(); renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1")); await user.click(await screen.findByTestId("workspace-toggle-1"));
@@ -244,7 +244,7 @@ describe("WorkspaceTree", () => {
"/workspace?lane=1&run=agenthub-lane-1-2&pane=1" "/workspace?lane=1&run=agenthub-lane-1-2&pane=1"
); );
await user.click(screen.getByTestId("workspace-session-s-old")); await user.click(screen.getByTestId("workspace-session-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(); expect(api.lanes.action).not.toHaveBeenCalled();
}); });
@@ -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 * @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
* Claude Desktop project page: the name, a prompt box that starts a NEW * 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 * 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), * 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, * 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 * Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked * server/lib/lane-sources.js). Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees. * worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
@@ -13,11 +17,12 @@
*/ */
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ArchiveRestore, ArchiveRestore,
ShieldAlert,
FileText, FileText,
Folder, Folder,
GitBranch, GitBranch,
@@ -29,8 +34,13 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
api, api,
RUN_EFFORT_CHOICES,
type CodexApproval,
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile, type LaneMemoryFile,
type LaneSourcesView, type LaneSourcesView,
type ModelChoice,
type PermissionMode, type PermissionMode,
type RunHandle, type RunHandle,
} from "../../lib/api"; } from "../../lib/api";
@@ -39,7 +49,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs"; import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup"; 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 { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview"; import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu"; 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)); 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]; type HomeProps = Parameters<typeof HomeBody>[0];
/** Keyed by workspace: every piece of state below (a half-typed prompt, the /** Keyed by workspace: every piece of state below (a half-typed prompt, the
@@ -98,17 +121,48 @@ function HomeBody({
const navigate = useNavigate(); const navigate = useNavigate();
const aiTools = useAiTools(); const aiTools = useAiTools();
const prefs = useProjectTreePrefs(); 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(""); const [prompt, setPrompt] = useState("");
// The tool's default model, also when the catalog arrives after mount. const [router, setRouter] = useSessionRouter();
const defaultModel = aiTools.byId(lane.tool)?.defaultModel ?? ""; 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); 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 [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 // Claude Desktop's worktree toggle: the new session gets a git worktree of
// its own (`claude --worktree`), made and cleaned up by Claude Code. // its own (`claude --worktree`), made and cleaned up by Claude Code.
const [inWorktree, setInWorktree] = useState(false); const [inWorktree, setInWorktree] = useState(false);
const [router, setRouter] = useSessionRouter();
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]); const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
@@ -230,10 +284,16 @@ function HomeBody({
const r = await api.lanes.action(lane.id, "start", { const r = await api.lanes.action(lane.id, "start", {
slot, slot,
prompt: prompt.trim(), 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 } : {}), ...(claude ? { permissionMode: mode } : {}),
...(codex ? { sandbox, approval } : {}),
...(canBypass && bypass ? { bypass: true } : {}),
...(hasEffort && effort ? { effort } : {}),
...(claude && inWorktree && project?.root ? { worktree: true } : {}), ...(claude && inWorktree && project?.root ? { worktree: true } : {}),
...routerArg(aiTools.byId(lane.tool)?.routerCapable, router),
}); });
setPrompt(""); setPrompt("");
const runId = r.run_id ?? paneRunId(lane.id, slot); const runId = r.run_id ?? paneRunId(lane.id, slot);
@@ -248,10 +308,27 @@ function HomeBody({
const openSession = (s: Session) => { const openSession = (s: Session) => {
const r = liveRunFor(lane, liveRuns, s.id); const r = liveRunFor(lane, liveRuns, s.id);
navigate( 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 files = (view?.sources ?? []).filter((s) => s.kind === "file");
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder"); const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
const name = workspaceName(lane); const name = workspaceName(lane);
@@ -340,13 +417,35 @@ function HomeBody({
slash={claude} slash={claude}
fileCwd={lane.cwd} fileCwd={lane.cwd}
/> />
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-end gap-2">
<div className="w-56"> <Field label={t("home.ai")} width="w-44">
<ModelPicker value={model} onChange={setModel} tool={lane.tool} /> <div data-testid="workspace-home-tool" className="flex items-center gap-1.5">
</div> <AiToolIcon
<RouterPicker tool={lane.tool} value={router} onChange={setRouter} /> 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 && ( {claude && (
<div className="w-44"> <Field label={t("run:fields.permissionMode")} width="w-44">
<Select<PermissionMode> <Select<PermissionMode>
value={mode} value={mode}
onChange={setMode} onChange={setMode}
@@ -358,7 +457,56 @@ function HomeBody({
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") }, { 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 && ( {claude && project?.root && (
<Checkbox <Checkbox
@@ -381,6 +529,12 @@ function HomeBody({
</button> </button>
</div> </div>
</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 && ( {error && (
<p role="alert" className="mt-2 text-xs text-status-danger"> <p role="alert" className="mt-2 text-xs text-status-danger">
{error} {error}
+1 -8
View File
@@ -2,7 +2,7 @@
* @file WorkspaceRow — one workspace (lane) in the sidebar's workspace list, one * @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 * line like a Claude Desktop project: a chevron that expands its sessions, the
* status dot, the name (click opens the workspace, double-click renames, * 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 * session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer * right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
* preference kept in localStorage (`laneOrderStorage.ts`), never written to * 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 { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react"; import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
import type { Lane } from "../../lib/types"; 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. */ /** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
export function workspaceDot(lane: Lane): string { export function workspaceDot(lane: Lane): string {
@@ -61,7 +59,6 @@ export function WorkspaceRow({
onRenameEnd: (title: string | null) => void; onRenameEnd: (title: string | null) => void;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const tools = useAiTools();
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [dropTarget, setDropTarget] = useState(false); const [dropTarget, setDropTarget] = useState(false);
const name = workspaceName(lane); 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={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
<span className="truncate text-[13px] text-fg-primary">{name}</span> <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> </button>
)} )}
{!renaming && ( {!renaming && (
@@ -3,15 +3,16 @@
* sidebar: the ones the workspace OWNS and has not archived * sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other * (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
* sessions that ran in its folder. A row's dot * 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}`, * 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 * 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 * 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 * (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
* split pane) — attaching, never starting or resuming anything; * split pane) — attaching, never starting or resuming anything;
* any other session opens its read-only transcript (`/sessions/:id`), where * any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* Resume is a separate, explicit action, because a resume re-reads the whole * `SessionReplay`): its conversation with a prompt box, resumed only when a
* transcript into context. * message is SENT, because a resume re-reads the whole transcript into context.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @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 { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
import { timeAgo } from "../../lib/format"; import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
export const SESSION_LIMIT = 20; export const SESSION_LIMIT = 20;
@@ -164,6 +167,7 @@ export function WorkspaceSessionList({
liveRuns, liveRuns,
query, query,
selectedRunId, selectedRunId,
selectedSessionId = null,
onMatches, onMatches,
hidden = false, hidden = false,
}: { }: {
@@ -174,6 +178,8 @@ export function WorkspaceSessionList({
query: string; query: string;
/** The `run` the Workspace page is attached to, to mark its row. */ /** The `run` the Workspace page is attached to, to mark its row. */
selectedRunId: string | null; 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 /** How many sessions match `query`, once they are loaded — lets the
* sidebar search find a workspace by a session's name. */ * sidebar search find a workspace by a session's name. */
onMatches?: (count: number) => void; onMatches?: (count: number) => void;
@@ -182,6 +188,7 @@ export function WorkspaceSessionList({
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const navigate = useNavigate(); const navigate = useNavigate();
const tools = useAiTools();
// Bumped after a rename or an archive, so the list re-reads. // Bumped after a rename or an archive, so the list re-reads.
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
const [renaming, setRenaming] = useState<string | null>(null); 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"> <ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
{shown.map((s) => { {shown.map((s) => {
const run = runFor(s); const run = runFor(s);
const selected = !!run && run.id === selectedRunId; const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
if (renaming === s.id) { if (renaming === s.id) {
return ( return (
<li key={s.id} className="py-0.5 pl-7 pr-2"> <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)}${ `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
e.ctrlKey || e.metaKey ? "&pane=1" : "" e.ctrlKey || e.metaKey ? "&pane=1" : ""
}` }`
: `/sessions/${s.id}` : `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`
) )
} }
onDoubleClick={() => { onDoubleClick={() => {
@@ -326,7 +333,7 @@ export function WorkspaceSessionList({
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}` ? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
: "" : ""
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`} }\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 selected
? "bg-accent/10 text-fg-primary" ? "bg-accent/10 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover: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" 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>
<button <button
type="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 * @file WorkspaceHome.test.tsx
* @description The workspace home: a new session starts in the first free * @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 * transcript (never a resume), instructions save, context files upload and
* remove, a folder is added with the repo's untracked worktrees suggested * remove, a folder is added with the repo's untracked worktrees suggested
* (minus hidden sources), an unsupported tool is said so, and memory opens * (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)} /> <textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
), ),
ModelPicker: ({ value, onChange }: { value: string; onChange: (s: string) => void }) => ( ModelPicker: ({
<input aria-label="model" value={value} onChange={(e) => onChange(e.target.value)} /> 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: () => "" }, 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 () => { it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderHome(); renderHome();
@@ -235,7 +320,7 @@ describe("WorkspaceHome", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false }); 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(); const user = userEvent.setup();
renderHome(); renderHome();
const live = await screen.findByTestId("workspace-recent-s-live"); const live = await screen.findByTestId("workspace-recent-s-live");
@@ -245,7 +330,7 @@ describe("WorkspaceHome", () => {
await user.click(live); await user.click(live);
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1"); expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
await user.click(screen.getByTestId("workspace-recent-s-old")); 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(); 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 () => { it("starts on the tool's default model once the catalog arrives", async () => {
__resetAiTools(); __resetAiTools();
vi.mocked(api.aiTools.list).mockResolvedValueOnce({ vi.mocked(api.aiTools.list).mockResolvedValueOnce({
tools: [{ id: "claude", label: "Claude", defaultModel: "sonnet" }], tools: [{ id: "claude", label: "Claude", launcher: "claude", defaultModel: "sonnet" }],
default: "claude", default: "claude",
} as never); } as never);
renderHome(); renderHome();
+54 -228
View File
@@ -1,26 +1,26 @@
/** /**
* @file LaneConsolePane.tsx * @file LaneConsolePane.tsx
* @description One lane's run console: the RunSetup ↔ TerminalView switcher, * @description One lane's run console: a TerminalView on the pane's tmux
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of * session, or — with no run — an empty pane whose button goes back to the
* these side by side (split terminal view). Owns its own prompt/cwd/model/ * workspace home, the ONE place a new session is started (its prompt box,
* permissionMode/effort/sandbox/approval/resumeSession/handle/busy/runHistory * `WorkspaceHome.tsx`; there is no run form in a pane any more). Moved out of
* state — nothing is shared between panes. `lanes`, `binaryStatus`, * Workspace.tsx so the Workspace page can render 1, 2, or 4 of these side by
* `cwdSuggestions`, `activeRuns`, and `externalSessions` are supplied as props * side (split terminal view). Owns its own handle/busy/runHistory state —
* because they are global, not lane-specific, and fetching them per pane would * nothing is shared between panes. `lanes`, `binaryStatus`, `activeRuns`, and
* mean N redundant identical requests for an N-pane layout. `binaryStatus` is * `externalSessions` are supplied as props because they are global, not
* keyed by tool (claude | codex) and probed once globally, so gating the Start * lane-specific, and fetching them per pane would mean N redundant identical
* button uses the binary status for the lane's configured tool. * requests for an N-pane layout.
* *
* That state is bound to the lane the pane currently shows: switching `laneId` * That state is bound to the lane the pane currently shows: switching `laneId`
* swaps the whole pane over to the new lane — its cwd, its tool, its history, * swaps the whole pane over to the new lane — its history
* and its live tmux session — instead of leaving the previous lane's terminal * and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header. * on screen under a new lane's header.
* *
* Each pane is one tmux session on its lane — split panes all show the same * Each pane is one tmux session on its lane — split panes all show the same
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming * lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a * into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch * prompt in flight — `onResumeFromHistory` catches
* that and attach to the still-live session instead of failing outright * that and attaches to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still * (with a notice explaining what happened), since that session is still
* reachable and the user did not ask to kill it. `killRun` is the other half: * reachable and the user did not ask to kill it. `killRun` is the other half:
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user * the Active runs list's Kill button (`RunHistory.tsx`), for when the user
@@ -30,23 +30,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Play, AlertCircle } from "lucide-react"; import { Play, AlertCircle, Plus } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { import type { DashboardRunHistoryItem, RunHandle, RunListResponse } from "../../lib/api";
CwdSuggestion,
DashboardRunHistoryItem,
EffortLevel,
PermissionMode,
RunHandle,
RunListResponse,
RunStartArgs,
CodexSandbox,
CodexApproval,
} from "../../lib/api";
import type { Session, Lane } from "../../lib/types"; import type { Session, Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory"; import { ActiveRunsSwitcher } from "./RunHistory";
/** The claude lane a session's cwd belongs to: the longest path-boundary /** The claude lane a session's cwd belongs to: the longest path-boundary
@@ -70,13 +59,11 @@ export interface LaneConsolePaneProps {
onLaneIdChange: (id: number) => void; onLaneIdChange: (id: number) => void;
onLaneCreated: (lane: Lane) => void; onLaneCreated: (lane: Lane) => void;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null; binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
/** Active Claude Code sessions started outside the dashboard, listed in the /** Active Claude Code sessions started outside the dashboard, listed in the
* active-runs switcher alongside dashboard runs. */ * active-runs switcher alongside dashboard runs. */
externalSessions?: Session[]; externalSessions?: Session[];
wsConnected: boolean; wsConnected: boolean;
defaultCwd?: string;
onHasActiveRunChange?: (active: boolean) => void; onHasActiveRunChange?: (active: boolean) => void;
/** Which split pane this is. Every pane shows the SAME lane, each in its own /** Which split pane this is. Every pane shows the SAME lane, each in its own
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */ * tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
@@ -89,6 +76,10 @@ export interface LaneConsolePaneProps {
/** A live run to show in this pane (a session picked in the sidebar). It /** A live run to show in this pane (a session picked in the sidebar). It
* attaches to that tmux session only — nothing is started or resumed. */ * attaches to that tmux session only — nothing is started or resumed. */
attachRequest?: { runId: string; nonce: string } | null; attachRequest?: { runId: string; nonce: string } | null;
/** Opens the workspace home, where a new session is started. */
onNewSession?: () => void;
/** The pane's run exiting goes back to the home too (a single pane). */
homeOnExit?: boolean;
} }
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */ /** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
@@ -103,33 +94,25 @@ export function LaneConsolePane({
onLaneIdChange, onLaneIdChange,
onLaneCreated, onLaneCreated,
binaryStatus, binaryStatus,
cwdSuggestions,
activeRuns, activeRuns,
externalSessions, externalSessions,
wsConnected, wsConnected,
defaultCwd,
onHasActiveRunChange, onHasActiveRunChange,
slot = 0, slot = 0,
hiddenRunIds, hiddenRunIds,
onHandleChange, onHandleChange,
attachRequest, attachRequest,
onNewSession,
homeOnExit = false,
}: LaneConsolePaneProps) { }: LaneConsolePaneProps) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const { t: tLanes } = useTranslation("lanes"); const { t: tLanes } = useTranslation("lanes");
const { t: tCommon } = useTranslation("common"); const { t: tCommon } = useTranslation("common");
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>("auto");
const [effort, setEffort] = useState<EffortLevel>("");
const [cwd, setCwd] = useState(() => lanes.find((l) => l.id === laneId)?.cwd ?? defaultCwd ?? "");
const [resumeSession, setResumeSession] = useState<Session | null>(null);
const [handle, setHandle] = useState<RunHandle | null>(null); const [handle, setHandle] = useState<RunHandle | null>(null);
const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null); const [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]); const [runHistory, setRunHistory] = useState<DashboardRunHistoryItem[]>([]);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null; const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
const laneTool = currentLane?.tool ?? "claude"; const laneTool = currentLane?.tool ?? "claude";
@@ -155,13 +138,10 @@ export function LaneConsolePane({
[externalSessions, laneId, lanes] [externalSessions, laneId, lanes]
); );
const aiTools = useAiTools(); const aiTools = useAiTools();
const toolLabel = aiTools.label(laneTool); // The tool of the run on screen — a session's own, which need not be the
// A new run starts on the tool's default model (AI Tools settings), and a // workspace's; the workspace's with no run.
// lane switch to another tool drops the previous tool's pick. const paneTool = handle?.tool ?? laneTool;
const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? ""; const toolLabel = aiTools.label(paneTool);
useEffect(() => {
setModel(defaultModel);
}, [laneTool, defaultModel]);
useEffect(() => { useEffect(() => {
onHasActiveRunChange?.(handle !== null); onHasActiveRunChange?.(handle !== null);
@@ -171,12 +151,6 @@ export function LaneConsolePane({
onHandleChange?.(handle?.id ?? null); onHandleChange?.(handle?.id ?? null);
}, [handle, onHandleChange]); }, [handle, onHandleChange]);
useEffect(() => {
if (!currentLane && defaultCwd && cwd === "") {
setCwd(defaultCwd);
}
}, [defaultCwd, currentLane, cwd]);
const refreshList = useCallback(() => { const refreshList = useCallback(() => {
if (laneId !== null) { if (laneId !== null) {
api.run api.run
@@ -194,19 +168,15 @@ export function LaneConsolePane({
// `lanes` and `activeRuns` are re-fetched every few seconds by the page, so // `lanes` and `activeRuns` are re-fetched every few seconds by the page, so
// reading them through a ref keeps the lane-switch effect below off their // reading them through a ref keeps the lane-switch effect below off their
// identity — a background poll must not wipe a half-typed prompt. // identity — a background poll must not wipe a half-typed prompt.
const latest = useRef({ lanes, activeRuns, defaultCwd }); const latest = useRef({ lanes, activeRuns });
latest.current = { lanes, activeRuns, defaultCwd }; latest.current = { lanes, activeRuns };
// A pane's prompt, cwd, history and terminal all belong to the lane it // A pane's history and terminal belong to the lane it shows, so switching
// shows, so switching lanes has to swap every one of them. Re-attach right // lanes has to swap both. Re-attach right away when the new lane already
// away when the new lane already has a live run: each lane sticks to its own // has a live run: each lane sticks to its own tmux session.
// tmux session, and the switch should land on that session rather than on an
// empty setup form the user then has to Start out of.
const autoAttachedForLane = useRef<number | null>(null); const autoAttachedForLane = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
const { activeRuns: runs } = latest.current; const { activeRuns: runs } = latest.current;
setPrompt("");
setResumeSession(null);
setError(null); setError(null);
setBusy(null); setBusy(null);
const own = laneId === null ? null : paneRunId(laneId, slot); const own = laneId === null ? null : paneRunId(laneId, slot);
@@ -266,18 +236,6 @@ export function LaneConsolePane({
} }
}, [activeRuns, laneId, slot, handle]); }, [activeRuns, laneId, slot, handle]);
// The cwd tracks the lane's own folder separately, keyed on the resolved
// path rather than on `laneId` alone: the pane can mount before the lane
// list has loaded (split view restores its pane lanes from localStorage),
// and `laneId` never changes afterwards, so a laneId-only effect would leave
// the console pointing at the default directory. RunSetup submits this
// string verbatim, so a stale one starts the run in the wrong folder.
const laneCwd = currentLane?.cwd ?? null;
useEffect(() => {
if (laneCwd) setCwd(laneCwd);
else if (laneId === null) setCwd(latest.current.defaultCwd ?? "");
}, [laneId, laneCwd]);
const attachToRun = useCallback( const attachToRun = useCallback(
async (id: string) => { async (id: string) => {
if (busy) return; if (busy) return;
@@ -296,118 +254,6 @@ export function LaneConsolePane({
[busy, t] [busy, t]
); );
const onStartFromSetup = useCallback(
async (args: RunStartArgs) => {
if (busy) return;
setBusy("start");
setError(null);
try {
const effectiveCwd = args.cwd || undefined;
if (!effectiveCwd) {
throw new Error(t("errors.cwdRequired"));
}
// Resolve the lane from the cwd the user actually typed, not from
// args.laneId — RunSetup always supplies this pane's laneId (a
// required prop), which would otherwise silently start a run in the
// wrong lane whenever the user types a cwd different from the one
// this pane currently shows. A cwd can now back both a claude lane
// and a codex lane, so match this pane's own tool too (laneTool,
// which already defaults to "claude" when no lane is selected —
// the same default RunSetup renders) — otherwise the wrong sibling
// lane could be picked.
const ownedLane = lanes.find((l) => l.cwd === effectiveCwd && l.tool === laneTool);
let targetLaneId: number;
if (ownedLane) {
targetLaneId = ownedLane.id;
if (ownedLane.id !== laneId) onLaneIdChange(ownedLane.id);
} else {
try {
const ensureResult = await api.lanes.ensure({
cwd: effectiveCwd,
tool: laneTool,
});
targetLaneId = ensureResult.lane.id;
onLaneIdChange(ensureResult.lane.id);
onLaneCreated(ensureResult.lane);
} catch (err) {
throw new Error(
t("errors.laneCreateFailed", {
message: err instanceof Error ? err.message : "unknown",
})
);
}
}
let laneStartResult;
try {
laneStartResult = await api.lanes.action(targetLaneId, "start", {
slot,
prompt: args.initialPrompt || "",
model: args.model || undefined,
permissionMode: args.permissionMode,
resumeSessionId: args.resumeSessionId,
effort: args.effort || undefined,
sandbox: args.sandbox,
approval: args.approval,
resumeLast: args.resumeLast,
bypass: args.bypass,
router: args.router,
});
} catch (laneErr: unknown) {
// A code-only check: the server's message is human prose ("lane
// already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
// This pane's own session is the live one — land on it.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
await attachToRun(paneRunId(targetLaneId, slot));
setError(t("errors.attachedToLiveInstead"));
return;
}
throw laneErr;
}
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
if (!startedId) {
throw new Error(t("errors.noRunIdReturned"));
}
try {
const fetched = await api.run.get(startedId);
setHandle(fetched);
refreshList();
} catch {
try {
await attachToRun(startedId);
refreshList();
} catch (fallbackErr: unknown) {
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
throw new Error(t("errors.runStartedButNotAttached", { message: attachMsg }));
}
}
} catch (err: unknown) {
const m = err instanceof Error ? err.message : "unknown";
setError(t("errors.startFailed", { message: m }));
} finally {
setBusy(null);
}
},
[
busy,
t,
lanes,
laneId,
laneTool,
slot,
onLaneIdChange,
onLaneCreated,
attachToRun,
refreshList,
]
);
const onResumeFromHistory = useCallback( const onResumeFromHistory = useCallback(
async (item: DashboardRunHistoryItem) => { async (item: DashboardRunHistoryItem) => {
if (!item.session_id) return; if (!item.session_id) return;
@@ -419,10 +265,11 @@ export function LaneConsolePane({
if (item.cwd) { if (item.cwd) {
const effectiveCwd = item.cwd; const effectiveCwd = item.cwd;
// A cwd can now back both a claude lane and a codex lane — match // This pane's history is its own lane's (`refreshList`), whatever
// this history item's own tool, the ground truth for which lane it // tool each session ran on. With no lane, a cwd can back both a
// belongs to, rather than an arbitrary lane at that cwd. // claude and a codex lane — match the item's own tool.
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id; let targetLaneId =
laneId ?? lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id;
if (!targetLaneId) { if (!targetLaneId) {
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool }); const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool });
@@ -476,7 +323,6 @@ export function LaneConsolePane({
} }
setHandle(fetched); setHandle(fetched);
setResumeSession(null);
refreshList(); refreshList();
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "unknown"; const msg = err instanceof Error ? err.message : "unknown";
@@ -485,7 +331,7 @@ export function LaneConsolePane({
setBusy(null); setBusy(null);
} }
}, },
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun] [busy, refreshList, t, lanes, laneId, slot, onLaneCreated, onLaneIdChange, attachToRun]
); );
const onViewFromHistory = useCallback( const onViewFromHistory = useCallback(
@@ -497,8 +343,6 @@ export function LaneConsolePane({
const newRun = useCallback(() => { const newRun = useCallback(() => {
setHandle(null); setHandle(null);
setPrompt("");
setResumeSession(null);
setError(null); setError(null);
}, []); }, []);
@@ -608,12 +452,12 @@ export function LaneConsolePane({
</header> </header>
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */} {/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
{binaryStatus?.[laneTool] && !binaryStatus[laneTool].found && ( {binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2"> <div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" /> <AlertCircle className="w-4 h-4 flex-shrink-0" />
<span> <span>
{t("binary.missing", { {t("binary.missing", {
cli: aiTools.byId(laneTool)?.command ?? laneTool, cli: aiTools.byId(paneTool)?.command ?? paneTool,
tool: toolLabel, tool: toolLabel,
})} })}
</span> </span>
@@ -628,52 +472,34 @@ export function LaneConsolePane({
)} )}
{!handle ? ( {!handle ? (
<RunSetup <div
laneId={laneId ?? 0} data-testid="pane-no-run"
prompt={prompt} className="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
onPromptChange={setPrompt} >
cwd={cwd} <p>{t("emptyPane")}</p>
onCwdChange={setCwd} {onNewSession && (
cwdSuggestions={cwdSuggestions} <button type="button" onClick={onNewSession} className="btn-primary text-xs">
model={model} <Plus className="h-3.5 w-3.5" />
onModelChange={setModel} {t("actions.newSession")}
permissionMode={permissionMode} </button>
onPermissionModeChange={setPermissionMode} )}
effort={effort} </div>
onEffortChange={setEffort}
binaryFound={binaryStatus?.[laneTool]?.found ?? true}
busy={busy === "start"}
onStart={onStartFromSetup}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
tool={laneTool}
sandbox={sandbox}
onSandboxChange={setSandbox}
approval={approval}
onApprovalChange={setApproval}
onResumeLast={(bypass) =>
onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass })
}
/>
) : ( ) : (
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<TerminalView <TerminalView
runId={handle!.id} runId={handle!.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")} wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) — // The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// go straight back to the setup form rather than leaving a dead // a single pane goes straight back to the home's prompt box rather
// terminal the user has to click "New run" out of. // than leaving a dead terminal the user has to click out of.
onExit={() => { onExit={() => {
newRun(); newRun();
refreshList(); refreshList();
if (homeOnExit) onNewSession?.();
}} }}
/> />
<button <button
onClick={newRun} onClick={onNewSession ?? newRun}
className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors" className="mt-3 px-3 py-1.5 text-sm rounded border border-border hover:border-border-light text-fg-secondary hover:text-fg-primary transition-colors"
> >
{t("actions.newRun")} {t("actions.newRun")}
+41 -34
View File
@@ -1,14 +1,15 @@
/** /**
* @file RouterPicker.tsx * @file RouterPicker.tsx
* @description The AI Router a NEW session starts on — a session's choice, not * @description The AI Router a NEW session starts on — a session's choice, not
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`). * its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
* Shown only for a tool that can use one (Claude, Codex) and only when a * Native or one of the saved profiles (`useAiRouters`, which the caller also
* profile beyond Native exists. The last pick is remembered for the next * reads to list the router's models). A tool that cannot use a router (only
* session (`agenthub.session.router`). * Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.router`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api, type AiRouter } from "../../lib/api"; import { api, type AiRouter } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools"; import { useAiTools } from "../../hooks/useAiTools";
@@ -36,28 +37,9 @@ export function useSessionRouter(): [string, (v: string) => void] {
return [value, set]; return [value, set];
} }
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */ /** The saved profiles (Native included), null until they arrive. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } { export function useAiRouters(): AiRouter[] | null {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
value,
onChange,
wrap = (el) => el,
}: {
tool: string;
value: string;
onChange: (v: string) => void;
/** Puts the select in the caller's labelled field; not called while hidden. */
wrap?: (select: ReactNode) => ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const [routers, setRouters] = useState<AiRouter[] | null>(null); const [routers, setRouters] = useState<AiRouter[] | null>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
api.aiRouters api.aiRouters
@@ -68,6 +50,30 @@ export function RouterPicker({
alive = false; alive = false;
}; };
}, []); }, []);
return routers;
}
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
return routerCapable && router && router !== "native" ? { router } : {};
}
export function RouterPicker({
tool,
routers,
value,
onChange,
}: {
tool: string;
/** From `useAiRouters`. */
routers: AiRouter[] | null;
value: string;
onChange: (v: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const aiTools = useAiTools();
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
const native = t("router.native", { tool: aiTools.label(tool) });
// A remembered profile that has since been deleted falls back to native, // A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a router the user cannot see. // or the start would be refused for a router the user cannot see.
@@ -75,16 +81,17 @@ export function RouterPicker({
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native"); if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
}, [routers, value, onChange]); }, [routers, value, onChange]);
if (!capable || !routers || routers.length <= 1) return null; const options =
return wrap( capable && routers?.length
<div className="w-44" data-testid="router-picker" title={t("router.label")}> ? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="router-picker" title={t("router.label")}>
<Select<string> <Select<string>
value={value} value={capable ? value : "native"}
onChange={onChange} onChange={onChange}
options={routers.map((r) => ({ options={options}
value: r.name, disabled={!capable}
label: r.builtin ? t("router.native", { tool: aiTools.label(tool) }) : r.label,
}))}
/> />
</div> </div>
); );
+19 -607
View File
@@ -1,65 +1,24 @@
/** /**
* @file RunSetup.tsx * @file RunSetup.tsx
* @description The pre-run setup panel: everything the user picks before a run * @description The run-form pieces the rest of the app reuses — the pane's
* exists. Moved verbatim out of `pages/Run.tsx` (where it was `ConfigCard`) so * own run form is gone, a new session starts from the workspace home's prompt
* the Run page and the Workspace page can both mount the same panel. * box (`WorkspaceHome.tsx`):
* * - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* What lives here: * Claude only) and `@` (`GET /api/run/files`) autocomplete;
* - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd / * - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* model / effort fields, plus per-tool advanced fields (permission-mode for * - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* Claude; sandbox and approval for Codex), plus the concurrency hint and * or a fixed one (an AI Router's models), plus Custom….
* the Start button. Its disabled state is driven by the `binaryFound` prop
* for the lane's tool, so a missing binary is a surfaced state here rather
* than a probe of its own. Picking a session to resume starts that run
* immediately — a resume carries its own history, so there is nothing to
* type first; the prompt stays optional for resumes and required for fresh
* runs. Codex lanes show a "resume --last" button instead of the session
* picker (no sessions in the dashboard database to list).
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
* `ModelPicker`, and the small `Field` layout helper.
*
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
* lifecycle — the page owns `api.run.start` and hands the result back through
* `onStart`. `SessionPicker` keeps the one API call it already owned (listing
* past sessions to resume) and `PromptEditor` keeps its `@`-file lookup.
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { Checkbox } from "../Checkbox";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { FolderOpen, GitBranch, Home, History as HistoryIcon } from "lucide-react";
Play, import { api, RUN_MODEL_CHOICES, type ModelChoice } from "../../lib/api";
RefreshCw, import type { CwdSuggestion, CcMdItem } from "../../lib/api";
AlertCircle, import type { LaneTool } from "../../lib/types";
ChevronDown,
ShieldAlert,
X,
FolderOpen,
GitBranch,
Home,
History as HistoryIcon,
Search,
RotateCcw,
Lock,
} from "lucide-react";
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES, type ModelChoice } from "../../lib/api";
import type {
CwdSuggestion,
DashboardRunHistoryItem,
RunListResponse,
EffortLevel,
PermissionMode,
RunStartArgs,
CodexSandbox,
CodexApproval,
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { Select } from "../Select"; import { Select } from "../Select";
import { RouterPicker, routerArg, useSessionRouter } from "./RouterPicker";
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
@@ -358,401 +317,6 @@ export function PromptEditor({
); );
} }
// ── Limitations banner (above the config card) ────────────────────────
interface RunSetupProps {
laneId: number;
prompt: string;
onPromptChange: (s: string) => void;
cwd: string;
onCwdChange: (s: string) => void;
cwdSuggestions: CwdSuggestion[];
model: string;
onModelChange: (s: string) => void;
permissionMode: PermissionMode;
onPermissionModeChange: (m: PermissionMode) => void;
effort: EffortLevel;
onEffortChange: (e: EffortLevel) => void;
binaryFound: boolean;
busy: boolean;
onStart: (args: RunStartArgs) => void;
activeRuns: RunListResponse | null;
resumeSession: Session | null;
onResumeSessionChange: (s: Session | null) => void;
/** The selected lane's cwd - narrows the resume picker to that lane's own
* sessions. Undefined when no lane is selected (the picker then lists
* everything, same as before lanes existed). */
laneCwd?: string;
runHistory: DashboardRunHistoryItem[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
/** The selected lane's CLI. Decides which advanced fields render. */
tool: LaneTool;
sandbox: CodexSandbox;
onSandboxChange: (s: CodexSandbox) => void;
approval: CodexApproval;
onApprovalChange: (a: CodexApproval) => void;
/** Every non-Claude lane resumes its tool's LAST session (`codex resume
* --last`, a catalog tool's `resume` args): the sessions table holds Claude
* sessions only, so there is nothing for the picker to list. */
onResumeLast: (bypass?: boolean) => void;
}
export function RunSetup(props: RunSetupProps) {
const { t } = useTranslation("run");
const atCap = false; // TODO: re-add when concurrency info is available
const isResume = !!props.resumeSession;
const [resumePicked, setResumePicked] = useState(isResume);
const aiTools = useAiTools();
const entry = aiTools.byId(props.tool);
// Claude and Codex keep their hand-written fields; every other catalog tool
// gets the plain form — cwd, prompt, resume and bypass when it has them.
const catalogTool = props.tool !== "claude" && props.tool !== "codex";
const canResume = !catalogTool || !!entry?.resume;
// Per run, never remembered: the form starts unticked every time it mounts.
const [bypass, setBypass] = useState(false);
const [router, setRouter] = useSessionRouter();
const routerSent = routerArg(entry?.routerCapable, router).router;
const canBypass = props.tool !== "claude" && !!entry?.bypass;
// Claude and Codex always take a model; a catalog tool only when its file names the flag.
const hasModel = !catalogTool || !!entry?.modelFlag;
// Keep "resume picked" in sync with the parent. Parent set a resume session
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
// is shown and the selection is visible.
useEffect(() => {
if (isResume && !resumePicked) setResumePicked(true);
}, [isResume, resumePicked]);
return (
<div className="rounded-xl border border-border bg-surface-1">
{/* Run settings and the fresh-vs-resume source picker share one row:
settings left, source right. They used to be two rows with a divider
between them, which spent a band of height on four labelled selects
that are each one line tall. The resume picker below is the only part
that still gets a row of its own, and only while resume is chosen. */}
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("fields.cwd")}>
{isResume && props.resumeSession ? (
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
<span className="truncate">{props.resumeSession.cwd}</span>
</div>
) : (
<div title={t("fields.cwdHint")}>
<CwdAutocomplete
value={props.cwd}
onChange={props.onCwdChange}
suggestions={props.cwdSuggestions}
/>
</div>
)}
</Field>
{/* A catalog tool takes no permission or effort flag, and a model only when it declares one. */}
{hasModel && (
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
)}
{/* A resume goes back to the router its session ran on (server-side). */}
{!props.resumeSession && (
<RouterPicker
tool={props.tool}
value={router}
onChange={setRouter}
wrap={(select) => <Field label={t("lanes:router.label")}>{select}</Field>}
/>
)}
{catalogTool ? null : props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
value={props.sandbox}
onChange={props.onSandboxChange}
options={[
{ value: "read-only", label: t("fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
]}
/>
</Field>
<Field label={t("fields.approval")}>
<Select<CodexApproval>
value={props.approval}
onChange={props.onApprovalChange}
options={[
{ value: "on-request", label: t("fields.approvalOnRequest") },
{ value: "never", label: t("fields.approvalNever") },
]}
/>
</Field>
</>
) : (
<Field label={t("fields.permissionMode")}>
<Select<PermissionMode>
value={props.permissionMode}
onChange={props.onPermissionModeChange}
options={[
{ value: "auto", label: t("fields.permissionAuto") },
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
{ value: "default", label: t("fields.permissionDefault") },
{ value: "plan", label: t("fields.permissionPlan") },
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
]}
/>
</Field>
)}
{!catalogTool && (
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
)}
</div>
{canResume && (
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
<Seg
active={!resumePicked}
label={t("resume.freshOption")}
title={t("resume.freshHint")}
onClick={() => {
setResumePicked(false);
props.onResumeSessionChange(null);
}}
/>
<Seg
active={resumePicked}
label={t("resume.resumeOption")}
title={t("resume.resumeHint")}
onClick={() => setResumePicked(true)}
/>
</div>
)}
</div>
{resumePicked && (
<div className="border-b border-border px-4 py-2 text-[11.5px]">
<div className="min-w-0">
{props.tool === "claude" ? (
<SessionPicker
selected={props.resumeSession}
onSelect={(s) => {
props.onResumeSessionChange(s);
// Pass the picked session explicitly: the parent's state
// update has not landed yet on this tick, so reading
// props.resumeSession here would resume nothing.
if (s && !props.busy)
handleStart(props, s, bypass, hasModel, entry?.defaultModel, routerSent);
}}
// Fall back to the typed cwd when no lane is selected — otherwise
// an unfiltered picker lists every session from every repo.
cwd={props.laneCwd || props.cwd.trim() || undefined}
/>
) : (
<button
onClick={() => props.onResumeLast(canBypass && bypass ? true : undefined)}
disabled={props.busy}
className="px-3 py-1.5 text-[11px] rounded-md bg-surface-2 border border-border hover:border-accent/50 text-fg-primary hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("resume.lastOption")}
</button>
)}
</div>
</div>
)}
{/* Prompt */}
<div className="px-4 py-3 border-b border-border">
<label className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5">
{t("fields.prompt")}
</label>
<PromptEditor
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() =>
handleStart(
props,
undefined,
canBypass && bypass,
hasModel,
entry?.defaultModel,
routerSent
)
}
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
rows={5}
slash={props.tool === "claude"}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
{t("hint.shortcut")} · / for slash commands · @ for file references
</div>
</div>
{canBypass && (
<div className="px-4 pb-3">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("fields.bypass")}
labelClassName="text-[11.5px] text-status-danger"
/>
{bypass && (
<div className="mt-2 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.bypassWarning", { tool: aiTools.label(props.tool) })}</span>
</div>
)}
</div>
)}
{props.tool === "codex" && !bypass && props.sandbox === "danger-full-access" && (
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.sandboxWarning")}</span>
</div>
)}
{props.tool === "claude" && props.permissionMode === "bypassPermissions" && (
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("hint.permissionWarning")}</span>
</div>
)}
{/* Footer: contextual run-state hint + run button */}
<div className="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3 text-[11px] min-w-0">
{atCap ? (
<span className="inline-flex items-center gap-1.5 text-status-warning">
<AlertCircle className="w-3.5 h-3.5" />
{t("concurrency.atCap", { max: 0 })}
</span>
) : null}
</div>
<button
onClick={() =>
handleStart(
props,
undefined,
canBypass && bypass,
hasModel,
entry?.defaultModel,
routerSent
)
}
disabled={
!props.binaryFound ||
// A resume needs no prompt — the session it continues is the input.
(!props.resumeSession && !props.prompt.trim()) ||
props.busy ||
atCap ||
(resumePicked && !props.resumeSession) ||
// Resume locks cwd to the original session, so allow it then;
// otherwise require a non-empty cwd so we never spawn at an
// invisible default.
(!props.resumeSession && !props.cwd.trim())
}
className="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{props.busy ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
{props.busy ? t("actions.starting") : t("actions.start")}
</button>
</div>
</div>
);
}
/** `session` overrides `props.resumeSession` for the auto-start fired straight
* out of the picker, before the parent's state has caught up. */
function handleStart(
props: RunSetupProps,
session?: Session,
bypass?: boolean,
hasModel?: boolean,
defaultModel?: string | null,
router?: string
) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
// Any catalog tool: none of Claude's or Codex's own flags apply.
const other = props.tool !== "claude" && !codex;
props.onStart({
laneId: props.laneId,
// A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined,
// A resume keeps the session's own model: the tool's default the form
// starts on is not a pick, so only a model the user changed is sent.
model:
!(other ? hasModel : true) || (resume && props.model === (defaultModel ?? ""))
? undefined
: props.model || undefined,
permissionMode: codex || other ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
effort: other ? undefined : props.effort || undefined,
resumeSessionId: codex || other ? undefined : resume?.id || undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
initialPrompt: resume ? undefined : props.prompt || undefined,
bypass: bypass || undefined,
// A resume names none: the server goes back to its session's own router.
router: resume ? undefined : router,
});
}
/** One segment of a two-way inline choice. The explanation rides on `title`
* instead of a hint line, which is what keeps the row to one line. */
function Seg({
active,
label,
title,
onClick,
}: {
active: boolean;
label: string;
title?: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-pressed={active}
className={`rounded px-2 py-0.5 font-medium transition-colors ${
active ? "bg-accent/20 text-accent" : "text-fg-secondary hover:text-fg-primary"
}`}
>
{label}
</button>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1">
{label}
</label>
{children}
</div>
);
}
// ── CWD autocomplete ────────────────────────────────────────────────── // ── CWD autocomplete ──────────────────────────────────────────────────
export function CwdAutocomplete({ export function CwdAutocomplete({
@@ -911,163 +475,6 @@ export function CwdAutocomplete({
// ── Model picker ────────────────────────────────────────────────────── // ── Model picker ──────────────────────────────────────────────────────
// ── Session picker (for resume) ───────────────────────────────────────
function SessionPicker({
selected,
onSelect,
cwd,
}: {
selected: Session | null;
onSelect: (s: Session | null) => void;
/** Restricts the list to sessions under this exact working directory - the
* lane a resume is started from should only offer that lane's own history. */
cwd?: string;
}) {
const { t } = useTranslation("run");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [sessions, setSessions] = useState<Session[] | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
// Lazily load sessions when the picker opens, and again whenever the lane
// (and so its cwd filter) changes - otherwise switching lanes would leave
// the picker showing the PREVIOUS lane's sessions until closed and reopened.
const fetchedForCwd = useRef<string | undefined>(undefined);
useEffect(() => {
if (!open) return;
if (sessions !== null && fetchedForCwd.current === cwd) return;
fetchedForCwd.current = cwd;
api.sessions
// `claude --resume` can only resume a Claude session; a Codex lane at the
// same directory records its own sessions there too. Filtered by the
// server, before its limit.
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd, tool: "claude" })
.then((r) => setSessions(r.sessions))
.catch(() => setSessions([]));
}, [open, cwd, sessions]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [open]);
const filtered = useMemo(() => {
if (!sessions) return [];
const q = query.toLowerCase().trim();
if (!q) return sessions;
return sessions.filter(
(s) =>
s.id.toLowerCase().includes(q) ||
(s.cwd || "").toLowerCase().includes(q) ||
(s.status || "").toLowerCase().includes(q)
);
}, [sessions, query]);
if (selected) {
return (
<div className="mt-2 rounded-lg border border-accent/30 bg-accent/5 px-3 py-2 flex items-start gap-2">
<RotateCcw className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
{t("resume.selectedBadge")}
</span>
<span className="font-mono text-[11px] text-fg-secondary truncate">{selected.id}</span>
</div>
<div className="font-mono text-[10px] text-fg-muted truncate mt-0.5">{selected.cwd}</div>
</div>
<button
onClick={() => onSelect(null)}
className="text-[10px] font-medium px-2 py-0.5 rounded border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary inline-flex items-center gap-1 flex-shrink-0"
>
<X className="w-3 h-3" />
{t("resume.clear")}
</button>
</div>
);
}
return (
<div ref={containerRef} className="relative mt-2">
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left rounded-md border border-dashed border-border bg-surface-2 hover:bg-surface-3 px-3 py-2 text-[11px] text-fg-secondary inline-flex items-center gap-2"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("resume.pickSession")}
<ChevronDown className="w-3 h-3 opacity-70 ml-auto" />
</button>
{open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 overflow-hidden">
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("resume.search")}
className="bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none w-full"
/>
</div>
<div className="max-h-72 overflow-auto py-1">
{sessions === null ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">…</div>
) : filtered.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("resume.noSessions")}</div>
) : (
filtered.map((s) => (
<button
key={s.id}
onClick={() => {
onSelect(s);
setOpen(false);
setQuery("");
}}
className="w-full text-left px-3 py-2 hover:bg-surface-3 transition-colors"
>
<div className="flex items-center gap-2 mb-0.5">
<span
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
s.status === "active"
? "bg-status-success/10 text-status-success border-status-success/30"
: s.status === "completed"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: s.status === "error"
? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-fg-secondary border-border"
}`}
>
{s.status}
</span>
{s.name?.trim() && (
<span className="text-[11px] text-fg-secondary truncate">
{s.name.trim()}
</span>
)}
<span className="font-mono text-[11px] text-fg-secondary truncate flex-shrink-0">
{s.id.slice(0, 12)}…
</span>
<span className="text-[10px] text-fg-muted ml-auto flex-shrink-0">
{new Date(s.started_at).toLocaleString()}
</span>
</div>
<div className="font-mono text-[10px] text-fg-muted truncate">{s.cwd}</div>
</button>
))
)}
</div>
</div>
)}
</div>
);
}
// The custom Select dropdown now lives in ../components/Select (shared with the // The custom Select dropdown now lives in ../components/Select (shared with the
// webhook settings form). Imported at the top of this file. // webhook settings form). Imported at the top of this file.
@@ -1079,10 +486,13 @@ export function ModelPicker({
value, value,
onChange, onChange,
tool, tool,
choices: fixed,
}: { }: {
value: string; value: string;
onChange: (s: string) => void; onChange: (s: string) => void;
tool: LaneTool; tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
choices?: ModelChoice[];
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
// The tool's own list (Codex's models cache, Claude's aliases named after // The tool's own list (Codex's models cache, Claude's aliases named after
@@ -1102,13 +512,15 @@ export function ModelPicker({
}, [tool]); }, [tool]);
const choices = useMemo<ModelChoice[]>(() => { const choices = useMemo<ModelChoice[]>(() => {
const inherit = { id: "", label: "" }; const inherit = { id: "", label: "" };
if (fixed) return [inherit, ...fixed];
if (fetched?.length) return [inherit, ...fetched]; if (fetched?.length) return [inherit, ...fetched];
return tool === "claude" ? RUN_MODEL_CHOICES : [inherit]; return tool === "claude" ? RUN_MODEL_CHOICES : [inherit];
}, [fetched, tool]); }, [fixed, fetched, tool]);
// "Custom" is selected when the value isn't one of the listed IDs — judged // "Custom" is selected when the value isn't one of the listed IDs — judged
// only once the list has arrived, so a listed default never flashes as custom. // only once the list has arrived, so a listed default never flashes as custom.
const knownIds = useMemo(() => choices.map((c) => c.id), [choices]); const knownIds = useMemo(() => choices.map((c) => c.id), [choices]);
const isCustom = fetched !== null && value !== "" && !knownIds.includes(value); const isCustom =
(fixed !== undefined || fetched !== null) && value !== "" && !knownIds.includes(value);
const [showCustom, setShowCustom] = useState(false); const [showCustom, setShowCustom] = useState(false);
// Reuse the shared Select so the Model dropdown renders identically to the // Reuse the shared Select so the Model dropdown renders identically to the
@@ -1,6 +1,8 @@
/** /**
* @file LaneConsolePane.test.tsx * @file LaneConsolePane.test.tsx
* @description Test suite for the LaneConsolePane component * @description Test suite for the LaneConsolePane component: a pane shows its
* lane's terminal or an empty state whose New session goes to the workspace
* home — a pane has no run form of its own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -91,7 +93,6 @@ function baseProps() {
codex: { found: true, path: "/usr/local/bin/codex" }, codex: { found: true, path: "/usr/local/bin/codex" },
antigravity: { found: true, path: "/usr/local/bin/agy" }, antigravity: { found: true, path: "/usr/local/bin/agy" },
}, },
cwdSuggestions: [],
activeRuns: { items: [] }, activeRuns: { items: [] },
wsConnected: true, wsConnected: true,
}; };
@@ -102,14 +103,21 @@ describe("LaneConsolePane", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("starts a run through /api/lanes/<id>/start, not /api/run/start", async () => { it("has no run form: an empty pane's New session goes to the workspace home", () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ const onNewSession = vi.fn();
lane: { ...LANE, run_id: "run-1" }, render(<LaneConsolePane {...baseProps()} onNewSession={onNewSession} />);
}); expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({ expect(screen.queryByRole("textbox")).toBeNull();
id: "run-1", fireEvent.click(screen.getByRole("button", { name: "New session" }));
expect(onNewSession).toHaveBeenCalledOnce();
expect(api.lanes.action).not.toHaveBeenCalled();
});
it("goes back to the home when a single pane's run exits, and on New run", async () => {
const live = {
id: "agenthub-lane-1",
laneId: 1, laneId: 1,
status: "running", status: "running" as const,
cwd: "/workspace/a", cwd: "/workspace/a",
model: null, model: null,
permissionMode: null, permissionMode: null,
@@ -118,81 +126,25 @@ describe("LaneConsolePane", () => {
sessionId: null, sessionId: null,
startedAt: null, startedAt: null,
promptPreview: null, promptPreview: null,
}); tool: "claude" as const,
};
render(<LaneConsolePane {...baseProps()} />); const onNewSession = vi.fn();
render(
// Set cwd and prompt <LaneConsolePane
const cwdInput = screen.getByPlaceholderText(/type to search/i); {...baseProps()}
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } }); activeRuns={{ items: [live] }}
onNewSession={onNewSession}
const promptTextarea = screen.getByPlaceholderText(/ask claude/i); homeOnExit
fireEvent.change(promptTextarea, { target: { value: "test prompt" } }); />
// Find and click the Run button (the main start button in RunSetup)
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", expect.any(Object))
); );
expect(api.run.start).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-1")
);
});
it("starts a run on the tool's default model from AI Tools settings", async () => {
__resetAiTools();
(api.aiTools.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
default: "claude",
tools: [{ id: "claude", label: "Claude Code", defaultModel: "sonnet", modelFlag: true }],
});
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ lane: LANE });
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/type to search/i), {
target: { value: "/workspace/a" },
});
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ model: "sonnet" })
)
);
__resetAiTools();
});
it("returns to the setup form when the pane exits, without a New run click", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "run-1" },
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-1",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(<LaneConsolePane {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await screen.findByTestId("terminal-view"); await screen.findByTestId("terminal-view");
fireEvent.click(screen.getByRole("button", { name: "New run" }));
expect(onNewSession).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId("fake-pane-exit")); fireEvent.click(screen.getByTestId("fake-pane-exit"));
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
expect(screen.getByPlaceholderText(/ask claude/i)).toBeInTheDocument(); expect(onNewSession).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
}); });
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => { it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
@@ -227,25 +179,12 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-2") expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "agenthub-lane-2")
); );
// A lane with no live run falls back to its setup form, not the previous // A lane with no live run falls back to an empty pane, not the previous
// lane's terminal. // lane's terminal.
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />); rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByTestId("terminal-view")).not.toBeInTheDocument());
}); });
it("adopts the lane's cwd when the lane list arrives after the pane mounted", async () => {
// Split view restores its pane lanes from localStorage, so a pane can
// render with a laneId before GET /api/lanes has answered. laneId never
// changes afterwards — only the resolved lane does.
const props = { ...baseProps(), lanes: [], defaultCwd: "/home/tester" };
const { rerender } = render(<LaneConsolePane {...props} />);
const cwdInput = screen.getByPlaceholderText(/type to search/i) as HTMLInputElement;
expect(cwdInput.value).toBe("/home/tester");
rerender(<LaneConsolePane {...props} lanes={[LANE]} />);
await waitFor(() => expect(cwdInput.value).toBe(LANE.cwd));
});
it("shows a lane dropdown only when showLaneSelector is true", () => { it("shows a lane dropdown only when showLaneSelector is true", () => {
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />); const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument(); expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
@@ -254,50 +193,12 @@ describe("LaneConsolePane", () => {
expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument(); expect(screen.queryByTestId("pane-lane-select")).not.toBeInTheDocument();
}); });
it("renders RunSetup when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => { it("renders the console body when laneId is null and showLaneSelector is false (layout-1, fresh install)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />); render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={false} />);
expect(screen.getByTestId("console-body")).toBeInTheDocument(); expect(screen.getByTestId("console-body")).toBeInTheDocument();
expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument(); expect(screen.queryByTestId("pane-empty")).not.toBeInTheDocument();
}); });
it("never pre-fills the prompt from the lane's pipeline, for either tool", async () => {
// Picking a pipeline used to write its driving slash command (claude) or a
// stage checklist (codex) into the box — an extra prompt sent on start.
const claudeLane = { ...LANE, pipeline_skill: "/agenthub:ship-feature" };
const codexLane = {
...LANE,
tool: "codex" as const,
pipeline_skill: "/agenthub:ship-feature-lane",
pipeline_nodes: [
{
id: "implement",
label: "Implement",
icon: "code",
gate: false,
state: "pending" as const,
},
{ id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const },
],
};
const props = { ...baseProps(), lanes: [claudeLane] };
const { rerender } = render(<LaneConsolePane {...props} />);
const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement;
await waitFor(() => expect(prompt.value).toBe(""));
// A typed requirement still survives a template switch.
fireEvent.change(prompt, { target: { value: "add a button" } });
rerender(
<LaneConsolePane
{...props}
lanes={[{ ...claudeLane, pipeline_skill: "/agenthub:ship-feature-lane" }]}
/>
);
await waitFor(() => expect(prompt.value).toBe("add a button"));
rerender(<LaneConsolePane {...props} lanes={[codexLane]} />);
await waitFor(() => expect(prompt.value).toBe("add a button"));
});
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => { it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
const HISTORY_ITEM = { const HISTORY_ITEM = {
id: "run-old", id: "run-old",
@@ -442,30 +343,6 @@ describe("LaneConsolePane", () => {
); );
}); });
it("starts into its own slot and shows the session the server opened", async () => {
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
lane: { ...LANE, run_id: "agenthub-lane-1" },
run_id: "agenthub-lane-1-1",
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
render(<LaneConsolePane {...baseProps()} slot={1} />);
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
await waitFor(() =>
expect(api.lanes.action).toHaveBeenCalledWith(
1,
"start",
expect.objectContaining({ slot: 1 })
)
);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
"data-run-id",
"agenthub-lane-1-1"
)
);
});
it("leaves runs other panes show out of Active runs", () => { it("leaves runs other panes show out of Active runs", () => {
render( render(
<MemoryRouter> <MemoryRouter>
@@ -1,15 +1,16 @@
/** /**
* @file RouterPicker.test.tsx * @file RouterPicker.test.tsx
* @description The per-session AI Router picker: hidden with only Native or on * @description The per-session AI Router picker: Native plus the saved
* a tool that cannot use a router, listing the saved profiles otherwise, * profiles, Native alone and disabled on a tool that cannot use a router,
* remembering the pick, and falling back to Native when the remembered * remembering the pick, and falling back to Native when the remembered
* profile is gone; plus `routerArg`, which never sends native. * profile is gone; plus `routerArg`, which never sends native.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
import type { AiRouter } from "../../../lib/api";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { RouterPicker, routerArg, useSessionRouter } from "../RouterPicker"; import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
@@ -31,8 +32,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}; };
}); });
const native = { name: "native", label: "Native", builtin: true }; const native = { name: "native", label: "Native", builtin: true } as AiRouter;
const nine = { name: "9router", label: "9Router", builtin: false }; const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
@@ -43,28 +44,32 @@ beforeEach(() => {
describe("RouterPicker", () => { describe("RouterPicker", () => {
it("lists the saved profiles for a tool that can use one", async () => { it("lists the saved profiles for a tool that can use one", async () => {
render(<RouterPicker tool="claude" value="9router" onChange={() => {}} />); render(
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
);
expect(await screen.findByText("9Router")).toBeInTheDocument(); expect(await screen.findByText("9Router")).toBeInTheDocument();
}); });
it("is hidden with only Native saved", async () => { it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] } as never); render(
render(<RouterPicker tool="claude" value="native" onChange={() => {}} />); <RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled()); );
expect(screen.queryByTestId("router-picker")).toBeNull(); const picker = screen.getByTestId("router-picker");
}); expect(picker.querySelector("button")).toBeDisabled();
expect(picker).not.toHaveTextContent("9Router");
it("is hidden on a tool with no endpoint to point at a router", async () => {
render(<RouterPicker tool="gemini" value="native" onChange={() => {}} />);
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
expect(screen.queryByTestId("router-picker")).toBeNull();
}); });
it("falls back to Native when the remembered profile is gone", async () => { it("falls back to Native when the remembered profile is gone", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
render(<RouterPicker tool="claude" value="deleted" onChange={onChange} />); render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native")); await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
}); });
it("useAiRouters reads the saved profiles once", async () => {
const { result } = renderHook(() => useAiRouters());
await waitFor(() => expect(result.current).toEqual([native, nine]));
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
});
}); });
describe("useSessionRouter / routerArg", () => { describe("useSessionRouter / routerArg", () => {
@@ -1,22 +1,18 @@
/** /**
* @file RunSetup.test.tsx * @file RunSetup.test.tsx
* @description Pins the props-only boundary of `RunSetup` after its move out of * @description The run-form pieces that outlived the pane's run form:
* `pages/Run.tsx` (where it was `ConfigCard`): every picker the panel owns — * `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* mode, prompt, cwd, model, permission mode, effort — reports its selection * open, `/` only where slash commands apply) and `ModelPicker` — a tool's own
* through the matching callback and nowhere else, `onStart` fires from the Run * list from the server, a fixed list (an AI Router's models) when given one,
* button, and a missing `claude` binary is surfaced purely from the * and Custom…, which is a sentinel, never a model id.
* `binaryFound` prop (the panel runs no probe of its own).
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { MemoryRouter } from "react-router-dom";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next"; import i18n from "i18next";
import { RunSetup } from "../RunSetup"; import { ModelPicker, PromptEditor } from "../RunSetup";
import type { CwdSuggestion } from "../../../lib/api";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
@@ -25,30 +21,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { return {
...actual, ...actual,
api: { api: {
aiRouters: { list: r({ items: [] }) },
aiTools: { aiTools: {
list: r({
default: "claude",
tools: [
{
id: "claude",
label: "Claude Code",
resume: false,
bypass: false,
modelFlag: true,
defaultModel: "sonnet",
},
{ id: "codex", label: "Codex", resume: false, bypass: true },
{
id: "antigravity",
label: "Antigravity",
resume: true,
bypass: true,
modelFlag: true,
},
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
}),
// Each tool's own model list, as GET /api/ai-tools/:id/models answers it. // Each tool's own model list, as GET /api/ai-tools/:id/models answers it.
models: vi.fn(async (id: string) => { models: vi.fn(async (id: string) => {
const lists: Record<string, { id: string; label: string }[]> = { const lists: Record<string, { id: string; label: string }[]> = {
@@ -57,15 +30,12 @@ vi.mock("../../../lib/api", async (importOriginal) => {
{ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" }, { id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
], ],
codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }], codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }],
antigravity: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
}; };
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null }; return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}), }),
}, },
run: { files: r({ items: [] }) }, run: { files: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
ccConfig: { ccConfig: {
file: r({ text: "" }),
commands: r({ commands: r({
items: [ items: [
{ {
@@ -84,386 +54,29 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}; };
}); });
const SUGGESTIONS: CwdSuggestion[] = [
{ kind: "home", path: "/Users/tester", label: "Home" },
{ kind: "recent", path: "/Users/tester/projects/other", label: "other" },
];
type Spies = ReturnType<typeof renderSetup>["spies"];
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
const spies = {
onPromptChange: vi.fn(),
onCwdChange: vi.fn(),
onModelChange: vi.fn(),
onPermissionModeChange: vi.fn(),
onEffortChange: vi.fn(),
onStart: vi.fn(),
onResumeSessionChange: vi.fn(),
onResumeFromHistory: vi.fn(),
onSandboxChange: vi.fn(),
onApprovalChange: vi.fn(),
onResumeLast: vi.fn(),
};
const utils = render(
<MemoryRouter>
<RunSetup
laneId={1}
prompt="do the thing"
cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS}
model=""
permissionMode="acceptEdits"
effort=""
binaryFound
busy={false}
activeRuns={null}
resumeSession={null}
runHistory={[]}
tool="claude"
sandbox="workspace-write"
approval="on-request"
{...spies}
{...overrides}
/>
</MemoryRouter>
);
return { ...utils, spies };
}
/** Open the `Select` whose trigger currently shows `currentLabel`, then pick
* the option labelled `optionLabel`. */
function pickFromSelect(currentLabel: string, optionLabel: string) {
fireEvent.click(screen.getByText(currentLabel));
fireEvent.click(screen.getByText(optionLabel));
}
/** Every callback except the named ones must stay untouched — a selection that
* leaks into a sibling prop is exactly the wiring bug a move can introduce. */
function onlyCalled(spies: Spies, ...called: (keyof Spies)[]) {
for (const [name, spy] of Object.entries(spies)) {
if (called.includes(name as keyof Spies)) continue;
expect(spy, `${name} should not have fired`).not.toHaveBeenCalled();
}
}
beforeEach(() => { beforeEach(() => {
i18n.changeLanguage("en"); i18n.changeLanguage("en");
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("RunSetup — selections report through callbacks", () => { function renderEditor(value: string, slash = true) {
it("reports the prompt from the editor", () => { const spies = { onChange: vi.fn(), onSubmit: vi.fn() };
const { spies } = renderSetup({ prompt: "" }); render(
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder")); <PromptEditor
fireEvent.change(box, { target: { value: "review the diff" } }); value={value}
expect(spies.onPromptChange).toHaveBeenCalledWith("review the diff"); placeholder="Ask anything…"
onlyCalled(spies, "onPromptChange"); slash={slash}
}); fileCwd="/Users/tester"
{...spies}
/>
);
return spies;
}
const box = () => screen.getByPlaceholderText("Ask anything…");
it("reports the cwd from typing and from a suggestion", () => { describe("PromptEditor — / and @ suggestions", () => {
const { spies } = renderSetup(); it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const input = screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")); const spies = renderEditor("");
fireEvent.change(input, { target: { value: "/tmp/pro" } });
expect(spies.onCwdChange).toHaveBeenCalledWith("/tmp/pro");
fireEvent.click(screen.getByText("/Users/tester/projects/other"));
expect(spies.onCwdChange).toHaveBeenLastCalledWith("/Users/tester/projects/other");
onlyCalled(spies, "onCwdChange");
});
it("reports the model from the picker, including a custom id", async () => {
const { spies } = renderSetup();
// The list is the tool's own, fetched from the server — aliases named
// after the newest version, then pinned versions — never a hardcoded copy.
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
// the free-text box it reveals is what reports.
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
expect(spies.onModelChange).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
target: { value: "claude-opus-5" },
});
expect(spies.onModelChange).toHaveBeenLastCalledWith("claude-opus-5");
onlyCalled(spies, "onModelChange");
});
it("reports the permission mode and the effort level", () => {
const { spies } = renderSetup();
pickFromSelect(i18n.t("run:fields.permissionAcceptEdits"), i18n.t("run:fields.permissionPlan"));
expect(spies.onPermissionModeChange).toHaveBeenCalledWith("plan");
pickFromSelect("Default (model decides)", "Medium");
expect(spies.onEffortChange).toHaveBeenCalledWith("medium");
onlyCalled(spies, "onPermissionModeChange", "onEffortChange");
});
it("fires onStart from the Run button", () => {
const { spies } = renderSetup();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledTimes(1);
onlyCalled(spies, "onStart");
});
});
describe("RunSetup — missing binary and other blocked states", () => {
/** The Run button, found by its label rather than by DOM position. */
function runButton(): HTMLButtonElement {
return screen.getByText(i18n.t("run:actions.start")).closest("button") as HTMLButtonElement;
}
it("disables Run when the claude binary was not found", () => {
renderSetup({ binaryFound: false });
expect(runButton().disabled).toBe(true);
});
it("enables Run when the binary is found and the form is complete", () => {
renderSetup();
expect(runButton().disabled).toBe(false);
});
it("still disables Run without a prompt or without a cwd", () => {
const { unmount } = renderSetup({ prompt: " " });
expect(runButton().disabled).toBe(true);
unmount();
const noCwd = renderSetup({ cwd: "" });
expect(runButton().disabled).toBe(true);
noCwd.unmount();
});
it("shows the Starting… label while busy", () => {
renderSetup({ busy: true });
expect(screen.getByText(i18n.t("run:actions.starting"))).toBeTruthy();
});
});
describe("RunSetup — resume picker scopes sessions to the selected lane", () => {
it("passes the lane's cwd as a filter and lists only that directory's sessions", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
renderSetup({ laneCwd: "/Users/tester/lane-a" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
await screen.findByText("/Users/tester/lane-a");
// The API call itself is what enforces the scope - the server filters by
// cwd, so the picker must never fetch without one when a lane is selected.
expect(api.sessions.list).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/Users/tester/lane-a" })
);
});
it("never offers a Codex session, which `claude --resume` cannot resume", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
renderSetup({ laneCwd: "/Users/tester/lane-a" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
await screen.findByText("/Users/tester/lane-a");
// Filtered by the server, before its limit: a busy Codex lane at the same
// directory cannot push Claude sessions off the page.
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }));
});
it("starts the resume immediately when a session is picked", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
expect(spies.onResumeSessionChange).toHaveBeenCalledWith(
expect.objectContaining({ id: "sess-in-lane" })
);
// No prompt typed, no Run click - the pick itself is the start, and it
// carries the session's own cwd rather than the form's.
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ resumeSessionId: "sess-in-lane", cwd: "/Users/tester/lane-a" })
);
});
it("sends no prompt when resuming, even with text in the box", async () => {
const { api } = await import("../../../lib/api");
vi.mocked(api.sessions.list).mockResolvedValue({
sessions: [
{ id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
],
total: 1,
limit: 100,
offset: 0,
} as never);
const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "leftover text" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
fireEvent.click(await screen.findByText("/Users/tester/lane-a"));
// The resumed session is the input; anything left in the box would be an
// extra turn the user never asked for.
expect(spies.onStart.mock.calls[0]?.[0]?.initialPrompt).toBeUndefined();
});
it("falls back to the typed cwd when no lane is selected", async () => {
const { api } = await import("../../../lib/api");
renderSetup({ laneCwd: undefined, cwd: "/Users/tester" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
await new Promise((r) => setTimeout(r, 0));
expect(api.sessions.list).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/Users/tester" })
);
});
it("lists everything when no lane is selected and cwd is empty", async () => {
const { api } = await import("../../../lib/api");
renderSetup({ laneCwd: undefined, cwd: "" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
await new Promise((r) => setTimeout(r, 0));
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ cwd: undefined }));
});
it("re-fetches with the new cwd when the selected lane changes", async () => {
const { api } = await import("../../../lib/api");
const { rerender } = renderSetup({ laneCwd: "/Users/tester/lane-a" });
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
await new Promise((r) => setTimeout(r, 0));
rerender(
<MemoryRouter>
<RunSetup
laneId={1}
prompt="do the thing"
cwd="/Users/tester"
cwdSuggestions={SUGGESTIONS}
model=""
permissionMode="acceptEdits"
effort=""
binaryFound
busy={false}
activeRuns={null}
resumeSession={null}
runHistory={[]}
laneCwd="/Users/tester/lane-b"
tool="claude"
sandbox="workspace-write"
approval="on-request"
onPromptChange={vi.fn()}
onCwdChange={vi.fn()}
onModelChange={vi.fn()}
onPermissionModeChange={vi.fn()}
onEffortChange={vi.fn()}
onStart={vi.fn()}
onResumeSessionChange={vi.fn()}
onResumeFromHistory={vi.fn()}
onSandboxChange={vi.fn()}
onApprovalChange={vi.fn()}
onResumeLast={vi.fn()}
/>
</MemoryRouter>
);
await new Promise((r) => setTimeout(r, 0));
expect(api.sessions.list).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/Users/tester/lane-b" })
);
});
});
describe("RunSetup — resume keeps the session's model", () => {
const session = { id: "sess-1", cwd: "/Users/tester" } as never;
it("sends no model when it is still the tool's default", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "sonnet", prompt: "" });
await screen.findByText(i18n.t("run:fields.model"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ resumeSessionId: "sess-1", model: undefined })
);
});
it("sends a model the user picked", async () => {
const { spies } = renderSetup({ resumeSession: session, model: "opus", prompt: "" });
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: "opus" }));
});
});
describe("RunSetup — per-tool fields", () => {
it("shows sandbox and approval instead of permission mode for a codex lane", () => {
renderSetup({ tool: "codex" });
expect(screen.getByText(/sandbox/i)).toBeInTheDocument();
expect(screen.getByText(/approval/i)).toBeInTheDocument();
expect(screen.queryByText(/permission mode/i)).not.toBeInTheDocument();
});
it("keeps permission mode for a claude lane", () => {
renderSetup({ tool: "claude" });
expect(screen.getByText(/permission mode/i)).toBeInTheDocument();
expect(screen.queryByText(/sandbox/i)).not.toBeInTheDocument();
});
it("sends codex fields on start", async () => {
const onStart = vi.fn();
const user = userEvent.setup();
renderSetup({ tool: "codex", onStart, prompt: "go" });
await user.click(screen.getByText(i18n.t("run:actions.start")));
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({ sandbox: "workspace-write", approval: "on-request" })
);
expect(onStart.mock.calls[0]?.[0]?.permissionMode).toBeUndefined();
});
it("calls onResumeLast when a codex lane's resume-last button is clicked", async () => {
const onResumeLast = vi.fn();
const user = userEvent.setup();
renderSetup({ tool: "codex", onResumeLast });
// Switch to resume mode
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
// Click the resume-last button
await user.click(screen.getByText(i18n.t("run:resume.lastOption")));
expect(onResumeLast).toHaveBeenCalled();
});
});
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(/ anything…$/);
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
fireEvent.change(box(), { target: { value: "/rev" } }); fireEvent.change(box(), { target: { value: "/rev" } });
// Both prefix matches; the shorter name ranks first. // Both prefix matches; the shorter name ranks first.
@@ -473,30 +86,30 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
expect.stringContaining("/review-pr"), expect.stringContaining("/review-pr"),
]); ]);
fireEvent.keyDown(box(), { key: "ArrowDown" }); fireEvent.keyDown(box(), { key: "ArrowDown" });
// Read for the directory the run will start in, not the server's own. // Read for the directory the session will start in, not the server's own.
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester"); expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester"); expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
fireEvent.keyDown(box(), { key: "Enter" }); fireEvent.keyDown(box(), { key: "Enter" });
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr "); expect(spies.onChange).toHaveBeenLastCalledWith("/review-pr ");
// Enter picked the suggestion; it did not send the prompt. // Enter picked the suggestion; it did not send the prompt.
expect(spies.onStart).not.toHaveBeenCalled(); expect(spies.onSubmit).not.toHaveBeenCalled();
}); });
it("suggests files under the cwd on @", async () => { it("suggests files under the folder on @", async () => {
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] }); vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
// The prompt is controlled by the page: put the cursor at the end of it. // The prompt is controlled by the caller: put the cursor at the end of it.
const { spies } = renderSetup({ prompt: "look at @ma" }); const spies = renderEditor("look at @ma");
(box() as HTMLTextAreaElement).setSelectionRange(11, 11); (box() as HTMLTextAreaElement).setSelectionRange(11, 11);
fireEvent.select(box()); fireEvent.select(box());
fireEvent.click(await screen.findByText("src/main.ts")); fireEvent.click(await screen.findByText("src/main.ts"));
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma"); expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts "); expect(spies.onChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
}); });
it("offers no slash commands on a codex lane", async () => { it("offers no slash commands where they do not apply", async () => {
renderSetup({ prompt: "", tool: "codex" }); renderEditor("", false);
fireEvent.change(box(), { target: { value: "/rev" } }); fireEvent.change(box(), { target: { value: "/rev" } });
await new Promise((r) => setTimeout(r, 20)); await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull(); expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
@@ -504,78 +117,40 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
}); });
}); });
describe("RunSetup — antigravity lanes", () => { describe("ModelPicker", () => {
it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => { it("lists the tool's own models, and Custom… reports only the typed id", async () => {
const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" }); const onChange = vi.fn();
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy(); render(<ModelPicker value="" onChange={onChange} tool="claude" />);
expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy(); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull(); fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
expect(onChange).toHaveBeenCalledWith("sonnet");
fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
expect(spies.onStart).toHaveBeenCalledWith( fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
expect.objectContaining({ expect(onChange).toHaveBeenCalledTimes(1);
model: "gemini-3.1-pro", fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
effort: undefined, target: { value: "claude-opus-5" },
permissionMode: undefined, });
}) expect(onChange).toHaveBeenLastCalledWith("claude-opus-5");
);
}); });
it("sends no model for a catalog tool whose CLI has no model flag", async () => {
const { spies } = renderSetup({ tool: "gemini", model: "stale", prompt: "go" });
await screen.findByPlaceholderText("Ask Gemini anything…");
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: undefined }));
});
});
describe("RunSetup — codex lanes", () => {
it("offers the models Codex itself lists", async () => { it("offers the models Codex itself lists", async () => {
renderSetup({ tool: "codex" }); render(<ModelPicker value="" onChange={() => {}} tool="codex" />);
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel"))); fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy(); expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
}); });
it("starts a fresh codex run instead of resuming the last one", () => { it("offers only a fixed list when given one (an AI Router's models)", async () => {
const { spies } = renderSetup({ tool: "codex" }); render(
fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); <ModelPicker
expect(spies.onStart).toHaveBeenCalledOnce(); value=""
expect(spies.onStart.mock.calls[0]?.[0].resumeLast).toBeUndefined(); onChange={() => {}}
}); tool="claude"
}); choices={[{ id: "glm-4.6", label: "glm-4.6" }]}
/>
describe("RunSetup — catalog tools", () => { );
it("offers a per-run bypass switch with a warning, and sends it", async () => { fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
const { spies } = renderSetup({ tool: "antigravity", prompt: "go" }); expect(await screen.findByText("glm-4.6")).toBeTruthy();
const box = await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") }); expect(screen.queryByText("Sonnet (latest · 5.5)")).toBeNull();
expect(box).not.toBeChecked();
expect(screen.queryByText(/without asking/)).toBeNull();
fireEvent.click(box);
expect(screen.getByText(/Antigravity will run every command/)).toBeTruthy();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBe(true);
// Resume is offered because the catalog gives the tool a resume flag.
expect(screen.getByText(i18n.t("run:resume.resumeOption"))).toBeTruthy();
});
it("leaves out resume and bypass for a tool with neither", async () => {
const { spies } = renderSetup({ tool: "gemini", prompt: "go" });
expect(await screen.findByPlaceholderText("Ask Gemini anything…")).toBeTruthy();
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBeUndefined();
});
it("gives Codex the switch but not Claude", async () => {
renderSetup({ tool: "codex" });
expect(await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeTruthy();
});
it("gives Claude no bypass switch (it has the bypassPermissions mode)", async () => {
renderSetup({ tool: "claude" });
await screen.findByPlaceholderText("Ask Claude Code anything…");
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
}); });
}); });
@@ -244,9 +244,11 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => { describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => { it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon"); const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "gemini", label: "Gemini" }} />); const { container, rerender } = render(
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
);
fireEvent.error(container.querySelector("img")!); fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("G")).toBeInTheDocument(); expect(screen.getByText("O")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />); rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy(); expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
}); });
+6 -1
View File
@@ -317,6 +317,10 @@
"pane.close": "Stop this pane's session", "pane.close": "Stop this pane's session",
"panel.files": "Files", "panel.files": "Files",
"panel.changes": "Changes", "panel.changes": "Changes",
"panel.analyst": "Analyst",
"replay.placeholder": "Message to continue this session…",
"replay.hint": "Sending resumes the session in a free pane.",
"replay.send": "Send",
"sessions.resume": "Resume", "sessions.resume": "Resume",
"resume.openPane": "Open pane", "resume.openPane": "Open pane",
"resume.hint": "Resume this session in a workspace pane — re-reads its whole transcript into context", "resume.hint": "Resume this session in a workspace pane — re-reads its whole transcript into context",
@@ -334,7 +338,7 @@
"tree.allTools": "All tools", "tree.allTools": "All tools",
"tree.newSession": "New session", "tree.newSession": "New session",
"tree.noSessions": "No sessions yet", "tree.noSessions": "No sessions yet",
"tree.transcriptHint": "No live pane — opens the transcript", "tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane", "tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a second pane",
"tree.renameSession": "Rename session", "tree.renameSession": "Rename session",
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)", "tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
@@ -346,6 +350,7 @@
"tree.archived_other": "{{count}} archived sessions", "tree.archived_other": "{{count}} archived sessions",
"tree.unarchive": "Unarchive", "tree.unarchive": "Unarchive",
"home.breadcrumb": "Workspaces", "home.breadcrumb": "Workspaces",
"home.ai": "AI",
"home.repoRoot": "Repository root", "home.repoRoot": "Repository root",
"home.mainBranch": "Main worktree's branch", "home.mainBranch": "Main worktree's branch",
"home.worktreeCount_one": "{{count}} worktree", "home.worktreeCount_one": "{{count}} worktree",
+2
View File
@@ -1,6 +1,8 @@
{ {
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
"actions": { "actions": {
"newRun": "New run", "newRun": "New run",
"newSession": "New session",
"send": "Send", "send": "Send",
"sending": "Sending…", "sending": "Sending…",
"start": "Run", "start": "Run",
+6 -1
View File
@@ -317,6 +317,10 @@
"pane.close": "Dừng phiên của ô này", "pane.close": "Dừng phiên của ô này",
"panel.files": "Tệp", "panel.files": "Tệp",
"panel.changes": "Thay đổi", "panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
"replay.placeholder": "Nhắn để tiếp tục session này…",
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
"replay.send": "Gửi",
"sessions.resume": "Tiếp tục", "sessions.resume": "Tiếp tục",
"resume.openPane": "Mở pane", "resume.openPane": "Mở pane",
"resume.hint": "Tiếp tục session này trong một pane của workspace — nạp lại toàn bộ transcript vào context", "resume.hint": "Tiếp tục session này trong một pane của workspace — nạp lại toàn bộ transcript vào context",
@@ -334,7 +338,7 @@
"tree.allTools": "Mọi tool", "tree.allTools": "Mọi tool",
"tree.newSession": "Session mới", "tree.newSession": "Session mới",
"tree.noSessions": "Chưa có session", "tree.noSessions": "Chưa có session",
"tree.transcriptHint": "Không còn pane — mở transcript", "tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai", "tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở pane thứ hai",
"tree.renameSession": "Đổi tên session", "tree.renameSession": "Đổi tên session",
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)", "tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
@@ -346,6 +350,7 @@
"tree.archived_other": "{{count}} session đã lưu trữ", "tree.archived_other": "{{count}} session đã lưu trữ",
"tree.unarchive": "Bỏ lưu trữ", "tree.unarchive": "Bỏ lưu trữ",
"home.breadcrumb": "Không gian làm việc", "home.breadcrumb": "Không gian làm việc",
"home.ai": "AI",
"home.repoRoot": "Thư mục gốc của repo", "home.repoRoot": "Thư mục gốc của repo",
"home.mainBranch": "Branch của worktree chính", "home.mainBranch": "Branch của worktree chính",
"home.worktreeCount_one": "{{count}} worktree", "home.worktreeCount_one": "{{count}} worktree",
+2
View File
@@ -1,6 +1,8 @@
{ {
"emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.",
"actions": { "actions": {
"newRun": "Lượt chạy mới", "newRun": "Lượt chạy mới",
"newSession": "Phiên mới",
"send": "Gửi", "send": "Gửi",
"sending": "Đang gửi…", "sending": "Đang gửi…",
"start": "Chạy", "start": "Chạy",
+76 -51
View File
@@ -44,7 +44,7 @@
* ----------------------------------------------------------------------------- */ * ----------------------------------------------------------------------------- */
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useLocation, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ArrowLeft, ArrowLeft,
@@ -58,6 +58,7 @@ import {
Maximize2, Maximize2,
FolderTree, FolderTree,
GitCompare, GitCompare,
ChartColumn,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
@@ -74,6 +75,7 @@ import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lane
import { AddLaneModal } from "../components/lanes/AddLaneModal"; import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel"; import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome"; import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal"; import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage"; import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage"; import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
@@ -163,7 +165,7 @@ function SplitLayoutToggle({
); );
} }
/** A split pane's one-line header: which lane, which slot, the run's model, /** A split pane's one-line header: the run's AI tool, which lane, which slot, the run's model,
* and — for slots 1–3 — a button that kills that pane's own tmux session * and — for slots 1–3 — a button that kills that pane's own tmux session
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */ * (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
function PaneHeader({ function PaneHeader({
@@ -171,11 +173,14 @@ function PaneHeader({
slot, slot,
runId, runId,
model, model,
tool,
}: { }: {
lane: Lane | null; lane: Lane | null;
slot: number; slot: number;
runId: string | null; runId: string | null;
model: string | null; model: string | null;
/** The tool the pane's run is on — a session's own, not the workspace's. */
tool: string | null;
}) { }) {
const { t } = useTranslation("lanes"); const { t } = useTranslation("lanes");
const tools = useAiTools(); const tools = useAiTools();
@@ -184,11 +189,8 @@ function PaneHeader({
data-testid={`pane-header-${slot}`} data-testid={`pane-header-${slot}`}
className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted" className="flex h-6 shrink-0 items-center gap-1.5 rounded border border-border bg-surface-2 px-2 text-[11px] text-fg-muted"
> >
{lane && ( {tool && (
<AiToolIcon <AiToolIcon tool={{ id: tool, label: tools.label(tool) }} className="h-3.5 w-3.5 !p-0" />
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
className="h-3.5 w-3.5 !p-0"
/>
)} )}
<span className="min-w-0 truncate font-medium text-fg-secondary"> <span className="min-w-0 truncate font-medium text-fg-secondary">
{lane ? lane.title || lane.cwd.split("/").pop() : ""} {lane ? lane.title || lane.cwd.split("/").pop() : ""}
@@ -216,33 +218,32 @@ function ConsoleArea({
splitView, splitView,
setLayout, setLayout,
binaryStatus, binaryStatus,
cwdSuggestions,
activeRuns, activeRuns,
externalSessions, externalSessions,
wsConnected, wsConnected,
defaultCwd,
onHasActiveRunChange, onHasActiveRunChange,
onLaneCreated, onLaneCreated,
onLaneIdChange, onLaneIdChange,
showToggle = true, showToggle = true,
attachRequest, attachRequest,
onNewSession,
}: { }: {
lanes: Lane[]; lanes: Lane[];
selectedLaneId: number | null; selectedLaneId: number | null;
splitView: SplitViewState; splitView: SplitViewState;
setLayout: (layout: SplitLayout) => void; setLayout: (layout: SplitLayout) => void;
binaryStatus: Record<string, { found: boolean; path: string | null }> | null; binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
cwdSuggestions: CwdSuggestion[];
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
externalSessions: Session[]; externalSessions: Session[];
wsConnected: boolean; wsConnected: boolean;
defaultCwd: string;
onHasActiveRunChange: (val: boolean) => void; onHasActiveRunChange: (val: boolean) => void;
onLaneCreated: (lane: Lane) => void; onLaneCreated: (lane: Lane) => void;
onLaneIdChange: (id: number | null) => void; onLaneIdChange: (id: number | null) => void;
showToggle?: boolean; showToggle?: boolean;
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */ /** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
attachRequest: { runId: string; nonce: string; pane: number } | null; attachRequest: { runId: string; nonce: string; pane: number } | null;
/** Back to the workspace home, where a new session is started. */
onNewSession?: () => void;
}) { }) {
// Every split pane shows the SELECTED lane, each in its own tmux session // Every split pane shows the SELECTED lane, each in its own tmux session
// (slot = pane index). Tracking what each pane shows lets the others leave // (slot = pane index). Tracking what each pane shows lets the others leave
@@ -280,12 +281,12 @@ function ConsoleArea({
onLaneIdChange={onLaneIdChange} onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated} onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus} binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns} activeRuns={activeRuns}
externalSessions={externalSessions} externalSessions={externalSessions}
wsConnected={wsConnected} wsConnected={wsConnected}
defaultCwd={defaultCwd}
onHasActiveRunChange={onHasActiveRunChange} onHasActiveRunChange={onHasActiveRunChange}
onNewSession={onNewSession}
homeOnExit
attachRequest={attachRequest?.pane === 0 ? attachRequest : null} attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
/> />
) : ( ) : (
@@ -301,6 +302,7 @@ function ConsoleArea({
slot={i} slot={i}
runId={paneRunIds[i] ?? null} runId={paneRunIds[i] ?? null}
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null} model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
tool={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.tool ?? null}
/> />
<LaneConsolePane <LaneConsolePane
lanes={lanes} lanes={lanes}
@@ -312,11 +314,10 @@ function ConsoleArea({
onLaneIdChange={onLaneIdChange} onLaneIdChange={onLaneIdChange}
onLaneCreated={onLaneCreated} onLaneCreated={onLaneCreated}
binaryStatus={binaryStatus} binaryStatus={binaryStatus}
cwdSuggestions={cwdSuggestions}
activeRuns={activeRuns} activeRuns={activeRuns}
externalSessions={externalSessions} externalSessions={externalSessions}
wsConnected={wsConnected} wsConnected={wsConnected}
defaultCwd={defaultCwd} onNewSession={onNewSession}
attachRequest={attachRequest?.pane === i ? attachRequest : null} attachRequest={attachRequest?.pane === i ? attachRequest : null}
/> />
</div> </div>
@@ -341,6 +342,10 @@ export function Workspace() {
// second pick of the same session a new request. // second pick of the same session a new request.
const location = useLocation(); const location = useLocation();
const runParam = searchParams.get("run"); const runParam = searchParams.get("run");
// `?session=` — an ENDED session picked in the sidebar or Recents: its
// conversation, with a prompt box whose message resumes it (SessionReplay).
const sessionParam = runParam ? null : searchParams.get("session");
const navigate = useNavigate();
// `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar // `&pane=<n>` — which split pane attaches it (Ctrl/Cmd+click in the sidebar
// asks for pane 1); pane 0 otherwise. // asks for pane 1); pane 0 otherwise.
const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3); const paneParam = Math.min(Math.max(Number(searchParams.get("pane")) || 0, 0), 3);
@@ -420,7 +425,6 @@ export function Workspace() {
{ found: boolean; path: string | null } { found: boolean; path: string | null }
> | null>(null); > | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]); const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [defaultCwd, setDefaultCwd] = useState<string>("");
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false); const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
// Split view state // Split view state
@@ -528,22 +532,7 @@ export function Workspace() {
.catch(() => undefined); .catch(() => undefined);
api.run api.run
.cwds() .cwds()
.then((r) => { .then((r) => setCwdSuggestions(r.items))
setCwdSuggestions(r.items);
// Pre-fill cwd with the user's home directory — a neutral default.
// Spawning in the dashboard's own cwd would make ad-hoc runs inherit
// this repo's project context (.claude/agents, skills, rules,
// CLAUDE.md, .mcp.json), which is almost never what an ad-hoc run
// wants and can bloat the initial request (issue #202). Fall back to
// the dashboard cwd when no home suggestion exists. The user can
// change it; we just don't want an invisible default.
const home = r.items.find((s) => s.kind === "home");
const dashboard = r.items.find((s) => s.kind === "dashboard");
const preferred = home || dashboard;
if (preferred) {
setDefaultCwd(preferred.path);
}
})
.catch(() => undefined); .catch(() => undefined);
}, []); }, []);
@@ -676,7 +665,11 @@ export function Workspace() {
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session — // A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
// the console, panes and Changes panel, which the home only hides (CSS), so // the console, panes and Changes panel, which the home only hides (CSS), so
// a live terminal never unmounts behind it. // a live terminal never unmounts behind it.
const home = !!currentLane && !runParam; const home = !!currentLane && !runParam && !sessionParam;
const replaying = !!currentLane && !!sessionParam;
// The session the Analyst button opens (`/sessions/:id`): the replayed one,
// or the one the opened pane holds.
const analystSessionId = sessionParam ?? openedRun?.sessionId ?? null;
const viewportLocked = const viewportLocked =
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full")); !home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
@@ -983,25 +976,42 @@ export function Workspace() {
currentLane ? "border-t border-border pt-3" : "" currentLane ? "border-t border-border pt-3" : ""
}`} }`}
> >
{replaying && (
<SessionReplay
lane={currentLane}
sessionId={sessionParam}
liveRuns={activeRuns?.items ?? []}
onResumed={(runId) =>
setSearchParams({ lane: String(currentLane.id), run: runId })
}
/>
)}
{/* `showToggle`: the lane header carries its own split toggle; with {/* `showToggle`: the lane header carries its own split toggle; with
no lane there is no header, so the console shows one instead. */} no lane there is no header, so the console shows one instead.
<ConsoleArea A replay only HIDES it: the console has one render site and a
lanes={lanes} live terminal must never unmount. */}
selectedLaneId={selectedLaneId} <div className={replaying ? "hidden" : "contents"}>
splitView={splitView} <ConsoleArea
setLayout={setLayout} lanes={lanes}
binaryStatus={binaryStatus} selectedLaneId={selectedLaneId}
cwdSuggestions={cwdSuggestions} splitView={splitView}
activeRuns={activeRuns} setLayout={setLayout}
externalSessions={externalSessions} binaryStatus={binaryStatus}
wsConnected={wsConnected} activeRuns={activeRuns}
defaultCwd={defaultCwd} externalSessions={externalSessions}
onHasActiveRunChange={setPaneHasActiveRun} wsConnected={wsConnected}
onLaneCreated={() => void refreshLanes()} onHasActiveRunChange={setPaneHasActiveRun}
onLaneIdChange={setSelectedLaneId} onLaneCreated={() => void refreshLanes()}
showToggle={!currentLane} onLaneIdChange={setSelectedLaneId}
attachRequest={attachRequest} showToggle={!currentLane}
/> attachRequest={attachRequest}
onNewSession={
currentLane
? () => setSearchParams({ lane: String(currentLane.id) })
: undefined
}
/>
</div>
</div> </div>
</section> </section>
@@ -1032,6 +1042,21 @@ export function Workspace() {
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</button> </button>
))} ))}
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
data-testid="panel-analyst"
disabled={!analystSessionId}
onClick={() =>
analystSessionId &&
navigate(`/sessions/${encodeURIComponent(analystSessionId)}`)
}
title={tLanes("panel.analyst")}
aria-label={tLanes("panel.analyst")}
className="flex h-7 w-7 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChartColumn className="h-4 w-4" />
</button>
<button <button
type="button" type="button"
data-testid="lane-changes-resize" data-testid="lane-changes-resize"
@@ -1,133 +0,0 @@
/**
* @file Run.defaultCwd.test.tsx
* @description The Run page must default its working directory to the user's
* HOME directory (a neutral spawn location) rather than the dashboard's own
* cwd, which would make ad-hoc runs inherit this repo's project context
* (.claude/agents, skills, rules, CLAUDE.md, .mcp.json) — issue #202. Falls
* back to the dashboard cwd only when no home suggestion is available.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import i18n from "i18next";
const HOME = { kind: "home", path: "/Users/tester", label: "Home" };
const DASHBOARD = {
kind: "dashboard",
path: "/Users/tester/projects/dashboard",
label: "Dashboard server",
};
const RECENT = { kind: "recent", path: "/Users/tester/projects/other", label: "other" };
// Mutable per-test cwd suggestion payload, read by the api mock below.
let cwdItems: Array<{ kind: string; path: string; label: string }> = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
...actual,
api: {
aiRouters: { list: r({ items: [] }) },
run: {
list: r({ runs: [], items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockImplementation(() => Promise.resolve({ items: cwdItems })),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running", messages: [], envelopes: [] }),
send: r({ messageId: "m-1" }),
kill: r({ ok: true }),
},
lanes: {
list: r({ lanes: [], counts: { total: 0, running: 0, needs_you: 0, dead: 0 } }),
pipelines: r({ pipelines: [] }),
},
ccConfig: {
commands: r({ items: [] }),
plugins: r({ plugins: [] }),
file: r({ content: "" }),
},
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: r({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace as Run } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function renderRun() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Run />
</MemoryRouter>
);
await settle();
return utils;
}
function cwdInput(): HTMLInputElement {
const placeholder = i18n.t("run:fields.cwdPlaceholder");
return screen.getByPlaceholderText(placeholder) as HTMLInputElement;
}
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Run page — default working directory (issue #202)", () => {
it("pre-fills cwd with the HOME suggestion, not the dashboard cwd", async () => {
cwdItems = [DASHBOARD, HOME, RECENT];
await renderRun();
expect(cwdInput().value).toBe(HOME.path);
});
it("falls back to the dashboard cwd when no home suggestion exists", async () => {
cwdItems = [DASHBOARD, RECENT];
await renderRun();
expect(cwdInput().value).toBe(DASHBOARD.path);
});
it("leaves cwd empty when there are no suggestions at all", async () => {
cwdItems = [];
await renderRun();
expect(cwdInput().value).toBe("");
});
});
@@ -1,180 +0,0 @@
/**
* @file Workspace.laneCwd.test.tsx
* @description The console's working directory must be the selected lane's own
* `cwd` — on first paint (the page auto-selects the first lane) and after every
* lane switch. A stale cwd is not cosmetic: `RunSetup` submits it verbatim, so
* the run would be started in the previously selected lane's folder.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, fireEvent } from "@testing-library/react";
import { MemoryRouter, useSearchParams } from "react-router-dom";
import i18n from "i18next";
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
// fixtures it reads have to be lifted with it.
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
const laneStub = (id: number, cwd: string) => ({
id,
title: `lane-${id}`,
cwd,
branch: null,
kind: "adopted",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "idle",
gate_decision: null,
ci_status: null,
needs_action: null,
links: {},
stages: {},
notes: null,
pipeline_name: "Default",
pipeline_nodes: [],
progress: 0,
stage_seconds: null,
last_event_seconds: null,
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
active_feature_id: null,
});
return {
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
LANE_A: laneStub(1, "/workspace/alpha"),
LANE_B: laneStub(2, "/workspace/beta"),
};
});
// 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);
return {
...actual,
api: {
run: {
list: r({ items: [] }),
history: r({ items: [] }),
binary: r({ found: true, path: "/usr/bin/claude" }),
cwds: r({ items: [HOME] }),
files: r({ items: [] }),
start: r({ id: "run-1", status: "running" }),
get: r({ id: "run-1", status: "running" }),
},
lanes: {
list: r({
lanes: [LANE_A, LANE_B],
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
}),
pipelines: r({ pipelines: [] }),
git: r({ available: false }),
runtime: r({ up: false, ports: {}, lastError: null }),
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
function cwdInput(): HTMLInputElement {
return screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")) as HTMLInputElement;
}
beforeEach(() => {
i18n.changeLanguage("en");
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Workspace — the console cwd follows the selected lane", () => {
it("shows the auto-selected first lane's cwd on load, not the home default", async () => {
render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
expect(cwdInput().value).toBe(LANE_A.cwd);
});
it("swaps the cwd when another lane is selected in the sidebar", async () => {
// Stands in for the sidebar's project tree: picking sets `?lane=`.
function Picker() {
const [, setParams] = useSearchParams();
return (
<>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`pick-${id}`}
onClick={() => setParams({ lane: String(id) })}
/>
))}
</>
);
}
render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
await settle();
fireEvent.click(screen.getByTestId("pick-2"));
await settle();
expect(cwdInput().value).toBe(LANE_B.cwd);
fireEvent.click(screen.getByTestId("pick-1"));
await settle();
expect(cwdInput().value).toBe(LANE_A.cwd);
});
});
+61 -131
View File
@@ -1,14 +1,14 @@
/** /**
* @file Workspace.test.tsx * @file Workspace.test.tsx
* @description Workspace page integration tests covering the lane-based run flow. * @description Workspace page integration tests covering the lane-based run flow.
* Verifies: lanes API contract, ensure before start flow, lane start vs /api/run, * Verifies: lanes API contract, the home vs session view, split panes, and
* and that no /stage endpoint is called. * that a pane has no run form of its own (a new session starts from the home).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react"; import { render, act, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { MemoryRouter, useSearchParams } from "react-router-dom"; import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint // Track all API calls made by recording the method and endpoint
@@ -57,6 +57,12 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
paneRunId: (id: number, slot: number) => paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`, slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
})); }));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({
SessionReplay: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-replay">{sessionId}</div>
),
}));
vi.mock("../../lib/api", async (importOriginal) => { vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => { const recordCall = (method: string, path: string) => {
@@ -323,6 +329,8 @@ async function renderWorkspace() {
beforeEach(() => { beforeEach(() => {
recordedCalls.length = 0; recordedCalls.length = 0;
// The selected workspace is remembered in localStorage; each test starts on the first.
window.localStorage.clear();
lanesToReturn = [ lanesToReturn = [
{ {
id: 1, id: 1,
@@ -382,133 +390,6 @@ describe("Workspace page — lane integration", () => {
expect(screen.queryByTestId("lane-card-1")).toBeNull(); expect(screen.queryByTestId("lane-card-1")).toBeNull();
expect(screen.queryByTestId("lane-card-1")).toBeNull(); expect(screen.queryByTestId("lane-card-1")).toBeNull();
}); });
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
const startButton = runButton;
await user.click(startButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify /api/run/start was NOT called
const runStartCalled = recordedCalls.some((c) => c.path === "/api/run/start");
expect(runStartCalled).toBe(false);
});
it("picking a cwd that no lane owns requests /api/lanes/ensure BEFORE start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to a path no lane owns
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/new/project/path");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(recordedCalls.some((c) => c.path === "/api/lanes/ensure")).toBe(true);
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify ensure was called BEFORE start
const ensureIndex = recordedCalls.findIndex((c) => c.path === "/api/lanes/ensure");
const startIndex = recordedCalls.findIndex(
(c) => c.path.includes("/api/lanes/") && c.path.includes("/start")
);
expect(ensureIndex).toBeLessThan(startIndex);
});
it("after a full start-then-message cycle, no recorded request URL matches /stage", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// After start, verify no /stage call was made
const stageCalls = recordedCalls.filter((c) => c.path.includes("/stage"));
expect(stageCalls).toHaveLength(0);
});
}); });
describe("Workspace layout", () => { describe("Workspace layout", () => {
@@ -722,6 +603,55 @@ describe("Workspace — home and session view", () => {
await userEvent.setup().click(screen.getByTestId("workspace-back-home")); await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument(); expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
}); });
it("opens an ended session's replay for ?session=, hiding (not unmounting) the console", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&session=s-ended"]}>
<Workspace />
<Routes>
<Route path="/sessions/:id" element={<div data-testid="analyst-page" />} />
<Route path="*" element={null} />
</Routes>
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("session-replay")).toHaveTextContent("s-ended");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// Analyst, beside Files and Changes, opens the session's analysis page.
await userEvent.setup().click(screen.getByTestId("panel-analyst"));
expect(await screen.findByTestId("analyst-page")).toBeInTheDocument();
});
it("has no Analyst target on a pane with no session yet", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.getByTestId("panel-analyst")).toBeDisabled();
});
it("has no run form in a pane: an empty pane's New session opens the home", async () => {
// The run opens in pane 1, so pane 0 is left with nothing to show.
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1&pane=1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByPlaceholderText(/absolute path/)).toBeNull();
const empty = screen.getByTestId("pane-no-run");
await userEvent.setup().click(within(empty).getByRole("button", { name: "New session" }));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
expect(recordedCalls.some((c) => c.path.includes("/start"))).toBe(false);
});
it("points the Changes panel at the opened run's worktree session", async () => { it("points the Changes panel at the opened run's worktree session", async () => {
localStorage.setItem("agenthub.workspace.changesOpen", "1"); localStorage.setItem("agenthub.workspace.changesOpen", "1");
const original = vi.mocked(api.run.list).getMockImplementation(); const original = vi.mocked(api.run.list).getMockImplementation();
@@ -5478,419 +5478,117 @@ exports[`screen snapshots > Run 1`] = `
class="flex min-h-0 flex-1 flex-col gap-2 " class="flex min-h-0 flex-1 flex-col gap-2 "
> >
<div <div
class="flex items-center gap-1.5" class="contents"
> >
<button
aria-label="1 pane"
aria-pressed="true"
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
title="1 pane"
type="button"
>
<svg
class="lucide lucide-square h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
</svg>
</button>
<button
aria-label="2 pane"
aria-pressed="false"
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
title="2 pane"
type="button"
>
<svg
class="lucide lucide-columns2 h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
<path
d="M12 3v18"
/>
</svg>
</button>
<button
aria-label="4 pane"
aria-pressed="false"
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
title="4 pane"
type="button"
>
<svg
class="lucide lucide-grid2x2 h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 3v18"
/>
<path
d="M3 12h18"
/>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
</svg>
</button>
</div>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<header
class="flex items-center gap-2.5"
>
<div
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
>
<svg
class="lucide lucide-play w-3.5 h-3.5 text-accent"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="6 3 20 12 6 21 6 3"
/>
</svg>
</div>
<div
class="flex min-w-0 flex-1 items-center gap-2"
>
<div
class="flex shrink-0 items-center gap-2"
>
<h1
class="text-base font-semibold text-fg-primary"
>
Run Claude
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
>
Streamed output, multi-turn, same hooks and analytics as your terminal.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button>
</header>
<div <div
class="rounded-xl border border-border bg-surface-1" class="flex items-center gap-1.5"
> >
<div <button
class="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3" aria-label="1 pane"
aria-pressed="true"
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
title="1 pane"
type="button"
> >
<div <svg
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4" class="lucide lucide-square h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
> >
<div> <rect
<label height="18"
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1" rx="2"
> width="18"
Working directory x="3"
</label> y="3"
<div
title="Absolute path. Defaults to the dashboard's own cwd."
>
<div
class="relative"
>
<div
class="relative"
>
<svg
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
/>
</svg>
<input
autocomplete="off"
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
placeholder="Type to search or paste an absolute path…"
spellcheck="false"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Model
</label>
<div
class="space-y-1.5"
>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Inherit from settings
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Permission mode
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
auto (recommended - asks only for risky actions)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div>
<label
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
Thinking effort
</label>
<div
class="relative"
>
<button
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
type="button"
>
<span
class="truncate"
>
Default (model decides)
</span>
<svg
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
>
<button
aria-pressed="true"
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
title="Start a fresh Claude Code session."
type="button"
>
New session
</button>
<button
aria-pressed="false"
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
type="button"
>
Resume existing session
</button>
</div>
</div>
<div
class="px-4 py-3 border-b border-border"
>
<label
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
>
Prompt
</label>
<div
class="relative"
>
<textarea
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
placeholder="Ask Claude anything…"
rows="5"
spellcheck="false"
/> />
</div> </svg>
<div </button>
class="mt-1 text-[10px] text-fg-muted" <button
aria-label="2 pane"
aria-pressed="false"
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
title="2 pane"
type="button"
>
<svg
class="lucide lucide-columns2 h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
> >
Enter to send · Shift+Enter for newline <rect
· / for slash commands · @ for file references height="18"
</div> rx="2"
</div> width="18"
<div x="3"
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap" y="3"
/>
<path
d="M12 3v18"
/>
</svg>
</button>
<button
aria-label="4 pane"
aria-pressed="false"
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
title="4 pane"
type="button"
>
<svg
class="lucide lucide-grid2x2 h-3.5 w-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 3v18"
/>
<path
d="M3 12h18"
/>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
</svg>
</button>
</div>
<div
class="flex min-h-0 flex-1 flex-col gap-5"
data-testid="console-body"
>
<header
class="flex items-center gap-2.5"
> >
<div <div
class="flex items-center gap-3 text-[11px] min-w-0" class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
/>
<button
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
> >
<svg <svg
class="lucide lucide-play w-3.5 h-3.5" class="lucide lucide-play w-3.5 h-3.5 text-accent"
fill="none" fill="none"
height="24" height="24"
stroke="currentColor" stroke="currentColor"
@@ -5905,8 +5603,78 @@ exports[`screen snapshots > Run 1`] = `
points="6 3 20 12 6 21 6 3" points="6 3 20 12 6 21 6 3"
/> />
</svg> </svg>
Run </div>
<div
class="flex min-w-0 flex-1 items-center gap-2"
>
<div
class="flex shrink-0 items-center gap-2"
>
<h1
class="text-base font-semibold text-fg-primary"
>
Run Claude
</h1>
<span
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
>
<span
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
/>
Live
</span>
</div>
<p
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
>
Streamed output, multi-turn, same hooks and analytics as your terminal.
</p>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
disabled=""
>
<svg
class="lucide lucide-list-ordered w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12h11"
/>
<path
d="M10 18h11"
/>
<path
d="M10 6h11"
/>
<path
d="M4 10h2"
/>
<path
d="M4 6h1v4"
/>
<path
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
/>
</svg>
Active runs
</button> </button>
</header>
<div
class="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
data-testid="pane-no-run"
>
<p>
No session in this pane. Start one from the workspace's prompt box.
</p>
</div> </div>
</div> </div>
</div> </div>
+9 -5
View File
@@ -150,8 +150,8 @@ it a `cwd` nested inside another workspace returns that parent workspace (`creat
false`); with it the nested directory gets a workspace of its own. The Add-workspace false`); with it the nested directory gets a workspace of its own. The Add-workspace
modal sends it, and says so when the directory already has a workspace. An id no template defines returns `400 EBADPIPELINE`. modal sends it, and says so when the directory already has a workspace. An id no template defines returns `400 EBADPIPELINE`.
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this workspace's `tool` (an AI Tools catalog id, default `"claude"`) is the CLI this workspace's
runs will spawn. Immutable once the workspace is created; an attempt to update it on sessions start on unless a start names another (`POST /api/lanes/:id/start` `tool`). Immutable once the workspace is created; an attempt to update it on
an existing workspace returns `400 EBADTOOL`. The tool choice appears as an immutable an existing workspace returns `400 EBADTOOL`. The tool choice appears as an immutable
badge on the workspace card (Codex workspaces only). badge on the workspace card (Codex workspaces only).
@@ -228,8 +228,8 @@ existing `lane_update` message when the workspace becomes `idle` or `failed`.
} }
``` ```
`tool` (`"claude"` or `"codex"`, default `"claude"`) is the CLI this workspace's `tool` (an AI Tools catalog id, default `"claude"`) is the CLI this workspace's
runs will spawn. Immutable once the workspace is created; an attempt to update it on sessions start on unless a start names another (`POST /api/lanes/:id/start` `tool`). Immutable once the workspace is created; an attempt to update it on
an existing workspace returns `400 EBADTOOL`. an existing workspace returns `400 EBADTOOL`.
`sourceRepo` must exist, be absolute, and be a git repository with at least `sourceRepo` must exist, be absolute, and be a git repository with at least
@@ -266,7 +266,11 @@ Starts a new run in the workspace's working directory, or attaches an existing i
} }
``` ```
All fields are optional. The tool (CLI invoked) comes from the workspace itself, never the request — **a client cannot override a workspace's tool**. If the workspace is a Codex workspace, three fields control Codex-specific behavior: All fields are optional.
- **`tool`** — the AI tool THIS session runs on, an AI Tools catalog id (only an id; the command, args and env still come from the catalog file). Omitted = the workspace's own tool. It must be known (`400 EBADTOOL`) and enabled (`400 EDISABLED`); the workspace's own tool is never re-checked, so a workspace on a tool disabled since keeps starting when `tool` is omitted. A resume (`resumeSessionId`, or `resume`) ignores `tool` and keeps the tool its session ran on (`sessions.tool`). The workspace's own `tool` column is unchanged. The run is recorded with its tool (`dashboard_runs.tool`, the run handle's `tool`).
When the session's tool is Codex, three fields control Codex-specific behavior:
- **`sandbox`** — one of `read-only`, `workspace-write`, or `danger-full-access`, or omitted/empty for no sandbox. Passed to `codex -s <value>`. - **`sandbox`** — one of `read-only`, `workspace-write`, or `danger-full-access`, or omitted/empty for no sandbox. Passed to `codex -s <value>`.
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`. - **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
+9 -13
View File
@@ -87,20 +87,20 @@ terminal with `agenthub lanes add --pipeline <id>`. *Model endpoint* is the one
per-workspace extra the form still asks for, and it sits in the form itself rather per-workspace extra the form still asks for, and it sits in the form itself rather
than behind a disclosure — a disclosure around a single field only hid it. than behind a disclosure — a disclosure around a single field only hid it.
A workspace has no AI Router of its own: each NEW session picks one. The **AI Router** select beside the model picker — in the workspace home's prompt box and in a pane's run form — lists the profiles saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it is hidden when only Native exists or the workspace's tool cannot use a router (only Claude and Codex can), and it remembers the last pick (`agenthub.session.router`). The start sends `router: "<name>"`; a Claude pane then runs with `--settings=<data dir>/router-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two routers, a worktree session gets its router too, and switching never touches a running session. A resume goes back to the router its session last ran on (`dashboard_runs.router`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's router into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Router profiles. A workspace has no AI Router of its own: each NEW session picks one. The **AI Router** select in the workspace home's prompt box lists the profiles saved in Settings › AI Router (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a router (only Claude and Codex can), and it remembers the last pick (`agenthub.session.router`). The start sends `router: "<name>"`; a Claude pane then runs with `--settings=<data dir>/router-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two routers, a worktree session gets its router too, and switching never touches a running session. A resume goes back to the router its session last ran on (`dashboard_runs.router`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's router into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Router profiles.
Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing. Both modes' path field has a **Browse** button next to it, opening a small folder browser (`GET /api/lanes/browse?path=<abs>`) instead of a native OS picker — a browser cannot hand a web page an absolute filesystem path from a native dialog, so this dashboard (local-first, server and browser on the same machine) lists directories server-side instead: click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field. Git repos are marked in the listing.
## Workspace tool: Claude or Codex ## Workspace tool: Claude or Codex
A workspace is driven by a tool: **Claude** (default) or **Codex**, chosen in the "+ Add workspace" modal and immutable once the workspace exists. A workspace's tool determines which CLI is spawned when a run starts. A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select, first of its fields (AI → AI Router → Model → Permission → Effort), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session. - **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
- **`codex` (experimental)** — `codex` binary is invoked instead. The Run Setup panel shows fields for model, sandbox, and approval mode instead of the Claude session picker, and offers "Resume last Codex run" instead of picking a session. Resume uses `codex resume --last`. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.) - **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
### How Codex arguments map ### How Codex arguments map
The Run Setup panel's fields map directly onto `codex` command-line arguments: The prompt box's fields map directly onto `codex` command-line arguments:
- **Model** → `codex -m <model>` (e.g. `codex -m claude-opus-5`) - **Model** → `codex -m <model>` (e.g. `codex -m claude-opus-5`)
- **Sandbox** (optional) → `-s <mode>` where mode is one of `read-only`, `workspace-write`, or `danger-full-access` - **Sandbox** (optional) → `-s <mode>` where mode is one of `read-only`, `workspace-write`, or `danger-full-access`
@@ -391,9 +391,9 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers. - **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. - **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens its read-only transcript at `/sessions/<id>`; resuming it is a separate, explicit action there, because a resume re-reads the whole transcript into context. - **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES pane 0 to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. Ctrl/Cmd+click adds `&pane=1`: the session view attaches pane 1 instead, widening a one-pane layout to two once for that request. Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace"). - **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box with the tool's model picker (and Claude's permission mode) whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its read-only transcript, exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it. - **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the ONE place a session starts (a pane has no run form) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the home's prompt box has a **Worktree** checkbox (Claude workspaces in a git repo), and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed. - **A session in its own worktree** — Claude Desktop's worktree toggle: the home's prompt box has a **Worktree** checkbox (Claude workspaces in a git repo), and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly. - **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows today's console, split panes and the Files / Changes panel, with pane 0 attached to that run; the header's back arrow returns to the home. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests. - **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
@@ -402,18 +402,14 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation. - **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
- **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) and the split-view toggle. The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first. - **Workspace-detail header row** — one row, and every per-workspace control lives on it: liveness + status, the workspace's name (read-only here — renaming is on the sidebar row; no "Workspace 23 ·" prefix — the id is on the row), time-on-stage, the pipeline picker, the Model (AI Router) select, the feature picker (time-on-stage and both pickers only while a pipeline template is installed; there are no stage chips here — the declared stage and the `auto: <stage>` detection both render in the PipelineMap directly below, so the chips were a second, smaller copy of the same two facts), and on the right the **Tools** menu (mcp sync / sync-base check / create-update readme, hidden when the workspace declares no runtime profile) and the split-view toggle. The detail block under it (the workspace card, the PipelineMap, the proof gallery) is always shown — there is no info toggle any more — and Delete lives in the sidebar row menu. Tools sits here rather than in Settings because every one of its actions runs against one workspace's `cwd` — in Settings it would need a workspace picker just to say which. **Create/update readme file** (Claude workspaces only — Codex has no slash commands) starts the workspace's terminal run with `/sg-all:sg-md-writing` as its first message, so the workspace opens already writing/refreshing its `CLAUDE.md`/`AGENTS.md` set; it needs the `sg-all` plugin installed in Claude Code, and without it the session just reports an unknown command. It used to be a checkbox in the Add-workspace modal: creating a workspace and writing its docs are separate decisions, and the second is worth re-running long after the first.
- **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states"). - **Detail panel** — the selected workspace's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment Hidden while no pipeline template is installed (see "Pipeline stages and the five node states").
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click. - **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first. A pane has **no run form of its own**: a new session starts only from the workspace home's prompt box. **New run** under the terminal goes back to the home, and so does a single pane whose process ends (a double Ctrl+C out of `claude`, `exit`, a crash); a split pane with nothing to show offers **New session**, which opens the home too.
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one. - **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the sidebar's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). In split view each pane carries a one-line header — tool icon, workspace name, `slot N`, the run's model — and panes 1–3 a close button that kills only that pane's own session (`DELETE /api/run/agenthub-lane-<id>-<slot>`); slot 0 has none. A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane workspace id from an older build is ignored. - **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the sidebar's selection — there is no per-pane workspace picker — and each pane owns its own tmux session on that workspace: pane 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). In split view each pane carries a one-line header — the icon of the AI tool its run is on, workspace name, `slot N`, the run's model — and panes 1–3 a close button that kills only that pane's own session (`DELETE /api/run/agenthub-lane-<id>-<slot>`); slot 0 has none. A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane workspace id from an older build is ignored.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner. - **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`. Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
**A console pane follows the workspace it shows.** Selecting another workspace on the workspace list swaps every pane's cwd, run history and terminal over to the new workspace. If the new workspace already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane shows that workspace's setup form. Nothing of the previous workspace (a half-typed prompt, its terminal) carries over. **A console pane follows the workspace it shows.** Selecting another workspace on the workspace list swaps every pane's cwd, run history and terminal over to the new workspace. If the new workspace already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane is empty. Nothing of the previous workspace (a half-typed prompt, its terminal) carries over.
The **working directory** field tracks the selected workspace's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (the workspace list loads after the page mounts), and its workspace id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous workspace or from the home default would start the run in the wrong folder. The home suggestion is used only while no workspace is selected at all.
**Picking a session to resume starts it right away.** In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — `POST /api/lanes/:id/start` with that `resumeSessionId` and the session's own `cwd` — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. A resume also **sends no prompt at all** — whatever is left in that box is dropped rather than fired as an extra turn into the session being continued. Type into the tmux terminal once it's attached.
### Active runs list ### Active runs list
+39
View File
@@ -1318,4 +1318,43 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
assert.equal(after.run_id, null); assert.equal(after.run_id, null);
assert.equal(after.status, "idle"); assert.equal(after.status, "idle");
}); });
it("starts a session on the AI tool the request names, not the workspace's", async () => {
const lane = await adoptedLane("session-tool");
assert.equal(lane.tool, "claude");
// A fake `codex` on PATH, so the pane runs this script, not a real CLI.
const bin = path.join(ROOT, "session-tool-bin");
fs.mkdirSync(bin, { recursive: true });
fs.writeFileSync(
path.join(bin, "codex"),
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
);
fs.chmodSync(path.join(bin, "codex"), 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
try {
const started = await request("POST", `/api/lanes/${lane.id}/start`, {
prompt: "hi",
tool: "codex",
});
assert.equal(started.status, 200, JSON.stringify(started.body));
const run = require("../lib/dashboard-runs").getRun(started.body.run_id);
assert.equal(run.tool, "codex");
// The workspace's own tool is untouched.
assert.equal(started.body.lane.tool, "claude");
} finally {
await request("POST", `/api/lanes/${lane.id}/stop`, {});
process.env.PATH = originalPath;
}
});
it("refuses a start naming a tool the catalog does not know", async () => {
const lane = await adoptedLane("session-tool-bad");
for (const tool of ["no-such-cli", 42]) {
const r = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "hi", tool });
assert.equal(r.status, 400, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EBADTOOL");
}
assert.equal(require("../lib/lanes").getLane(lane.id).run_id, null);
});
}); });
+21 -4
View File
@@ -1808,6 +1808,26 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
}); });
} }
} }
// The SESSION's AI tool: a resume keeps the CLI its session ran on
// (`sessions.tool`); a new session takes the catalog id the request
// names — known and enabled, only an id, never a command — and the
// workspace's own tool otherwise.
const resumeId = body.resumeSessionId || (body.resume ? lane.session_id : null);
let sessionTool = null;
if (resumeId) {
sessionTool =
db.prepare("SELECT tool FROM sessions WHERE id = ?").get(resumeId)?.tool || null;
} else if (body.tool !== undefined) {
try {
if (typeof body.tool !== "string") {
throw Object.assign(new Error("tool must be a string"), { code: "EBADTOOL" });
}
lanesLib.validateTool(body.tool); // EBADTOOL, EDISABLED
} catch (err) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
sessionTool = body.tool;
}
// Overwriting run_id while its child is alive orphans that child: a later // Overwriting run_id while its child is alive orphans that child: a later
// reset would kill and await only the RECORDED run, then `git clean -fd` // reset would kill and await only the RECORDED run, then `git clean -fd`
// the directory the orphan is still writing into. Stop the first run before starting a // the directory the orphan is still writing into. Stop the first run before starting a
@@ -1834,10 +1854,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
inWorktree: !!resumeWorktree, inWorktree: !!resumeWorktree,
// A NEW session in a worktree of its own (`claude --worktree`). // A NEW session in a worktree of its own (`claude --worktree`).
worktree: body.worktree === true && !body.resumeSessionId, worktree: body.worktree === true && !body.resumeSessionId,
// The tool is the LANE's fact, never the request's: a client that tool: sessionTool || current.tool || "claude",
// asked for the other CLI would otherwise get a pane that doesn't
// match the lane's badge, its history, or its binary probe.
tool: current.tool || "claude",
// The SESSION's AI Router, by name; a resume that names none goes // The SESSION's AI Router, by name; a resume that names none goes
// back to the one its session last ran on. // back to the one its session last ran on.
router: router: