open-design/apps/web/src/components/ChatComposer.tsx
qiongyu1999 75c17f2422 fix(web): remove pet button from chat composer
The Pets entry in the composer toolbar is no longer needed here.
Users can still manage pets via /pet slash commands and Settings.
2026-05-30 14:03:55 +08:00

2791 lines
100 KiB
TypeScript

import {
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from 'react-dom';
import { useI18n, useT } from '../i18n';
import type { Dict } from '../i18n/types';
import {
localizeSkillDescription,
localizeSkillName,
} from '../i18n/content';
import { useAnalytics } from '../analytics/provider';
import {
trackChatPanelClick,
trackFileUploadResult,
} from '../analytics/events';
import { deriveUploadCohort } from '../analytics/upload-tracking';
import { IMAGE_MODELS } from "../media/models";
import { projectRawUrl, uploadProjectFiles, openFolderDialog, fetchConnectors } from "../providers/registry";
import { patchProject } from "../state/projects";
import { fetchMcpServers } from "../state/mcp";
import type { McpServerConfig, McpTemplate } from "../state/mcp";
import { listPlugins } from "../state/projects";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata, SkillSummary } from "../types";
import type {
ContextItem,
ConnectorDetail,
InstalledPluginRecord,
PluginSourceKind,
ResearchOptions,
RunContextSelection,
} from '@open-design/contracts';
import { buildVisualAnnotationAttachment, commentTargetDisplayName } from '../comments';
import { Icon } from "./Icon";
import { PluginDetailsModal } from "./PluginDetailsModal";
import { PluginsSection, type PluginsSectionHandle } from "./PluginsSection";
import { BUILT_IN_PETS, CUSTOM_PET_ID } from "./pet/pets";
import {
buildInlineMentionParts,
inlineMentionToken,
type InlineMentionEntity,
} from '../utils/inlineMentions';
import { isImeComposing } from '../utils/imeComposing';
import { ANNOTATION_EVENT, type AnnotationEventDetail } from "./PreviewDrawOverlay";
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
type ToolsTab = 'plugins' | 'skills' | 'mcp' | 'import' | 'pet';
type MentionTab = 'all' | 'plugins' | 'skills' | 'mcp' | 'connectors' | 'files';
const USER_PLUGIN_SOURCE_KINDS = new Set<PluginSourceKind>([
'user',
'project',
'marketplace',
'github',
'url',
'local',
]);
const COMPOSER_TEXTAREA_MIN_HEIGHT = 88;
const COMPOSER_TEXTAREA_MAX_HEIGHT = 184;
function composerTextareaMaxHeight(): number {
if (typeof window === 'undefined') return COMPOSER_TEXTAREA_MAX_HEIGHT;
return Math.max(
COMPOSER_TEXTAREA_MIN_HEIGHT,
Math.min(COMPOSER_TEXTAREA_MAX_HEIGHT, Math.round(window.innerHeight * 0.34)),
);
}
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;
sendDisabled?: boolean;
initialDraft?: string;
draftStorageKey?: 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;
// Available skills the user can compose into a turn via @<skill>. The
// chat layer already filters out disabled skills before passing them in
// here, so the picker can render the list as-is. Keep this optional so
// the composer still works on surfaces that don't show a skills picker
// (e.g. tests, screenshot harnesses).
skills?: SkillSummary[];
onSend: (
prompt: string,
attachments: ChatAttachment[],
commentAttachments: ChatCommentAttachment[],
meta?: ChatSendMeta,
) => 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;
// Opens settings on the External MCP tab. Wired from ChatPane → App.
// The composer's `/mcp` slash command and the MCP picker button route here.
onOpenMcpSettings?: () => 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;
researchAvailable?: boolean;
projectMetadata?: ProjectMetadata;
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
// SenseAudio BYOK image-model picker shown above the textarea. Hidden
// when the active chat protocol is anything other than 'senseaudio',
// so the composer stays clean for every other BYOK tab. The state
// owner is ProjectView (per-session, reset on refresh); ChatComposer
// is a fully controlled select.
byokApiProtocol?: AppConfig['apiProtocol'];
byokImageModel?: string;
onChangeByokImageModel?: (model: string) => void;
currentSkillId?: string | null;
onProjectSkillChange?: (skillId: string | null) => void;
// Set when the project was created with a plugin already pinned
// (PluginLoopHome on Home). When provided, the in-composer plugin
// rail collapses to the single pinned plugin so the user can see
// which plugin is active without being offered every other installed
// plugin (the user reported "选了 new-generation, 结果 composer 显
// 示了多个 plugin"). The active plugin still appears as an
// ActivePluginChip on each user message (see UserMessage in
// ChatPane). Pass `null` (or omit) to render the full rail.
pinnedPluginId?: string | null;
footerAccessory?: ReactNode;
}
// 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;
restoreDraft: (draft: {
text: string;
attachments?: ChatAttachment[];
commentAttachments?: ChatCommentAttachment[];
}) => void;
focus: () => void;
}
export interface ChatSendMeta {
research?: ResearchOptions;
context?: RunContextSelection;
// Per-turn skill ids picked via the @-mention popover. The chat layer
// forwards these to the daemon's `skillIds` field so the system prompt
// for this run only is composed with the extra skill bodies, without
// touching the project's persistent `skillId`.
skillIds?: string[];
}
/**
* 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,
sendDisabled = false,
initialDraft,
draftStorageKey,
onEnsureProject,
commentAttachments = [],
onRemoveCommentAttachment,
skills = [],
onSend,
onStop,
onOpenMcpSettings,
petConfig,
onAdoptPet,
onTogglePet,
onOpenPetSettings,
researchAvailable = false,
projectMetadata,
onProjectMetadataChange,
byokApiProtocol,
byokImageModel,
onChangeByokImageModel,
currentSkillId = null,
onProjectSkillChange,
pinnedPluginId = null,
footerAccessory,
},
ref
) {
const t = useT();
const analytics = useAnalytics();
const [draft, setDraft] = useState(() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "");
// chat_panel page_view fires from ProjectView (which outlives
// conversation switches) so the event measures real chat-panel
// entries rather than ChatComposer remounts. See PR #2285 review
// 2026-05-20 04:08 for the rationale.
const [staged, setStaged] = useState<ChatAttachment[]>([]);
const [stagedVisualComments, setStagedVisualComments] = useState<ChatCommentAttachment[]>([]);
const streamingAnnotationSendPendingRef = useRef(false);
const [streamingAnnotationSendPending, setStreamingAnnotationSendPendingState] = useState(false);
// Skills the user has @-mentioned for this turn. We dedupe on id and
// strip the chip when the user removes the corresponding `@<skill>`
// token from the draft, keeping draft and chips in sync.
const [stagedSkills, setStagedSkills] = useState<SkillSummary[]>([]);
const [stagedMcpServers, setStagedMcpServers] = useState<McpServerConfig[]>([]);
const [stagedConnectors, setStagedConnectors] = useState<ConnectorDetail[]>([]);
const [dragActive, setDragActive] = useState(false);
const [mention, setMention] = useState<{
q: string;
cursor: number;
} | null>(null);
const [composerScrollTop, setComposerScrollTop] = useState(0);
// 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);
// External MCP servers configured by the user. Fetched lazily on mount;
// shown in the slash-command palette so `/mcp <id>` inserts a hint into
// the prompt that nudges the model to use that server's tools.
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
const [mcpTemplates, setMcpTemplates] = useState<McpTemplate[]>([]);
const [connectors, setConnectors] = useState<ConnectorDetail[]>([]);
// Installed plugins, fetched lazily for the tools-menu Plugins tab and
// the @-mention picker. Both surfaces share the same list so applying
// a plugin from either path lands on the same project context.
const [installedPlugins, setInstalledPlugins] = useState<InstalledPluginRecord[]>([]);
// Detail modal — opened from a context chip click (kind === 'plugin')
// or from the tools-menu "Details" affordance.
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
const pluginsSectionRef = useRef<PluginsSectionHandle | null>(null);
// Consolidated "tools" popover — a single dropdown anchored to the
// leading sliders icon that hosts MCP / Import / Pet quick actions and
// a shortcut to open the full Settings dialog. Replaces the previous
// row of three standalone buttons (which overflowed in narrow chats).
const [toolsOpen, setToolsOpen] = useState(false);
const [toolsTab, setToolsTab] = useState<ToolsTab>('plugins');
const fileInputRef = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const composingRef = useRef(false);
const toolsMenuRef = useRef<HTMLDivElement | null>(null);
const toolsTriggerRef = useRef<HTMLButtonElement | null>(null);
const petEnabled = Boolean(onAdoptPet && onTogglePet);
const linkedDirs = projectMetadata?.linkedDirs ?? [];
// 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(() => {
saveComposerDraft(draftStorageKey, draft);
}, [draftStorageKey, draft]);
useEffect(() => {
if (!toolsOpen) return;
function onPointer(e: MouseEvent) {
const target = e.target as Node;
if (toolsMenuRef.current?.contains(target)) return;
if (toolsTriggerRef.current?.contains(target)) return;
setToolsOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setToolsOpen(false);
}
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [toolsOpen]);
// Lazy-fetch the user's external MCP servers list once on mount so the
// `/mcp …` slash palette and the composer's MCP button popover have
// something to render. We deliberately do not reactively re-fetch when
// the user toggles servers from Settings — the dialog refreshes itself,
// and the chat composer rehydrates next time the user re-opens it. A
// background poll would be cheap but unnecessary for the typical
// edit-once-then-chat workflow.
useEffect(() => {
let cancelled = false;
void (async () => {
const data = await fetchMcpServers();
if (cancelled || !data) return;
setMcpServers(data.servers);
setMcpTemplates(data.templates);
})();
return () => {
cancelled = true;
};
}, []);
// Skills now come from the parent (App.tsx → ProjectView → ChatPane → ChatComposer)
// pre-filtered by enabled/disabled state. We no longer fetch a fresh list
// here to avoid showing skills the user has disabled via Settings.
// Lazy-fetch installed plugins once on mount; the tools-menu Plugins
// tab and the @-mention picker both consume this list.
useEffect(() => {
if (!projectId) return;
let cancelled = false;
void listPlugins().then((rows) => {
if (cancelled) return;
setInstalledPlugins(rows);
});
return () => {
cancelled = true;
};
}, [projectId]);
useEffect(() => {
let cancelled = false;
void fetchConnectors().then((rows) => {
if (cancelled) return;
setConnectors(rows.filter((connector) => connector.status === 'connected'));
});
return () => {
cancelled = true;
};
}, []);
// Composer-side plugin list: hide bundled atoms (pipeline-only). Keep
// the full installed list available even when the project was created
// from a pinned plugin, so users can switch or layer different plugin
// context from the tools menu and @ picker.
const pluginsForComposer = useMemo<InstalledPluginRecord[]>(() => {
const allowedKinds = new Set(['skill', 'scenario', 'bundle']);
return installedPlugins.filter((p) => {
const k = p.manifest?.od?.kind;
return !k || allowedKinds.has(k);
});
}, [installedPlugins]);
const enabledMcpServers = useMemo(
() => mcpServers.filter((s) => s.enabled),
[mcpServers],
);
const composerMentionEntities = useMemo(
() =>
buildComposerMentionEntities({
connectors,
files: projectFiles,
mcpServers: enabledMcpServers,
plugins: pluginsForComposer,
skills,
staged,
}),
[connectors, enabledMcpServers, pluginsForComposer, projectFiles, skills, staged],
);
const composerMentionParts = useMemo(
() => buildInlineMentionParts(draft, composerMentionEntities),
[composerMentionEntities, draft],
);
function resizeTextarea() {
const ta = textareaRef.current;
if (!ta) return;
const maxHeight = composerTextareaMaxHeight();
ta.style.height = 'auto';
const nextHeight = Math.min(
Math.max(ta.scrollHeight, COMPOSER_TEXTAREA_MIN_HEIGHT),
maxHeight,
);
ta.style.height = `${nextHeight}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? 'auto' : 'hidden';
}
useLayoutEffect(() => {
resizeTextarea();
}, [draft, composerMentionParts, staged.length, stagedSkills.length]);
useEffect(() => {
function onResize() {
resizeTextarea();
}
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
setComposerScrollTop(textareaRef.current?.scrollTop ?? 0);
}, [composerMentionParts]);
// Resolve which tabs to surface in the consolidated tools popover.
// Plugins is always visible while a project is active so users can
// apply context without leaving the composer. MCP shows when wired by
// the parent (App); Import is always available. Pet controls stay out
// of the project context picker so the @ panel remains project-scoped.
const availableTabs = useMemo<ToolsTab[]>(() => {
const tabs: ToolsTab[] = [];
if (projectId) {
tabs.push('plugins');
tabs.push('skills');
}
if (onOpenMcpSettings) tabs.push('mcp');
tabs.push('import');
return tabs;
}, [projectId, onOpenMcpSettings]);
// When the popover opens, snap the active tab to the first available one
// so the user never lands on an empty / hidden tab if their config
// changes mid-session.
useEffect(() => {
if (!toolsOpen) return;
if (!availableTabs.includes(toolsTab)) {
const first = availableTabs[0];
if (first) setToolsTab(first);
}
}, [toolsOpen, availableTabs, toolsTab]);
// 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[] = [];
// External MCP servers — `/mcp` opens settings, `/mcp <id>` inserts a
// prompt-side hint nudging the model to use that server's tools. The
// hint flows through to the agent verbatim; the daemon already wired
// the MCP config into the agent's launch so the tools are callable.
if (onOpenMcpSettings) {
list.push({
id: 'mcp',
label: '/mcp',
insert: '/mcp ',
descKey: 'pet.slashPet',
icon: 'sliders',
argHint: 'open settings · <server-id> to insert hint',
});
}
for (const s of enabledMcpServers) {
list.push({
id: `mcp-${s.id}`,
label: `/mcp ${s.id}`,
insert: `Use the \`${s.id}\` MCP server tools. `,
descKey: 'pet.slashPet',
icon: 'sparkles',
argHint: s.label || s.transport,
});
}
if (researchAvailable) {
list.push({
id: 'search',
label: '/search',
insert: '/search ',
descKey: 'pet.slashSearch',
icon: 'sparkles',
argHint: t('pet.slashSearchArg'),
});
}
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, researchAvailable, t, enabledMcpServers, onOpenMcpSettings]);
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');
}
// `/mcp` (no arg) opens settings on the External MCP tab — pure UX hook,
// never sent to the agent. `/mcp <id>` is intentionally NOT intercepted
// here: the slash palette already replaces it with a natural-language
// hint sentence ("Use the `<id>` MCP server tools."), and the user is
// expected to keep typing the rest of the prompt before sending.
function tryHandleMcpSlash(): boolean {
if (!onOpenMcpSettings) return false;
const trimmed = draft.trim();
if (!/^\/mcp\s*$/i.test(trimmed)) return false;
onOpenMcpSettings();
setDraft('');
return true;
}
function expandSearchCommand(input: string): { prompt: string; query: string } | null {
const m = /^\/search(?:\s+([\s\S]*))?$/i.exec(input.trim());
if (!m) return null;
const query = m[1]?.trim() ?? '';
if (!query) return null;
return {
query,
prompt: [
`Search for: ${query}`,
'',
'Before answering, your first tool action must be the OD research command for your shell.',
'POSIX: "$OD_NODE_BIN" "$OD_BIN" research search --query "<search query>" --max-sources 5',
'PowerShell: & $env:OD_NODE_BIN $env:OD_BIN research search --query "<search query>" --max-sources 5',
'cmd.exe: "%OD_NODE_BIN%" "%OD_BIN%" research search --query "<search query>" --max-sources 5',
'Use the canonical query below as the exact search query, with safe quoting for your shell.',
'',
'Canonical query:',
'',
'```text',
query.replace(/```/g, '`\u200b`\u200b`'),
'```',
'If the OD command fails because Tavily is not configured or unavailable, report that error, then use your own search capability as fallback and label the fallback clearly.',
'After the command returns JSON or fallback search results, write a reusable Markdown report into Design Files at `research/<safe-query-slug>.md` or another fresh project-relative path.',
'The report must include the query, fetched time, short summary, key findings, source list with [1], [2] citations, and a note that source content is external untrusted evidence.',
'Then summarize the findings with citations by source index and mention the Markdown report path.',
].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);
});
},
restoreDraft: ({ text, attachments = [], commentAttachments = [] }) => {
setDraft(text);
setStaged(attachments);
setStagedVisualComments(commentAttachments);
setStagedSkills([]);
setStagedMcpServers([]);
setStagedConnectors([]);
setUploadError(null);
setMention(null);
setSlash(null);
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([]);
setStagedVisualComments([]);
setStagedSkills([]);
setStagedMcpServers([]);
setStagedConnectors([]);
setUploadError(null);
setMention(null);
setSlash(null);
}
function currentCommentAttachments(extra: ChatCommentAttachment[] = []): ChatCommentAttachment[] {
return [...commentAttachments, ...stagedVisualComments, ...extra];
}
function setStreamingAnnotationSendPending(value: boolean) {
streamingAnnotationSendPendingRef.current = value;
setStreamingAnnotationSendPendingState(value);
}
function currentRunContextMeta(): ChatSendMeta | undefined {
const skillIds = stagedSkills.map((s) => s.id);
const mcpServerIds = stagedMcpServers.map((s) => s.id);
const connectorIds = stagedConnectors.map((c) => c.id);
const context: RunContextSelection = {
...(skillIds.length > 0 ? { skillIds } : {}),
...(mcpServerIds.length > 0 ? { mcpServerIds } : {}),
...(connectorIds.length > 0 ? { connectorIds } : {}),
};
const meta: ChatSendMeta = {
...(skillIds.length > 0 ? { skillIds } : {}),
...(Object.keys(context).length > 0 ? { context } : {}),
};
return Object.keys(meta).length > 0 ? meta : undefined;
}
function sendComposedTurn(
prompt: string,
attachments: ChatAttachment[],
nextCommentAttachments: ChatCommentAttachment[],
meta?: ChatSendMeta,
): boolean {
setStreamingAnnotationSendPending(false);
if (!prompt && attachments.length === 0 && nextCommentAttachments.length === 0) return false;
onSend(prompt, attachments, nextCommentAttachments, meta);
reset();
return true;
}
async function insertSkillMention(skill: SkillSummary) {
const applied = await applyProjectSkill(skill);
if (!applied) return;
replaceMentionWithText(`${inlineMentionToken(skill.name)} `);
}
function removeStagedSkill(id: string) {
setStagedSkills((prev) => prev.filter((s) => s.id !== id));
// Also strip the matching `@<id>` token from the draft so the chip
// and the textarea stay in sync. We allow trailing whitespace to be
// collapsed too.
setDraft((d) =>
d
.replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2')
.replace(/\s{2,}/g, ' '),
);
}
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);
// Cohort math is identical to the Design Files Upload button; see
// `analytics/upload-tracking.ts`. v2 doc fires one
// file_upload_result per surface so this path reports
// `page_name='chat_panel'` / `area='chat_composer'`.
const cohort = deriveUploadCohort(files);
try {
const result = await uploadProjectFiles(id, files);
if (result.uploaded.length > 0) {
setStaged((s) => [...s, ...result.uploaded]);
}
const partial = result.failed.length > 0;
if (partial) {
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);
}
trackFileUploadResult(analytics.track, {
page_name: 'chat_panel',
area: 'chat_composer',
project_id: id,
...cohort,
result: partial ? 'failed' : 'success',
...(partial && result.error ? { error_code: result.error } : {}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
setUploadError(`Attachment upload failed (${detail}).`);
trackFileUploadResult(analytics.track, {
page_name: 'chat_panel',
area: 'chat_composer',
project_id: id,
...cohort,
result: 'failed',
error_code: detail,
});
} finally {
setUploading(false);
}
}
async function uploadClipboardImagesFromAsyncClipboard() {
if (!navigator.clipboard?.read) return false;
try {
const items = await navigator.clipboard.read();
const files: File[] = [];
const stamp = Date.now();
for (const item of items) {
const imageType = item.types.find((type) => type.startsWith('image/'));
if (!imageType) continue;
const blob = await item.getType(imageType);
const extension = imageType.split('/')[1]?.replace('jpeg', 'jpg') || 'png';
files.push(new File([blob], `clipboard-screenshot-${stamp}.${extension}`, { type: imageType }));
}
if (files.length === 0) return false;
await uploadFiles(files);
return true;
} catch (err) {
console.warn('Could not read image from clipboard', err);
return false;
}
}
useEffect(() => {
function onAnnotation(e: Event) {
const detail = (e as CustomEvent<AnnotationEventDetail>).detail;
if (!detail) return;
void (async () => {
let acked = false;
const ack = (result: { ok: boolean; message?: string }) => {
if (acked) return;
acked = true;
detail.ack?.(result);
};
let uploaded: ChatAttachment[] = [];
let visualAttachmentInput: Parameters<typeof buildVisualAnnotationAttachment>[0] | null = null;
let visualAttachment: ChatCommentAttachment | null = null;
try {
if (detail.file) {
const id = await ensureProject();
if (!id) {
ack({ ok: false, message: t('chat.annotationProjectCreateFailed') });
return;
}
setUploading(true);
const result = await uploadProjectFiles(id, [detail.file]);
if (result.uploaded.length > 0) {
uploaded = result.uploaded;
if (detail.action !== 'send') {
setStaged((s) => [...s, ...uploaded]);
}
const screenshot = uploaded[0];
if (screenshot && detail.markKind && detail.bounds) {
visualAttachmentInput = {
order: 1,
idSeed: screenshot.path,
screenshotPath: screenshot.path,
markKind: detail.markKind,
note: detail.note,
bounds: detail.bounds,
target: detail.target
? {
filePath: detail.target.filePath || detail.filePath || screenshot.path,
elementId: detail.target.elementId,
selector: detail.target.selector,
label: detail.target.label,
text: detail.target.text,
position: detail.target.position,
htmlHint: detail.target.htmlHint,
}
: {
filePath: detail.filePath || screenshot.path,
position: detail.bounds,
},
};
if (detail.action !== 'send') {
setStagedVisualComments((current) => [
...current,
buildVisualAnnotationAttachment({
...visualAttachmentInput!,
order: commentAttachments.length + current.length + 1,
}),
]);
}
}
}
if (result.failed.length > 0) {
const detailText = result.error ? ` (${result.error})` : '';
setUploadError(`Attachment upload failed for ${result.failed.length} file(s)${detailText}.`);
if (uploaded.length === 0) {
ack({ ok: false, message: t('chat.annotationUploadFailed') });
return;
}
}
}
setUploading(false);
if (detail.action === 'send') {
if (streaming) {
if (uploaded.length > 0) setStaged((s) => [...s, ...uploaded]);
if (visualAttachmentInput) {
setStagedVisualComments((current) => [
...current,
buildVisualAnnotationAttachment({
...visualAttachmentInput!,
order: commentAttachments.length + current.length + 1,
}),
]);
}
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
setStreamingAnnotationSendPending(true);
textareaRef.current?.focus();
ack({ ok: true });
return;
}
if (visualAttachmentInput) {
visualAttachment = buildVisualAnnotationAttachment({
...visualAttachmentInput,
order: commentAttachments.length + stagedVisualComments.length + 1,
});
}
const prompt = [draft.trim(), detail.note].filter(Boolean).join('\n');
const attachments = [...staged, ...uploaded];
const nextCommentAttachments = currentCommentAttachments(visualAttachment ? [visualAttachment] : []);
sendComposedTurn(prompt, attachments, nextCommentAttachments, currentRunContextMeta());
ack({ ok: true });
return;
}
if (detail.note) {
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
textareaRef.current?.focus();
}
ack({ ok: true });
} catch (err) {
console.warn('Could not send annotation', err);
setUploadError(err instanceof Error ? err.message : t('chat.annotationFailed'));
ack({ ok: false, message: t('chat.annotationFailed') });
} finally {
setUploading(false);
}
})();
}
window.addEventListener(ANNOTATION_EVENT, onAnnotation);
return () => window.removeEventListener(ANNOTATION_EVENT, onAnnotation);
}, [
commentAttachments,
draft,
onSend,
projectId,
staged,
stagedConnectors,
stagedMcpServers,
stagedSkills,
stagedVisualComments,
streaming,
t,
]);
useEffect(() => {
if (!streamingAnnotationSendPending || !streamingAnnotationSendPendingRef.current) return;
if (streaming || sendDisabled) return;
const prompt = draft.trim();
sendComposedTurn(prompt, staged, currentCommentAttachments(), currentRunContextMeta());
}, [
commentAttachments,
draft,
onSend,
sendDisabled,
staged,
stagedConnectors,
stagedMcpServers,
stagedSkills,
stagedVisualComments,
streaming,
streamingAnnotationSendPending,
]);
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);
return;
}
void uploadClipboardImagesFromAsyncClipboard();
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragActive(false);
const files = Array.from(e.dataTransfer.files ?? []);
if (files.length > 0) void uploadFiles(files);
}
async function handleLinkFolder() {
if (!projectId) return;
const selected = await openFolderDialog();
if (!selected) return;
const base = projectMetadata ?? { kind: 'prototype' as const };
const existing = base.linkedDirs ?? [];
if (existing.includes(selected)) return;
const metadata: ProjectMetadata = { ...base, linkedDirs: [...existing, selected] };
const result = await patchProject(projectId, { metadata });
if (result?.metadata) onProjectMetadataChange?.(result.metadata);
}
async function handleUnlinkFolder(dir: string) {
if (!projectId) return;
const base = projectMetadata ?? { kind: 'prototype' as const };
const existing = base.linkedDirs ?? [];
const metadata: ProjectMetadata = { ...base, linkedDirs: existing.filter((d) => d !== dir) };
const result = await patchProject(projectId, { metadata });
if (result?.metadata) onProjectMetadataChange?.(result.metadata);
}
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const value = e.target.value;
const cursor = e.target.selectionStart;
setDraft(value);
// Keep the staged-skill chips in sync with the draft. If the user
// hand-deletes an `@<id>` token from the textarea, the chip must
// disappear too — otherwise submit() would still forward that id in
// skillIds and the daemon would compose a skill the prompt no
// longer references. Mirror the removeStagedSkill() boundary
// (whitespace or string edge) so partial matches don't keep a chip
// alive accidentally. We do not run the same prune for `staged`
// file attachments because users frequently attach files via the
// upload button without leaving an `@<path>` token in the draft.
setStagedSkills((prev) =>
prev.filter((s) =>
new RegExp(`(^|\\s)@${escapeRegExp(s.id)}(\\s|$)`).test(value),
),
);
// Skip mention and slash detection during IME composition (e.g.,
// Chinese, Japanese, Korean input) to prevent cursor jumping.
// Issue #2851.
if (composingRef.current) return;
// 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);
});
}
async function insertPluginMention(record: InstalledPluginRecord) {
const inserted = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
if (!inserted) return;
await pluginsSectionRef.current?.applyById(record.id, record);
}
function replaceMentionWithText(text: string): boolean {
if (!mention) return false;
const ta = textareaRef.current;
const cursor = mention.cursor;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const replaced = before.replace(/(^|\s)@([^\s@]*)$/, `$1${text}`);
const next = replaced + after;
setDraft(next);
setMention(null);
requestAnimationFrame(() => {
if (!ta) return;
ta.focus();
const pos = replaced.length;
ta.setSelectionRange(pos, pos);
});
return true;
}
function insertMcpMention(server: McpServerConfig) {
setStagedMcpServers((current) => (
current.some((item) => item.id === server.id) ? current : [...current, server]
));
replaceMentionWithText(`${inlineMentionToken(server.label || server.id)} `);
}
function insertConnectorMention(connector: ConnectorDetail) {
setStagedConnectors((current) => (
current.some((item) => item.id === connector.id) ? current : [...current, connector]
));
replaceMentionWithText(`${inlineMentionToken(connector.name)} `);
}
async function applyProjectSkill(skill: SkillSummary): Promise<boolean> {
if (!projectId) return false;
const result = await patchProject(projectId, { skillId: skill.id });
if (!result) return false;
onProjectSkillChange?.(result.skillId ?? skill.id);
return true;
}
function removeStaged(p: string) {
setStaged((s) => s.filter((a) => a.path !== p));
setStagedVisualComments((current) => current.filter((attachment) => attachment.screenshotPath !== p));
}
function removeCommentAttachment(id: string) {
setStagedVisualComments((current) => current.filter((attachment) => attachment.id !== id));
if (!stagedVisualComments.some((attachment) => attachment.id === id)) {
onRemoveCommentAttachment?.(id);
}
}
async function submit() {
const prompt = draft.trim();
if (sendDisabled) return;
// Intercept `/pet …` and `/mcp` before sending so the slash command
// never hits the agent — these are local UX hooks, not model prompts.
if (tryHandlePetSlash()) return;
if (tryHandleMcpSlash()) 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 contextMeta = currentRunContextMeta();
const hatched = expandHatchCommand(prompt);
const nextCommentAttachments = currentCommentAttachments();
if (hatched) {
if (streaming) return;
setStreamingAnnotationSendPending(false);
onSend(hatched, staged, nextCommentAttachments, contextMeta);
reset();
return;
}
const search = researchAvailable ? expandSearchCommand(prompt) : null;
if (search) {
if (streaming) return;
setStreamingAnnotationSendPending(false);
onSend(search.prompt, staged, nextCommentAttachments, {
...contextMeta,
research: { enabled: true, query: search.query },
});
reset();
return;
}
if (!prompt && staged.length === 0 && nextCommentAttachments.length === 0) return;
sendComposedTurn(prompt, staged, nextCommentAttachments, contextMeta);
}
// The @-picker offers a unified search across context surfaces:
// project files, plugins, active MCP servers, and skills. Picked
// entities keep an inline @ token for orientation while richer
// context is still applied behind the scenes when available.
const mentionQuery = mention ? mention.q.toLowerCase() : '';
const filteredFiles = mention
? projectFiles
.filter((f) => f.type === undefined || f.type === "file")
.filter((f) => {
const key = f.path ?? f.name;
return key.toLowerCase().includes(mentionQuery);
})
.slice(0, 12)
: [];
const filteredPlugins = mention
? pluginsForComposer
.filter((p) => {
if (!mentionQuery) return true;
return (
p.title.toLowerCase().includes(mentionQuery) ||
p.id.toLowerCase().includes(mentionQuery) ||
(p.manifest?.description ?? '').toLowerCase().includes(mentionQuery) ||
(p.manifest?.tags ?? []).join(' ').toLowerCase().includes(mentionQuery)
);
})
.slice(0, 8)
: [];
const filteredMcpServers = mention
? enabledMcpServers
.filter((s) => {
if (!mentionQuery) return true;
return [
s.id,
s.label ?? '',
s.transport,
s.url ?? '',
s.command ?? '',
]
.join(' ')
.toLowerCase()
.includes(mentionQuery);
})
.slice(0, 8)
: [];
const filteredConnectors = mention
? connectors
.filter((connector) => {
if (!mentionQuery) return true;
return [
connector.id,
connector.name,
connector.provider,
connector.category,
connector.description ?? '',
connector.accountLabel ?? '',
]
.join(' ')
.toLowerCase()
.includes(mentionQuery);
})
.slice(0, 8)
: [];
// Already-staged skills drop out of the suggestion list (carried over
// from main) so the @-popover keeps moving forward as the user picks.
const stagedSkillIds = new Set(stagedSkills.map((s) => s.id));
const filteredSkills = mention
? skills
.filter((s) => !stagedSkillIds.has(s.id))
.filter((s) => skillMatchesQuery(s, mentionQuery))
.sort((a, b) => skillMentionRank(a, mentionQuery) - skillMentionRank(b, mentionQuery))
: [];
const hasComposerPayload =
draft.trim().length > 0 || staged.length > 0 || currentCommentAttachments().length > 0;
const showStopButton = streaming && !hasComposerPayload;
const showSendButton = !streaming || hasComposerPayload;
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">
{stagedSkills.length > 0 ? (
<StagedSkills
skills={stagedSkills}
onRemove={removeStagedSkill}
t={t}
/>
) : null}
{staged.length > 0 ? (
<StagedAttachments
attachments={staged}
projectId={projectId}
onRemove={removeStaged}
t={t}
/>
) : null}
{linkedDirs.length > 0 ? (
<div className="linked-dirs-row" data-testid="linked-dirs">
{linkedDirs.map((dir) => (
<div key={dir} className="linked-dir-chip">
<Icon name="folder" size={13} />
<span className="linked-dir-name" title={dir}>
{dir.split('/').pop() || dir}
</span>
<button
className="staged-remove"
onClick={() => handleUnlinkFolder(dir)}
title={t('chat.linkedFolderRemoveAria', { path: dir })}
aria-label={t('chat.linkedFolderRemoveAria', { path: dir })}
>
<Icon name="close" size={11} />
</button>
</div>
))}
</div>
) : null}
{currentCommentAttachments().length > 0 ? (
<StagedCommentAttachments
attachments={currentCommentAttachments()}
onRemove={removeCommentAttachment}
t={t}
/>
) : null}
{byokApiProtocol === 'senseaudio' && onChangeByokImageModel ? (
<div
className="composer-byok-image-model"
data-testid="composer-byok-image-model"
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '4px 8px',
fontSize: 12,
color: 'var(--text-muted, #888)',
}}
>
<Icon name="image" size={13} />
<label
htmlFor="composer-byok-image-model-select"
style={{ flexShrink: 0 }}
>
{t('settings.byokImageModel')}
</label>
<select
id="composer-byok-image-model-select"
value={byokImageModel ?? ''}
onChange={(e) => onChangeByokImageModel(e.target.value)}
style={{
background: 'transparent',
border: '1px solid var(--border, #444)',
borderRadius: 4,
padding: '2px 6px',
color: 'inherit',
fontSize: 12,
}}
>
<option value="">
{(IMAGE_MODELS.find((m) => m.provider === 'senseaudio')?.label
?? 'senseaudio-image-2.0') + ' (default)'}
</option>
{IMAGE_MODELS.filter((m) => m.provider === 'senseaudio').map(
(m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
),
)}
</select>
</div>
) : null}
{/*
Spec §8.4 — context bar above the composer input. The
section now behaves as a pure context bar: it renders the
active plugin's chips + inputs form when one is applied,
but never the always-on rail. Plugins are picked from the
tools-menu Plugins tab or the @-mention popover so the
composer chrome stays out of the way until the user wants
to attach context.
*/}
{projectId ? (
<PluginsSection
ref={pluginsSectionRef}
projectId={projectId}
showRail={false}
onApplied={(brief) => {
// Use functional setState so stale closures from the @-mention
// flow (which awaits applyById after setDraft) still see the
// latest draft value before deciding whether to seed.
if (typeof brief === 'string' && brief.length > 0) {
setDraft((cur) => (cur.trim().length === 0 ? brief : cur));
}
}}
onChipDetails={(item: ContextItem) => {
if (item.kind !== 'plugin') return;
const record = installedPlugins.find((p) => p.id === item.id);
if (record) setDetailsRecord(record);
}}
/>
) : null}
<div
className={`composer-input-wrap${
composerMentionParts ? ' has-mention-overlay' : ''
}`}
>
<div className="composer-textarea-layer">
{composerMentionParts ? (
<div
className="composer-input-overlay"
data-testid="chat-composer-mention-overlay"
aria-hidden="true"
style={{ ['--composer-input-scroll' as string]: `${composerScrollTop}px` }}
>
<div className="composer-input-overlay-inner">
{composerMentionParts.map((part, index) =>
part.kind === 'mention' ? (
<span
key={`${part.entity.kind}-${part.entity.id}-${index}`}
className={`composer-inline-mention composer-inline-mention--${part.entity.kind}`}
title={part.entity.title ?? part.text}
>
{part.text}
</span>
) : (
<span key={`text-${index}`}>{part.text}</span>
),
)}
</div>
</div>
) : null}
<textarea
ref={textareaRef}
data-testid="chat-composer-input"
// ph-no-capture: prompt content is the most sensitive
// surface in the product. PostHog autocapture skips this
// element + subtree entirely.
className="ph-no-capture"
value={draft}
placeholder={t('chat.composerPlaceholder')}
spellCheck={false}
onChange={handleChange}
onPaste={handlePaste}
onScroll={(event) => {
setComposerScrollTop(event.currentTarget.scrollTop);
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
}}
onKeyDown={(e) => {
if (isImeComposing(e, composingRef.current)) return;
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.shiftKey &&
!e.altKey &&
(e.metaKey || e.ctrlKey || !mention)
) {
e.preventDefault();
void submit();
}
}}
/>
</div>
{mention ? (
<MentionPopover
files={filteredFiles}
plugins={filteredPlugins}
skills={filteredSkills}
mcpServers={filteredMcpServers}
connectors={filteredConnectors}
query={mention.q}
currentSkillId={currentSkillId}
onPickFile={insertMention}
onPickPlugin={(record) => void insertPluginMention(record)}
onPickSkill={(skill) => void insertSkillMention(skill)}
onPickMcp={insertMcpMention}
onPickConnector={insertConnectorMention}
/>
) : 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 = '';
}}
/>
<div className="composer-tools-wrap">
<button
ref={toolsTriggerRef}
type="button"
className={`icon-btn composer-tools-trigger${toolsOpen ? ' active' : ''}`}
onClick={() => {
setToolsOpen((v) => {
const next = !v;
if (next) {
// P0 ui_click resources_popover_trigger — only emit on
// the open transition so accidental double-clicks
// don't pair an open + close into a "double tap" the
// dashboard can't interpret.
trackChatPanelClick(analytics.track, {
page_name: 'chat_panel',
area: 'chat_panel',
element: 'resources_popover_trigger',
});
}
return next;
});
}}
title={t('chat.cliSettingsTitle')}
aria-haspopup="menu"
aria-expanded={toolsOpen}
aria-label={t('chat.cliSettingsAria')}
>
<span className="composer-tools-at" aria-hidden>
@
</span>
</button>
{toolsOpen ? (
<div
ref={toolsMenuRef}
className="composer-tools-menu"
role="menu"
>
<div className="composer-tools-tabs" role="tablist">
{availableTabs.map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={toolsTab === tab}
className={`composer-tools-tab${toolsTab === tab ? ' active' : ''}`}
onClick={() => setToolsTab(tab)}
>
{tab === 'plugins' ? (
<>
<Icon name="sparkles" size={12} />
<span>Plugins</span>
</>
) : null}
{tab === 'skills' ? (
<>
<Icon name="file" size={12} />
<span>Skills</span>
</>
) : null}
{tab === 'mcp' ? (
<>
<Icon name="link" size={12} />
<span>MCP</span>
</>
) : null}
{tab === 'import' ? (
<>
<Icon name="import" size={12} />
<span>{t('chat.importLabel')}</span>
</>
) : null}
</button>
))}
</div>
<div className="composer-tools-content">
{toolsTab === 'plugins' ? (
<ToolsPluginsPanel
plugins={pluginsForComposer}
activePluginId={pinnedPluginId}
onApply={async (record) => {
const result = await pluginsSectionRef.current?.applyById(
record.id,
record,
);
if (result) setToolsOpen(false);
}}
onShowDetails={(record) => {
setDetailsRecord(record);
setToolsOpen(false);
}}
/>
) : null}
{toolsTab === 'skills' ? (
<ToolsSkillsPanel
skills={skills}
currentSkillId={currentSkillId}
onPick={async (skill) => {
const applied = await applyProjectSkill(skill);
if (applied) setToolsOpen(false);
}}
/>
) : null}
{toolsTab === 'mcp' && onOpenMcpSettings ? (
<ToolsMcpPanel
servers={enabledMcpServers}
templates={mcpTemplates}
onInsert={(serverId) => {
const ta = textareaRef.current;
const server = enabledMcpServers.find((item) => item.id === serverId);
const insert = `${inlineMentionToken(server?.label || serverId)} `;
const cursor = ta?.selectionStart ?? draft.length;
const before = draft.slice(0, cursor);
const after = draft.slice(cursor);
const next = before + insert + after;
setDraft(next);
setToolsOpen(false);
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
const pos = before.length + insert.length;
el.setSelectionRange(pos, pos);
});
}}
onManage={() => {
setToolsOpen(false);
onOpenMcpSettings?.();
}}
/>
) : null}
{toolsTab === 'import' ? (
<ToolsImportPanel
t={t}
onLinkFolder={async () => {
setToolsOpen(false);
await handleLinkFolder();
}}
/>
) : null}
</div>
</div>
) : null}
</div>
<button
className="icon-btn"
data-testid="chat-attach"
onClick={() => {
trackChatPanelClick(analytics.track, {
page_name: 'chat_panel',
area: 'chat_panel',
element: 'attachment',
});
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>
{footerAccessory}
<span className="composer-spacer" />
{showStopButton ? (
<button
type="button"
className="composer-send stop"
onClick={onStop}
>
<Icon name="stop" size={13} />
<span>{t('chat.stop')}</span>
</button>
) : null}
{showSendButton ? (
<button
type="button"
className="composer-send"
data-testid="chat-send"
onClick={() => {
trackChatPanelClick(analytics.track, {
page_name: 'chat_panel',
area: 'chat_panel',
element: 'send',
});
void submit();
}}
disabled={sendDisabled || !hasComposerPayload}
aria-label={t('chat.send')}
title={t('chat.send')}
>
<Icon name="send" size={13} />
<span>{t('chat.send')}</span>
</button>
) : null}
</div>
</div>
{uploadError ? <span className="composer-hint">{uploadError}</span> : null}
{detailsRecord ? (
<PluginDetailsModal
record={detailsRecord}
onClose={() => setDetailsRecord(null)}
onUse={async (record) => {
await pluginsSectionRef.current?.applyById(record.id, record);
setDetailsRecord(null);
}}
/>
) : null}
</div>
);
}
);
function buildComposerMentionEntities({
connectors,
files,
mcpServers,
plugins,
skills,
staged,
}: {
connectors: ConnectorDetail[];
files: ProjectFile[];
mcpServers: McpServerConfig[];
plugins: InstalledPluginRecord[];
skills: SkillSummary[];
staged: ChatAttachment[];
}): InlineMentionEntity[] {
const entities: InlineMentionEntity[] = [];
for (const plugin of plugins) {
entities.push({
id: plugin.id,
kind: 'plugin',
label: plugin.title,
token: inlineMentionToken(plugin.title),
title: `Plugin: ${plugin.title}`,
});
}
for (const skill of skills) {
entities.push({
id: skill.id,
kind: 'skill',
label: skill.name,
token: inlineMentionToken(skill.name),
title: `Skill: ${skill.name}`,
});
if (skill.id !== skill.name) {
entities.push({
id: skill.id,
kind: 'skill',
label: skill.id,
token: inlineMentionToken(skill.id),
title: `Skill: ${skill.name}`,
});
}
}
for (const server of mcpServers) {
const label = server.label || server.id;
entities.push({
id: server.id,
kind: 'mcp',
label,
token: inlineMentionToken(label),
title: `MCP: ${label}`,
});
if (server.id !== label) {
entities.push({
id: server.id,
kind: 'mcp',
label: server.id,
token: inlineMentionToken(server.id),
title: `MCP: ${label}`,
});
}
}
for (const connector of connectors) {
entities.push({
id: connector.id,
kind: 'connector',
label: connector.name,
token: inlineMentionToken(connector.name),
title: `Connector: ${connector.name}`,
});
if (connector.id !== connector.name) {
entities.push({
id: connector.id,
kind: 'connector',
label: connector.id,
token: inlineMentionToken(connector.id),
title: `Connector: ${connector.name}`,
});
}
}
const filePaths = new Set<string>();
for (const file of files) {
const path = file.path ?? file.name;
if (!path || filePaths.has(path)) continue;
filePaths.add(path);
entities.push({
id: path,
kind: 'file',
label: path,
token: inlineMentionToken(path),
title: `File: ${path}`,
});
}
for (const attachment of staged) {
if (!attachment.path || filePaths.has(attachment.path)) continue;
filePaths.add(attachment.path);
entities.push({
id: attachment.path,
kind: 'file',
label: attachment.path,
token: inlineMentionToken(attachment.path),
title: `File: ${attachment.path}`,
});
}
return entities;
}
function StagedAttachments({
attachments,
projectId,
onRemove,
t,
}: {
attachments: ChatAttachment[];
projectId: string | null;
onRemove: (path: string) => void;
t: TranslateFn;
}) {
const [preview, setPreview] = useState<ChatAttachment | null>(null);
const previewUrl = preview && projectId ? projectRawUrl(projectId, preview.path) : null;
useEffect(() => {
if (!preview) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setPreview(null);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [preview]);
return (
<>
<div className="staged-row" data-testid="staged-attachments">
{attachments.map((a) => {
const canPreview = a.kind === "image" && Boolean(projectId);
const imageUrl = canPreview ? projectRawUrl(projectId!, a.path) : null;
return (
<div key={a.path} className={`staged-chip staged-${a.kind}`}>
{canPreview && imageUrl ? (
<button
type="button"
className="staged-preview-trigger"
onClick={() => setPreview(a)}
title={a.path}
aria-label={`Preview ${a.name}`}
>
<img src={imageUrl} alt="" aria-hidden />
<span className="staged-name">
{a.name}
</span>
</button>
) : (
<>
<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>
{preview && previewUrl ? createPortal(
<div
className="staged-preview-modal"
role="dialog"
aria-modal="true"
aria-label={preview.name}
onMouseDown={(e) => {
if (e.target === e.currentTarget) setPreview(null);
}}
>
<div className="staged-preview-card">
<div className="staged-preview-head">
<span title={preview.path}>{preview.name}</span>
<button
type="button"
className="icon-only"
onClick={() => setPreview(null)}
aria-label={t('common.close')}
title={t('common.close')}
>
<Icon name="close" size={14} />
</button>
</div>
<img src={previewUrl} alt={preview.name} />
</div>
</div>,
document.body
) : null}
</>
);
}
function StagedSkills({
skills,
onRemove,
t,
}: {
skills: SkillSummary[];
onRemove: (id: string) => void;
t: TranslateFn;
}) {
return (
<div
className="staged-row staged-skills-row"
data-testid="staged-skills"
>
{skills.map((s) => (
<div
key={s.id}
className={`staged-chip staged-skill staged-skill-${s.source ?? 'built-in'}`}
>
<span className="staged-icon" aria-hidden>
<Icon name="sparkles" size={12} />
</span>
<span className="staged-name" title={s.description || s.name}>
@{s.id}
</span>
<button
className="staged-remove"
onClick={() => onRemove(s.id)}
title={t('common.delete')}
aria-label={`Remove skill ${s.id}`}
>
<Icon name="close" size={11} />
</button>
</div>
))}
</div>
);
}
function StagedCommentAttachments({
attachments,
onRemove,
t,
}: {
attachments: ChatCommentAttachment[];
onRemove: (id: string) => void;
t: TranslateFn;
}) {
const visibleAttachments = attachments.filter((attachment) => attachment.selectionKind !== 'visual');
if (visibleAttachments.length === 0) return null;
return (
<div className="staged-row comment-staged-row" data-testid="staged-comment-attachments">
{visibleAttachments.map((a) => (
<div key={a.id} className="staged-chip staged-comment">
<span className="staged-name" title={`${a.screenshotPath ? `${a.screenshotPath}: ` : ''}${commentTargetDisplayName(a)}: ${a.comment}`}>
<strong>{commentTargetDisplayName(a)}</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 ToolsPluginsPanel({
plugins,
activePluginId,
onApply,
onShowDetails,
}: {
plugins: InstalledPluginRecord[];
activePluginId: string | null;
onApply: (record: InstalledPluginRecord) => void | Promise<void>;
onShowDetails: (record: InstalledPluginRecord) => void;
}) {
const [pendingId, setPendingId] = useState<string | null>(null);
const [source, setSource] = useState<'community' | 'mine'>('community');
const [query, setQuery] = useState('');
const communityPlugins = useMemo(
() => plugins.filter((p) => p.sourceKind === 'bundled'),
[plugins],
);
const userPlugins = useMemo(
() => plugins.filter((p) => USER_PLUGIN_SOURCE_KINDS.has(p.sourceKind)),
[plugins],
);
const scopedPlugins = source === 'community' ? communityPlugins : userPlugins;
const visiblePlugins = useMemo(
() => scopedPlugins.filter((p) => pluginMatchesQuery(p, query)),
[scopedPlugins, query],
);
return (
<>
<div className="composer-tools-filter">
<div className="composer-tools-segments" role="tablist" aria-label="Plugin source">
<button
type="button"
role="tab"
aria-selected={source === 'community'}
className={`composer-tools-segment${source === 'community' ? ' active' : ''}`}
onClick={() => setSource('community')}
title={`${communityPlugins.length} installed official plugins`}
>
Official
</button>
<button
type="button"
role="tab"
aria-selected={source === 'mine'}
className={`composer-tools-segment${source === 'mine' ? ' active' : ''}`}
onClick={() => setSource('mine')}
title={`${userPlugins.length} installed user plugins`}
>
My plugins
</button>
</div>
<input
className="composer-tools-search"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder="Search plugins…"
aria-label="Search plugins"
/>
</div>
{visiblePlugins.length === 0 ? (
<div className="composer-tools-empty">
{plugins.length === 0 ? (
<>
No plugins installed yet. Browse Official or add your own with{' '}
<code>od plugin install &lt;source&gt;</code>.
</>
) : query ? (
<>No {source === 'community' ? 'Official' : 'My plugins'} results for {query}.</>
) : (
<>No {source === 'community' ? 'Official' : 'My plugins'} plugins available.</>
)}
</div>
) : (
<div className="composer-tools-list">
{visiblePlugins.map((p) => (
<div
key={p.id}
className={`composer-tools-row composer-tools-row--plugin${
p.id === activePluginId ? ' active' : ''
}`}
>
<button
type="button"
className="composer-tools-row-main"
onClick={async () => {
setPendingId(p.id);
try {
await onApply(p);
} finally {
setPendingId(null);
}
}}
disabled={pendingId !== null}
aria-busy={pendingId === p.id ? 'true' : undefined}
title={p.manifest?.description ?? p.title}
>
<Icon name="sparkles" size={12} />
<span className="composer-tools-row-body">
<strong>{p.title}</strong>
{p.manifest?.description ? (
<span className="composer-tools-row-meta">
{p.manifest.description}
</span>
) : (
<span className="composer-tools-row-meta">{p.id}</span>
)}
</span>
{pendingId === p.id ? (
<span className="composer-tools-row-pending">Applying</span>
) : null}
</button>
<button
type="button"
className="composer-tools-row-side"
onClick={() => onShowDetails(p)}
title={`View details for ${p.title}`}
aria-label={`View details for ${p.title}`}
>
<Icon name="eye" size={12} />
</button>
</div>
))}
</div>
)}
</>
);
}
function ToolsMcpPanel({
servers,
templates,
onInsert,
onManage,
}: {
servers: McpServerConfig[];
templates: McpTemplate[];
onInsert: (serverId: string) => void;
onManage: () => void;
}) {
const [query, setQuery] = useState('');
const visibleServers = useMemo(
() => servers.filter((s) => mcpServerMatchesQuery(s, query)),
[servers, query],
);
const visibleTemplates = useMemo(
() => templates.filter((tpl) => mcpTemplateMatchesQuery(tpl, query)).slice(0, 8),
[templates, query],
);
return (
<>
<div className="composer-tools-filter">
<input
className="composer-tools-search"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder="Search MCP…"
aria-label="Search MCP servers and templates"
/>
</div>
{visibleServers.length === 0 ? (
<div className="composer-tools-empty">
{servers.length === 0
? 'No enabled MCP servers configured yet.'
: `No configured MCP results for “${query}”.`}
</div>
) : (
<div className="composer-tools-list">
<div className="composer-tools-section-label">Configured</div>
{visibleServers.map((s) => (
<button
key={s.id}
type="button"
role="menuitem"
className="composer-tools-row"
onClick={() => onInsert(s.id)}
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
>
<Icon name="link" size={12} />
<span className="composer-tools-row-body">
<strong>{s.label || s.id}</strong>
<span className="composer-tools-row-meta">{s.transport}</span>
</span>
</button>
))}
</div>
)}
{visibleTemplates.length > 0 ? (
<div className="composer-tools-list">
<div className="composer-tools-section-label">Templates</div>
{visibleTemplates.map((tpl) => (
<button
key={tpl.id}
type="button"
role="menuitem"
className="composer-tools-row"
onClick={onManage}
title={`Add ${tpl.label} from Settings`}
>
<Icon name="plus" size={12} />
<span className="composer-tools-row-body">
<strong>{tpl.label}</strong>
<span className="composer-tools-row-meta">
{tpl.transport}
{tpl.category ? ` · ${tpl.category}` : ''}
</span>
</span>
</button>
))}
</div>
) : null}
<button
type="button"
role="menuitem"
className="composer-tools-row composer-tools-row-action"
onClick={onManage}
>
<Icon name="settings" size={12} />
<span>Manage MCP servers</span>
</button>
</>
);
}
function ToolsSkillsPanel({
skills,
currentSkillId,
onPick,
}: {
skills: SkillSummary[];
currentSkillId: string | null;
onPick: (skill: SkillSummary) => void | Promise<void>;
}) {
const { locale } = useI18n();
const [query, setQuery] = useState('');
const [pendingId, setPendingId] = useState<string | null>(null);
const visibleSkills = useMemo(
() => skills.filter((s) => skillMatchesQuery(s, query)).slice(0, 24),
[skills, query],
);
return (
<>
<div className="composer-tools-filter">
<input
className="composer-tools-search"
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder="Search skills…"
aria-label="Search skills"
/>
</div>
{visibleSkills.length === 0 ? (
<div className="composer-tools-empty">
{skills.length === 0 ? 'No skills available yet.' : `No skills found for “${query}”.`}
</div>
) : (
<div className="composer-tools-list">
{visibleSkills.map((skill) => {
const active = skill.id === currentSkillId;
return (
<button
key={skill.id}
type="button"
role="menuitem"
className={`composer-tools-row${active ? ' active' : ''}`}
onClick={async () => {
setPendingId(skill.id);
try {
await onPick(skill);
} finally {
setPendingId(null);
}
}}
disabled={pendingId !== null}
title={localizeSkillDescription(locale, skill)}
>
<Icon name={active ? 'check' : 'file'} size={12} />
<span className="composer-tools-row-body">
<strong>{localizeSkillName(locale, skill)}</strong>
<span className="composer-tools-row-meta">
{skill.mode}
{skill.surface ? ` · ${skill.surface}` : ''}
</span>
</span>
{pendingId === skill.id ? (
<span className="composer-tools-row-pending">Applying</span>
) : null}
</button>
);
})}
</div>
)}
</>
);
}
function pluginMatchesQuery(plugin: InstalledPluginRecord, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
plugin.title,
plugin.id,
plugin.sourceKind,
plugin.source,
plugin.manifest?.description ?? '',
...(plugin.manifest?.tags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q);
}
function skillMatchesQuery(skill: SkillSummary, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
skill.id,
skill.name,
skill.description,
skill.mode,
skill.surface ?? '',
...skill.triggers,
]
.join(' ')
.toLowerCase()
.includes(q);
}
function skillMentionRank(skill: SkillSummary, query: string): number {
const q = query.trim().toLowerCase();
if (!q) return 1;
const id = skill.id.toLowerCase();
const name = skill.name.toLowerCase();
if (id.startsWith(q) || name.startsWith(q)) return 0;
return 1;
}
function mcpServerMatchesQuery(server: McpServerConfig, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
server.id,
server.label ?? '',
server.transport,
server.url ?? '',
server.command ?? '',
]
.join(' ')
.toLowerCase()
.includes(q);
}
function mcpTemplateMatchesQuery(tpl: McpTemplate, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return [
tpl.id,
tpl.label,
tpl.description,
tpl.transport,
tpl.category,
tpl.homepage ?? '',
tpl.example ?? '',
]
.join(' ')
.toLowerCase()
.includes(q);
}
function pluginSourceLabel(plugin: InstalledPluginRecord, t: TranslateFn): string {
return plugin.sourceKind === 'bundled' ? t('chat.mentionPluginOfficial') : t('chat.mentionPluginMine');
}
function ToolsImportPanel({
t,
onLinkFolder,
}: {
t: TranslateFn;
onLinkFolder: () => Promise<void> | void;
}) {
return (
<div className="composer-tools-list">
<ImportItem icon="upload" label={t('chat.importFig')} t={t} />
<ImportItem icon="grid" label={t('chat.importWeb')} t={t} />
<ImportItem
icon="folder"
label={t('chat.importFolder')}
t={t}
enabled
onClick={() => void onLinkFolder()}
/>
<ImportItem icon="sparkles" label={t('chat.importSkills')} t={t} />
<ImportItem icon="file" label={t('chat.importProject')} t={t} />
</div>
);
}
function ImportItem({
icon,
label,
t,
enabled,
onClick,
}: {
icon: "upload" | "link" | "grid" | "folder" | "sparkles" | "file";
label: string;
t: TranslateFn;
enabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
className={`composer-import-item${enabled ? ' composer-import-item-enabled' : ''}`}
role="menuitem"
tabIndex={-1}
disabled={!enabled}
title={enabled ? label : t('chat.importComingSoon')}
onClick={enabled && onClick ? onClick : (e) => e.preventDefault()}
>
<span className="ico" aria-hidden>
<Icon name={icon} size={14} />
</span>
<span className="composer-import-item-label">{label}</span>
{!enabled && <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,
connectors,
plugins,
skills,
mcpServers,
query,
currentSkillId,
onPickFile,
onPickPlugin,
onPickSkill,
onPickMcp,
onPickConnector,
}: {
files: ProjectFile[];
connectors: ConnectorDetail[];
plugins: InstalledPluginRecord[];
skills: SkillSummary[];
mcpServers: McpServerConfig[];
query: string;
currentSkillId: string | null;
onPickFile: (path: string) => void;
onPickPlugin: (record: InstalledPluginRecord) => void;
onPickSkill: (skill: SkillSummary) => void;
onPickMcp: (server: McpServerConfig) => void;
onPickConnector: (connector: ConnectorDetail) => void;
}) {
const { locale, t } = useI18n();
const ref = useRef<HTMLDivElement | null>(null);
const [tab, setTab] = useState<MentionTab>('all');
const tabs: Array<{ id: MentionTab; label: string }> = [
{ id: 'all', label: t('chat.mentionTabAll') },
{ id: 'plugins', label: t('chat.mentionTabPlugins') },
{ id: 'skills', label: t('chat.mentionTabSkills') },
{ id: 'mcp', label: t('chat.mentionTabMcp') },
{ id: 'connectors', label: t('chat.mentionTabConnectors') },
{ id: 'files', label: t('chat.mentionTabFiles') },
];
const showPlugins = tab === 'all' || tab === 'plugins';
const showSkills = tab === 'all' || tab === 'skills';
const showMcp = tab === 'all' || tab === 'mcp';
const showConnectors = tab === 'all' || tab === 'connectors';
const showFiles = tab === 'all' || tab === 'files';
const hasVisibleResults =
(showPlugins && plugins.length > 0) ||
(showSkills && skills.length > 0) ||
(showMcp && mcpServers.length > 0) ||
(showConnectors && connectors.length > 0) ||
(showFiles && files.length > 0);
useEffect(() => {
if (ref.current) ref.current.scrollTop = 0;
}, [connectors, files, plugins, skills, mcpServers, tab]);
return (
<div className="mention-popover" data-testid="mention-popover">
<div className="mention-tabs" role="tablist" aria-label={t('chat.mentionTabsAria')}>
{tabs.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
className={`mention-tab${tab === item.id ? ' active' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setTab(item.id)}
>
{item.label}
</button>
))}
</div>
<div className="mention-results" ref={ref}>
{!hasVisibleResults ? (
<div className="mention-empty">
{query ? (
<>{t('chat.mentionNoResults', { query })}</>
) : (
<>{t('chat.mentionSearchPrompt')}</>
)}
</div>
) : null}
{showPlugins && plugins.length > 0 ? (
<>
<div className="mention-section-label">{t('chat.mentionSectionPlugins')}</div>
{plugins.map((p) => (
<button
key={`plugin-${p.id}`}
className="mention-item mention-item--plugin"
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPickPlugin(p)}
title={p.manifest?.description ?? p.title}
>
<Icon name="sparkles" size={12} />
<span className="mention-item-body">
<strong>{p.title}</strong>
<span className="mention-meta mention-meta--desc">
{p.manifest?.description ?? p.id}
</span>
</span>
<span className="mention-meta">{pluginSourceLabel(p, t)}</span>
</button>
))}
</>
) : null}
{showSkills && skills.length > 0 ? (
<>
<div className="mention-section-label">{t('chat.mentionSectionSkills')}</div>
{skills.map((skill) => {
const active = skill.id === currentSkillId;
return (
<button
key={`skill-${skill.id}`}
className="mention-item"
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPickSkill(skill)}
title={localizeSkillDescription(locale, skill)}
>
<Icon name={active ? 'check' : 'file'} size={12} />
<span className="mention-item-body">
<strong>{localizeSkillName(locale, skill)}</strong>
<span className="mention-meta mention-meta--desc">
{localizeSkillDescription(locale, skill) || skill.id}
</span>
</span>
<span className="mention-meta">{active ? t('chat.mentionActiveSkill') : skill.mode}</span>
</button>
);
})}
</>
) : null}
{showMcp && mcpServers.length > 0 ? (
<>
<div className="mention-section-label">{t('chat.mentionSectionMcp')}</div>
{mcpServers.map((server) => (
<button
key={`mcp-${server.id}`}
className="mention-item"
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPickMcp(server)}
title={t('chat.mentionUseMcpTitle', { name: server.label || server.id })}
>
<Icon name="link" size={12} />
<span className="mention-item-body">
<strong>{server.label || server.id}</strong>
<span className="mention-meta mention-meta--desc">
{server.url || server.command || server.id}
</span>
</span>
<span className="mention-meta">{server.transport}</span>
</button>
))}
</>
) : null}
{showConnectors && connectors.length > 0 ? (
<>
<div className="mention-section-label">{t('chat.mentionSectionConnectors')}</div>
{connectors.map((connector) => (
<button
key={`connector-${connector.id}`}
className="mention-item"
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPickConnector(connector)}
title={t('chat.mentionUseConnectorTitle', { name: connector.name })}
>
<Icon name="link" size={12} />
<span className="mention-item-body">
<strong>{connector.name}</strong>
<span className="mention-meta mention-meta--desc">
{connector.description || connector.provider || connector.id}
</span>
</span>
<span className="mention-meta">{connector.accountLabel ?? connector.provider}</span>
</button>
))}
</>
) : null}
{showFiles && files.length > 0 ? (
<>
<div className="mention-section-label">{t('chat.mentionSectionFiles')}</div>
{files.map((f) => {
const key = f.path ?? f.name;
return (
<button
key={`file-${key}`}
className="mention-item"
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPickFile(key)}
>
<Icon name="file" size={12} />
<code>{key}</code>
{f.size != null ? (
<span className="mention-meta">{prettySize(f.size)}</span>
) : null}
</button>
);
})}
</>
) : null}
</div>
</div>
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function loadComposerDraft(key?: string): string | null {
if (!key || typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function saveComposerDraft(key: string | undefined, draft: string) {
if (!key || typeof window === 'undefined') return;
try {
if (draft) {
window.localStorage.setItem(key, draft);
} else {
window.localStorage.removeItem(key);
}
} catch {
// Storage can be unavailable in privacy modes; the composer should still work.
}
}
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`;
}