feat(workspace): AI tool per session, one place to start a session, ended sessions open like Claude Desktop
- A session picks its own AI tool: `tool` on POST /api/lanes/:id/start (a catalog id, known + enabled, else 400 EBADTOOL/EDISABLED); a resume keeps the tool its session ran on (`sessions.tool`). The workspace's `lanes.tool` is now only the default. - The workspace home's prompt box is the one place a session starts. Its fields run in order: AI → AI Router (Native or a profile) → Model (the tool's own list, or the router's models) → Permission (Claude mode / Codex sandbox+approval / catalog bypass) → Effort. - The pane's run form (RunSetup) is gone: New run, a single pane's exit and an empty split pane's New session all go to the home. RunSetup.tsx keeps PromptEditor, CwdAutocomplete and ModelPicker (new `choices` prop). - The tool icon moved from the sidebar's workspace row to each session row (right edge, `session.tool`); the split-pane header shows the run's tool. - An ended session opens in its workspace (`&session=`, SessionReplay.tsx): its conversation read-only plus a prompt box; only a SENT message resumes it (resumeSessionId + prompt, first free slot). The console is only hidden behind it. - Analyst button beside Files/Changes in the right panel opens /sessions/:id for the opened session. - Gemini CLI ships its own icon (client/public/ai-tools/gemini.svg, from zonalogo.com). - RouterPicker always shows (Native disabled for tools without router support); useAiRouters() exported. - Tests: server start-with-tool cases; SessionReplay, home field order/tool/ router models, empty pane, Analyst; dropped the cwd-field suites (Run.defaultCwd, Workspace.laneCwd). Docs: CLAUDE.md rules, API.md, LANES.md, README, ARCHITECTURE, INSTALL.
This commit is contained in:
@@ -0,0 +1,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 |
@@ -14,6 +14,7 @@ import { api, type AiTool } from "../lib/api";
|
||||
const BUNDLED: Record<string, string | [string, string]> = {
|
||||
claude: "/ai-tools/claude.svg",
|
||||
codex: "/ai-tools/codex.svg",
|
||||
gemini: "/ai-tools/gemini.svg",
|
||||
openclaw: "/ai-tools/openclaw.svg",
|
||||
hermes: ["/ai-tools/hermes-black.svg", "/ai-tools/hermes-white.svg"],
|
||||
};
|
||||
|
||||
@@ -35,10 +35,10 @@ import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
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 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() {
|
||||
@@ -240,7 +240,7 @@ export function WorkspaceTree() {
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
selected={selected.lane === lane.id && !selected.run}
|
||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
@@ -262,6 +262,7 @@ export function WorkspaceTree() {
|
||||
// A workspace found by its name shows all of its sessions.
|
||||
query={q && nameHit(lane) ? "" : query}
|
||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
||||
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
||||
hidden={!visible(lane)}
|
||||
/>
|
||||
|
||||
@@ -230,7 +230,7 @@ describe("WorkspaceTree", () => {
|
||||
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();
|
||||
renderTree();
|
||||
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"
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,14 @@
|
||||
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
|
||||
* Claude Desktop project page: the name, a prompt box that starts a NEW
|
||||
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
|
||||
* then opens it (`&run=<runId>`), the workspace's Recents — the sessions it
|
||||
* then opens it (`&run=<runId>`) — the ONE place a session is started, its
|
||||
* fields in order: AI (the session's own tool, the workspace's by default),
|
||||
* AI Router (Native or a profile), Model (the tool's own list, or the
|
||||
* router's models), Permission (Claude's mode, Codex's sandbox/approval, a
|
||||
* catalog tool's bypass) and Effort — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
|
||||
* then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its transcript, nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
@@ -13,11 +17,12 @@
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArchiveRestore,
|
||||
ShieldAlert,
|
||||
FileText,
|
||||
Folder,
|
||||
GitBranch,
|
||||
@@ -29,8 +34,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
RUN_EFFORT_CHOICES,
|
||||
type CodexApproval,
|
||||
type CodexSandbox,
|
||||
type EffortLevel,
|
||||
type LaneMemoryFile,
|
||||
type LaneSourcesView,
|
||||
type ModelChoice,
|
||||
type PermissionMode,
|
||||
type RunHandle,
|
||||
} from "../../lib/api";
|
||||
@@ -39,7 +49,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelPicker, PromptEditor } from "../run/RunSetup";
|
||||
import { RouterPicker, routerArg, useSessionRouter } from "../run/RouterPicker";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
||||
import { Select } from "../Select";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
@@ -74,6 +85,18 @@ function fmtBytes(n: number | null): string {
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** One labelled field of the prompt box's row. */
|
||||
function Field({ label, width, children }: { label: string; width: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className={width}>
|
||||
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HomeProps = Parameters<typeof HomeBody>[0];
|
||||
|
||||
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
||||
@@ -98,17 +121,48 @@ function HomeBody({
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const claude = lane.tool === "claude";
|
||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||
const [tool, setTool] = useState(lane.tool);
|
||||
const entry = aiTools.byId(tool);
|
||||
const claude = tool === "claude";
|
||||
const codex = tool === "codex";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
// The tool's default model, also when the catalog arrives after mount.
|
||||
const defaultModel = aiTools.byId(lane.tool)?.defaultModel ?? "";
|
||||
const [router, setRouter] = useSessionRouter();
|
||||
const routers = useAiRouters();
|
||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
||||
const routerCapable = entry?.routerCapable ?? (claude || codex);
|
||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||
const profile = routerCapable
|
||||
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
|
||||
: null;
|
||||
const routerModels: ModelChoice[] | undefined = profile
|
||||
? [
|
||||
...new Set(
|
||||
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
||||
Boolean
|
||||
)
|
||||
),
|
||||
].map((id) => ({ id, label: id }))
|
||||
: undefined;
|
||||
// The tool's default model (also when the catalog arrives after mount); on
|
||||
// a router, the router's own default. A new tool or router drops the pick.
|
||||
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
||||
const [model, setModel] = useState(defaultModel);
|
||||
useEffect(() => setModel(defaultModel), [defaultModel]);
|
||||
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||||
// model only when its file names the flag, an effort never.
|
||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||
const [mode, setMode] = useState<PermissionMode>("auto");
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
const [bypass, setBypass] = useState(false);
|
||||
const canBypass = !claude && !!entry?.bypass;
|
||||
const [effort, setEffort] = useState<EffortLevel>("");
|
||||
const hasEffort = claude || codex;
|
||||
// Claude Desktop's worktree toggle: the new session gets a git worktree of
|
||||
// its own (`claude --worktree`), made and cleaned up by Claude Code.
|
||||
const [inWorktree, setInWorktree] = useState(false);
|
||||
const [router, setRouter] = useSessionRouter();
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
@@ -230,10 +284,16 @@ function HomeBody({
|
||||
const r = await api.lanes.action(lane.id, "start", {
|
||||
slot,
|
||||
prompt: prompt.trim(),
|
||||
...(model ? { model } : {}),
|
||||
// Only a different tool is named: the workspace's own keeps starting
|
||||
// even after it was disabled in Settings.
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...routerArg(routerCapable, router),
|
||||
...(hasModel && model ? { model } : {}),
|
||||
...(claude ? { permissionMode: mode } : {}),
|
||||
...(codex ? { sandbox, approval } : {}),
|
||||
...(canBypass && bypass ? { bypass: true } : {}),
|
||||
...(hasEffort && effort ? { effort } : {}),
|
||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||
...routerArg(aiTools.byId(lane.tool)?.routerCapable, router),
|
||||
});
|
||||
setPrompt("");
|
||||
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
||||
@@ -248,10 +308,27 @@ function HomeBody({
|
||||
const openSession = (s: Session) => {
|
||||
const r = liveRunFor(lane, liveRuns, s.id);
|
||||
navigate(
|
||||
r ? `/workspace?lane=${lane.id}&run=${encodeURIComponent(r.id)}` : `/sessions/${s.id}`
|
||||
`/workspace?lane=${lane.id}&${r ? `run=${encodeURIComponent(r.id)}` : `session=${encodeURIComponent(s.id)}`}`
|
||||
);
|
||||
};
|
||||
|
||||
// The tools a session may start on: installed and enabled, plus the
|
||||
// workspace's own whatever its state.
|
||||
const toolOptions = [
|
||||
...new Set([
|
||||
lane.tool,
|
||||
...aiTools.tools.filter((x) => x.installed && x.enabled).map((x) => x.id),
|
||||
]),
|
||||
];
|
||||
const danger =
|
||||
claude && mode === "bypassPermissions"
|
||||
? t("run:hint.permissionWarning")
|
||||
: canBypass && bypass
|
||||
? t("run:hint.bypassWarning", { tool: aiTools.label(tool) })
|
||||
: codex && sandbox === "danger-full-access"
|
||||
? t("run:hint.sandboxWarning")
|
||||
: null;
|
||||
|
||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||
const name = workspaceName(lane);
|
||||
@@ -340,13 +417,35 @@ function HomeBody({
|
||||
slash={claude}
|
||||
fileCwd={lane.cwd}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<div className="w-56">
|
||||
<ModelPicker value={model} onChange={setModel} tool={lane.tool} />
|
||||
</div>
|
||||
<RouterPicker tool={lane.tool} value={router} onChange={setRouter} />
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<Field label={t("home.ai")} width="w-44">
|
||||
<div data-testid="workspace-home-tool" className="flex items-center gap-1.5">
|
||||
<AiToolIcon
|
||||
tool={{ id: tool, label: aiTools.label(tool) }}
|
||||
className="h-6 w-6 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Select<string>
|
||||
value={tool}
|
||||
onChange={(v) => {
|
||||
setTool(v);
|
||||
setBypass(false);
|
||||
}}
|
||||
options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t("router.label")} width="w-44">
|
||||
<RouterPicker tool={tool} routers={routers} value={router} onChange={setRouter} />
|
||||
</Field>
|
||||
{hasModel && (
|
||||
<Field label={t("run:fields.model")} width="w-56">
|
||||
<ModelPicker value={model} onChange={setModel} tool={tool} choices={routerModels} />
|
||||
</Field>
|
||||
)}
|
||||
{claude && (
|
||||
<div className="w-44">
|
||||
<Field label={t("run:fields.permissionMode")} width="w-44">
|
||||
<Select<PermissionMode>
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
@@ -358,7 +457,56 @@ function HomeBody({
|
||||
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{codex && (
|
||||
<>
|
||||
<Field label={t("run:fields.sandbox")} width="w-40">
|
||||
<Select<CodexSandbox>
|
||||
value={sandbox}
|
||||
onChange={setSandbox}
|
||||
options={[
|
||||
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
|
||||
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
|
||||
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("run:fields.approval")} width="w-32">
|
||||
<Select<CodexApproval>
|
||||
value={approval}
|
||||
onChange={setApproval}
|
||||
options={[
|
||||
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
|
||||
{ value: "never", label: t("run:fields.approvalNever") },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{hasEffort && (
|
||||
<Field label={t("run:fields.effort")} width="w-40">
|
||||
<Select<EffortLevel>
|
||||
value={effort}
|
||||
onChange={setEffort}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{canBypass && (
|
||||
<Checkbox
|
||||
tone="danger"
|
||||
checked={bypass}
|
||||
onChange={setBypass}
|
||||
label={t("run:fields.bypass")}
|
||||
labelClassName="text-xs text-status-danger"
|
||||
/>
|
||||
)}
|
||||
{claude && project?.root && (
|
||||
<Checkbox
|
||||
@@ -381,6 +529,12 @@ function HomeBody({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{danger && (
|
||||
<p className="mt-2 flex items-start gap-2 text-xs text-status-danger">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{danger}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file WorkspaceRow — one workspace (lane) in the sidebar's workspace list, one
|
||||
* line like a Claude Desktop project: a chevron that expands its sessions, the
|
||||
* status dot, the name (click opens the workspace, double-click renames,
|
||||
* `PATCH /api/lanes/:id {title}`), the tool's icon, and on hover `+` (a new
|
||||
* `PATCH /api/lanes/:id {title}`) and on hover `+` (a new
|
||||
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
|
||||
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
|
||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||
@@ -14,8 +14,6 @@ import { useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
|
||||
export function workspaceDot(lane: Lane): string {
|
||||
@@ -61,7 +59,6 @@ export function WorkspaceRow({
|
||||
onRenameEnd: (title: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const tools = useAiTools();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
const name = workspaceName(lane);
|
||||
@@ -153,10 +150,6 @@ export function WorkspaceRow({
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error); double-click
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, Ctrl/Cmd+click `&pane=1` to open it in a second
|
||||
* split pane) — attaching, never starting or resuming anything;
|
||||
* any other session opens its read-only transcript (`/sessions/:id`), where
|
||||
* Resume is a separate, explicit action, because a resume re-reads the whole
|
||||
* transcript into context.
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -23,6 +24,8 @@ import { api, type RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
export const SESSION_LIMIT = 20;
|
||||
|
||||
@@ -164,6 +167,7 @@ export function WorkspaceSessionList({
|
||||
liveRuns,
|
||||
query,
|
||||
selectedRunId,
|
||||
selectedSessionId = null,
|
||||
onMatches,
|
||||
hidden = false,
|
||||
}: {
|
||||
@@ -174,6 +178,8 @@ export function WorkspaceSessionList({
|
||||
query: string;
|
||||
/** The `run` the Workspace page is attached to, to mark its row. */
|
||||
selectedRunId: string | null;
|
||||
/** The ended session the Workspace page is showing (`session`), likewise. */
|
||||
selectedSessionId?: string | null;
|
||||
/** How many sessions match `query`, once they are loaded — lets the
|
||||
* sidebar search find a workspace by a session's name. */
|
||||
onMatches?: (count: number) => void;
|
||||
@@ -182,6 +188,7 @@ export function WorkspaceSessionList({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const tools = useAiTools();
|
||||
// Bumped after a rename or an archive, so the list re-reads.
|
||||
const [version, setVersion] = useState(0);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
@@ -276,7 +283,7 @@ export function WorkspaceSessionList({
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
{shown.map((s) => {
|
||||
const run = runFor(s);
|
||||
const selected = !!run && run.id === selectedRunId;
|
||||
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
||||
if (renaming === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||
@@ -314,7 +321,7 @@ export function WorkspaceSessionList({
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}${
|
||||
e.ctrlKey || e.metaKey ? "&pane=1" : ""
|
||||
}`
|
||||
: `/sessions/${s.id}`
|
||||
: `/workspace?lane=${lane.id}&session=${encodeURIComponent(s.id)}`
|
||||
)
|
||||
}
|
||||
onDoubleClick={() => {
|
||||
@@ -326,7 +333,7 @@ export function WorkspaceSessionList({
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
: ""
|
||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-7 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -344,6 +351,10 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<AiToolIcon
|
||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file SessionReplay.test.tsx
|
||||
* @description An ended session opened in its workspace: its conversation and
|
||||
* a prompt box; opening it starts nothing, and only a SENT message resumes it
|
||||
* (`resumeSessionId` + the prompt, in the first free pane slot), after which
|
||||
* the pane opens.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
ConversationView: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="conversation">{sessionId}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("../../run/RunSetup", () => ({
|
||||
PromptEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../WorkspaceHome", () => ({
|
||||
freeSlot: (_id: number, runs: RunHandle[]) => (runs.length ? 1 : 0),
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
sessions: {
|
||||
get: vi.fn(async () => ({ session: { id: "s-old", tool: "claude", cwd: "/r" } })),
|
||||
},
|
||||
lanes: { action: vi.fn(async () => ({ run_id: "agenthub-lane-1-1" })) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { SessionReplay } from "../SessionReplay";
|
||||
|
||||
const lane = { id: 1, cwd: "/r", tool: "claude" } as unknown as Lane;
|
||||
const busy = [{ id: "agenthub-lane-1", laneId: 1, slot: 0, status: "running" }] as RunHandle[];
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("SessionReplay", () => {
|
||||
it("shows the conversation and resumes nothing until a message is sent", async () => {
|
||||
const onResumed = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
|
||||
expect(screen.getByTestId("conversation")).toHaveTextContent("s-old");
|
||||
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old"));
|
||||
expect(screen.getByTestId("session-replay-send")).toBeDisabled();
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(screen.getByLabelText("Message to continue this session…"), "carry on");
|
||||
await user.click(screen.getByTestId("session-replay-send"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
resumeSessionId: "s-old",
|
||||
prompt: "carry on",
|
||||
});
|
||||
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1"));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file WorkspaceHome.test.tsx
|
||||
* @description The workspace home: a new session starts in the first free
|
||||
* pane slot and opens it, all-busy is refused, Recents open a live pane or the
|
||||
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
||||
* default), with its fields in order — AI, AI Router, Model (the router's
|
||||
* models on a router), Permission, Effort —, all-busy is refused, Recents open a live pane or the
|
||||
* transcript (never a resume), instructions save, context files upload and
|
||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
||||
@@ -28,8 +30,21 @@ vi.mock("../../run/RunSetup", () => ({
|
||||
}) => (
|
||||
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
ModelPicker: ({ value, onChange }: { value: string; onChange: (s: string) => void }) => (
|
||||
<input aria-label="model" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
ModelPicker: ({
|
||||
value,
|
||||
onChange,
|
||||
choices,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
choices?: { id: string }[];
|
||||
}) => (
|
||||
<input
|
||||
aria-label="model"
|
||||
data-choices={choices ? choices.map((c) => c.id).join(",") : "tool"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -89,7 +104,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
},
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
||||
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -193,6 +208,76 @@ describe("WorkspaceHome", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const card = screen.getByTestId("workspace-home-start").closest(".card") as HTMLElement;
|
||||
const labels = [...card.querySelectorAll("span.uppercase")].map((x) => x.textContent);
|
||||
expect(labels).toEqual(["AI", "AI Router", "Model", "Permission mode", "Thinking effort"]);
|
||||
});
|
||||
|
||||
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
|
||||
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
|
||||
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
||||
].map((x) => ({ installed: true, enabled: true, ...x })),
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const ai = screen.getByTestId("workspace-home-tool");
|
||||
await user.click(await within(ai).findByText("Claude Code"));
|
||||
// Not installed: not offered.
|
||||
expect(screen.queryByText("Cursor CLI")).toBeNull();
|
||||
await user.click(screen.getByText("Codex"));
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "port it");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
prompt: "port it",
|
||||
tool: "codex",
|
||||
sandbox: "workspace-write",
|
||||
approval: "on-request",
|
||||
});
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({ tools: [], default: "claude" } as never);
|
||||
__resetAiTools();
|
||||
});
|
||||
|
||||
it("offers the router's models on a router, and sends the router", async () => {
|
||||
localStorage.setItem("agenthub.session.router", "glm");
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ name: "native", label: "Native", builtin: true, models: {} },
|
||||
{
|
||||
name: "glm",
|
||||
label: "GLM",
|
||||
builtin: false,
|
||||
model: "glm-4.6",
|
||||
models: { opus: "glm-4.6", sonnet: "glm-4.5", haiku: "glm-4.5-air" },
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("model")).toHaveAttribute(
|
||||
"data-choices",
|
||||
"glm-4.6,glm-4.5,glm-4.5-air"
|
||||
)
|
||||
);
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "go");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ router: "glm" })
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the session in its own worktree when Worktree is ticked (a repo only)", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
@@ -235,7 +320,7 @@ describe("WorkspaceHome", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
||||
});
|
||||
|
||||
it("opens a live recent's pane and an ended one's transcript, resuming nothing", async () => {
|
||||
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
const live = await screen.findByTestId("workspace-recent-s-live");
|
||||
@@ -245,7 +330,7 @@ describe("WorkspaceHome", () => {
|
||||
await user.click(live);
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await user.click(screen.getByTestId("workspace-recent-s-old"));
|
||||
expect(screen.getByTestId("where").textContent).toBe("/sessions/s-old");
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&session=s-old");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -361,7 +446,7 @@ describe("WorkspaceHome across workspaces", () => {
|
||||
it("starts on the tool's default model once the catalog arrives", async () => {
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockResolvedValueOnce({
|
||||
tools: [{ id: "claude", label: "Claude", defaultModel: "sonnet" }],
|
||||
tools: [{ id: "claude", label: "Claude", launcher: "claude", defaultModel: "sonnet" }],
|
||||
default: "claude",
|
||||
} as never);
|
||||
renderHome();
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
/**
|
||||
* @file LaneConsolePane.tsx
|
||||
* @description One lane's run console: the RunSetup ↔ TerminalView switcher,
|
||||
* moved out of Workspace.tsx so the Workspace page can render 1, 2, or 4 of
|
||||
* these side by side (split terminal view). Owns its own prompt/cwd/model/
|
||||
* permissionMode/effort/sandbox/approval/resumeSession/handle/busy/runHistory
|
||||
* state — nothing is shared between panes. `lanes`, `binaryStatus`,
|
||||
* `cwdSuggestions`, `activeRuns`, and `externalSessions` are supplied as props
|
||||
* because they are global, not lane-specific, and fetching them per pane would
|
||||
* mean N redundant identical requests for an N-pane layout. `binaryStatus` is
|
||||
* keyed by tool (claude | codex) and probed once globally, so gating the Start
|
||||
* button uses the binary status for the lane's configured tool.
|
||||
* @description One lane's run console: a TerminalView on the pane's tmux
|
||||
* session, or — with no run — an empty pane whose button goes back to the
|
||||
* workspace home, the ONE place a new session is started (its prompt box,
|
||||
* `WorkspaceHome.tsx`; there is no run form in a pane any more). Moved out of
|
||||
* Workspace.tsx so the Workspace page can render 1, 2, or 4 of these side by
|
||||
* side (split terminal view). Owns its own handle/busy/runHistory state —
|
||||
* nothing is shared between panes. `lanes`, `binaryStatus`, `activeRuns`, and
|
||||
* `externalSessions` are supplied as props because they are global, not
|
||||
* lane-specific, and fetching them per pane would mean N redundant identical
|
||||
* requests for an N-pane layout.
|
||||
*
|
||||
* 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
|
||||
* on screen under a new lane's header.
|
||||
*
|
||||
* 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
|
||||
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
|
||||
* that and attach to the still-live session instead of failing outright
|
||||
* prompt in flight — `onResumeFromHistory` catches
|
||||
* that and attaches to the still-live session instead of failing outright
|
||||
* (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:
|
||||
* 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 { useTranslation } from "react-i18next";
|
||||
import { Play, AlertCircle } from "lucide-react";
|
||||
import { Play, AlertCircle, Plus } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
DashboardRunHistoryItem,
|
||||
EffortLevel,
|
||||
PermissionMode,
|
||||
RunHandle,
|
||||
RunListResponse,
|
||||
RunStartArgs,
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
} from "../../lib/api";
|
||||
import type { DashboardRunHistoryItem, RunHandle, RunListResponse } from "../../lib/api";
|
||||
import type { Session, Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
|
||||
/** The claude lane a session's cwd belongs to: the longest path-boundary
|
||||
@@ -70,13 +59,11 @@ export interface LaneConsolePaneProps {
|
||||
onLaneIdChange: (id: number) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||
* active-runs switcher alongside dashboard runs. */
|
||||
externalSessions?: Session[];
|
||||
wsConnected: boolean;
|
||||
defaultCwd?: string;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
/** 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>`. */
|
||||
@@ -89,6 +76,10 @@ export interface LaneConsolePaneProps {
|
||||
/** 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. */
|
||||
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). */
|
||||
@@ -103,33 +94,25 @@ export function LaneConsolePane({
|
||||
onLaneIdChange,
|
||||
onLaneCreated,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
externalSessions,
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
slot = 0,
|
||||
hiddenRunIds,
|
||||
onHandleChange,
|
||||
attachRequest,
|
||||
onNewSession,
|
||||
homeOnExit = false,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
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 [busy, setBusy] = useState<"start" | "kill" | "attach" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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 laneTool = currentLane?.tool ?? "claude";
|
||||
@@ -155,13 +138,10 @@ export function LaneConsolePane({
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const aiTools = useAiTools();
|
||||
const toolLabel = aiTools.label(laneTool);
|
||||
// A new run starts on the tool's default model (AI Tools settings), and a
|
||||
// lane switch to another tool drops the previous tool's pick.
|
||||
const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? "";
|
||||
useEffect(() => {
|
||||
setModel(defaultModel);
|
||||
}, [laneTool, defaultModel]);
|
||||
// The tool of the run on screen — a session's own, which need not be the
|
||||
// workspace's; the workspace's with no run.
|
||||
const paneTool = handle?.tool ?? laneTool;
|
||||
const toolLabel = aiTools.label(paneTool);
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
@@ -171,12 +151,6 @@ export function LaneConsolePane({
|
||||
onHandleChange?.(handle?.id ?? null);
|
||||
}, [handle, onHandleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLane && defaultCwd && cwd === "") {
|
||||
setCwd(defaultCwd);
|
||||
}
|
||||
}, [defaultCwd, currentLane, cwd]);
|
||||
|
||||
const refreshList = useCallback(() => {
|
||||
if (laneId !== null) {
|
||||
api.run
|
||||
@@ -194,19 +168,15 @@ export function LaneConsolePane({
|
||||
// `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
|
||||
// identity — a background poll must not wipe a half-typed prompt.
|
||||
const latest = useRef({ lanes, activeRuns, defaultCwd });
|
||||
latest.current = { lanes, activeRuns, defaultCwd };
|
||||
const latest = useRef({ lanes, activeRuns });
|
||||
latest.current = { lanes, activeRuns };
|
||||
|
||||
// A pane's prompt, cwd, history and terminal all belong to the lane it
|
||||
// shows, so switching lanes has to swap every one of them. Re-attach right
|
||||
// away when the new lane already has a live run: each lane sticks to its own
|
||||
// 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.
|
||||
// A pane's history and terminal belong to the lane it shows, so switching
|
||||
// lanes has to swap both. Re-attach right away when the new lane already
|
||||
// has a live run: each lane sticks to its own tmux session.
|
||||
const autoAttachedForLane = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const { activeRuns: runs } = latest.current;
|
||||
setPrompt("");
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
const own = laneId === null ? null : paneRunId(laneId, slot);
|
||||
@@ -266,18 +236,6 @@ export function LaneConsolePane({
|
||||
}
|
||||
}, [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(
|
||||
async (id: string) => {
|
||||
if (busy) return;
|
||||
@@ -296,118 +254,6 @@ export function LaneConsolePane({
|
||||
[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(
|
||||
async (item: DashboardRunHistoryItem) => {
|
||||
if (!item.session_id) return;
|
||||
@@ -419,10 +265,11 @@ export function LaneConsolePane({
|
||||
|
||||
if (item.cwd) {
|
||||
const effectiveCwd = item.cwd;
|
||||
// A cwd can now back both a claude lane and a codex lane — match
|
||||
// this history item's own tool, the ground truth for which lane it
|
||||
// belongs to, rather than an arbitrary lane at that cwd.
|
||||
let targetLaneId = lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id;
|
||||
// This pane's history is its own lane's (`refreshList`), whatever
|
||||
// tool each session ran on. With no lane, a cwd can back both a
|
||||
// claude and a codex lane — match the item's own tool.
|
||||
let targetLaneId =
|
||||
laneId ?? lanes.find((l) => l.cwd === effectiveCwd && l.tool === item.tool)?.id;
|
||||
|
||||
if (!targetLaneId) {
|
||||
const ensureResult = await api.lanes.ensure({ cwd: effectiveCwd, tool: item.tool });
|
||||
@@ -476,7 +323,6 @@ export function LaneConsolePane({
|
||||
}
|
||||
|
||||
setHandle(fetched);
|
||||
setResumeSession(null);
|
||||
refreshList();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
@@ -485,7 +331,7 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
[busy, refreshList, t, lanes, laneId, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
@@ -497,8 +343,6 @@ export function LaneConsolePane({
|
||||
|
||||
const newRun = useCallback(() => {
|
||||
setHandle(null);
|
||||
setPrompt("");
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
@@ -608,12 +452,12 @@ export function LaneConsolePane({
|
||||
</header>
|
||||
|
||||
{/* 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">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>
|
||||
{t("binary.missing", {
|
||||
cli: aiTools.byId(laneTool)?.command ?? laneTool,
|
||||
cli: aiTools.byId(paneTool)?.command ?? paneTool,
|
||||
tool: toolLabel,
|
||||
})}
|
||||
</span>
|
||||
@@ -628,52 +472,34 @@ export function LaneConsolePane({
|
||||
)}
|
||||
|
||||
{!handle ? (
|
||||
<RunSetup
|
||||
laneId={laneId ?? 0}
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
cwd={cwd}
|
||||
onCwdChange={setCwd}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
permissionMode={permissionMode}
|
||||
onPermissionModeChange={setPermissionMode}
|
||||
effort={effort}
|
||||
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
|
||||
data-testid="pane-no-run"
|
||||
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"
|
||||
>
|
||||
<p>{t("emptyPane")}</p>
|
||||
{onNewSession && (
|
||||
<button type="button" onClick={onNewSession} className="btn-primary text-xs">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("actions.newSession")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<TerminalView
|
||||
runId={handle!.id}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||
// go straight back to the setup form rather than leaving a dead
|
||||
// terminal the user has to click "New run" out of.
|
||||
// a single pane goes straight back to the home's prompt box rather
|
||||
// than leaving a dead terminal the user has to click out of.
|
||||
onExit={() => {
|
||||
newRun();
|
||||
refreshList();
|
||||
if (homeOnExit) onNewSession?.();
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
{t("actions.newRun")}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* @file RouterPicker.tsx
|
||||
* @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`).
|
||||
* Shown only for a tool that can use one (Claude, Codex) and only when a
|
||||
* profile beyond Native exists. The last pick is remembered for the next
|
||||
* session (`agenthub.session.router`).
|
||||
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
|
||||
* Native or one of the saved profiles (`useAiRouters`, which the caller also
|
||||
* reads to list the router's models). A tool that cannot use a router (only
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AiRouter } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -36,28 +37,9 @@ export function useSessionRouter(): [string, (v: string) => void] {
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** 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,
|
||||
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");
|
||||
/** The saved profiles (Native included), null until they arrive. */
|
||||
export function useAiRouters(): AiRouter[] | null {
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiRouters
|
||||
@@ -68,6 +50,30 @@ export function RouterPicker({
|
||||
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,
|
||||
// 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");
|
||||
}, [routers, value, onChange]);
|
||||
|
||||
if (!capable || !routers || routers.length <= 1) return null;
|
||||
return wrap(
|
||||
<div className="w-44" data-testid="router-picker" title={t("router.label")}>
|
||||
const options =
|
||||
capable && routers?.length
|
||||
? 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>
|
||||
value={value}
|
||||
value={capable ? value : "native"}
|
||||
onChange={onChange}
|
||||
options={routers.map((r) => ({
|
||||
value: r.name,
|
||||
label: r.builtin ? t("router.native", { tool: aiTools.label(tool) }) : r.label,
|
||||
}))}
|
||||
options={options}
|
||||
disabled={!capable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,65 +1,24 @@
|
||||
/**
|
||||
* @file RunSetup.tsx
|
||||
* @description The pre-run setup panel: everything the user picks before a run
|
||||
* exists. Moved verbatim out of `pages/Run.tsx` (where it was `ConfigCard`) so
|
||||
* the Run page and the Workspace page can both mount the same panel.
|
||||
*
|
||||
* What lives here:
|
||||
* - `RunSetup` — fresh-vs-resume source, the prompt editor, and the cwd /
|
||||
* model / effort fields, plus per-tool advanced fields (permission-mode for
|
||||
* Claude; sandbox and approval for Codex), plus the concurrency hint and
|
||||
* 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.
|
||||
* @description The run-form pieces the rest of the app reuses — the pane's
|
||||
* own run form is gone, a new session starts from the workspace home's prompt
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete;
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
* or a fixed one (an AI Router's models), plus Custom….
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
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 { FolderOpen, GitBranch, Home, History as HistoryIcon } from "lucide-react";
|
||||
import { api, RUN_MODEL_CHOICES, type ModelChoice } from "../../lib/api";
|
||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||
import type { LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { RouterPicker, routerArg, useSessionRouter } from "./RouterPicker";
|
||||
|
||||
export interface SlashCommand {
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
export function CwdAutocomplete({
|
||||
@@ -911,163 +475,6 @@ export function CwdAutocomplete({
|
||||
|
||||
// ── 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
|
||||
// webhook settings form). Imported at the top of this file.
|
||||
|
||||
@@ -1079,10 +486,13 @@ export function ModelPicker({
|
||||
value,
|
||||
onChange,
|
||||
tool,
|
||||
choices: fixed,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
tool: LaneTool;
|
||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
||||
choices?: ModelChoice[];
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
// The tool's own list (Codex's models cache, Claude's aliases named after
|
||||
@@ -1102,13 +512,15 @@ export function ModelPicker({
|
||||
}, [tool]);
|
||||
const choices = useMemo<ModelChoice[]>(() => {
|
||||
const inherit = { id: "", label: "" };
|
||||
if (fixed) return [inherit, ...fixed];
|
||||
if (fetched?.length) return [inherit, ...fetched];
|
||||
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
|
||||
// only once the list has arrived, so a listed default never flashes as custom.
|
||||
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);
|
||||
|
||||
// Reuse the shared Select so the Model dropdown renders identically to the
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @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>
|
||||
*/
|
||||
|
||||
@@ -91,7 +93,6 @@ function baseProps() {
|
||||
codex: { found: true, path: "/usr/local/bin/codex" },
|
||||
antigravity: { found: true, path: "/usr/local/bin/agy" },
|
||||
},
|
||||
cwdSuggestions: [],
|
||||
activeRuns: { items: [] },
|
||||
wsConnected: true,
|
||||
};
|
||||
@@ -102,14 +103,21 @@ describe("LaneConsolePane", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts a run through /api/lanes/<id>/start, not /api/run/start", 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",
|
||||
it("has no run form: an empty pane's New session goes to the workspace home", () => {
|
||||
const onNewSession = vi.fn();
|
||||
render(<LaneConsolePane {...baseProps()} onNewSession={onNewSession} />);
|
||||
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
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,
|
||||
status: "running",
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
@@ -118,81 +126,25 @@ describe("LaneConsolePane", () => {
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
});
|
||||
|
||||
render(<LaneConsolePane {...baseProps()} />);
|
||||
|
||||
// Set cwd and prompt
|
||||
const cwdInput = screen.getByPlaceholderText(/type to search/i);
|
||||
fireEvent.change(cwdInput, { target: { value: "/workspace/a" } });
|
||||
|
||||
const promptTextarea = screen.getByPlaceholderText(/ask claude/i);
|
||||
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))
|
||||
tool: "claude" as const,
|
||||
};
|
||||
const onNewSession = vi.fn();
|
||||
render(
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
activeRuns={{ items: [live] }}
|
||||
onNewSession={onNewSession}
|
||||
homeOnExit
|
||||
/>
|
||||
);
|
||||
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");
|
||||
fireEvent.click(screen.getByRole("button", { name: "New run" }));
|
||||
expect(onNewSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByTestId("fake-pane-exit"));
|
||||
|
||||
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 () => {
|
||||
@@ -227,25 +179,12 @@ describe("LaneConsolePane", () => {
|
||||
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.
|
||||
rerender(<LaneConsolePane {...props} lanes={[LANE, LANE2, { ...LANE, id: 3 }]} laneId={3} />);
|
||||
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", () => {
|
||||
const { rerender } = render(<LaneConsolePane {...baseProps()} showLaneSelector />);
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
@@ -254,50 +193,12 @@ describe("LaneConsolePane", () => {
|
||||
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} />);
|
||||
expect(screen.getByTestId("console-body")).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 () => {
|
||||
const HISTORY_ITEM = {
|
||||
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", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* @file RouterPicker.test.tsx
|
||||
* @description The per-session AI Router picker: hidden with only Native or on
|
||||
* a tool that cannot use a router, listing the saved profiles otherwise,
|
||||
* @description The per-session AI Router picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
||||
* remembering the pick, and falling back to Native when the remembered
|
||||
* profile is gone; plus `routerArg`, which never sends native.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 { RouterPicker, routerArg, useSessionRouter } from "../RouterPicker";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
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 nine = { name: "9router", label: "9Router", builtin: false };
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -43,28 +44,32 @@ beforeEach(() => {
|
||||
|
||||
describe("RouterPicker", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it("is hidden with only Native saved", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] } as never);
|
||||
render(<RouterPicker tool="claude" value="native" onChange={() => {}} />);
|
||||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("router-picker")).toBeNull();
|
||||
});
|
||||
|
||||
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("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
|
||||
render(
|
||||
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
const picker = screen.getByTestId("router-picker");
|
||||
expect(picker.querySelector("button")).toBeDisabled();
|
||||
expect(picker).not.toHaveTextContent("9Router");
|
||||
});
|
||||
|
||||
it("falls back to Native when the remembered profile is gone", async () => {
|
||||
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"));
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
/**
|
||||
* @file RunSetup.test.tsx
|
||||
* @description Pins the props-only boundary of `RunSetup` after its move out of
|
||||
* `pages/Run.tsx` (where it was `ConfigCard`): every picker the panel owns —
|
||||
* mode, prompt, cwd, model, permission mode, effort — reports its selection
|
||||
* through the matching callback and nowhere else, `onStart` fires from the Run
|
||||
* button, and a missing `claude` binary is surfaced purely from the
|
||||
* `binaryFound` prop (the panel runs no probe of its own).
|
||||
* @description The run-form pieces that outlived the pane's run form:
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply) and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Router's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id.
|
||||
*
|
||||
* @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 userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import { ModelPicker, PromptEditor } from "../RunSetup";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -25,30 +21,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
aiRouters: { list: r({ items: [] }) },
|
||||
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.
|
||||
models: vi.fn(async (id: 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" },
|
||||
],
|
||||
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 };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: {
|
||||
file: r({ text: "" }),
|
||||
commands: r({
|
||||
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(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("RunSetup — selections report through callbacks", () => {
|
||||
it("reports the prompt from the editor", () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder"));
|
||||
fireEvent.change(box, { target: { value: "review the diff" } });
|
||||
expect(spies.onPromptChange).toHaveBeenCalledWith("review the diff");
|
||||
onlyCalled(spies, "onPromptChange");
|
||||
});
|
||||
function renderEditor(value: string, slash = true) {
|
||||
const spies = { onChange: vi.fn(), onSubmit: vi.fn() };
|
||||
render(
|
||||
<PromptEditor
|
||||
value={value}
|
||||
placeholder="Ask anything…"
|
||||
slash={slash}
|
||||
fileCwd="/Users/tester"
|
||||
{...spies}
|
||||
/>
|
||||
);
|
||||
return spies;
|
||||
}
|
||||
const box = () => screen.getByPlaceholderText("Ask anything…");
|
||||
|
||||
it("reports the cwd from typing and from a suggestion", () => {
|
||||
const { spies } = renderSetup();
|
||||
const input = screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder"));
|
||||
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: "" });
|
||||
describe("PromptEditor — / and @ suggestions", () => {
|
||||
it("lists the folder's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const spies = renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
// Both prefix matches; the shorter name ranks first.
|
||||
@@ -473,30 +86,30 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
expect.stringContaining("/review-pr"),
|
||||
]);
|
||||
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.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
|
||||
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.
|
||||
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"] });
|
||||
// The prompt is controlled by the page: put the cursor at the end of it.
|
||||
const { spies } = renderSetup({ prompt: "look at @ma" });
|
||||
// The prompt is controlled by the caller: put the cursor at the end of it.
|
||||
const spies = renderEditor("look at @ma");
|
||||
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
|
||||
fireEvent.select(box());
|
||||
|
||||
fireEvent.click(await screen.findByText("src/main.ts"));
|
||||
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 () => {
|
||||
renderSetup({ prompt: "", tool: "codex" });
|
||||
it("offers no slash commands where they do not apply", async () => {
|
||||
renderEditor("", false);
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
|
||||
@@ -504,78 +117,40 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — antigravity lanes", () => {
|
||||
it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => {
|
||||
const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" });
|
||||
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
|
||||
expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy();
|
||||
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
|
||||
describe("ModelPicker", () => {
|
||||
it("lists the tool's own models, and Custom… reports only the typed id", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ModelPicker value="" onChange={onChange} tool="claude" />);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
|
||||
expect(onChange).toHaveBeenCalledWith("sonnet");
|
||||
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "gemini-3.1-pro",
|
||||
effort: undefined,
|
||||
permissionMode: undefined,
|
||||
})
|
||||
);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom")));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), {
|
||||
target: { value: "claude-opus-5" },
|
||||
});
|
||||
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 () => {
|
||||
renderSetup({ tool: "codex" });
|
||||
render(<ModelPicker value="" onChange={() => {}} tool="codex" />);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a fresh codex run instead of resuming the last one", () => {
|
||||
const { spies } = renderSetup({ tool: "codex" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledOnce();
|
||||
expect(spies.onStart.mock.calls[0]?.[0].resumeLast).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — catalog tools", () => {
|
||||
it("offers a per-run bypass switch with a warning, and sends it", async () => {
|
||||
const { spies } = renderSetup({ tool: "antigravity", prompt: "go" });
|
||||
const box = await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") });
|
||||
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();
|
||||
it("offers only a fixed list when given one (an AI Router's models)", async () => {
|
||||
render(
|
||||
<ModelPicker
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
tool="claude"
|
||||
choices={[{ id: "glm-4.6", label: "glm-4.6" }]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
expect(await screen.findByText("glm-4.6")).toBeTruthy();
|
||||
expect(screen.queryByText("Sonnet (latest · 5.5)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,9 +244,11 @@ describe("AiToolsSettings — install and update", () => {
|
||||
describe("AiToolIcon", () => {
|
||||
it("retries the icon when the same instance shows another tool", async () => {
|
||||
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")!);
|
||||
expect(screen.getByText("G")).toBeInTheDocument();
|
||||
expect(screen.getByText("O")).toBeInTheDocument();
|
||||
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
|
||||
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -317,6 +317,10 @@
|
||||
"pane.close": "Stop this pane's session",
|
||||
"panel.files": "Files",
|
||||
"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",
|
||||
"resume.openPane": "Open pane",
|
||||
"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.newSession": "New session",
|
||||
"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.renameSession": "Rename session",
|
||||
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
||||
@@ -346,6 +350,7 @@
|
||||
"tree.archived_other": "{{count}} archived sessions",
|
||||
"tree.unarchive": "Unarchive",
|
||||
"home.breadcrumb": "Workspaces",
|
||||
"home.ai": "AI",
|
||||
"home.repoRoot": "Repository root",
|
||||
"home.mainBranch": "Main worktree's branch",
|
||||
"home.worktreeCount_one": "{{count}} worktree",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
|
||||
"actions": {
|
||||
"newRun": "New run",
|
||||
"newSession": "New session",
|
||||
"send": "Send",
|
||||
"sending": "Sending…",
|
||||
"start": "Run",
|
||||
|
||||
@@ -317,6 +317,10 @@
|
||||
"pane.close": "Dừng phiên của ô này",
|
||||
"panel.files": "Tệp",
|
||||
"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",
|
||||
"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",
|
||||
@@ -334,7 +338,7 @@
|
||||
"tree.allTools": "Mọi tool",
|
||||
"tree.newSession": "Session mới",
|
||||
"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.renameSession": "Đổi tên session",
|
||||
"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.unarchive": "Bỏ lưu trữ",
|
||||
"home.breadcrumb": "Không gian làm việc",
|
||||
"home.ai": "AI",
|
||||
"home.repoRoot": "Thư mục gốc của repo",
|
||||
"home.mainBranch": "Branch của worktree chính",
|
||||
"home.worktreeCount_one": "{{count}} worktree",
|
||||
|
||||
@@ -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": {
|
||||
"newRun": "Lượt chạy mới",
|
||||
"newSession": "Phiên mới",
|
||||
"send": "Gửi",
|
||||
"sending": "Đang gửi…",
|
||||
"start": "Chạy",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
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 {
|
||||
ArrowLeft,
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
ChartColumn,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
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 { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
import { readSplitViewState, writeSplitViewState } 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
|
||||
* (`DELETE /api/run/<runId>`). Slot 0 is the lane's run and has no close. */
|
||||
function PaneHeader({
|
||||
@@ -171,11 +173,14 @@ function PaneHeader({
|
||||
slot,
|
||||
runId,
|
||||
model,
|
||||
tool,
|
||||
}: {
|
||||
lane: Lane | null;
|
||||
slot: number;
|
||||
runId: 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 tools = useAiTools();
|
||||
@@ -184,11 +189,8 @@ function PaneHeader({
|
||||
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"
|
||||
>
|
||||
{lane && (
|
||||
<AiToolIcon
|
||||
tool={{ id: lane.tool, label: tools.label(lane.tool) }}
|
||||
className="h-3.5 w-3.5 !p-0"
|
||||
/>
|
||||
{tool && (
|
||||
<AiToolIcon tool={{ id: tool, label: tools.label(tool) }} className="h-3.5 w-3.5 !p-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-medium text-fg-secondary">
|
||||
{lane ? lane.title || lane.cwd.split("/").pop() : ""}
|
||||
@@ -216,33 +218,32 @@ function ConsoleArea({
|
||||
splitView,
|
||||
setLayout,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
externalSessions,
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
onLaneCreated,
|
||||
onLaneIdChange,
|
||||
showToggle = true,
|
||||
attachRequest,
|
||||
onNewSession,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
selectedLaneId: number | null;
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
externalSessions: Session[];
|
||||
wsConnected: boolean;
|
||||
defaultCwd: string;
|
||||
onHasActiveRunChange: (val: boolean) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
/** A live run to attach a pane to, from the sidebar (`?run=`, `&pane=`). */
|
||||
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
|
||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||
@@ -280,12 +281,12 @@ function ConsoleArea({
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={onHasActiveRunChange}
|
||||
onNewSession={onNewSession}
|
||||
homeOnExit
|
||||
attachRequest={attachRequest?.pane === 0 ? attachRequest : null}
|
||||
/>
|
||||
) : (
|
||||
@@ -301,6 +302,7 @@ function ConsoleArea({
|
||||
slot={i}
|
||||
runId={paneRunIds[i] ?? null}
|
||||
model={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.model ?? null}
|
||||
tool={activeRuns?.items.find((r) => r.id === paneRunIds[i])?.tool ?? null}
|
||||
/>
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
@@ -312,11 +314,10 @@ function ConsoleArea({
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onNewSession={onNewSession}
|
||||
attachRequest={attachRequest?.pane === i ? attachRequest : null}
|
||||
/>
|
||||
</div>
|
||||
@@ -341,6 +342,10 @@ export function Workspace() {
|
||||
// second pick of the same session a new request.
|
||||
const location = useLocation();
|
||||
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
|
||||
// asks for pane 1); pane 0 otherwise.
|
||||
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 }
|
||||
> | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [defaultCwd, setDefaultCwd] = useState<string>("");
|
||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||
|
||||
// Split view state
|
||||
@@ -528,22 +532,7 @@ export function Workspace() {
|
||||
.catch(() => undefined);
|
||||
api.run
|
||||
.cwds()
|
||||
.then((r) => {
|
||||
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);
|
||||
}
|
||||
})
|
||||
.then((r) => setCwdSuggestions(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
@@ -676,7 +665,11 @@ export function Workspace() {
|
||||
// A workspace opens on its HOME (WorkspaceHome); `?run=` opens a session —
|
||||
// the console, panes and Changes panel, which the home only hides (CSS), so
|
||||
// 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 =
|
||||
!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full"));
|
||||
|
||||
@@ -983,25 +976,42 @@ export function Workspace() {
|
||||
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
|
||||
no lane there is no header, so the console shows one instead. */}
|
||||
<ConsoleArea
|
||||
lanes={lanes}
|
||||
selectedLaneId={selectedLaneId}
|
||||
splitView={splitView}
|
||||
setLayout={setLayout}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={setPaneHasActiveRun}
|
||||
onLaneCreated={() => void refreshLanes()}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
attachRequest={attachRequest}
|
||||
/>
|
||||
no lane there is no header, so the console shows one instead.
|
||||
A replay only HIDES it: the console has one render site and a
|
||||
live terminal must never unmount. */}
|
||||
<div className={replaying ? "hidden" : "contents"}>
|
||||
<ConsoleArea
|
||||
lanes={lanes}
|
||||
selectedLaneId={selectedLaneId}
|
||||
splitView={splitView}
|
||||
setLayout={setLayout}
|
||||
binaryStatus={binaryStatus}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
onHasActiveRunChange={setPaneHasActiveRun}
|
||||
onLaneCreated={() => void refreshLanes()}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
attachRequest={attachRequest}
|
||||
onNewSession={
|
||||
currentLane
|
||||
? () => setSearchParams({ lane: String(currentLane.id) })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1032,6 +1042,21 @@ export function Workspace() {
|
||||
<Icon className="h-4 w-4" />
|
||||
</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
|
||||
type="button"
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file Workspace.test.tsx
|
||||
* @description Workspace page integration tests covering the lane-based run flow.
|
||||
* Verifies: lanes API contract, ensure before start flow, lane start vs /api/run,
|
||||
* and that no /stage endpoint is called.
|
||||
* Verifies: lanes API contract, the home vs session view, split panes, and
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import { render, act, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// 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) =>
|
||||
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) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const recordCall = (method: string, path: string) => {
|
||||
@@ -323,6 +329,8 @@ async function renderWorkspace() {
|
||||
|
||||
beforeEach(() => {
|
||||
recordedCalls.length = 0;
|
||||
// The selected workspace is remembered in localStorage; each test starts on the first.
|
||||
window.localStorage.clear();
|
||||
lanesToReturn = [
|
||||
{
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -722,6 +603,55 @@ describe("Workspace — home and session view", () => {
|
||||
await userEvent.setup().click(screen.getByTestId("workspace-back-home"));
|
||||
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 () => {
|
||||
localStorage.setItem("agenthub.workspace.changesOpen", "1");
|
||||
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 "
|
||||
>
|
||||
<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
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3"
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
<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"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<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"
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 text-[10px] text-fg-muted"
|
||||
</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"
|
||||
>
|
||||
Enter to send · Shift+Enter for newline
|
||||
· / for slash commands · @ for file references
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
<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="flex items-center gap-3 text-[11px] min-w-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=""
|
||||
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"
|
||||
class="lucide lucide-play w-3.5 h-3.5 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5905,8 +5603,78 @@ exports[`screen snapshots > Run 1`] = `
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user