d581705eb0
Fix two issues identified in code review:
1. Critical: onViewFromHistory was a silent no-op. Now navigates to the
SessionDetail page using the same route pattern as the external link in
RunHistory, allowing users to view a finished run's transcript.
2. Important: Removed dead slashCommands={[]} prop from RunSetup invocation.
Made slashCommands optional in RunSetupProps to maintain type safety while
reflecting that the discovery logic was removed.
All tests pass (396 client, 1152 server).
688 lines
25 KiB
TypeScript
688 lines
25 KiB
TypeScript
/**
|
|
* @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 / permission-mode / effort fields, plus the concurrency hint and
|
|
* the Start button. Its disabled state is driven by the `binaryFound` prop,
|
|
* so a missing `claude` binary is a surfaced state here rather than a probe
|
|
* of its own.
|
|
* - the pickers the panel owns: `CwdAutocomplete`, `SessionPicker`,
|
|
* `ModelPicker`, and the small `Field` layout helper.
|
|
*
|
|
* Props only for `RunSetup`: no `/stage` call, no lane API call, and no run
|
|
* lifecycle — the page owns `api.run.start` and hands the result back through
|
|
* `onStart`. `SessionPicker` keeps the one API call it already owned (listing
|
|
* past sessions to resume) and `PromptEditor` keeps its `@`-file lookup.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Play,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
ChevronDown,
|
|
ShieldAlert,
|
|
X,
|
|
FolderOpen,
|
|
Home,
|
|
History as HistoryIcon,
|
|
Search,
|
|
RotateCcw,
|
|
Lock,
|
|
} from "lucide-react";
|
|
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES } from "../../lib/api";
|
|
import type {
|
|
CwdSuggestion,
|
|
DashboardRunHistoryItem,
|
|
RunListResponse,
|
|
EffortLevel,
|
|
PermissionMode,
|
|
RunStartArgs,
|
|
} from "../../lib/api";
|
|
import type { Session } from "../../lib/types";
|
|
import { Select } from "../Select";
|
|
|
|
// Minimal SlashCommand type (from deleted RunConsole)
|
|
export interface SlashCommand {
|
|
name: string;
|
|
source: "project" | "user" | "plugin" | "builtin";
|
|
description?: string;
|
|
}
|
|
|
|
// Minimal PromptEditor component (from deleted RunConsole)
|
|
interface PromptEditorProps {
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
onSubmit: () => void;
|
|
placeholder: string;
|
|
rows?: number;
|
|
slashCommands: SlashCommand[];
|
|
fileCwd: string;
|
|
}
|
|
|
|
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
|
e.preventDefault();
|
|
onSubmit();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<textarea
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
className="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"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── 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;
|
|
slashCommands?: SlashCommand[];
|
|
runHistory: DashboardRunHistoryItem[];
|
|
onResumeFromHistory: (item: DashboardRunHistoryItem) => 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);
|
|
// 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">
|
|
{/* Fresh vs resume source picker */}
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
|
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
|
<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>
|
|
{resumePicked && (
|
|
<div className="min-w-0 flex-1">
|
|
<SessionPicker
|
|
selected={props.resumeSession}
|
|
onSelect={props.onResumeSessionChange}
|
|
cwd={props.laneCwd}
|
|
/>
|
|
</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)}
|
|
placeholder={t("fields.promptPlaceholderTerminal")}
|
|
rows={5}
|
|
slashCommands={props.slashCommands ?? []}
|
|
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>
|
|
|
|
{/* Advanced fields */}
|
|
<div className="grid grid-cols-1 gap-3 px-4 py-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>
|
|
<Field label={t("fields.model")}>
|
|
<ModelPicker value={props.model} onChange={props.onModelChange} />
|
|
</Field>
|
|
<Field label={t("fields.permissionMode")}>
|
|
<Select<PermissionMode>
|
|
value={props.permissionMode}
|
|
onChange={props.onPermissionModeChange}
|
|
options={[
|
|
{ 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>
|
|
<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>
|
|
|
|
{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)}
|
|
disabled={
|
|
!props.binaryFound ||
|
|
!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>
|
|
);
|
|
}
|
|
|
|
function handleStart(props: RunSetupProps) {
|
|
props.onStart({
|
|
laneId: props.laneId,
|
|
cwd: props.cwd || undefined,
|
|
model: props.model || undefined,
|
|
permissionMode: props.permissionMode || undefined,
|
|
effort: props.effort || undefined,
|
|
resumeSessionId: props.resumeSession?.id || undefined,
|
|
initialPrompt: props.prompt || undefined,
|
|
});
|
|
}
|
|
|
|
/** 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({
|
|
value,
|
|
onChange,
|
|
suggestions,
|
|
inputId,
|
|
}: {
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
suggestions: CwdSuggestion[];
|
|
/** Sets the input's `id` so an external `<label htmlFor>` can target it. */
|
|
inputId?: string;
|
|
}) {
|
|
const { t } = useTranslation("run");
|
|
const [open, setOpen] = useState(false);
|
|
const [active, setActive] = useState(0);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// Close dropdown on outside click
|
|
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(() => {
|
|
const q = value.toLowerCase().trim();
|
|
const out = suggestions.filter(
|
|
(s) => !q || s.path.toLowerCase().includes(q) || s.label.toLowerCase().includes(q)
|
|
);
|
|
return out;
|
|
}, [value, suggestions]);
|
|
|
|
// Group suggestions by kind preserving fixed order — home first, matching
|
|
// the neutral default the page pre-fills (issue #202).
|
|
const groups = useMemo(() => {
|
|
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
|
|
return order
|
|
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
|
|
.filter((g) => g.items.length > 0);
|
|
}, [filtered]);
|
|
|
|
// Flat index for keyboard navigation
|
|
const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
|
|
|
// Keep `active` clamped within bounds
|
|
useEffect(() => {
|
|
if (active >= flat.length) setActive(Math.max(0, flat.length - 1));
|
|
}, [flat.length, active]);
|
|
|
|
const choose = (s: CwdSuggestion) => {
|
|
onChange(s.path);
|
|
setOpen(false);
|
|
inputRef.current?.blur();
|
|
};
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
setOpen(true);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActive((a) => Math.min(flat.length - 1, a + 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActive((a) => Math.max(0, a - 1));
|
|
} else if (e.key === "Enter") {
|
|
if (open && flat[active]) {
|
|
e.preventDefault();
|
|
choose(flat[active]);
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div className="relative">
|
|
<FolderOpen className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none" />
|
|
<input
|
|
ref={inputRef}
|
|
id={inputId}
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => {
|
|
onChange(e.target.value);
|
|
setOpen(true);
|
|
setActive(0);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onKeyDown={onKeyDown}
|
|
placeholder={t("fields.cwdPlaceholder")}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="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"
|
|
/>
|
|
</div>
|
|
{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 max-h-72 overflow-auto py-1">
|
|
{groups.length === 0 ? (
|
|
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
|
|
) : (
|
|
groups.map((g) => (
|
|
<div key={g.kind}>
|
|
<div className="px-3 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted flex items-center gap-1.5">
|
|
{g.kind === "dashboard" ? (
|
|
<FolderOpen className="w-3 h-3" />
|
|
) : g.kind === "home" ? (
|
|
<Home className="w-3 h-3" />
|
|
) : (
|
|
<HistoryIcon className="w-3 h-3" />
|
|
)}
|
|
{t(`fields.cwdGroups.${g.kind}`)}
|
|
</div>
|
|
{g.items.map((s) => {
|
|
const idx = flat.indexOf(s);
|
|
const isActive = idx === active;
|
|
return (
|
|
<button
|
|
key={s.path}
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault() /* keep input focused */}
|
|
onClick={() => choose(s)}
|
|
onMouseEnter={() => setActive(idx)}
|
|
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
|
isActive ? "bg-accent/15" : "hover:bg-surface-3"
|
|
}`}
|
|
>
|
|
<div className="text-[11px] text-fg-secondary truncate">{s.label}</div>
|
|
<div className="font-mono text-[10px] text-fg-muted truncate">{s.path}</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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
|
|
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
|
|
.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-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.
|
|
|
|
// Sentinel option value for "Custom model…". Empty string is already taken by
|
|
// the "inherit from settings" choice, so use a non-empty marker.
|
|
const MODEL_CUSTOM = "__custom__";
|
|
|
|
function ModelPicker({ value, onChange }: { value: string; onChange: (s: string) => void }) {
|
|
const { t } = useTranslation("run");
|
|
// "Custom" is selected when the value isn't one of our curated IDs.
|
|
const knownIds = useMemo(() => RUN_MODEL_CHOICES.map((c) => c.id), []);
|
|
const isCustom = value !== "" && !knownIds.includes(value);
|
|
const [showCustom, setShowCustom] = useState(isCustom);
|
|
|
|
// Reuse the shared Select so the Model dropdown renders identically to the
|
|
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
|
|
// a browser-native <select>.
|
|
const options = useMemo(
|
|
() => [
|
|
...RUN_MODEL_CHOICES.map((c) => ({
|
|
value: c.id === "" ? "" : c.id,
|
|
label: c.id === "" ? t("fields.modelInheritLabel") : c.label,
|
|
hint: c.hint,
|
|
})),
|
|
{ value: MODEL_CUSTOM, label: t("fields.modelCustom") },
|
|
],
|
|
[t]
|
|
);
|
|
|
|
const onSelect = (v: string) => {
|
|
if (v === MODEL_CUSTOM) {
|
|
setShowCustom(true);
|
|
return;
|
|
}
|
|
setShowCustom(false);
|
|
onChange(v);
|
|
};
|
|
|
|
const selectValue = showCustom || isCustom ? MODEL_CUSTOM : value;
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Select<string> value={selectValue} onChange={onSelect} options={options} />
|
|
{(showCustom || isCustom) && (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={t("fields.modelCustomPlaceholder")}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|