+ {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({
);
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 (