feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker

This commit is contained in:
2026-10-02 16:13:27 +07:00
parent 4802684d2f
commit f35bf2043f
15 changed files with 414 additions and 185 deletions
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 29 KiB

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

+5 -13
View File
@@ -1,7 +1,7 @@
/**
* @file AiToolIcon.tsx
* @description An AI tool's icon: the mark shipped in `client/public/ai-tools/`
* when there is one (a light/dark pair where a mark needs one per theme), else
* when there is one, else
* the server's cached favicon (GET /api/ai-tools/:id/icon), else the label's
* first letter. Every tool icon in the app renders through here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -10,13 +10,13 @@
import { useState } from "react";
import { api, type AiTool } from "../lib/api";
/** Shipped marks by tool id; `[light, dark]` swaps with the theme (CSS only). */
const BUNDLED: Record<string, string | [string, string]> = {
/** Shipped marks by tool id. */
const BUNDLED: Record<string, string> = {
claude: "/ai-tools/claude.svg",
codex: "/ai-tools/codex.svg",
gemini: "/ai-tools/gemini.svg",
openclaw: "/ai-tools/openclaw.svg",
hermes: ["/ai-tools/hermes-black.svg", "/ai-tools/hermes-white.svg"],
hermes: "/ai-tools/hermes.svg",
};
/**
@@ -34,15 +34,7 @@ export function AiToolIcon({
const [failedId, setFailedId] = useState<string | null>(null);
const box = `${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`;
const bundled = BUNDLED[tool.id];
if (typeof bundled === "string") return <img src={bundled} alt="" className={box} />;
if (bundled) {
return (
<>
<img src={bundled[0]} alt="" className={`${box} dark:hidden`} />
<img src={bundled[1]} alt="" className={`${box} hidden dark:block`} />
</>
);
}
if (bundled) return <img src={bundled} alt="" className={box} />;
if (failedId !== tool.id) {
return (
<img
+31 -6
View File
@@ -63,7 +63,7 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
/** Single option in a {@link Select} list. */
@@ -74,6 +74,8 @@ export interface SelectOption<T extends string> {
label: string;
/** Optional secondary line (smaller gray text). */
hint?: string;
/** Optional leading icon, shown in the trigger and the list. */
icon?: ReactNode;
}
/** Props for {@link Select}. */
@@ -86,13 +88,25 @@ export interface SelectProps<T extends string> {
options: SelectOption<T>[];
/** Disables opening the list. */
disabled?: boolean;
/** `ghost`: a borderless, content-wide trigger for a composer's toolbar. */
variant?: "field" | "ghost";
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
label?: string;
}
/**
* Accessible custom select control.
* @typeParam T - string union of allowed option values.
*/
export function Select<T extends string>({ value, onChange, options, disabled }: SelectProps<T>) {
export function Select<T extends string>({
value,
onChange,
options,
disabled,
variant = "field",
label,
}: SelectProps<T>) {
const ghost = variant === "ghost";
const [open, setOpen] = useState(false);
const [active, setActive] = useState(() =>
Math.max(
@@ -166,21 +180,31 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
const current = options.find((o) => o.value === value) || options[0];
return (
<div ref={containerRef} className="relative">
<div ref={containerRef} className={ghost ? "relative inline-block max-w-[11rem]" : "relative"}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
aria-label={label}
aria-haspopup="listbox"
title={label}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKey}
className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className={
ghost
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-accent/50 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
}
>
<span className="truncate">{current?.label ?? "-"}</span>
<span className="flex min-w-0 items-center gap-2">
{current?.icon}
<span className="truncate">{current?.label ?? "-"}</span>
</span>
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
</button>
{open && (
<div
className={`absolute z-30 left-0 right-0 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
@@ -199,6 +223,7 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
}`}
>
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
+17 -9
View File
@@ -2,7 +2,7 @@
* @file SessionReplay — an ENDED session opened in its workspace
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
* an old one: its conversation, read-only (`ConversationView`), and a prompt
* box under it. Opening it resumes nothing; only SENDING a message does —
* box under it (a pasted image rides along as a thumbnail). Opening it resumes nothing; only SENDING a message does —
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the
* first free pane slot, after which `onResumed` opens the pane (`&run=`). The
* session's tool and folder come from its row (`GET /api/sessions/:id`), so a
@@ -12,11 +12,11 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Send } from "lucide-react";
import { ArrowUp } from "lucide-react";
import { api, type RunHandle } from "../../lib/api";
import type { Lane, Session } from "../../lib/types";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor } from "../run/RunSetup";
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { freeSlot, paneRunId } from "./WorkspaceHome";
export function SessionReplay({
@@ -35,6 +35,7 @@ export function SessionReplay({
const { t } = useTranslation("lanes");
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -42,6 +43,7 @@ export function SessionReplay({
let live = true;
setSession(null);
setPrompt("");
setImages([]);
setError(null);
api.sessions
.get(sessionId)
@@ -53,7 +55,7 @@ export function SessionReplay({
}, [sessionId]);
const send = async () => {
const text = prompt.trim();
const text = promptWithImages(prompt, images);
if (!text || busy) return;
const slot = freeSlot(lane.id, liveRuns);
if (slot === null) {
@@ -69,6 +71,8 @@ export function SessionReplay({
prompt: text,
});
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]);
onResumed(r.run_id ?? paneRunId(lane.id, slot));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -82,8 +86,11 @@ export function SessionReplay({
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} />
</div>
<div className="rounded-xl border border-border bg-surface-1 p-2">
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
value={prompt}
onChange={setPrompt}
onSubmit={() => void send()}
@@ -98,11 +105,12 @@ export function SessionReplay({
type="button"
data-testid="session-replay-send"
onClick={() => void send()}
disabled={busy || !prompt.trim()}
className="btn-primary ml-auto text-xs disabled:opacity-50"
disabled={busy || (!prompt.trim() && images.length === 0)}
aria-label={t("replay.send")}
title={t("replay.send")}
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<Send className="h-3.5 w-3.5" />
{t("replay.send")}
<ArrowUp className="h-4 w-4" />
</button>
</div>
{error && (
@@ -119,7 +119,7 @@ export function SessionResumeButton({
};
return (
<div className="flex flex-col items-end gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{run ? (
<button type="button" onClick={() => open(run.id)} className="btn-ghost text-xs">
<SquareTerminal className="h-4 w-4" />
@@ -148,7 +148,7 @@ export function SessionResumeButton({
</button>
)}
{error && (
<p role="alert" className="max-w-xs text-right text-[11px] text-status-danger">
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
{error}
</p>
)}
+126 -117
View File
@@ -17,7 +17,7 @@
*/
import { Checkbox } from "../Checkbox";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
@@ -28,7 +28,7 @@ import {
GitBranch,
MoreHorizontal,
Pencil,
Play,
ArrowUp,
Plus,
X,
} from "lucide-react";
@@ -48,7 +48,7 @@ import type { Lane, Project, Session } from "../../lib/types";
import { fmtCost, timeAgo } from "../../lib/format";
import { useAiTools } from "../../hooks/useAiTools";
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { ModelPicker, PromptEditor } from "../run/RunSetup";
import { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
import { AiToolIcon } from "../AiToolIcon";
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
import { Select } from "../Select";
@@ -85,18 +85,6 @@ function fmtBytes(n: number | null): string {
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** One labelled field of the prompt box's row. */
function Field({ label, width, children }: { label: string; width: string; children: ReactNode }) {
return (
<div className={width}>
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</span>
{children}
</div>
);
}
type HomeProps = Parameters<typeof HomeBody>[0];
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
@@ -127,6 +115,7 @@ function HomeBody({
const claude = tool === "claude";
const codex = tool === "codex";
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
@@ -283,7 +272,7 @@ function HomeBody({
try {
const r = await api.lanes.action(lane.id, "start", {
slot,
prompt: prompt.trim(),
prompt: promptWithImages(prompt, images),
// Only a different tool is named: the workspace's own keeps starting
// even after it was disabled in Settings.
...(tool !== lane.tool ? { tool } : {}),
@@ -296,6 +285,8 @@ function HomeBody({
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]);
const runId = r.run_id ?? paneRunId(lane.id, slot);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
@@ -407,7 +398,10 @@ function HomeBody({
</p>
)}
<div className="card mt-5 p-3">
<div
data-testid="workspace-home-composer"
className="mt-5 rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50"
>
<PromptEditor
value={prompt}
onChange={setPrompt}
@@ -416,117 +410,132 @@ function HomeBody({
rows={3}
slash={claude}
fileCwd={lane.cwd}
images={images}
onImagesChange={setImages}
bare
/>
<div className="mt-2 flex flex-wrap items-end gap-2">
<Field label={t("home.ai")} width="w-44">
<div data-testid="workspace-home-tool" className="flex items-center gap-1.5">
<AiToolIcon
tool={{ id: tool, label: aiTools.label(tool) }}
className="h-6 w-6 shrink-0"
/>
<div className="min-w-0 flex-1">
<Select<string>
value={tool}
onChange={(v) => {
setTool(v);
setBypass(false);
}}
options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id) }))}
/>
</div>
</div>
</Field>
<Field label={t("router.label")} width="w-44">
<RouterPicker tool={tool} routers={routers} value={router} onChange={setRouter} />
</Field>
<div className="mt-1 flex flex-wrap items-center gap-0.5">
<div data-testid="workspace-home-tool">
<Select<string>
variant="ghost"
label={t("home.ai")}
value={tool}
onChange={(v) => {
setTool(v);
setBypass(false);
}}
options={toolOptions.map((id) => ({
value: id,
label: aiTools.label(id),
icon: (
<AiToolIcon
tool={{ id, label: aiTools.label(id) }}
className="h-4 w-4 !p-0.5"
/>
),
}))}
/>
</div>
<RouterPicker
variant="ghost"
tool={tool}
routers={routers}
value={router}
onChange={setRouter}
/>
{hasModel && (
<Field label={t("run:fields.model")} width="w-56">
<ModelPicker value={model} onChange={setModel} tool={tool} choices={routerModels} />
</Field>
<ModelPicker
variant="ghost"
value={model}
onChange={setModel}
tool={tool}
choices={routerModels}
/>
)}
{claude && (
<Field label={t("run:fields.permissionMode")} width="w-44">
<Select<PermissionMode>
value={mode}
onChange={setMode}
options={[
{ value: "auto", label: t("run:fields.permissionAuto") },
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
{ value: "default", label: t("run:fields.permissionDefault") },
{ value: "plan", label: t("run:fields.permissionPlan") },
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
]}
/>
</Field>
<Select<PermissionMode>
variant="ghost"
label={t("run:fields.permissionMode")}
value={mode}
onChange={setMode}
options={[
{ value: "auto", label: t("run:fields.permissionAuto") },
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
{ value: "default", label: t("run:fields.permissionDefault") },
{ value: "plan", label: t("run:fields.permissionPlan") },
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
]}
/>
)}
{codex && (
<>
<Field label={t("run:fields.sandbox")} width="w-40">
<Select<CodexSandbox>
value={sandbox}
onChange={setSandbox}
options={[
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
]}
/>
</Field>
<Field label={t("run:fields.approval")} width="w-32">
<Select<CodexApproval>
value={approval}
onChange={setApproval}
options={[
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
{ value: "never", label: t("run:fields.approvalNever") },
]}
/>
</Field>
<Select<CodexSandbox>
variant="ghost"
label={t("run:fields.sandbox")}
value={sandbox}
onChange={setSandbox}
options={[
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
]}
/>
<Select<CodexApproval>
variant="ghost"
label={t("run:fields.approval")}
value={approval}
onChange={setApproval}
options={[
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
{ value: "never", label: t("run:fields.approvalNever") },
]}
/>
</>
)}
{hasEffort && (
<Field label={t("run:fields.effort")} width="w-40">
<Select<EffortLevel>
value={effort}
onChange={setEffort}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
<Select<EffortLevel>
variant="ghost"
label={t("run:fields.effort")}
value={effort}
onChange={setEffort}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
)}
<div className="ml-auto flex items-center gap-3 pl-2">
{canBypass && (
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
</Field>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{canBypass && (
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
/>
)}
{claude && project?.root && (
<Checkbox
checked={inWorktree}
onChange={setInWorktree}
label={t("home.worktree")}
title={t("home.worktreeHint")}
className="ml-1"
/>
)}
<button
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting}
className="btn-primary ml-auto text-xs disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
{t("home.start")}
</button>
)}
{claude && project?.root && (
<Checkbox
checked={inWorktree}
onChange={setInWorktree}
label={t("home.worktree")}
title={t("home.worktreeHint")}
labelClassName="text-xs text-fg-secondary"
/>
)}
<button
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting}
aria-label={t("home.start")}
title={t("home.start")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
>
<ArrowUp className="h-4 w-4" />
</button>
</div>
</div>
</div>
{danger && (
@@ -19,6 +19,8 @@ vi.mock("../../conversation/ConversationView", () => ({
),
}));
vi.mock("../../run/RunSetup", () => ({
promptWithImages: (text: string, images: { path: string }[]) =>
[text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "),
PromptEditor: ({
value,
onChange,
@@ -19,6 +19,8 @@ import type { Lane, Session } from "../../../lib/types";
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
vi.mock("../../run/RunSetup", () => ({
promptWithImages: (text: string, images: { path: string }[]) =>
[text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "),
PromptEditor: ({
value,
onChange,
@@ -211,9 +213,11 @@ describe("WorkspaceHome", () => {
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
const card = screen.getByTestId("workspace-home-start").closest(".card") as HTMLElement;
const labels = [...card.querySelectorAll("span.uppercase")].map((x) => x.textContent);
expect(labels).toEqual(["AI", "AI Router", "Model", "Permission mode", "Thinking effort"]);
const box = screen.getByTestId("workspace-home-composer");
const labels = [
...box.querySelectorAll("button[aria-haspopup='listbox'], input[aria-label='model']"),
].map((x) => x.getAttribute("aria-label"));
expect(labels).toEqual(["AI", "AI Router", "model", "Permission mode", "Thinking effort"]);
});
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
+5 -1
View File
@@ -63,7 +63,9 @@ export function RouterPicker({
routers,
value,
onChange,
variant,
}: {
variant?: "field" | "ghost";
tool: string;
/** From `useAiRouters`. */
routers: AiRouter[] | null;
@@ -86,12 +88,14 @@ export function RouterPicker({
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
: [{ value: "native", label: native }];
return (
<div data-testid="router-picker" title={t("router.label")}>
<div data-testid="router-picker">
<Select<string>
value={capable ? value : "native"}
onChange={onChange}
options={options}
disabled={!capable}
variant={variant}
label={t("router.label")}
/>
</div>
);
+146 -7
View File
@@ -4,7 +4,9 @@
* 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;
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and a pasted or dropped image attached by its saved path
* (`POST /api/run/drop-image`);
* - `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….
@@ -12,9 +14,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen, GitBranch, Home, History as HistoryIcon } from "lucide-react";
import { FolderOpen, GitBranch, Home, History as HistoryIcon, X } 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";
@@ -101,6 +103,21 @@ interface PromptEditorProps {
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string;
/** Images pasted or dropped so far, 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. */
bare?: boolean;
}
/** An attached image: its path on the server's machine and a local preview. */
export type PromptImage = { path: string; url: string; name: string };
/** The prompt as sent: the text, then each image's saved path — which is how
* the CLI is handed an image. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
export function PromptEditor({
@@ -111,6 +128,9 @@ export function PromptEditor({
rows = 5,
slash,
fileCwd,
images,
onImagesChange,
bare,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
@@ -204,6 +224,52 @@ export function PromptEditor({
}
};
/** 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 image is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail; 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 attachImages = (list: FileList | null | undefined) => {
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropImage(f)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
path: saved[i]!.path,
url: URL.createObjectURL(f),
name: f.name,
}));
onImagesChange((prev) => [...prev, ...added]);
})
.catch(() => setUpload("failed"));
return true;
};
const removeImage = (img: PromptImage) => {
URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
};
useEffect(() => {
if (!viewing) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [viewing]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
@@ -223,6 +289,13 @@ export function PromptEditor({
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.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
@@ -240,6 +313,32 @@ export function PromptEditor({
return (
<div 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.url} className="group relative">
<button
type="button"
aria-label={img.name}
title={img.name}
onClick={() => setViewing(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>
<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>
))}
</div>
)}
<textarea
ref={taRef}
value={value}
@@ -251,11 +350,29 @@ export function PromptEditor({
onSelect={(e) => retrack(e.currentTarget)}
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
}}
onDrop={(e) => {
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
}}
placeholder={placeholder}
rows={rows}
spellCheck={false}
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"
className={
bare
? "block w-full resize-none 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"
@@ -313,6 +430,20 @@ export function PromptEditor({
)}
</div>
)}
{viewing && (
<div
role="dialog"
aria-label={viewing.name}
onClick={() => setViewing(null)}
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={viewing.url}
alt={viewing.name}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>
)}
</div>
);
}
@@ -487,12 +618,14 @@ export function ModelPicker({
onChange,
tool,
choices: fixed,
variant,
}: {
value: string;
onChange: (s: string) => void;
tool: LaneTool;
/** A fixed list instead of the tool's own — an AI Router's models. */
choices?: ModelChoice[];
variant?: "field" | "ghost";
}) {
const { t } = useTranslation("run");
// The tool's own list (Codex's models cache, Claude's aliases named after
@@ -550,8 +683,14 @@ export function ModelPicker({
const selectValue = showCustom || isCustom ? MODEL_CUSTOM : value;
return (
<div className="space-y-1.5">
<Select<string> value={selectValue} onChange={onSelect} options={options} />
<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"
@@ -560,7 +699,7 @@ export function ModelPicker({
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"
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>
@@ -2,17 +2,19 @@
* @file RunSetup.test.tsx
* @description The run-form pieces that outlived the pane's run form:
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
* open, `/` only where slash commands apply) and `ModelPicker` — a tool's own
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
* list from the server, a fixed list (an AI Router's models) when given one,
* and Custom…, which is a sentinel, never a model id.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ModelPicker, PromptEditor } from "../RunSetup";
import { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../RunSetup";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", async (importOriginal) => {
@@ -34,7 +36,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }) },
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
ccConfig: {
commands: r({
items: [
@@ -67,6 +69,8 @@ function renderEditor(value: string, slash = true) {
placeholder="Ask anything…"
slash={slash}
fileCwd="/Users/tester"
images={[]}
onImagesChange={() => {}}
{...spies}
/>
);
@@ -117,6 +121,65 @@ describe("PromptEditor — / and @ suggestions", () => {
});
});
describe("PromptEditor — newlines and images", () => {
it("Alt+Enter inserts a newline and does not send", () => {
const spies = renderEditor("ab");
(box() as HTMLTextAreaElement).setSelectionRange(1, 1);
fireEvent.keyDown(box(), { key: "Enter", altKey: true });
expect(spies.onChange).toHaveBeenLastCalledWith("a\nb");
fireEvent.keyDown(box(), { key: "Enter", shiftKey: true });
expect(spies.onSubmit).not.toHaveBeenCalled();
fireEvent.keyDown(box(), { key: "Enter" });
expect(spies.onSubmit).toHaveBeenCalledTimes(1);
});
it("shows a pasted image as a thumbnail that opens full size and can be removed", async () => {
URL.createObjectURL = vi.fn(() => "blob:shot");
URL.revokeObjectURL = vi.fn();
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="see"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("see", images)}</output>
</>
);
}
render(<Harness />);
const img = new File(["x"], "shot.png", { type: "image/png" });
fireEvent.paste(box(), { clipboardData: { files: [img] } });
const thumb = await screen.findByRole("button", { name: "shot.png" });
expect(api.run.dropImage).toHaveBeenCalledWith(img);
// The path is not typed into the prompt; it goes with it on send.
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
fireEvent.click(thumb);
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
expect(screen.getByTestId("sent").textContent).toBe("see");
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } });
expect(api.run.dropImage).not.toHaveBeenCalled();
});
});
describe("ModelPicker", () => {
it("lists the tool's own models, and Custom… reports only the typed id", async () => {
const onChange = vi.fn();
@@ -253,16 +253,12 @@ describe("AiToolIcon", () => {
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
});
it("uses the shipped mark, and a light/dark pair for Hermes", async () => {
it("uses the shipped mark, one for every theme", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
expect(container.querySelector('img[src="/ai-tools/claude.svg"]')).toBeTruthy();
rerender(<AiToolIcon tool={{ id: "hermes", label: "Hermes" }} />);
expect(container.querySelector('img[src="/ai-tools/hermes-black.svg"]')?.className).toMatch(
/dark:hidden/
);
expect(container.querySelector('img[src="/ai-tools/hermes-white.svg"]')?.className).toMatch(
/hidden dark:block/
);
expect(container.querySelectorAll("img")).toHaveLength(1);
expect(container.querySelector('img[src="/ai-tools/hermes.svg"]')).toBeTruthy();
});
});
+2 -1
View File
@@ -173,6 +173,7 @@
"hint": "Drop image to attach",
"uploading": "Uploading image…",
"failed": "Could not attach the image",
"imagesOnly": "Only images can be dropped here"
"imagesOnly": "Only images can be dropped here",
"remove": "Remove image"
}
}
+2 -1
View File
@@ -172,6 +172,7 @@
"hint": "Thả ảnh để đính kèm",
"uploading": "Đang tải ảnh lên…",
"failed": "Không đính kèm được ảnh",
"imagesOnly": "Chỉ thả được ảnh vào đây"
"imagesOnly": "Chỉ thả được ảnh vào đây",
"remove": "Bỏ ảnh"
}
}