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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user