first-commit
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
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
This commit is contained in:
@@ -0,0 +1,968 @@
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user