a46764fb1b
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
969 lines
33 KiB
TypeScript
969 lines
33 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useT } from '../i18n';
|
|
import type { Dict } from '../i18n/types';
|
|
import { projectRawUrl, uploadProjectFiles } from "../providers/registry";
|
|
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile } from "../types";
|
|
import { Icon } from "./Icon";
|
|
import { BUILT_IN_PETS, CUSTOM_PET_ID, resolveActivePet } from "./pet/pets";
|
|
|
|
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
|
|
|
interface SlashCommand {
|
|
id: string;
|
|
// Visible label, e.g. `/hatch`. Shown in the popover row.
|
|
label: string;
|
|
// Text inserted into the draft when the user picks the entry. The
|
|
// cursor is positioned at the end of `insert`, so a trailing space
|
|
// is the difference between a "ready for argument" command and a
|
|
// "submit immediately" one.
|
|
insert: string;
|
|
// i18n key of the short description shown next to the label.
|
|
descKey: keyof Dict;
|
|
// Optional argument hint shown after the description.
|
|
argHint?: string;
|
|
// Icon glyph from the project Icon set.
|
|
icon: 'sparkles' | 'eye' | 'sliders';
|
|
}
|
|
|
|
interface Props {
|
|
projectId: string | null;
|
|
projectFiles: ProjectFile[];
|
|
streaming: boolean;
|
|
initialDraft?: string;
|
|
// Lazy ensure — the composer calls this before its first upload, so the
|
|
// project folder exists on disk before files land in it. Returns the
|
|
// project id when ready.
|
|
onEnsureProject: () => Promise<string | null>;
|
|
commentAttachments?: ChatCommentAttachment[];
|
|
onRemoveCommentAttachment?: (id: string) => void;
|
|
onSend: (prompt: string, attachments: ChatAttachment[], commentAttachments: ChatCommentAttachment[]) => void;
|
|
onStop: () => void;
|
|
// Opens the global settings dialog (CLI / model / agent picker). The
|
|
// composer's leading gear icon routes here so users can switch models
|
|
// without leaving the chat.
|
|
onOpenSettings?: () => void;
|
|
// Optional pet wiring — when present, the composer renders a small
|
|
// 🐾 button + popover so users can adopt / wake / tuck a pet without
|
|
// leaving chat. Typing `/pet` (or `/pet wake|tuck|<id>`) is parsed
|
|
// out of the draft and routed to the same handlers.
|
|
petConfig?: AppConfig['pet'];
|
|
onAdoptPet?: (petId: string) => void;
|
|
onTogglePet?: () => void;
|
|
onOpenPetSettings?: () => void;
|
|
}
|
|
|
|
// Imperative handle so ancestors (e.g. example chips in ChatPane) can
|
|
// push text into the composer without owning its draft state.
|
|
export interface ChatComposerHandle {
|
|
setDraft: (text: string) => void;
|
|
focus: () => void;
|
|
}
|
|
|
|
/**
|
|
* The chat composer: textarea + paste/drop/attach buttons + @-mention
|
|
* picker. Attachments are uploaded into the active project's folder so
|
|
* the agent can reference them by relative path on its next turn.
|
|
*
|
|
* `@` typed at a word boundary opens a popover listing project files.
|
|
* Selecting one inserts `@<path>` into the prompt and stages it as an
|
|
* attachment so the daemon also includes it explicitly.
|
|
*/
|
|
export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|
function ChatComposer(
|
|
{
|
|
projectId,
|
|
projectFiles,
|
|
streaming,
|
|
initialDraft,
|
|
onEnsureProject,
|
|
commentAttachments = [],
|
|
onRemoveCommentAttachment,
|
|
onSend,
|
|
onStop,
|
|
onOpenSettings,
|
|
petConfig,
|
|
onAdoptPet,
|
|
onTogglePet,
|
|
onOpenPetSettings,
|
|
},
|
|
ref
|
|
) {
|
|
const t = useT();
|
|
const [draft, setDraft] = useState(initialDraft ?? "");
|
|
const [staged, setStaged] = useState<ChatAttachment[]>([]);
|
|
const [dragActive, setDragActive] = useState(false);
|
|
const [mention, setMention] = useState<{
|
|
q: string;
|
|
cursor: number;
|
|
} | null>(null);
|
|
// Slash-command popover state — when the draft starts with `/` and
|
|
// the cursor is still inside that token (no space committed yet),
|
|
// we show a small palette of supported commands. The query is the
|
|
// text after `/` so the user can type-to-filter.
|
|
const [slash, setSlash] = useState<{
|
|
q: string;
|
|
cursor: number;
|
|
} | null>(null);
|
|
const [slashIndex, setSlashIndex] = useState(0);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [petOpen, setPetOpen] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const importMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const importTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
const petMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const petTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
const petEnabled = Boolean(onAdoptPet && onTogglePet);
|
|
// initialDraft is only honored on the first non-empty value the parent
|
|
// hands us. After we seed once, the composer is fully under user control
|
|
// — re-renders that pass the same prompt back must not reseed. If the
|
|
// initial useState above already consumed a non-empty initialDraft we
|
|
// mark it seeded immediately, so an early clear by the user (typing or
|
|
// backspace before the parent stops passing initialDraft) does not get
|
|
// overwritten by the effect.
|
|
const seededRef = useRef(Boolean(initialDraft));
|
|
|
|
useEffect(() => {
|
|
if (seededRef.current) return;
|
|
if (initialDraft && initialDraft !== draft) {
|
|
setDraft(initialDraft);
|
|
seededRef.current = true;
|
|
} else if (initialDraft === undefined) {
|
|
seededRef.current = true;
|
|
}
|
|
}, [initialDraft, draft]);
|
|
|
|
useEffect(() => {
|
|
if (!importOpen) return;
|
|
function onPointer(e: MouseEvent) {
|
|
const target = e.target as Node;
|
|
if (importMenuRef.current?.contains(target)) return;
|
|
if (importTriggerRef.current?.contains(target)) return;
|
|
setImportOpen(false);
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") setImportOpen(false);
|
|
}
|
|
document.addEventListener("mousedown", onPointer);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onPointer);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [importOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!petOpen) return;
|
|
function onPointer(e: MouseEvent) {
|
|
const target = e.target as Node;
|
|
if (petMenuRef.current?.contains(target)) return;
|
|
if (petTriggerRef.current?.contains(target)) return;
|
|
setPetOpen(false);
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") setPetOpen(false);
|
|
}
|
|
document.addEventListener("mousedown", onPointer);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onPointer);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [petOpen]);
|
|
|
|
// Catalog of supported slash commands. Each entry shows up in the
|
|
// popover when the user types `/` in the composer. The `insert`
|
|
// value is what we drop into the draft when the user picks the
|
|
// entry — usually the canonical command form with a trailing space
|
|
// ready for an argument.
|
|
const slashCommands = useMemo<SlashCommand[]>(() => {
|
|
const list: SlashCommand[] = [];
|
|
if (petEnabled) {
|
|
list.push(
|
|
{
|
|
id: 'pet',
|
|
label: '/pet',
|
|
insert: '/pet ',
|
|
descKey: 'pet.slashPet',
|
|
icon: 'sparkles',
|
|
argHint: 'wake | tuck | <petId>',
|
|
},
|
|
{
|
|
id: 'pet-wake',
|
|
label: '/pet wake',
|
|
insert: '/pet wake',
|
|
descKey: 'pet.slashPetWake',
|
|
icon: 'eye',
|
|
},
|
|
{
|
|
id: 'pet-tuck',
|
|
label: '/pet tuck',
|
|
insert: '/pet tuck',
|
|
descKey: 'pet.slashPetTuck',
|
|
icon: 'eye',
|
|
},
|
|
{
|
|
id: 'hatch',
|
|
label: '/hatch',
|
|
insert: '/hatch ',
|
|
descKey: 'pet.slashHatch',
|
|
icon: 'sparkles',
|
|
argHint: t('pet.slashHatchArg'),
|
|
},
|
|
);
|
|
}
|
|
return list;
|
|
}, [petEnabled, t]);
|
|
|
|
const filteredSlash = useMemo(() => {
|
|
if (!slash) return [] as SlashCommand[];
|
|
const q = slash.q.toLowerCase();
|
|
if (!q) return slashCommands;
|
|
return slashCommands.filter((c) => c.label.toLowerCase().includes(q));
|
|
}, [slash, slashCommands]);
|
|
|
|
function pickSlash(cmd: SlashCommand) {
|
|
const ta = textareaRef.current;
|
|
if (!ta || !slash) return;
|
|
const before = draft.slice(0, slash.cursor);
|
|
const after = draft.slice(slash.cursor);
|
|
// Replace the in-flight `/<query>` token with the picked
|
|
// command's canonical insertion text.
|
|
const replaced = before.replace(/\/[^\s/]*$/, cmd.insert);
|
|
const next = replaced + after;
|
|
setDraft(next);
|
|
setSlash(null);
|
|
requestAnimationFrame(() => {
|
|
ta.focus();
|
|
const pos = replaced.length;
|
|
ta.setSelectionRange(pos, pos);
|
|
});
|
|
}
|
|
|
|
// Expand a `/hatch <concept>` draft into the canonical hatch-pet
|
|
// skill prompt before sending. Returns null when the draft is not a
|
|
// hatch command so the caller can fall through to the regular
|
|
// submit path.
|
|
function expandHatchCommand(input: string): string | null {
|
|
const m = /^\/hatch(?:\s+([\s\S]*))?$/i.exec(input.trim());
|
|
if (!m) return null;
|
|
const concept = m[1]?.trim() ?? '';
|
|
const intro = concept
|
|
? `Hatch a Codex-compatible animated pet for me. Concept: ${concept}.`
|
|
: 'Hatch a Codex-compatible animated pet for me.';
|
|
return [
|
|
intro,
|
|
'',
|
|
'Use the @hatch-pet skill end-to-end:',
|
|
'1. Generate the base look with $imagegen.',
|
|
'2. Generate every row strip (idle, running-right, waving, jumping, failed, waiting, running, review).',
|
|
'3. Mirror running-left from running-right only when the design is symmetric.',
|
|
'4. Run the deterministic scripts (extract / compose / validate / contact-sheet / videos).',
|
|
'5. Package the result into ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>/ with pet.json + spritesheet.webp.',
|
|
'',
|
|
'When the spritesheet is saved, tell me the absolute path and the pet folder name. I will adopt it from Settings → Pets → Recently hatched.',
|
|
].join('\n');
|
|
}
|
|
|
|
// Parse a `/pet [arg]` slash command out of the draft. Recognized
|
|
// forms: `/pet` (toggle wake/tuck), `/pet wake`, `/pet tuck`,
|
|
// `/pet adopt` (open settings), or `/pet <id>` to adopt a built-in
|
|
// by id. The slash is stripped from the draft on a successful match
|
|
// so the user does not accidentally send the command to the agent.
|
|
function tryHandlePetSlash(): boolean {
|
|
if (!petEnabled) return false;
|
|
const trimmed = draft.trim();
|
|
const match = /^\/pet(?:\s+(\S+))?$/i.exec(trimmed);
|
|
if (!match) return false;
|
|
const arg = match[1]?.toLowerCase();
|
|
if (!arg || arg === 'toggle') {
|
|
onTogglePet?.();
|
|
} else if (arg === 'wake' || arg === 'show') {
|
|
if (petConfig?.adopted) {
|
|
if (!petConfig.enabled) onTogglePet?.();
|
|
} else {
|
|
onOpenPetSettings?.();
|
|
}
|
|
} else if (arg === 'tuck' || arg === 'hide') {
|
|
if (petConfig?.enabled) onTogglePet?.();
|
|
} else if (arg === 'adopt' || arg === 'settings' || arg === 'change') {
|
|
onOpenPetSettings?.();
|
|
} else if (arg === CUSTOM_PET_ID) {
|
|
onAdoptPet?.(CUSTOM_PET_ID);
|
|
} else {
|
|
const pet = BUILT_IN_PETS.find((p) => p.id === arg);
|
|
if (pet) {
|
|
onAdoptPet?.(pet.id);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
setDraft('');
|
|
return true;
|
|
}
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
setDraft: (text: string) => {
|
|
setDraft(text);
|
|
seededRef.current = true;
|
|
requestAnimationFrame(() => {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
ta.focus();
|
|
const pos = text.length;
|
|
ta.setSelectionRange(pos, pos);
|
|
});
|
|
},
|
|
focus: () => {
|
|
textareaRef.current?.focus();
|
|
},
|
|
}),
|
|
[]
|
|
);
|
|
|
|
function reset() {
|
|
setDraft("");
|
|
setStaged([]);
|
|
setUploadError(null);
|
|
setMention(null);
|
|
setSlash(null);
|
|
}
|
|
|
|
async function ensureProject(): Promise<string | null> {
|
|
if (projectId) return projectId;
|
|
return onEnsureProject();
|
|
}
|
|
|
|
async function uploadFiles(files: File[]) {
|
|
if (files.length === 0) return;
|
|
const id = await ensureProject();
|
|
if (!id) return;
|
|
setUploading(true);
|
|
setUploadError(null);
|
|
try {
|
|
const result = await uploadProjectFiles(id, files);
|
|
if (result.uploaded.length > 0) {
|
|
setStaged((s) => [...s, ...result.uploaded]);
|
|
}
|
|
if (result.failed.length > 0) {
|
|
const failedCount = result.failed.length;
|
|
const uploadedCount = result.uploaded.length;
|
|
const detail = result.error ? ` (${result.error})` : '';
|
|
setUploadError(
|
|
uploadedCount > 0
|
|
? `Attached ${uploadedCount} file(s), but ${failedCount} failed${detail}.`
|
|
: `Attachment upload failed for ${failedCount} file(s)${detail}.`,
|
|
);
|
|
console.warn('Some attachments failed to upload', result.failed);
|
|
}
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
function handlePaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
|
|
const items = Array.from(e.clipboardData?.items ?? []);
|
|
const files: File[] = [];
|
|
for (const item of items) {
|
|
if (item.kind === "file") {
|
|
const f = item.getAsFile();
|
|
if (f) files.push(f);
|
|
}
|
|
}
|
|
if (files.length > 0) {
|
|
e.preventDefault();
|
|
void uploadFiles(files);
|
|
}
|
|
}
|
|
|
|
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
|
|
e.preventDefault();
|
|
setDragActive(false);
|
|
const files = Array.from(e.dataTransfer.files ?? []);
|
|
if (files.length > 0) void uploadFiles(files);
|
|
}
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
|
const value = e.target.value;
|
|
const cursor = e.target.selectionStart;
|
|
setDraft(value);
|
|
// Detect a fresh @ at start or after whitespace; capture the typed
|
|
// query up to the cursor.
|
|
const before = value.slice(0, cursor);
|
|
const m = /(^|\s)@([^\s@]*)$/.exec(before);
|
|
if (m) setMention({ q: m[2] ?? "", cursor });
|
|
else setMention(null);
|
|
// Slash-command popover — open as soon as the draft starts with
|
|
// `/` (and the cursor is still inside the bare command token, no
|
|
// space yet). Closes once the user commits a space or moves past
|
|
// the prefix.
|
|
const slashMatch = /^\/([^\s/]*)$/.exec(before);
|
|
if (slashMatch) {
|
|
setSlash({ q: slashMatch[1] ?? '', cursor });
|
|
setSlashIndex(0);
|
|
} else {
|
|
setSlash(null);
|
|
}
|
|
}
|
|
|
|
function insertMention(filePath: string) {
|
|
if (!mention) return;
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
const cursor = mention.cursor;
|
|
const before = draft.slice(0, cursor);
|
|
const after = draft.slice(cursor);
|
|
const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `);
|
|
const next = replaced + after;
|
|
setDraft(next);
|
|
setMention(null);
|
|
if (!staged.some((s) => s.path === filePath)) {
|
|
setStaged((s) => [
|
|
...s,
|
|
{
|
|
path: filePath,
|
|
name: filePath.split("/").pop() || filePath,
|
|
kind: looksLikeImage(filePath) ? "image" : "file",
|
|
},
|
|
]);
|
|
}
|
|
requestAnimationFrame(() => {
|
|
ta.focus();
|
|
const pos = replaced.length;
|
|
ta.setSelectionRange(pos, pos);
|
|
});
|
|
}
|
|
|
|
function removeStaged(p: string) {
|
|
setStaged((s) => s.filter((a) => a.path !== p));
|
|
}
|
|
|
|
async function submit() {
|
|
const prompt = draft.trim();
|
|
// Intercept `/pet …` before sending so the slash command never
|
|
// hits the agent — it is a local UX hook, not a model prompt.
|
|
if (tryHandlePetSlash()) return;
|
|
// `/hatch <concept>` expands into the canonical hatch-pet skill
|
|
// prompt and *is* sent to the agent — the agent runs the skill,
|
|
// packages a Codex pet under `~/.codex/pets/`, and the user
|
|
// adopts it from "Recently hatched" in pet settings afterwards.
|
|
const hatched = expandHatchCommand(prompt);
|
|
if (hatched) {
|
|
if (streaming) return;
|
|
onSend(hatched, staged, commentAttachments);
|
|
reset();
|
|
return;
|
|
}
|
|
if ((!prompt && commentAttachments.length === 0) || streaming) return;
|
|
onSend(prompt, staged, commentAttachments);
|
|
reset();
|
|
}
|
|
|
|
// The @-picker treats the project listing as path-shaped (path + size).
|
|
// ProjectFile.path is optional, so fall back to .name for the legacy
|
|
// flat shape — both ChatComposer and the old code paths see the same
|
|
// entries.
|
|
const filteredFiles = mention
|
|
? projectFiles
|
|
.filter((f) => f.type === undefined || f.type === "file")
|
|
.filter((f) => {
|
|
const key = f.path ?? f.name;
|
|
return key.toLowerCase().includes(mention.q.toLowerCase());
|
|
})
|
|
.slice(0, 12)
|
|
: [];
|
|
|
|
return (
|
|
<div
|
|
className={`composer${dragActive ? " drag-active" : ""}`}
|
|
data-testid="chat-composer"
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
setDragActive(true);
|
|
}}
|
|
onDragLeave={() => setDragActive(false)}
|
|
onDrop={handleDrop}
|
|
>
|
|
<div className="composer-shell">
|
|
{staged.length > 0 ? (
|
|
<StagedAttachments
|
|
attachments={staged}
|
|
projectId={projectId}
|
|
onRemove={removeStaged}
|
|
t={t}
|
|
/>
|
|
) : null}
|
|
{commentAttachments.length > 0 ? (
|
|
<StagedCommentAttachments
|
|
attachments={commentAttachments}
|
|
onRemove={(id) => onRemoveCommentAttachment?.(id)}
|
|
t={t}
|
|
/>
|
|
) : null}
|
|
<div className="composer-input-wrap">
|
|
<textarea
|
|
ref={textareaRef}
|
|
data-testid="chat-composer-input"
|
|
value={draft}
|
|
placeholder={t('chat.composerPlaceholder')}
|
|
onChange={handleChange}
|
|
onPaste={handlePaste}
|
|
onKeyDown={(e) => {
|
|
if (slash && filteredSlash.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSlashIndex((i) => (i + 1) % filteredSlash.length);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSlashIndex(
|
|
(i) => (i - 1 + filteredSlash.length) % filteredSlash.length,
|
|
);
|
|
return;
|
|
}
|
|
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey)) {
|
|
e.preventDefault();
|
|
const safe = Math.min(slashIndex, filteredSlash.length - 1);
|
|
pickSlash(filteredSlash[safe]!);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setSlash(null);
|
|
return;
|
|
}
|
|
}
|
|
if (mention && e.key === "Escape") {
|
|
setMention(null);
|
|
return;
|
|
}
|
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
void submit();
|
|
}
|
|
}}
|
|
/>
|
|
{mention && filteredFiles.length > 0 ? (
|
|
<MentionPopover files={filteredFiles} onPick={insertMention} />
|
|
) : null}
|
|
{slash && filteredSlash.length > 0 ? (
|
|
<SlashPopover
|
|
commands={filteredSlash}
|
|
activeIndex={Math.min(slashIndex, filteredSlash.length - 1)}
|
|
onPick={pickSlash}
|
|
onHover={(i) => setSlashIndex(i)}
|
|
t={t}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div className="composer-row">
|
|
<input
|
|
ref={fileInputRef}
|
|
data-testid="chat-file-input"
|
|
type="file"
|
|
multiple
|
|
style={{ display: "none" }}
|
|
onChange={(e) => {
|
|
const files = Array.from(e.target.files ?? []);
|
|
void uploadFiles(files);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<button
|
|
className="icon-btn"
|
|
onClick={() => onOpenSettings?.()}
|
|
title={t('chat.cliSettingsTitle')}
|
|
aria-label={t('chat.cliSettingsAria')}
|
|
disabled={!onOpenSettings}
|
|
>
|
|
<Icon name="sliders" size={15} />
|
|
</button>
|
|
<button
|
|
className="icon-btn"
|
|
data-testid="chat-attach"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
title={t('chat.attachTitle')}
|
|
disabled={uploading}
|
|
aria-label={t('chat.attachAria')}
|
|
>
|
|
{uploading ? (
|
|
<Icon name="spinner" size={15} />
|
|
) : (
|
|
<Icon name="attach" size={15} />
|
|
)}
|
|
</button>
|
|
<span className="composer-icon-divider" aria-hidden />
|
|
<div className="composer-import-wrap">
|
|
<button
|
|
ref={importTriggerRef}
|
|
type="button"
|
|
className="composer-import"
|
|
onClick={() => setImportOpen((v) => !v)}
|
|
aria-haspopup="menu"
|
|
aria-expanded={importOpen}
|
|
title={t('chat.importTitle')}
|
|
>
|
|
<Icon name="import" size={13} />
|
|
<span>{t('chat.importLabel')}</span>
|
|
<Icon name="chevron-down" size={12} />
|
|
</button>
|
|
{importOpen ? (
|
|
<div
|
|
ref={importMenuRef}
|
|
className="composer-import-menu"
|
|
role="menu"
|
|
>
|
|
<ImportItem icon="upload" label={t('chat.importFig')} t={t} />
|
|
<ImportItem icon="link" label={t('chat.importGitHub')} t={t} />
|
|
<ImportItem icon="grid" label={t('chat.importWeb')} t={t} />
|
|
<ImportItem icon="folder" label={t('chat.importFolder')} t={t} />
|
|
<ImportItem
|
|
icon="sparkles"
|
|
label={t('chat.importSkills')}
|
|
t={t}
|
|
/>
|
|
<ImportItem icon="file" label={t('chat.importProject')} t={t} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{petEnabled ? (
|
|
<div className="composer-pet-wrap">
|
|
<button
|
|
ref={petTriggerRef}
|
|
type="button"
|
|
className={`composer-pet${petConfig?.adopted ? ' adopted' : ''}`}
|
|
onClick={() => setPetOpen((v) => !v)}
|
|
aria-haspopup="menu"
|
|
aria-expanded={petOpen}
|
|
title={t('pet.composerTitle')}
|
|
>
|
|
<span className="composer-pet-glyph" aria-hidden>
|
|
{(() => {
|
|
const active = resolveActivePet(petConfig);
|
|
if (active) return active.glyph;
|
|
return '🐾';
|
|
})()}
|
|
</span>
|
|
<span className="composer-pet-label">
|
|
{petConfig?.adopted
|
|
? petConfig.enabled
|
|
? t('pet.tuck')
|
|
: t('pet.wake')
|
|
: t('pet.adopt')}
|
|
</span>
|
|
<Icon name="chevron-down" size={12} />
|
|
</button>
|
|
{petOpen ? (
|
|
<div
|
|
ref={petMenuRef}
|
|
className="composer-pet-menu"
|
|
role="menu"
|
|
>
|
|
<div className="composer-pet-menu-head">
|
|
<strong>{t('pet.composerMenuTitle')}</strong>
|
|
<span>{t('pet.composerMenuHint')}</span>
|
|
</div>
|
|
{petConfig?.adopted ? (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="composer-pet-menu-row toggle"
|
|
onClick={() => {
|
|
onTogglePet?.();
|
|
setPetOpen(false);
|
|
}}
|
|
>
|
|
<Icon
|
|
name={petConfig.enabled ? 'eye' : 'sparkles'}
|
|
size={12}
|
|
/>
|
|
<span>
|
|
{petConfig.enabled
|
|
? t('pet.tuck')
|
|
: t('pet.wake')}
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
<div className="composer-pet-menu-grid">
|
|
{BUILT_IN_PETS.map((p) => {
|
|
const active =
|
|
petConfig?.adopted && petConfig.petId === p.id;
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
key={p.id}
|
|
className={`composer-pet-menu-pet${active ? ' active' : ''}`}
|
|
onClick={() => {
|
|
onAdoptPet?.(p.id);
|
|
setPetOpen(false);
|
|
}}
|
|
style={{ ['--pet-accent' as string]: p.accent }}
|
|
title={p.flavor}
|
|
>
|
|
<span aria-hidden>{p.glyph}</span>
|
|
<span>{p.name}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="composer-pet-menu-row settings"
|
|
onClick={() => {
|
|
onOpenPetSettings?.();
|
|
setPetOpen(false);
|
|
}}
|
|
>
|
|
<Icon name="settings" size={12} />
|
|
<span>{t('pet.composerOpenSettings')}</span>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<span className="composer-spacer" />
|
|
{streaming ? (
|
|
<button
|
|
type="button"
|
|
className="composer-send stop"
|
|
onClick={onStop}
|
|
>
|
|
<Icon name="stop" size={13} />
|
|
<span>{t('chat.stop')}</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="composer-send"
|
|
data-testid="chat-send"
|
|
onClick={() => void submit()}
|
|
disabled={!draft.trim() && commentAttachments.length === 0}
|
|
>
|
|
<Icon name="send" size={13} />
|
|
<span>{t('chat.send')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{uploadError ? <span className="composer-hint">{uploadError}</span> : null}
|
|
<span className="composer-hint">{t('chat.composerHint')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
function StagedAttachments({
|
|
attachments,
|
|
projectId,
|
|
onRemove,
|
|
t,
|
|
}: {
|
|
attachments: ChatAttachment[];
|
|
projectId: string | null;
|
|
onRemove: (path: string) => void;
|
|
t: TranslateFn;
|
|
}) {
|
|
return (
|
|
<div className="staged-row" data-testid="staged-attachments">
|
|
{attachments.map((a) => (
|
|
<div key={a.path} className={`staged-chip staged-${a.kind}`}>
|
|
{a.kind === "image" && projectId ? (
|
|
<img src={projectRawUrl(projectId, a.path)} alt={a.name} />
|
|
) : (
|
|
<span className="staged-icon" aria-hidden>
|
|
<Icon name="file" size={13} />
|
|
</span>
|
|
)}
|
|
<span className="staged-name" title={a.path}>
|
|
{a.name}
|
|
</span>
|
|
<button
|
|
className="staged-remove"
|
|
onClick={() => onRemove(a.path)}
|
|
title={t('common.delete')}
|
|
aria-label={t('chat.removeAria', { name: a.name })}
|
|
>
|
|
<Icon name="close" size={11} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StagedCommentAttachments({
|
|
attachments,
|
|
onRemove,
|
|
t,
|
|
}: {
|
|
attachments: ChatCommentAttachment[];
|
|
onRemove: (id: string) => void;
|
|
t: TranslateFn;
|
|
}) {
|
|
return (
|
|
<div className="staged-row comment-staged-row" data-testid="staged-comment-attachments">
|
|
{attachments.map((a) => (
|
|
<div key={a.id} className="staged-chip staged-comment">
|
|
<span className="staged-name" title={`${a.elementId}: ${a.comment}`}>
|
|
<strong>{a.elementId}</strong>
|
|
<span>{a.comment}</span>
|
|
</span>
|
|
<button
|
|
className="staged-remove"
|
|
onClick={() => onRemove(a.id)}
|
|
title={t('chat.comments.removeAttachment')}
|
|
aria-label={t('chat.comments.removeAttachmentAria', { name: a.elementId })}
|
|
>
|
|
<Icon name="close" size={11} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ImportItem({
|
|
icon,
|
|
label,
|
|
t,
|
|
}: {
|
|
icon: "upload" | "link" | "grid" | "folder" | "sparkles" | "file";
|
|
label: string;
|
|
t: TranslateFn;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="composer-import-item"
|
|
role="menuitem"
|
|
tabIndex={-1}
|
|
disabled
|
|
title={t('chat.importComingSoon')}
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<span className="ico" aria-hidden>
|
|
<Icon name={icon} size={14} />
|
|
</span>
|
|
<span className="composer-import-item-label">{label}</span>
|
|
<span className="composer-import-item-soon">{t('chat.importSoon')}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SlashPopover({
|
|
commands,
|
|
activeIndex,
|
|
onPick,
|
|
onHover,
|
|
t,
|
|
}: {
|
|
commands: SlashCommand[];
|
|
activeIndex: number;
|
|
onPick: (cmd: SlashCommand) => void;
|
|
onHover: (index: number) => void;
|
|
t: TranslateFn;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="slash-popover"
|
|
data-testid="slash-popover"
|
|
role="listbox"
|
|
aria-label={t('pet.slashPopoverAria')}
|
|
>
|
|
<div className="slash-popover-head">
|
|
<span>{t('pet.slashPopoverTitle')}</span>
|
|
<span className="slash-popover-hint">{t('pet.slashPopoverHint')}</span>
|
|
</div>
|
|
{commands.map((cmd, idx) => {
|
|
const active = idx === activeIndex;
|
|
return (
|
|
<button
|
|
key={cmd.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={active}
|
|
className={`slash-item${active ? ' active' : ''}`}
|
|
onMouseDown={(e) => {
|
|
// Prevent the textarea from losing focus before the click
|
|
// handler fires — otherwise selectionStart resets and the
|
|
// pick replacement targets the wrong substring.
|
|
e.preventDefault();
|
|
}}
|
|
onMouseEnter={() => onHover(idx)}
|
|
onClick={() => onPick(cmd)}
|
|
>
|
|
<span className="slash-item-icon" aria-hidden>
|
|
<Icon name={cmd.icon} size={13} />
|
|
</span>
|
|
<span className="slash-item-body">
|
|
<span className="slash-item-row">
|
|
<code className="slash-item-label">{cmd.label}</code>
|
|
{cmd.argHint ? (
|
|
<span className="slash-item-arg">{cmd.argHint}</span>
|
|
) : null}
|
|
</span>
|
|
<span className="slash-item-desc">{t(cmd.descKey)}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MentionPopover({
|
|
files,
|
|
onPick,
|
|
}: {
|
|
files: ProjectFile[];
|
|
onPick: (path: string) => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
useEffect(() => {
|
|
if (ref.current) ref.current.scrollTop = 0;
|
|
}, [files]);
|
|
return (
|
|
<div className="mention-popover" data-testid="mention-popover" ref={ref}>
|
|
{files.map((f) => {
|
|
const key = f.path ?? f.name;
|
|
return (
|
|
<button
|
|
key={key}
|
|
className="mention-item"
|
|
onClick={() => onPick(key)}
|
|
>
|
|
<code>{key}</code>
|
|
{f.size != null ? (
|
|
<span className="mention-meta">{prettySize(f.size)}</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function looksLikeImage(name: string): boolean {
|
|
return /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(name);
|
|
}
|
|
|
|
function prettySize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
}
|