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