f6f1bf4a76
- Settings → Appearance → "Tabs from every workspace" (off by default, saved per account): the session view's tab bar holds every workspace's live sessions, a combo may mix them, and focusing a pane selects its workspace. Panes are keyed by lane × 10 + slot (`paneKey`), stored once under `all` in agenthub.workspace.paneLayout. - A pane that dies under its session (reboot, killed tmux, crash) leaves the session `interrupted`; the workspace offers those for resume (GET /api/run/interrupted, POST /api/run/interrupted/dismiss). - Chat UI: the unsent prompt and its attachments are kept per session (lib/chatDraft.ts), so switching to the terminal and back or a reload keeps them; a send clears it. Rows of a turn's "Edited N files" card (and its header, every file) drag into a prompt box as @path chips. - Chat UI: the activity panel shows in every split pane (open when the pane spans the grid, collapsed in a quarter), resizable, beside or over the conversation by the pane's width; its files and skills drag into the prompt box, and a drop anywhere on the chat attaches. - Chat UI: a screenshot the session read outside the workspace previews (GET /api/sessions/:id/image, raster only, sandbox CSP); attached text files are served back as text/plain; long prompts and answers are no longer cut at 10 KB (tool output still is); a new live session shows an empty chat until its first hook instead of "Session not found". - Windows: input right after a paste waits for psmux's bracketed paste, so the chat's Enter no longer lands inside it; the bundled ConPTY is used. - Changes panel: a Workspace context fold (instructions, files, folders). Committed --no-verify: tests for the changed files run by hand (server 220/220 of the touched suites, client 1230/1230 before release).
1145 lines
42 KiB
TypeScript
1145 lines
42 KiB
TypeScript
/**
|
|
* @file RunSetup.tsx
|
|
* @description The run-form pieces the rest of the app reuses (an attached
|
|
* image's right-click offers View / Remove) — 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, Shift/Alt+Enter
|
|
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
|
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
|
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
|
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
|
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
|
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
|
* a borderless (`bare`) one grows with its text up to 40vh;
|
|
* - `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 Proxy's models), plus Custom…;
|
|
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
|
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
|
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
|
* back to the model's own default).
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type Dispatch,
|
|
type MutableRefObject,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
FileText,
|
|
FolderOpen,
|
|
GitBranch,
|
|
Home,
|
|
History as HistoryIcon,
|
|
RotateCcw,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
api,
|
|
RUN_EFFORT_CHOICES,
|
|
RUN_MODEL_CHOICES,
|
|
type EffortLevel,
|
|
type ModelChoice,
|
|
} from "../../lib/api";
|
|
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
|
import type { LaneTool } from "../../lib/types";
|
|
import { Select, menuRoom } from "../Select";
|
|
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
|
import { isSubmitEnter } from "../../lib/keys";
|
|
import { ImageLightbox } from "../ImageLightbox";
|
|
import {
|
|
ATTACH_MIME,
|
|
addAttachments,
|
|
droppedAttachments,
|
|
droppedSkill,
|
|
SKILL_MIME,
|
|
rememberUploads,
|
|
} from "../../lib/promptAttach";
|
|
|
|
export interface SlashCommand {
|
|
name: string;
|
|
source: "project" | "user" | "plugin" | "builtin";
|
|
description?: string;
|
|
}
|
|
|
|
// Commands the CLI answers itself, and the skills bundled inside it. They are
|
|
// typed into the pane like any other text, so they do run — they are just not
|
|
// files anyone can list.
|
|
// ponytail: a hand-kept list, so it lags new Claude Code releases; no file or
|
|
// API lists the built-ins, add names here as they ship.
|
|
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|
"help",
|
|
"clear",
|
|
"compact",
|
|
"config",
|
|
"model",
|
|
"memory",
|
|
"hooks",
|
|
"cost",
|
|
"usage",
|
|
"context",
|
|
"agents",
|
|
"review",
|
|
"permissions",
|
|
"status",
|
|
"init",
|
|
"mcp",
|
|
"plugin",
|
|
"reload-plugins",
|
|
"skills",
|
|
"output-style",
|
|
"resume",
|
|
"rewind",
|
|
"add-dir",
|
|
"doctor",
|
|
"export",
|
|
"login",
|
|
"logout",
|
|
"statusline",
|
|
"theme",
|
|
"fast",
|
|
"exit",
|
|
"code-review",
|
|
"security-review",
|
|
"simplify",
|
|
"loop",
|
|
"schedule",
|
|
"fewer-permission-prompts",
|
|
"update-config",
|
|
"claude-api",
|
|
"run",
|
|
].map((name) => ({ name, source: "builtin" }));
|
|
|
|
const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
|
|
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
|
project: "text-status-success border-status-success/40",
|
|
user: "text-accent border-accent/40",
|
|
plugin: "text-accent border-accent/40",
|
|
builtin: "text-fg-muted border-border",
|
|
};
|
|
|
|
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
|
|
* description (3+ chars only, so one keystroke drags nothing tangential in). */
|
|
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
|
|
if (!q) return 1;
|
|
const n = name.toLowerCase();
|
|
if (n === q) return 1000;
|
|
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
|
|
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
|
|
const idx = n.indexOf(q);
|
|
if (idx >= 0) return 400 - Math.min(idx, 100);
|
|
let i = 0;
|
|
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
|
|
if (i === q.length) return 200;
|
|
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
|
|
return 0;
|
|
}
|
|
|
|
interface Trigger {
|
|
kind: "slash" | "file";
|
|
query: string;
|
|
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
|
|
start: number;
|
|
cursor: number;
|
|
}
|
|
|
|
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
|
|
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
|
|
function detectTrigger(value: string, cursor: number): Trigger | null {
|
|
let start = cursor;
|
|
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
|
|
const tok = value.slice(start, cursor);
|
|
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
|
|
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
|
|
return null;
|
|
}
|
|
|
|
interface PromptEditorProps {
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
onSubmit: () => void;
|
|
placeholder: string;
|
|
rows?: number;
|
|
/** Offer slash commands. Off for codex: these are Claude Code's. */
|
|
slash: boolean;
|
|
/** Where `@` looks for files and project commands/skills are read from. */
|
|
fileCwd: string;
|
|
/** Files pasted or dropped so far (images shown as thumbnails); the caller
|
|
* sends them with the text through `promptWithImages`. */
|
|
images: PromptImage[];
|
|
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
|
/** Borderless, for a composer box that draws its own frame — and grows
|
|
* with its text, from `rows` up to 40% of the screen, then scrolls. */
|
|
bare?: boolean;
|
|
/** An image upload started (true) or settled (false): the caller holds its
|
|
* send button meanwhile, or the prompt would go out without the image. */
|
|
onBusyChange?: (busy: boolean) => void;
|
|
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
|
lane?: number | string;
|
|
/** Set to a function that opens a file picker (a composer's + menu). */
|
|
pickFiles?: MutableRefObject<(() => void) | null>;
|
|
}
|
|
|
|
/** An attached file: its path on the server's machine, and a local preview
|
|
* `url` when it is an image. */
|
|
export type PromptImage = { path: string; url?: string; name: string };
|
|
|
|
/** The prompt as sent: the text, then each file's saved path — which is how
|
|
* the CLI is handed an image or a file. */
|
|
export function promptWithImages(text: string, images: PromptImage[]): string {
|
|
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
|
}
|
|
|
|
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
|
|
export const PASTE_AS_FILE_CHARS = 5000;
|
|
|
|
export function PromptEditor({
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
placeholder,
|
|
rows = 5,
|
|
slash,
|
|
fileCwd,
|
|
images,
|
|
onImagesChange,
|
|
bare,
|
|
onBusyChange,
|
|
lane,
|
|
pickFiles,
|
|
}: PromptEditorProps) {
|
|
const { t } = useTranslation("run");
|
|
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
|
// A composer grows with its text: reset to its rows, then take what the
|
|
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
|
useLayoutEffect(() => {
|
|
const ta = taRef.current;
|
|
if (!bare || !ta) return;
|
|
ta.style.height = "auto";
|
|
ta.style.height = `${ta.scrollHeight}px`;
|
|
}, [bare, value]);
|
|
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
|
const [active, setActive] = useState(0);
|
|
const [files, setFiles] = useState<string[]>([]);
|
|
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
|
|
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
|
|
if (commandsCwd !== fileCwd) {
|
|
setCommandsCwd(fileCwd);
|
|
setCommands(null);
|
|
}
|
|
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
|
|
|
|
// Commands and skills are read the first time `/` is typed for this cwd —
|
|
// two directory walks nobody should pay for on every render of the form.
|
|
useEffect(() => {
|
|
if (kind !== "slash" || commands) return;
|
|
let live = true;
|
|
const toCmd = (c: CcMdItem): SlashCommand => ({
|
|
name: c.name,
|
|
source: c.scope,
|
|
description: c.frontmatter?.description || c.preview.slice(0, 80),
|
|
});
|
|
Promise.all([
|
|
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
|
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
|
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
|
]).then(([c, sk, pl]) => {
|
|
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
|
// installed at both user and project scope is listed once.
|
|
const fromPlugins = pl.plugins
|
|
.filter((p) => p.enabled !== false && p.contributes)
|
|
.flatMap((p) =>
|
|
[...p.contributes!.items.commands, ...p.contributes!.items.skills].map(
|
|
(it): SlashCommand => ({
|
|
name: `${p.name}:${it.name}`,
|
|
source: "plugin",
|
|
description: it.description || undefined,
|
|
})
|
|
)
|
|
)
|
|
.filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i);
|
|
if (live)
|
|
setCommands(
|
|
[...c.items, ...sk.items].map(toCmd).concat(fromPlugins, BUILTIN_SLASH_COMMANDS)
|
|
);
|
|
});
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [kind, commands, fileCwd]);
|
|
|
|
// Files: debounced, and only the newest query may land.
|
|
const query = trigger?.query ?? "";
|
|
useEffect(() => {
|
|
if (kind !== "file") return;
|
|
let live = true;
|
|
const tid = window.setTimeout(() => {
|
|
api.run
|
|
.files(fileCwd, query)
|
|
.then((r) => live && setFiles(r.items))
|
|
.catch(() => live && setFiles([]));
|
|
}, 120);
|
|
return () => {
|
|
live = false;
|
|
window.clearTimeout(tid);
|
|
};
|
|
}, [kind, query, fileCwd]);
|
|
|
|
const slashItems = useMemo(() => {
|
|
if (kind !== "slash" || !commands) return [];
|
|
const q = query.toLowerCase();
|
|
return commands
|
|
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
|
|
.filter((x) => x.score > 0)
|
|
.sort(
|
|
(a, b) =>
|
|
b.score - a.score ||
|
|
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
|
|
a.cmd.name.localeCompare(b.cmd.name)
|
|
)
|
|
.map((x) => x.cmd);
|
|
}, [kind, commands, query]);
|
|
const count = kind === "file" ? files.length : slashItems.length;
|
|
const current = Math.min(active, Math.max(0, count - 1));
|
|
|
|
const pick = (token: string) => {
|
|
if (!trigger) return;
|
|
const before = value.slice(0, trigger.start);
|
|
const after = value.slice(trigger.cursor);
|
|
const sep = after.startsWith(" ") ? "" : " ";
|
|
onChange(before + token + sep + after);
|
|
setTrigger(null);
|
|
setActive(0);
|
|
const pos = before.length + token.length + 1;
|
|
requestAnimationFrame(() => {
|
|
taRef.current?.focus();
|
|
taRef.current?.setSelectionRange(pos, pos);
|
|
});
|
|
};
|
|
const pickAt = (i: number) => {
|
|
if (kind === "file") {
|
|
const f = files[i];
|
|
if (f) pick(`@${f}`);
|
|
} else {
|
|
const c = slashItems[i];
|
|
if (c) pick(`/${c.name}`);
|
|
}
|
|
};
|
|
|
|
/** Replace the selection with `text`, caret after it. */
|
|
const insert = (text: string) => {
|
|
const ta = taRef.current;
|
|
const from = ta?.selectionStart ?? value.length;
|
|
const to = ta?.selectionEnd ?? from;
|
|
onChange(value.slice(0, from) + text + value.slice(to));
|
|
const pos = from + text.length;
|
|
requestAnimationFrame(() => {
|
|
taRef.current?.focus();
|
|
taRef.current?.setSelectionRange(pos, pos);
|
|
});
|
|
};
|
|
|
|
// A pasted or dropped file is saved on the server's machine (the same
|
|
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
|
// its path is added to the prompt only when it is sent (promptWithImages).
|
|
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
|
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
|
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
|
const uploading = upload === "uploading";
|
|
const busyRef = useRef(onBusyChange);
|
|
busyRef.current = onBusyChange;
|
|
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
|
const attachImages = (list: FileList | File[] | null | undefined) => {
|
|
const picked = Array.from(list ?? []);
|
|
if (picked.length === 0) return false;
|
|
setUpload("uploading");
|
|
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
|
.then((saved) => {
|
|
setUpload(null);
|
|
const added = picked.map((f, i) => ({
|
|
path: saved[i]!.path,
|
|
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
|
name: f.name,
|
|
}));
|
|
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
|
onImagesChange((prev) => [...prev, ...added]);
|
|
})
|
|
.catch(() => setUpload("failed"));
|
|
return true;
|
|
};
|
|
const removeImage = (img: PromptImage) => {
|
|
if (img.url) URL.revokeObjectURL(img.url);
|
|
onImagesChange((prev) => prev.filter((x) => x !== img));
|
|
};
|
|
const closeViewer = useCallback(() => setViewing(null), []);
|
|
// The composer's own "Upload or add files" opens this picker; what it picks
|
|
// takes the same path as a paste or a drop.
|
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
|
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
|
|
|
// A drop lands anywhere on the screen around the composer — the nearest
|
|
// `data-prompt-drop` ancestor (a Chat pane, a replay, the workspace home),
|
|
// else a `bare` editor's parent, the box every caller draws around it and
|
|
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
|
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
|
// `/command`, a file is uploaded.
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
|
dropRef.current = (e) => {
|
|
const atts = droppedAttachments(e.dataTransfer);
|
|
const skill = droppedSkill(e.dataTransfer);
|
|
if (atts.length) {
|
|
e.preventDefault();
|
|
onImagesChange((prev) => addAttachments(prev, atts));
|
|
} else if (skill) {
|
|
e.preventDefault();
|
|
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
|
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
|
};
|
|
useEffect(() => {
|
|
const zone =
|
|
rootRef.current?.closest<HTMLElement>("[data-prompt-drop]") ??
|
|
(bare ? rootRef.current?.parentElement : rootRef.current);
|
|
if (!zone) return;
|
|
const over = (e: DragEvent) => {
|
|
const types = e.dataTransfer?.types ?? [];
|
|
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
|
|
e.preventDefault();
|
|
e.dataTransfer!.dropEffect = "copy";
|
|
};
|
|
const drop = (e: DragEvent) => dropRef.current(e);
|
|
zone.addEventListener("dragover", over);
|
|
zone.addEventListener("drop", drop);
|
|
return () => {
|
|
zone.removeEventListener("dragover", over);
|
|
zone.removeEventListener("drop", drop);
|
|
};
|
|
}, [bare]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (kind && count > 0) {
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
const step = e.key === "ArrowDown" ? 1 : -1;
|
|
setActive(Math.min(count - 1, Math.max(0, current + step)));
|
|
return;
|
|
}
|
|
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
e.preventDefault();
|
|
pickAt(current);
|
|
return;
|
|
}
|
|
}
|
|
if (kind && e.key === "Escape") {
|
|
e.preventDefault();
|
|
setTrigger(null);
|
|
return;
|
|
}
|
|
// Alt+Enter is a newline too, as in the CLI's own prompt; a textarea does
|
|
// nothing on it, so insert it here.
|
|
if (e.key === "Enter" && e.altKey) {
|
|
e.preventDefault();
|
|
insert("\n");
|
|
return;
|
|
}
|
|
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
|
// isSubmitEnter guards IME input (Vietnamese/CJK), where Enter commits the
|
|
// candidate. An image still uploading holds the send.
|
|
if (isSubmitEnter(e)) {
|
|
e.preventDefault();
|
|
if (!uploading) onSubmit();
|
|
}
|
|
};
|
|
|
|
const retrack = (ta: HTMLTextAreaElement) => {
|
|
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
|
|
};
|
|
|
|
const rowClass = (i: number) =>
|
|
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
|
|
|
return (
|
|
<div ref={rootRef} className="relative">
|
|
{images.length > 0 && (
|
|
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
|
{images.map((img) => (
|
|
<div key={img.path} className="group relative">
|
|
{img.url ? (
|
|
<button
|
|
type="button"
|
|
aria-label={img.name}
|
|
title={img.name}
|
|
onClick={() => setViewing(img)}
|
|
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
|
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
|
>
|
|
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
|
</button>
|
|
) : (
|
|
<span
|
|
title={img.path}
|
|
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
|
>
|
|
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
|
<span className="truncate">{img.name}</span>
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
aria-label={t("drop.remove")}
|
|
title={t("drop.remove")}
|
|
onClick={() => removeImage(img)}
|
|
className="absolute -right-1.5 -top-1.5 rounded-full border border-border bg-surface-1 p-0.5 text-fg-muted opacity-0 transition-opacity hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{imageMenu && (
|
|
<ContextMenu x={imageMenu.x} y={imageMenu.y} onClose={() => setImageMenu(null)}>
|
|
<MenuItem
|
|
onSelect={() => {
|
|
setImageMenu(null);
|
|
setViewing(imageMenu.img);
|
|
}}
|
|
>
|
|
{t("drop.view")}
|
|
</MenuItem>
|
|
<MenuItem
|
|
danger
|
|
onSelect={() => {
|
|
setImageMenu(null);
|
|
removeImage(imageMenu.img);
|
|
}}
|
|
>
|
|
{t("drop.remove")}
|
|
</MenuItem>
|
|
</ContextMenu>
|
|
)}
|
|
</div>
|
|
)}
|
|
{pickFiles && (
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
data-testid="prompt-file-input"
|
|
onChange={(e) => {
|
|
attachImages(e.target.files);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
)}
|
|
<textarea
|
|
ref={taRef}
|
|
value={value}
|
|
onChange={(e) => {
|
|
onChange(e.target.value);
|
|
retrack(e.target);
|
|
setActive(0);
|
|
}}
|
|
onSelect={(e) => retrack(e.currentTarget)}
|
|
onBlur={() => setTrigger(null)}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={(e) => {
|
|
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
|
else {
|
|
// A very long paste goes in as a text file, like Claude Desktop:
|
|
// the prompt then carries its path, never the text (#68: a CLI's
|
|
// argument and a tmux message both have a size limit).
|
|
const text = e.clipboardData?.getData("text/plain") ?? "";
|
|
if (text.length > PASTE_AS_FILE_CHARS) {
|
|
e.preventDefault();
|
|
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
|
}
|
|
}
|
|
}}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
spellCheck={false}
|
|
className={
|
|
bare
|
|
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
|
: "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"
|
|
}
|
|
/>
|
|
{upload && (
|
|
<p
|
|
role={upload === "failed" ? "alert" : "status"}
|
|
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
|
>
|
|
{t(`drop.${upload}`)}
|
|
</p>
|
|
)}
|
|
{kind && (
|
|
<div
|
|
role="listbox"
|
|
data-testid="prompt-autocomplete"
|
|
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
|
>
|
|
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
|
|
</div>
|
|
{count === 0 ? (
|
|
<div className="px-3 py-2 text-[11px] text-fg-muted">
|
|
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
|
|
</div>
|
|
) : kind === "slash" ? (
|
|
slashItems.map((c, i) => (
|
|
<button
|
|
key={`${c.source}:${c.name}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === current}
|
|
// Keep focus in the textarea, or its blur closes the list first.
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => pickAt(i)}
|
|
onMouseEnter={() => setActive(i)}
|
|
className={rowClass(i)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
|
<span
|
|
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
|
|
>
|
|
{c.source}
|
|
</span>
|
|
</div>
|
|
{c.description && (
|
|
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
|
|
)}
|
|
</button>
|
|
))
|
|
) : (
|
|
files.map((f, i) => (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === current}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => pickAt(i)}
|
|
onMouseEnter={() => setActive(i)}
|
|
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
|
|
>
|
|
{f}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
|
|
</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(() => {
|
|
// "repo" (source repos lanes were cut from) sits above the recents: on the
|
|
// Add-lane form it is the group the field is actually asking for, and on
|
|
// the Run form it is a short, deliberate list rather than history.
|
|
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "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
|
|
data-cwd-suggestions
|
|
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" />
|
|
) : g.kind === "repo" ? (
|
|
<GitBranch 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 ──────────────────────────────────────────────────────
|
|
|
|
// 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__";
|
|
|
|
/**
|
|
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
|
|
* own list (Codex's models cache, Claude's aliases named after the newest
|
|
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
|
|
* Claude's static aliases only when the server cannot answer. `loaded` once
|
|
* the list is final.
|
|
*/
|
|
function useModelChoices(tool: LaneTool, fixed?: ModelChoice[]) {
|
|
const [fetched, setFetched] = useState<ModelChoice[] | null>(null);
|
|
useEffect(() => {
|
|
let live = true;
|
|
setFetched(null);
|
|
Promise.resolve()
|
|
.then(() => api.aiTools.models(tool))
|
|
.then((r) => live && setFetched(r.models))
|
|
.catch(() => live && setFetched([]));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [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];
|
|
}, [fixed, fetched, tool]);
|
|
return { choices, loaded: fixed !== undefined || fetched !== null };
|
|
}
|
|
|
|
export function ModelPicker({
|
|
value,
|
|
onChange,
|
|
tool,
|
|
choices: fixed,
|
|
variant,
|
|
}: {
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
tool: LaneTool;
|
|
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
|
choices?: ModelChoice[];
|
|
variant?: "field" | "ghost";
|
|
}) {
|
|
const { t } = useTranslation("run");
|
|
const { choices, loaded } = useModelChoices(tool, fixed);
|
|
// "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 = loaded && value !== "" && !knownIds.includes(value);
|
|
const [showCustom, setShowCustom] = useState(false);
|
|
|
|
// 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(
|
|
() => [
|
|
...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, choices]
|
|
);
|
|
|
|
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={variant === "ghost" ? "flex items-center gap-1.5" : "space-y-1.5"}>
|
|
<Select<string>
|
|
value={selectValue}
|
|
onChange={onSelect}
|
|
options={options}
|
|
variant={variant}
|
|
label={t("fields.model")}
|
|
/>
|
|
{(showCustom || isCustom) && (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={t("fields.modelCustomPlaceholder")}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className={`${variant === "ghost" ? "w-40" : "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>
|
|
);
|
|
}
|
|
|
|
/** {@link ModelPicker} + the effort picker folded into one chip and popover. */
|
|
export function ModelMenu({
|
|
value,
|
|
onChange,
|
|
tool,
|
|
choices: fixed,
|
|
effort,
|
|
onEffortChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
tool: LaneTool;
|
|
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
|
choices?: ModelChoice[];
|
|
/** Omitted for a tool that takes no effort. */
|
|
effort?: EffortLevel;
|
|
onEffortChange?: (e: EffortLevel) => void;
|
|
}) {
|
|
const { t } = useTranslation("run");
|
|
const { choices } = useModelChoices(tool, fixed);
|
|
const [open, setOpen] = useState(false);
|
|
const [more, setMore] = useState(false);
|
|
const [custom, setCustom] = useState(false);
|
|
const [up, setUp] = useState(false);
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
// Below the chip unless its pane (or the viewport) has more room above it.
|
|
if (ref.current) {
|
|
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
|
|
setUp(below < 360 && above > below);
|
|
}
|
|
const away = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
document.addEventListener("mousedown", away);
|
|
document.addEventListener("keydown", esc);
|
|
return () => {
|
|
document.removeEventListener("mousedown", away);
|
|
document.removeEventListener("keydown", esc);
|
|
};
|
|
}, [open]);
|
|
|
|
const label = (c: ModelChoice) => (c.id === "" ? t("fields.modelInheritLabel") : c.label);
|
|
// Claude's aliases carry no hint from the server; the static list's apply.
|
|
const hint = (c: ModelChoice) => c.hint ?? RUN_MODEL_CHOICES.find((x) => x.id === c.id)?.hint;
|
|
// Claude: the aliases up front, the pinned versions under More models.
|
|
const pinned = (c: ModelChoice) => tool === "claude" && c.id.startsWith("claude-");
|
|
const main = choices.filter((c) => !pinned(c));
|
|
const rest = choices.filter(pinned);
|
|
const current = choices.find((c) => c.id === value);
|
|
const effortLabel = RUN_EFFORT_CHOICES.find((c) => c.id === effort && c.id)?.label;
|
|
const pick = (id: string) => {
|
|
onChange(id);
|
|
setOpen(false);
|
|
};
|
|
const row = (c: ModelChoice) => (
|
|
<button
|
|
key={c.id || "__default__"}
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={c.id === value}
|
|
onClick={() => pick(c.id)}
|
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left hover:bg-surface-3"
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-xs text-fg-primary">{label(c)}</span>
|
|
{hint(c) && <span className="block truncate text-[11px] text-fg-muted">{hint(c)}</span>}
|
|
</span>
|
|
{c.id === value && <Check className="h-3.5 w-3.5 shrink-0 text-accent" />}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div ref={ref} className="relative" data-testid="model-menu">
|
|
<button
|
|
type="button"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
title={t("fields.model")}
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex max-w-[16rem] items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<span className="truncate text-fg-primary">
|
|
{current ? label(current) : value || t("fields.modelInheritLabel")}
|
|
</span>
|
|
{effortLabel && <span className="shrink-0 text-fg-muted">{effortLabel}</span>}
|
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
|
</button>
|
|
{open && (
|
|
<div
|
|
role="menu"
|
|
className={`absolute right-0 z-30 ${up ? "bottom-full mb-1" : "top-full mt-1"} max-h-[70vh] w-72 overflow-auto rounded-xl border border-border bg-surface-1 p-1 shadow-lg shadow-black/40`}
|
|
>
|
|
{main.map(row)}
|
|
{(!!rest.length || !fixed) && <div className="my-1 border-t border-border" />}
|
|
{!!rest.length && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMore((v) => !v)}
|
|
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
|
|
>
|
|
{t("fields.moreModels")}
|
|
<ChevronRight
|
|
className={`h-3.5 w-3.5 transition-transform ${more ? "rotate-90" : ""}`}
|
|
/>
|
|
</button>
|
|
{more && rest.map(row)}
|
|
</>
|
|
)}
|
|
{custom ? (
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
defaultValue={current ? "" : value}
|
|
placeholder={t("fields.modelCustomPlaceholder")}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
onKeyDown={(e) => {
|
|
if (!isSubmitEnter(e)) return;
|
|
e.preventDefault();
|
|
setCustom(false);
|
|
pick(e.currentTarget.value.trim());
|
|
}}
|
|
className="m-1 w-[calc(100%-0.5rem)] rounded-md border border-border bg-surface-2 px-2 py-1 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setCustom(true)}
|
|
className="flex w-full rounded-md px-2.5 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
|
|
>
|
|
{t("fields.modelCustom")}
|
|
</button>
|
|
)}
|
|
{onEffortChange && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
<EffortSlider value={effort ?? ""} onChange={onEffortChange} />
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Low → Max as one slider, Claude Desktop's; no level picked = the model's own default. */
|
|
function EffortSlider({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: EffortLevel;
|
|
onChange: (e: EffortLevel) => void;
|
|
}) {
|
|
const { t } = useTranslation("run");
|
|
const levels = RUN_EFFORT_CHOICES.filter((c) => c.id);
|
|
const index = levels.findIndex((c) => c.id === value);
|
|
const level = levels[index];
|
|
const set = levels.length > 1 && !!level;
|
|
const pct = set ? (index / (levels.length - 1)) * 100 : 0;
|
|
const pick = (i: number) => levels[i] && onChange(levels[i].id);
|
|
return (
|
|
<div className="px-2.5 pb-2 pt-1" data-testid="effort-slider">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="text-fg-muted">{t("fields.effort")}</span>
|
|
<span className="flex-1 text-fg-primary">
|
|
{level ? level.label : t("fields.effortDefault")}
|
|
</span>
|
|
{set && (
|
|
<button
|
|
type="button"
|
|
title={t("fields.effortReset")}
|
|
aria-label={t("fields.effortReset")}
|
|
onClick={() => onChange("")}
|
|
className="rounded p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="mt-2 flex justify-between text-[11px] text-fg-muted">
|
|
<span>{t("fields.effortFaster")}</span>
|
|
<span>{t("fields.effortSmarter")}</span>
|
|
</div>
|
|
<div className="relative mt-1 h-6">
|
|
{/* The track, its fill up to the thumb, and a dot per level. */}
|
|
<div className="absolute inset-0 overflow-hidden rounded-full bg-surface-3">
|
|
{set && (
|
|
<div
|
|
className="h-full rounded-full bg-accent/30"
|
|
style={{ width: `calc(0.75rem + (100% - 1.5rem) * ${pct / 100})` }}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="pointer-events-none absolute inset-x-3 top-1/2 flex -translate-y-1/2 justify-between">
|
|
{levels.map((c) => (
|
|
<span key={c.id} className="h-1 w-1 rounded-full bg-fg-muted/60" />
|
|
))}
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={levels.length - 1}
|
|
step={1}
|
|
// Unset: no thumb, and a click anywhere picks that level (onClick,
|
|
// because onChange stays silent when the click lands on this value).
|
|
value={set ? index : 0}
|
|
aria-label={t("fields.effort")}
|
|
aria-valuetext={level ? level.label : t("fields.effortDefault")}
|
|
onChange={(e) => pick(Number(e.currentTarget.value))}
|
|
onClick={(e) => pick(Number(e.currentTarget.value))}
|
|
className={`absolute inset-0 h-full w-full cursor-pointer appearance-none bg-transparent focus:outline-none [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-fg-primary [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-fg-primary [&::-webkit-slider-thumb]:shadow focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-accent/50 ${
|
|
set ? "" : "[&::-moz-range-thumb]:opacity-0 [&::-webkit-slider-thumb]:opacity-0"
|
|
}`}
|
|
/>
|
|
</div>
|
|
<p className="mt-1.5 text-[11px] text-fg-muted">
|
|
{level ? level.hint : t("fields.effortDefaultHint")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|