feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user