feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker
This commit is contained in:
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 |
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @file AiToolIcon.tsx
|
* @file AiToolIcon.tsx
|
||||||
* @description An AI tool's icon: the mark shipped in `client/public/ai-tools/`
|
* @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
|
* 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.
|
* first letter. Every tool icon in the app renders through here.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api, type AiTool } from "../lib/api";
|
import { api, type AiTool } from "../lib/api";
|
||||||
|
|
||||||
/** Shipped marks by tool id; `[light, dark]` swaps with the theme (CSS only). */
|
/** Shipped marks by tool id. */
|
||||||
const BUNDLED: Record<string, string | [string, string]> = {
|
const BUNDLED: Record<string, string> = {
|
||||||
claude: "/ai-tools/claude.svg",
|
claude: "/ai-tools/claude.svg",
|
||||||
codex: "/ai-tools/codex.svg",
|
codex: "/ai-tools/codex.svg",
|
||||||
gemini: "/ai-tools/gemini.svg",
|
gemini: "/ai-tools/gemini.svg",
|
||||||
openclaw: "/ai-tools/openclaw.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 [failedId, setFailedId] = useState<string | null>(null);
|
||||||
const box = `${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`;
|
const box = `${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`;
|
||||||
const bundled = BUNDLED[tool.id];
|
const bundled = BUNDLED[tool.id];
|
||||||
if (typeof bundled === "string") return <img src={bundled} alt="" className={box} />;
|
if (bundled) 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 (failedId !== tool.id) {
|
if (failedId !== tool.id) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { ChevronDown, Check } from "lucide-react";
|
import { ChevronDown, Check } from "lucide-react";
|
||||||
|
|
||||||
/** Single option in a {@link Select} list. */
|
/** Single option in a {@link Select} list. */
|
||||||
@@ -74,6 +74,8 @@ export interface SelectOption<T extends string> {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Optional secondary line (smaller gray text). */
|
/** Optional secondary line (smaller gray text). */
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
/** Optional leading icon, shown in the trigger and the list. */
|
||||||
|
icon?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for {@link Select}. */
|
/** Props for {@link Select}. */
|
||||||
@@ -86,13 +88,25 @@ export interface SelectProps<T extends string> {
|
|||||||
options: SelectOption<T>[];
|
options: SelectOption<T>[];
|
||||||
/** Disables opening the list. */
|
/** Disables opening the list. */
|
||||||
disabled?: boolean;
|
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.
|
* Accessible custom select control.
|
||||||
* @typeParam T - string union of allowed option values.
|
* @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 [open, setOpen] = useState(false);
|
||||||
const [active, setActive] = useState(() =>
|
const [active, setActive] = useState(() =>
|
||||||
Math.max(
|
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];
|
const current = options.find((o) => o.value === value) || options[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className={ghost ? "relative inline-block max-w-[11rem]" : "relative"}>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
title={label}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
onKeyDown={onKey}
|
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" />
|
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<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"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
|
{opt.icon}
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] flex-1 truncate ${
|
className={`text-[11px] flex-1 truncate ${
|
||||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file SessionReplay — an ENDED session opened in its workspace
|
* @file SessionReplay — an ENDED session opened in its workspace
|
||||||
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
|
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
|
||||||
* an old one: its conversation, read-only (`ConversationView`), and a prompt
|
* 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
|
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the
|
||||||
* first free pane slot, after which `onResumed` opens the pane (`&run=`). 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
|
* 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 { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Send } from "lucide-react";
|
import { ArrowUp } from "lucide-react";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
import type { Lane, Session } from "../../lib/types";
|
import type { Lane, Session } from "../../lib/types";
|
||||||
import { ConversationView } from "../conversation/ConversationView";
|
import { ConversationView } from "../conversation/ConversationView";
|
||||||
import { PromptEditor } from "../run/RunSetup";
|
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||||
import { freeSlot, paneRunId } from "./WorkspaceHome";
|
import { freeSlot, paneRunId } from "./WorkspaceHome";
|
||||||
|
|
||||||
export function SessionReplay({
|
export function SessionReplay({
|
||||||
@@ -35,6 +35,7 @@ export function SessionReplay({
|
|||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export function SessionReplay({
|
|||||||
let live = true;
|
let live = true;
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
setImages([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
api.sessions
|
api.sessions
|
||||||
.get(sessionId)
|
.get(sessionId)
|
||||||
@@ -53,7 +55,7 @@ export function SessionReplay({
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
const text = prompt.trim();
|
const text = promptWithImages(prompt, images);
|
||||||
if (!text || busy) return;
|
if (!text || busy) return;
|
||||||
const slot = freeSlot(lane.id, liveRuns);
|
const slot = freeSlot(lane.id, liveRuns);
|
||||||
if (slot === null) {
|
if (slot === null) {
|
||||||
@@ -69,6 +71,8 @@ export function SessionReplay({
|
|||||||
prompt: text,
|
prompt: text,
|
||||||
});
|
});
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||||
|
setImages([]);
|
||||||
onResumed(r.run_id ?? paneRunId(lane.id, slot));
|
onResumed(r.run_id ?? paneRunId(lane.id, slot));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(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">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<ConversationView sessionId={sessionId} />
|
<ConversationView sessionId={sessionId} />
|
||||||
</div>
|
</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
|
<PromptEditor
|
||||||
|
bare
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={setPrompt}
|
onChange={setPrompt}
|
||||||
onSubmit={() => void send()}
|
onSubmit={() => void send()}
|
||||||
@@ -98,11 +105,12 @@ export function SessionReplay({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="session-replay-send"
|
data-testid="session-replay-send"
|
||||||
onClick={() => void send()}
|
onClick={() => void send()}
|
||||||
disabled={busy || !prompt.trim()}
|
disabled={busy || (!prompt.trim() && images.length === 0)}
|
||||||
className="btn-primary ml-auto text-xs disabled:opacity-50"
|
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" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
{t("replay.send")}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function SessionResumeButton({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
{run ? (
|
{run ? (
|
||||||
<button type="button" onClick={() => open(run.id)} className="btn-ghost text-xs">
|
<button type="button" onClick={() => open(run.id)} className="btn-ghost text-xs">
|
||||||
<SquareTerminal className="h-4 w-4" />
|
<SquareTerminal className="h-4 w-4" />
|
||||||
@@ -148,7 +148,7 @@ export function SessionResumeButton({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Checkbox } from "../Checkbox";
|
import { Checkbox } from "../Checkbox";
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
ArrowUp,
|
||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -48,7 +48,7 @@ import type { Lane, Project, Session } from "../../lib/types";
|
|||||||
import { fmtCost, timeAgo } from "../../lib/format";
|
import { fmtCost, timeAgo } from "../../lib/format";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
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 { AiToolIcon } from "../AiToolIcon";
|
||||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
||||||
import { Select } from "../Select";
|
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));
|
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];
|
type HomeProps = Parameters<typeof HomeBody>[0];
|
||||||
|
|
||||||
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
||||||
@@ -127,6 +115,7 @@ function HomeBody({
|
|||||||
const claude = tool === "claude";
|
const claude = tool === "claude";
|
||||||
const codex = tool === "codex";
|
const codex = tool === "codex";
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
const [router, setRouter] = useSessionRouter();
|
const [router, setRouter] = useSessionRouter();
|
||||||
const routers = useAiRouters();
|
const routers = useAiRouters();
|
||||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
||||||
@@ -283,7 +272,7 @@ function HomeBody({
|
|||||||
try {
|
try {
|
||||||
const r = await api.lanes.action(lane.id, "start", {
|
const r = await api.lanes.action(lane.id, "start", {
|
||||||
slot,
|
slot,
|
||||||
prompt: prompt.trim(),
|
prompt: promptWithImages(prompt, images),
|
||||||
// Only a different tool is named: the workspace's own keeps starting
|
// Only a different tool is named: the workspace's own keeps starting
|
||||||
// even after it was disabled in Settings.
|
// even after it was disabled in Settings.
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
@@ -296,6 +285,8 @@ function HomeBody({
|
|||||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||||
});
|
});
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||||
|
setImages([]);
|
||||||
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -407,7 +398,10 @@ function HomeBody({
|
|||||||
</p>
|
</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
|
<PromptEditor
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={setPrompt}
|
onChange={setPrompt}
|
||||||
@@ -416,117 +410,132 @@ function HomeBody({
|
|||||||
rows={3}
|
rows={3}
|
||||||
slash={claude}
|
slash={claude}
|
||||||
fileCwd={lane.cwd}
|
fileCwd={lane.cwd}
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
bare
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
<div className="mt-1 flex flex-wrap items-center gap-0.5">
|
||||||
<Field label={t("home.ai")} width="w-44">
|
<div data-testid="workspace-home-tool">
|
||||||
<div data-testid="workspace-home-tool" className="flex items-center gap-1.5">
|
<Select<string>
|
||||||
<AiToolIcon
|
variant="ghost"
|
||||||
tool={{ id: tool, label: aiTools.label(tool) }}
|
label={t("home.ai")}
|
||||||
className="h-6 w-6 shrink-0"
|
value={tool}
|
||||||
/>
|
onChange={(v) => {
|
||||||
<div className="min-w-0 flex-1">
|
setTool(v);
|
||||||
<Select<string>
|
setBypass(false);
|
||||||
value={tool}
|
}}
|
||||||
onChange={(v) => {
|
options={toolOptions.map((id) => ({
|
||||||
setTool(v);
|
value: id,
|
||||||
setBypass(false);
|
label: aiTools.label(id),
|
||||||
}}
|
icon: (
|
||||||
options={toolOptions.map((id) => ({ value: id, label: aiTools.label(id) }))}
|
<AiToolIcon
|
||||||
/>
|
tool={{ id, label: aiTools.label(id) }}
|
||||||
</div>
|
className="h-4 w-4 !p-0.5"
|
||||||
</div>
|
/>
|
||||||
</Field>
|
),
|
||||||
<Field label={t("router.label")} width="w-44">
|
}))}
|
||||||
<RouterPicker tool={tool} routers={routers} value={router} onChange={setRouter} />
|
/>
|
||||||
</Field>
|
</div>
|
||||||
|
<RouterPicker
|
||||||
|
variant="ghost"
|
||||||
|
tool={tool}
|
||||||
|
routers={routers}
|
||||||
|
value={router}
|
||||||
|
onChange={setRouter}
|
||||||
|
/>
|
||||||
{hasModel && (
|
{hasModel && (
|
||||||
<Field label={t("run:fields.model")} width="w-56">
|
<ModelPicker
|
||||||
<ModelPicker value={model} onChange={setModel} tool={tool} choices={routerModels} />
|
variant="ghost"
|
||||||
</Field>
|
value={model}
|
||||||
|
onChange={setModel}
|
||||||
|
tool={tool}
|
||||||
|
choices={routerModels}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{claude && (
|
{claude && (
|
||||||
<Field label={t("run:fields.permissionMode")} width="w-44">
|
<Select<PermissionMode>
|
||||||
<Select<PermissionMode>
|
variant="ghost"
|
||||||
value={mode}
|
label={t("run:fields.permissionMode")}
|
||||||
onChange={setMode}
|
value={mode}
|
||||||
options={[
|
onChange={setMode}
|
||||||
{ value: "auto", label: t("run:fields.permissionAuto") },
|
options={[
|
||||||
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
|
{ value: "auto", label: t("run:fields.permissionAuto") },
|
||||||
{ value: "default", label: t("run:fields.permissionDefault") },
|
{ value: "acceptEdits", label: t("run:fields.permissionAcceptEdits") },
|
||||||
{ value: "plan", label: t("run:fields.permissionPlan") },
|
{ value: "default", label: t("run:fields.permissionDefault") },
|
||||||
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
|
{ value: "plan", label: t("run:fields.permissionPlan") },
|
||||||
]}
|
{ value: "bypassPermissions", label: t("run:fields.permissionBypass") },
|
||||||
/>
|
]}
|
||||||
</Field>
|
/>
|
||||||
)}
|
)}
|
||||||
{codex && (
|
{codex && (
|
||||||
<>
|
<>
|
||||||
<Field label={t("run:fields.sandbox")} width="w-40">
|
<Select<CodexSandbox>
|
||||||
<Select<CodexSandbox>
|
variant="ghost"
|
||||||
value={sandbox}
|
label={t("run:fields.sandbox")}
|
||||||
onChange={setSandbox}
|
value={sandbox}
|
||||||
options={[
|
onChange={setSandbox}
|
||||||
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
|
options={[
|
||||||
{ value: "workspace-write", label: t("run:fields.sandboxWorkspaceWrite") },
|
{ value: "read-only", label: t("run:fields.sandboxReadOnly") },
|
||||||
{ value: "danger-full-access", label: t("run:fields.sandboxFullAccess") },
|
{ 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>
|
||||||
<Select<CodexApproval>
|
variant="ghost"
|
||||||
value={approval}
|
label={t("run:fields.approval")}
|
||||||
onChange={setApproval}
|
value={approval}
|
||||||
options={[
|
onChange={setApproval}
|
||||||
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
|
options={[
|
||||||
{ value: "never", label: t("run:fields.approvalNever") },
|
{ value: "on-request", label: t("run:fields.approvalOnRequest") },
|
||||||
]}
|
{ value: "never", label: t("run:fields.approvalNever") },
|
||||||
/>
|
]}
|
||||||
</Field>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasEffort && (
|
{hasEffort && (
|
||||||
<Field label={t("run:fields.effort")} width="w-40">
|
<Select<EffortLevel>
|
||||||
<Select<EffortLevel>
|
variant="ghost"
|
||||||
value={effort}
|
label={t("run:fields.effort")}
|
||||||
onChange={setEffort}
|
value={effort}
|
||||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
onChange={setEffort}
|
||||||
value: c.id,
|
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||||
label: c.label,
|
value: c.id,
|
||||||
hint: c.hint,
|
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>
|
)}
|
||||||
)}
|
{claude && project?.root && (
|
||||||
</div>
|
<Checkbox
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
checked={inWorktree}
|
||||||
{canBypass && (
|
onChange={setInWorktree}
|
||||||
<Checkbox
|
label={t("home.worktree")}
|
||||||
tone="danger"
|
title={t("home.worktreeHint")}
|
||||||
checked={bypass}
|
labelClassName="text-xs text-fg-secondary"
|
||||||
onChange={setBypass}
|
/>
|
||||||
label={t("run:fields.bypass")}
|
)}
|
||||||
labelClassName="text-xs text-status-danger"
|
<button
|
||||||
/>
|
type="button"
|
||||||
)}
|
data-testid="workspace-home-start"
|
||||||
{claude && project?.root && (
|
onClick={() => void start()}
|
||||||
<Checkbox
|
disabled={starting}
|
||||||
checked={inWorktree}
|
aria-label={t("home.start")}
|
||||||
onChange={setInWorktree}
|
title={t("home.start")}
|
||||||
label={t("home.worktree")}
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
title={t("home.worktreeHint")}
|
>
|
||||||
className="ml-1"
|
<ArrowUp className="h-4 w-4" />
|
||||||
/>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{danger && (
|
{danger && (
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ vi.mock("../../conversation/ConversationView", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../run/RunSetup", () => ({
|
vi.mock("../../run/RunSetup", () => ({
|
||||||
|
promptWithImages: (text: string, images: { path: string }[]) =>
|
||||||
|
[text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "),
|
||||||
PromptEditor: ({
|
PromptEditor: ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type { Lane, Session } from "../../../lib/types";
|
|||||||
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
import type { LaneSourcesView, RunHandle } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../run/RunSetup", () => ({
|
vi.mock("../../run/RunSetup", () => ({
|
||||||
|
promptWithImages: (text: string, images: { path: string }[]) =>
|
||||||
|
[text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "),
|
||||||
PromptEditor: ({
|
PromptEditor: ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -211,9 +213,11 @@ describe("WorkspaceHome", () => {
|
|||||||
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
|
it("lays its fields out in order: AI, AI Router, Model, Permission mode, Thinking effort", async () => {
|
||||||
renderHome();
|
renderHome();
|
||||||
await screen.findByTestId("workspace-recent-s-live");
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
const card = screen.getByTestId("workspace-home-start").closest(".card") as HTMLElement;
|
const box = screen.getByTestId("workspace-home-composer");
|
||||||
const labels = [...card.querySelectorAll("span.uppercase")].map((x) => x.textContent);
|
const labels = [
|
||||||
expect(labels).toEqual(["AI", "AI Router", "Model", "Permission mode", "Thinking effort"]);
|
...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 () => {
|
it("starts the session on the AI tool picked for it, with that tool's own fields", async () => {
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ export function RouterPicker({
|
|||||||
routers,
|
routers,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
variant,
|
||||||
}: {
|
}: {
|
||||||
|
variant?: "field" | "ghost";
|
||||||
tool: string;
|
tool: string;
|
||||||
/** From `useAiRouters`. */
|
/** From `useAiRouters`. */
|
||||||
routers: AiRouter[] | null;
|
routers: AiRouter[] | null;
|
||||||
@@ -86,12 +88,14 @@ export function RouterPicker({
|
|||||||
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||||
: [{ value: "native", label: native }];
|
: [{ value: "native", label: native }];
|
||||||
return (
|
return (
|
||||||
<div data-testid="router-picker" title={t("router.label")}>
|
<div data-testid="router-picker">
|
||||||
<Select<string>
|
<Select<string>
|
||||||
value={capable ? value : "native"}
|
value={capable ? value : "native"}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
disabled={!capable}
|
disabled={!capable}
|
||||||
|
variant={variant}
|
||||||
|
label={t("router.label")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* own run form is gone, a new session starts from the workspace home's prompt
|
* own run form is gone, a new session starts from the workspace home's prompt
|
||||||
* box (`WorkspaceHome.tsx`):
|
* box (`WorkspaceHome.tsx`):
|
||||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
* - `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;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
* or a fixed one (an AI Router's models), plus Custom….
|
* 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>
|
* @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 { 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 { api, RUN_MODEL_CHOICES, type ModelChoice } from "../../lib/api";
|
||||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||||
import type { LaneTool } from "../../lib/types";
|
import type { LaneTool } from "../../lib/types";
|
||||||
@@ -101,6 +103,21 @@ interface PromptEditorProps {
|
|||||||
slash: boolean;
|
slash: boolean;
|
||||||
/** Where `@` looks for files and project commands/skills are read from. */
|
/** Where `@` looks for files and project commands/skills are read from. */
|
||||||
fileCwd: string;
|
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({
|
export function PromptEditor({
|
||||||
@@ -111,6 +128,9 @@ export function PromptEditor({
|
|||||||
rows = 5,
|
rows = 5,
|
||||||
slash,
|
slash,
|
||||||
fileCwd,
|
fileCwd,
|
||||||
|
images,
|
||||||
|
onImagesChange,
|
||||||
|
bare,
|
||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
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>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (kind && count > 0) {
|
if (kind && count > 0) {
|
||||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
@@ -223,6 +289,13 @@ export function PromptEditor({
|
|||||||
setTrigger(null);
|
setTrigger(null);
|
||||||
return;
|
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.
|
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
||||||
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
||||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||||
@@ -240,6 +313,32 @@ export function PromptEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<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
|
<textarea
|
||||||
ref={taRef}
|
ref={taRef}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -251,11 +350,29 @@ export function PromptEditor({
|
|||||||
onSelect={(e) => retrack(e.currentTarget)}
|
onSelect={(e) => retrack(e.currentTarget)}
|
||||||
onBlur={() => setTrigger(null)}
|
onBlur={() => setTrigger(null)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={(e) => {
|
||||||
|
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||||
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
spellCheck={false}
|
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 && (
|
{kind && (
|
||||||
<div
|
<div
|
||||||
role="listbox"
|
role="listbox"
|
||||||
@@ -313,6 +430,20 @@ export function PromptEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -487,12 +618,14 @@ export function ModelPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
tool,
|
tool,
|
||||||
choices: fixed,
|
choices: fixed,
|
||||||
|
variant,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (s: string) => void;
|
onChange: (s: string) => void;
|
||||||
tool: LaneTool;
|
tool: LaneTool;
|
||||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
/** A fixed list instead of the tool's own — an AI Router's models. */
|
||||||
choices?: ModelChoice[];
|
choices?: ModelChoice[];
|
||||||
|
variant?: "field" | "ghost";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
// The tool's own list (Codex's models cache, Claude's aliases named after
|
// 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;
|
const selectValue = showCustom || isCustom ? MODEL_CUSTOM : value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className={variant === "ghost" ? "flex items-center gap-1.5" : "space-y-1.5"}>
|
||||||
<Select<string> value={selectValue} onChange={onSelect} options={options} />
|
<Select<string>
|
||||||
|
value={selectValue}
|
||||||
|
onChange={onSelect}
|
||||||
|
options={options}
|
||||||
|
variant={variant}
|
||||||
|
label={t("fields.model")}
|
||||||
|
/>
|
||||||
{(showCustom || isCustom) && (
|
{(showCustom || isCustom) && (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -560,7 +699,7 @@ export function ModelPicker({
|
|||||||
placeholder={t("fields.modelCustomPlaceholder")}
|
placeholder={t("fields.modelCustomPlaceholder")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
* @file RunSetup.test.tsx
|
* @file RunSetup.test.tsx
|
||||||
* @description The run-form pieces that outlived the pane's run form:
|
* @description The run-form pieces that outlived the pane's run form:
|
||||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
* `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,
|
* 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.
|
* and Custom…, which is a sentinel, never a model id.
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { ModelPicker, PromptEditor } from "../RunSetup";
|
import { ModelPicker, PromptEditor, promptWithImages, type PromptImage } from "../RunSetup";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
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 };
|
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: {
|
ccConfig: {
|
||||||
commands: r({
|
commands: r({
|
||||||
items: [
|
items: [
|
||||||
@@ -67,6 +69,8 @@ function renderEditor(value: string, slash = true) {
|
|||||||
placeholder="Ask anything…"
|
placeholder="Ask anything…"
|
||||||
slash={slash}
|
slash={slash}
|
||||||
fileCwd="/Users/tester"
|
fileCwd="/Users/tester"
|
||||||
|
images={[]}
|
||||||
|
onImagesChange={() => {}}
|
||||||
{...spies}
|
{...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", () => {
|
describe("ModelPicker", () => {
|
||||||
it("lists the tool's own models, and Custom… reports only the typed id", async () => {
|
it("lists the tool's own models, and Custom… reports only the typed id", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
|
|||||||
@@ -253,16 +253,12 @@ describe("AiToolIcon", () => {
|
|||||||
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
|
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 { AiToolIcon } = await import("../../AiToolIcon");
|
||||||
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
|
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
|
||||||
expect(container.querySelector('img[src="/ai-tools/claude.svg"]')).toBeTruthy();
|
expect(container.querySelector('img[src="/ai-tools/claude.svg"]')).toBeTruthy();
|
||||||
rerender(<AiToolIcon tool={{ id: "hermes", label: "Hermes" }} />);
|
rerender(<AiToolIcon tool={{ id: "hermes", label: "Hermes" }} />);
|
||||||
expect(container.querySelector('img[src="/ai-tools/hermes-black.svg"]')?.className).toMatch(
|
expect(container.querySelectorAll("img")).toHaveLength(1);
|
||||||
/dark:hidden/
|
expect(container.querySelector('img[src="/ai-tools/hermes.svg"]')).toBeTruthy();
|
||||||
);
|
|
||||||
expect(container.querySelector('img[src="/ai-tools/hermes-white.svg"]')?.className).toMatch(
|
|
||||||
/hidden dark:block/
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,6 +173,7 @@
|
|||||||
"hint": "Drop image to attach",
|
"hint": "Drop image to attach",
|
||||||
"uploading": "Uploading image…",
|
"uploading": "Uploading image…",
|
||||||
"failed": "Could not attach the image",
|
"failed": "Could not attach the image",
|
||||||
"imagesOnly": "Only images can be dropped here"
|
"imagesOnly": "Only images can be dropped here",
|
||||||
|
"remove": "Remove image"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@
|
|||||||
"hint": "Thả ảnh để đính kèm",
|
"hint": "Thả ảnh để đính kèm",
|
||||||
"uploading": "Đang tải ảnh lên…",
|
"uploading": "Đang tải ảnh lên…",
|
||||||
"failed": "Không đính kèm được ảnh",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user