From f35bf2043f9f325166a842a55e053a8f8d133beb Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 2 Oct 2026 16:13:27 +0700 Subject: [PATCH] feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker --- client/public/ai-tools/hermes-white.svg | 15 -- .../ai-tools/{hermes-black.svg => hermes.svg} | 0 client/src/components/AiToolIcon.tsx | 18 +- client/src/components/Select.tsx | 37 ++- client/src/components/lanes/SessionReplay.tsx | 26 +- .../components/lanes/SessionResumeButton.tsx | 4 +- client/src/components/lanes/WorkspaceHome.tsx | 243 +++++++++--------- .../lanes/__tests__/SessionReplay.test.tsx | 2 + .../lanes/__tests__/WorkspaceHome.test.tsx | 10 +- client/src/components/run/RouterPicker.tsx | 6 +- client/src/components/run/RunSetup.tsx | 153 ++++++++++- .../run/__tests__/RunSetup.test.tsx | 69 ++++- .../__tests__/AiToolsSettings.test.tsx | 10 +- client/src/i18n/locales/en/run.json | 3 +- client/src/i18n/locales/vi/run.json | 3 +- 15 files changed, 414 insertions(+), 185 deletions(-) delete mode 100644 client/public/ai-tools/hermes-white.svg rename client/public/ai-tools/{hermes-black.svg => hermes.svg} (100%) diff --git a/client/public/ai-tools/hermes-white.svg b/client/public/ai-tools/hermes-white.svg deleted file mode 100644 index 2477b436..00000000 --- a/client/public/ai-tools/hermes-white.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/client/public/ai-tools/hermes-black.svg b/client/public/ai-tools/hermes.svg similarity index 100% rename from client/public/ai-tools/hermes-black.svg rename to client/public/ai-tools/hermes.svg diff --git a/client/src/components/AiToolIcon.tsx b/client/src/components/AiToolIcon.tsx index 8c50f8a0..e687c1cd 100644 --- a/client/src/components/AiToolIcon.tsx +++ b/client/src/components/AiToolIcon.tsx @@ -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ĩ @@ -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 = { +/** Shipped marks by tool id. */ +const BUNDLED: Record = { 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(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 ; - if (bundled) { - return ( - <> - - - - ); - } + if (bundled) return ; if (failedId !== tool.id) { return ( { 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 { options: SelectOption[]; /** 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({ value, onChange, options, disabled }: SelectProps) { +export function Select({ + value, + onChange, + options, + disabled, + variant = "field", + label, +}: SelectProps) { + const ghost = variant === "ghost"; const [open, setOpen] = useState(false); const [active, setActive] = useState(() => Math.max( @@ -166,21 +180,31 @@ export function Select({ value, onChange, options, disabled }: const current = options.find((o) => o.value === value) || options[0]; return ( -
+
{open && (
@@ -199,6 +223,7 @@ export function Select({ value, onChange, options, disabled }: }`} >
+ {opt.icon} &session=`), 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(null); const [prompt, setPrompt] = useState(""); + const [images, setImages] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(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({
-
+
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" > - - {t("replay.send")} +
{error && ( diff --git a/client/src/components/lanes/SessionResumeButton.tsx b/client/src/components/lanes/SessionResumeButton.tsx index e4daaffa..952b993a 100644 --- a/client/src/components/lanes/SessionResumeButton.tsx +++ b/client/src/components/lanes/SessionResumeButton.tsx @@ -119,7 +119,7 @@ export function SessionResumeButton({ }; return ( -
+
{run ? ( )} {error && ( -

+

{error}

)} diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index e28c6e54..c54a8943 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -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 ( -
- - {label} - - {children} -
- ); -} - type HomeProps = Parameters[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([]); 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({

)} -
+
-
- -
- -
- - value={tool} - onChange={(v) => { - setTool(v); - setBypass(false); - }} - options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id) }))} - /> -
-
-
- - - +
+
+ + 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: ( + + ), + }))} + /> +
+ {hasModel && ( - - - + )} {claude && ( - - - 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") }, - ]} - /> - + + 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 && ( <> - - - 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") }, - ]} - /> - - - - value={approval} - onChange={setApproval} - options={[ - { value: "on-request", label: t("run:fields.approvalOnRequest") }, - { value: "never", label: t("run:fields.approvalNever") }, - ]} - /> - + + 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") }, + ]} + /> + + 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 && ( - - - value={effort} - onChange={setEffort} - options={RUN_EFFORT_CHOICES.map((c) => ({ - value: c.id, - label: c.label, - hint: c.hint, - }))} + + 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, + }))} + /> + )} +
+ {canBypass && ( + - - )} -
-
- {canBypass && ( - - )} - {claude && project?.root && ( - - )} - + )} + {claude && project?.root && ( + + )} + +
{danger && ( diff --git a/client/src/components/lanes/__tests__/SessionReplay.test.tsx b/client/src/components/lanes/__tests__/SessionReplay.test.tsx index 704f3c19..bc48cfa7 100644 --- a/client/src/components/lanes/__tests__/SessionReplay.test.tsx +++ b/client/src/components/lanes/__tests__/SessionReplay.test.tsx @@ -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, diff --git a/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx b/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx index 3de6ab7e..c28e088f 100644 --- a/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx +++ b/client/src/components/lanes/__tests__/WorkspaceHome.test.tsx @@ -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 () => { diff --git a/client/src/components/run/RouterPicker.tsx b/client/src/components/run/RouterPicker.tsx index 2fb9ee16..e876eae1 100644 --- a/client/src/components/run/RouterPicker.tsx +++ b/client/src/components/run/RouterPicker.tsx @@ -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 ( -
+
value={capable ? value : "native"} onChange={onChange} options={options} disabled={!capable} + variant={variant} + label={t("router.label")} />
); diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index cb175548..78e863c1 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -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ĩ */ -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>; + /** 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(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(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) => { 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 (
+ {images.length > 0 && ( +
+ {images.map((img) => ( +
+ + +
+ ))} +
+ )}