mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(web): align resources picker polish
This commit is contained in:
parent
0bd6c012d3
commit
a46d8e4bc6
5 changed files with 479 additions and 170 deletions
|
|
@ -6,7 +6,9 @@ import {
|
|||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useI18n } from '../i18n';
|
||||
|
|
@ -434,6 +436,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const composingRef = useRef(false);
|
||||
const toolsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const toolsTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const toolsSearchRef = useRef<HTMLInputElement | null>(null);
|
||||
const [toolsActiveIndex, setToolsActiveIndex] = useState(0);
|
||||
const petEnabled = Boolean(onAdoptPet && onTogglePet);
|
||||
const [petMenuOpen, setPetMenuOpen] = useState(false);
|
||||
const petWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -462,6 +466,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
saveComposerDraft(draftStorageKey, draft);
|
||||
}, [draftStorageKey, draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolsOpen) return;
|
||||
setToolsActiveIndex(0);
|
||||
requestAnimationFrame(() => {
|
||||
toolsSearchRef.current?.focus();
|
||||
});
|
||||
}, [toolsOpen, toolsTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toolsOpen) return;
|
||||
function onPointer(e: MouseEvent) {
|
||||
|
|
@ -2166,12 +2178,6 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
className="composer-tools-menu"
|
||||
role="menu"
|
||||
>
|
||||
<div className="composer-tools-menu-head">
|
||||
<span className="composer-tools-menu-title">
|
||||
<Icon name="blocks" size={13} />
|
||||
<span>{t('chat.resourcesMenuTitle')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="composer-tools-tabs" role="tablist">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
|
|
@ -2215,6 +2221,9 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
<ToolsPluginsPanel
|
||||
plugins={pluginsForComposer}
|
||||
activePluginId={pinnedPluginId}
|
||||
activeIndex={toolsActiveIndex}
|
||||
searchRef={toolsSearchRef}
|
||||
onActiveIndexChange={setToolsActiveIndex}
|
||||
onApply={async (record) => {
|
||||
// Tools-menu apply: no draft write, so the
|
||||
// tracked-insertion array gets no new
|
||||
|
|
@ -2253,6 +2262,9 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
<ToolsSkillsPanel
|
||||
skills={skills}
|
||||
currentSkillId={currentSkillId}
|
||||
activeIndex={toolsActiveIndex}
|
||||
searchRef={toolsSearchRef}
|
||||
onActiveIndexChange={setToolsActiveIndex}
|
||||
onPick={async (skill) => {
|
||||
const applied = await applyProjectSkill(skill);
|
||||
if (!applied) return;
|
||||
|
|
@ -2279,6 +2291,9 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
<ToolsMcpPanel
|
||||
servers={enabledMcpServers}
|
||||
templates={mcpTemplates}
|
||||
activeIndex={toolsActiveIndex}
|
||||
searchRef={toolsSearchRef}
|
||||
onActiveIndexChange={setToolsActiveIndex}
|
||||
onInsert={(serverId) => {
|
||||
const ta = textareaRef.current;
|
||||
const server = enabledMcpServers.find((item) => item.id === serverId);
|
||||
|
|
@ -2306,6 +2321,9 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
{toolsTab === 'import' ? (
|
||||
<ToolsImportPanel
|
||||
t={t}
|
||||
activeIndex={toolsActiveIndex}
|
||||
searchRef={toolsSearchRef}
|
||||
onActiveIndexChange={setToolsActiveIndex}
|
||||
onLinkFolder={async () => {
|
||||
setToolsOpen(false);
|
||||
await handleLinkFolder();
|
||||
|
|
@ -2749,11 +2767,17 @@ function StagedCommentAttachments({
|
|||
function ToolsPluginsPanel({
|
||||
plugins,
|
||||
activePluginId,
|
||||
activeIndex,
|
||||
searchRef,
|
||||
onActiveIndexChange,
|
||||
onApply,
|
||||
onShowDetails,
|
||||
}: {
|
||||
plugins: InstalledPluginRecord[];
|
||||
activePluginId: string | null;
|
||||
activeIndex: number;
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onApply: (record: InstalledPluginRecord) => void | Promise<void>;
|
||||
onShowDetails: (record: InstalledPluginRecord) => void;
|
||||
}) {
|
||||
|
|
@ -2773,6 +2797,19 @@ function ToolsPluginsPanel({
|
|||
() => scopedPlugins.filter((p) => pluginMatchesQuery(p, query)),
|
||||
[scopedPlugins, query],
|
||||
);
|
||||
const activeResourceIndex = resourceActiveIndex(activeIndex, visiblePlugins.length);
|
||||
const pickActivePlugin = () => {
|
||||
const plugin = activeResourceIndex >= 0 ? visiblePlugins[activeResourceIndex] : null;
|
||||
if (!plugin || pendingId !== null) return;
|
||||
void (async () => {
|
||||
setPendingId(plugin.id);
|
||||
try {
|
||||
await onApply(plugin);
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -2785,6 +2822,12 @@ function ToolsPluginsPanel({
|
|||
className={`composer-tools-segment${source === 'community' ? ' active' : ''}`}
|
||||
onClick={() => setSource('community')}
|
||||
title={`${communityPlugins.length} installed official plugins`}
|
||||
onKeyDown={(event) => handleResourceKeyboardEvent(event, {
|
||||
activeIndex: activeResourceIndex,
|
||||
itemCount: visiblePlugins.length,
|
||||
onActiveIndexChange,
|
||||
onPickActive: pickActivePlugin,
|
||||
})}
|
||||
>
|
||||
Official
|
||||
</button>
|
||||
|
|
@ -2795,16 +2838,33 @@ function ToolsPluginsPanel({
|
|||
className={`composer-tools-segment${source === 'mine' ? ' active' : ''}`}
|
||||
onClick={() => setSource('mine')}
|
||||
title={`${userPlugins.length} installed user plugins`}
|
||||
onKeyDown={(event) => handleResourceKeyboardEvent(event, {
|
||||
activeIndex: activeResourceIndex,
|
||||
itemCount: visiblePlugins.length,
|
||||
onActiveIndexChange,
|
||||
onPickActive: pickActivePlugin,
|
||||
})}
|
||||
>
|
||||
My plugins
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="composer-tools-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
onKeyDown={(event) => handleResourceKeyboardEvent(event, {
|
||||
activeIndex: activeResourceIndex,
|
||||
itemCount: visiblePlugins.length,
|
||||
onActiveIndexChange,
|
||||
onPickActive: pickActivePlugin,
|
||||
})}
|
||||
placeholder="Search plugins…"
|
||||
aria-label="Search plugins"
|
||||
aria-controls="composer-tools-plugin-results"
|
||||
aria-activedescendant={
|
||||
activeResourceIndex >= 0 ? resourceOptionDomId('plugins', activeResourceIndex) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{visiblePlugins.length === 0 ? (
|
||||
|
|
@ -2821,17 +2881,22 @@ function ToolsPluginsPanel({
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="composer-tools-list">
|
||||
{visiblePlugins.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`composer-tools-row composer-tools-row--plugin${
|
||||
<div className="composer-tools-list" id="composer-tools-plugin-results">
|
||||
{visiblePlugins.map((p, index) => {
|
||||
const canShowDetails = pluginHasDetails(p);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="composer-tools-row-group"
|
||||
>
|
||||
<button
|
||||
id={resourceOptionDomId('plugins', index)}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-selected={index === activeResourceIndex}
|
||||
className={`composer-tools-row composer-tools-row--plugin${
|
||||
p.id === activePluginId ? ' active' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tools-row-main"
|
||||
// Match the @-mention popover: prevent the textarea from
|
||||
// losing focus before the click handler runs so
|
||||
// selectionStart isn't reset to 0 and the inserted token
|
||||
|
|
@ -2848,6 +2913,14 @@ function ToolsPluginsPanel({
|
|||
disabled={pendingId !== null}
|
||||
aria-busy={pendingId === p.id ? 'true' : undefined}
|
||||
title={p.manifest?.description ?? p.title}
|
||||
onMouseEnter={() => onActiveIndexChange(index)}
|
||||
onFocus={() => onActiveIndexChange(index)}
|
||||
onKeyDown={(event) => handleResourceKeyboardEvent(event, {
|
||||
activeIndex: activeResourceIndex,
|
||||
itemCount: visiblePlugins.length,
|
||||
onActiveIndexChange,
|
||||
onPickActive: pickActivePlugin,
|
||||
})}
|
||||
>
|
||||
<Icon name="sparkles" size={12} />
|
||||
<span className="composer-tools-row-body">
|
||||
|
|
@ -2863,20 +2936,34 @@ function ToolsPluginsPanel({
|
|||
{pendingId === p.id ? (
|
||||
<span className="composer-tools-row-pending">Applying…</span>
|
||||
) : (
|
||||
<span className="composer-tools-action-pill">Apply</span>
|
||||
<span className="composer-tools-row-actions">
|
||||
<span className="composer-tools-action-pill">Apply</span>
|
||||
</span>
|
||||
)}
|
||||
</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}`}
|
||||
disabled={!canShowDetails}
|
||||
title={
|
||||
canShowDetails
|
||||
? `View details for ${p.title}`
|
||||
: `No extra details available for ${p.title}`
|
||||
}
|
||||
aria-label={
|
||||
canShowDetails
|
||||
? `View details for ${p.title}`
|
||||
: `No extra details available for ${p.title}`
|
||||
}
|
||||
onMouseEnter={() => onActiveIndexChange(index)}
|
||||
onFocus={() => onActiveIndexChange(index)}
|
||||
>
|
||||
<Icon name="eye" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -2886,11 +2973,17 @@ function ToolsPluginsPanel({
|
|||
function ToolsMcpPanel({
|
||||
servers,
|
||||
templates,
|
||||
activeIndex,
|
||||
searchRef,
|
||||
onActiveIndexChange,
|
||||
onInsert,
|
||||
onManage,
|
||||
}: {
|
||||
servers: McpServerConfig[];
|
||||
templates: McpTemplate[];
|
||||
activeIndex: number;
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onInsert: (serverId: string) => void;
|
||||
onManage: () => void;
|
||||
}) {
|
||||
|
|
@ -2903,16 +2996,39 @@ function ToolsMcpPanel({
|
|||
() => templates.filter((tpl) => mcpTemplateMatchesQuery(tpl, query)).slice(0, 8),
|
||||
[templates, query],
|
||||
);
|
||||
const itemCount = visibleServers.length + visibleTemplates.length + 1;
|
||||
const activeResourceIndex = resourceActiveIndex(activeIndex, itemCount);
|
||||
const pickActiveResource = () => {
|
||||
if (activeResourceIndex < 0) return;
|
||||
if (activeResourceIndex < visibleServers.length) {
|
||||
onInsert(visibleServers[activeResourceIndex]!.id);
|
||||
return;
|
||||
}
|
||||
onManage();
|
||||
};
|
||||
const keyboard = (event: ReactKeyboardEvent<HTMLElement>) => handleResourceKeyboardEvent(event, {
|
||||
activeIndex: activeResourceIndex,
|
||||
itemCount,
|
||||
onActiveIndexChange,
|
||||
onPickActive: pickActiveResource,
|
||||
});
|
||||
let itemIndex = 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="composer-tools-filter">
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="composer-tools-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
onKeyDown={keyboard}
|
||||
placeholder="Search MCP…"
|
||||
aria-label="Search MCP servers and templates"
|
||||
aria-controls="composer-tools-mcp-results"
|
||||
aria-activedescendant={
|
||||
activeResourceIndex >= 0 ? resourceOptionDomId('mcp', activeResourceIndex) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{visibleServers.length === 0 ? (
|
||||
|
|
@ -2922,61 +3038,84 @@ function ToolsMcpPanel({
|
|||
: `No configured MCP results for “${query}”.`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="composer-tools-list">
|
||||
<div className="composer-tools-list" id="composer-tools-mcp-results">
|
||||
<div className="composer-tools-section-label">Configured</div>
|
||||
{visibleServers.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="composer-tools-row"
|
||||
// Match the @-mention popover: prevent the textarea from
|
||||
// losing focus before the click handler runs so
|
||||
// selectionStart isn't reset to 0 (#3195).
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onInsert(s.id)}
|
||||
title={`Insert a hint that nudges the model to use ${s.label || s.id}`}
|
||||
>
|
||||
<Icon name="link" size={12} />
|
||||
{visibleServers.map((s) => {
|
||||
const index = itemIndex;
|
||||
itemIndex += 1;
|
||||
return (
|
||||
<button
|
||||
id={resourceOptionDomId('mcp', index)}
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-selected={index === activeResourceIndex}
|
||||
className="composer-tools-row"
|
||||
// Match the @-mention popover: prevent the textarea from
|
||||
// losing focus before the click handler runs so
|
||||
// selectionStart isn't reset to 0 (#3195).
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onInsert(s.id)}
|
||||
onMouseEnter={() => onActiveIndexChange(index)}
|
||||
onFocus={() => onActiveIndexChange(index)}
|
||||
onKeyDown={keyboard}
|
||||
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>
|
||||
<span className="composer-tools-action-pill">Insert</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}` : ''}
|
||||
{visibleTemplates.map((tpl) => {
|
||||
const index = itemIndex;
|
||||
itemIndex += 1;
|
||||
return (
|
||||
<button
|
||||
id={resourceOptionDomId('mcp', index)}
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-selected={index === activeResourceIndex}
|
||||
className="composer-tools-row"
|
||||
onClick={onManage}
|
||||
onMouseEnter={() => onActiveIndexChange(index)}
|
||||
onFocus={() => onActiveIndexChange(index)}
|
||||
onKeyDown={keyboard}
|
||||
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>
|
||||
</span>
|
||||
<span className="composer-tools-action-pill">Manage</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="composer-tools-action-pill">Manage</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
id={resourceOptionDomId('mcp', itemIndex)}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-selected={itemIndex === activeResourceIndex}
|
||||
className="composer-tools-row composer-tools-row-action"
|
||||
onClick={onManage}
|
||||
onMouseEnter={() => onActiveIndexChange(itemIndex)}
|
||||
onFocus={() => onActiveIndexChange(itemIndex)}
|
||||
onKeyDown={keyboard}
|
||||
>
|
||||
<Icon name="settings" size={12} />
|
||||
<span>Manage MCP servers…</span>
|
||||
|
|
@ -2988,10 +3127,16 @@ function ToolsMcpPanel({
|
|||
function ToolsSkillsPanel({
|
||||
skills,
|
||||
currentSkillId,
|
||||
activeIndex,
|
||||
searchRef,
|
||||
onActiveIndexChange,
|
||||
onPick,
|
||||
}: {
|
||||
skills: SkillSummary[];
|
||||
currentSkillId: string | null;
|
||||
activeIndex: number;
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onPick: (skill: SkillSummary) => void | Promise<void>;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
|
|
@ -3001,15 +3146,40 @@ function ToolsSkillsPanel({
|
|||
() => skills.filter((s) => skillMatchesQuery(s, query)).slice(0, 24),
|
||||
[skills, query],
|
||||
);
|
||||
const activeResourceIndex = resourceActiveIndex(activeIndex, visibleSkills.length);
|
||||
const pickActiveSkill = () => {
|
||||
const skill = activeResourceIndex >= 0 ? visibleSkills[activeResourceIndex] : null;
|
||||
if (!skill || pendingId !== null) return;
|
||||
void (async () => {
|
||||
setPendingId(skill.id);
|
||||
try {
|
||||
await onPick(skill);
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
})();
|
||||
};
|
||||
const keyboard = (event: ReactKeyboardEvent<HTMLElement>) => handleResourceKeyboardEvent(event, {
|
||||
activeIndex: activeResourceIndex,
|
||||
itemCount: visibleSkills.length,
|
||||
onActiveIndexChange,
|
||||
onPickActive: pickActiveSkill,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="composer-tools-filter">
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="composer-tools-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
onKeyDown={keyboard}
|
||||
placeholder="Search skills…"
|
||||
aria-label="Search skills"
|
||||
aria-controls="composer-tools-skill-results"
|
||||
aria-activedescendant={
|
||||
activeResourceIndex >= 0 ? resourceOptionDomId('skills', activeResourceIndex) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{visibleSkills.length === 0 ? (
|
||||
|
|
@ -3017,14 +3187,16 @@ function ToolsSkillsPanel({
|
|||
{skills.length === 0 ? 'No skills available yet.' : `No skills found for “${query}”.`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="composer-tools-list">
|
||||
{visibleSkills.map((skill) => {
|
||||
<div className="composer-tools-list" id="composer-tools-skill-results">
|
||||
{visibleSkills.map((skill, index) => {
|
||||
const active = skill.id === currentSkillId;
|
||||
return (
|
||||
<button
|
||||
id={resourceOptionDomId('skills', index)}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-selected={index === activeResourceIndex}
|
||||
className={`composer-tools-row${active ? ' active' : ''}`}
|
||||
// Match the @-mention popover: prevent the textarea from
|
||||
// losing focus before the click handler runs so
|
||||
|
|
@ -3040,6 +3212,9 @@ function ToolsSkillsPanel({
|
|||
}}
|
||||
disabled={pendingId !== null}
|
||||
title={localizeSkillDescription(locale, skill)}
|
||||
onMouseEnter={() => onActiveIndexChange(index)}
|
||||
onFocus={() => onActiveIndexChange(index)}
|
||||
onKeyDown={keyboard}
|
||||
>
|
||||
<Icon name={active ? 'check' : 'file'} size={12} />
|
||||
<span className="composer-tools-row-body">
|
||||
|
|
@ -3067,6 +3242,19 @@ function pluginMatchesQuery(plugin: InstalledPluginRecord, query: string): boole
|
|||
return pluginMentionScore(plugin, query) !== null;
|
||||
}
|
||||
|
||||
function pluginHasDetails(plugin: InstalledPluginRecord): boolean {
|
||||
const manifest = plugin.manifest;
|
||||
const od = manifest?.od as Record<string, unknown> | undefined;
|
||||
return Boolean(
|
||||
manifest?.description ||
|
||||
manifest?.tags?.length ||
|
||||
od?.preview ||
|
||||
od?.context ||
|
||||
od?.useCase ||
|
||||
(Array.isArray(od?.inputs) && od.inputs.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function skillMatchesQuery(skill: SkillSummary, query: string): boolean {
|
||||
return skillMentionScore(skill, query) !== null;
|
||||
}
|
||||
|
|
@ -3189,6 +3377,52 @@ function isMentionTokenChar(char: string): boolean {
|
|||
return /[a-z0-9]/.test(char);
|
||||
}
|
||||
|
||||
function resourceOptionDomId(panel: ToolsTab, index: number): string {
|
||||
return `composer-tools-${panel}-option-${index}`;
|
||||
}
|
||||
|
||||
function resourceActiveIndex(activeIndex: number, itemCount: number): number {
|
||||
if (itemCount <= 0) return -1;
|
||||
return Math.min(Math.max(activeIndex, 0), itemCount - 1);
|
||||
}
|
||||
|
||||
function handleResourceKeyboardEvent(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
{
|
||||
activeIndex,
|
||||
itemCount,
|
||||
onActiveIndexChange,
|
||||
onPickActive,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
itemCount: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onPickActive: () => void;
|
||||
},
|
||||
): void {
|
||||
if (itemCount <= 0) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
onActiveIndexChange((activeIndex + 1) % itemCount);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
onActiveIndexChange((activeIndex - 1 + itemCount) % itemCount);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
onPickActive();
|
||||
}
|
||||
}
|
||||
|
||||
function mcpServerMatchesQuery(server: McpServerConfig, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
|
|
@ -3227,49 +3461,112 @@ function pluginSourceLabel(plugin: InstalledPluginRecord, t: TranslateFn): strin
|
|||
|
||||
function ToolsImportPanel({
|
||||
t,
|
||||
activeIndex,
|
||||
searchRef,
|
||||
onActiveIndexChange,
|
||||
onLinkFolder,
|
||||
}: {
|
||||
t: TranslateFn;
|
||||
activeIndex: number;
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
onLinkFolder: () => Promise<void> | void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const items = [
|
||||
{ icon: 'upload' as const, label: t('chat.importFig') },
|
||||
{ icon: 'grid' as const, label: t('chat.importWeb') },
|
||||
{ icon: 'folder' as const, label: t('chat.importFolder'), enabled: true, onClick: onLinkFolder },
|
||||
{ icon: 'sparkles' as const, label: t('chat.importSkills') },
|
||||
{ icon: 'file' as const, label: t('chat.importProject') },
|
||||
];
|
||||
const visibleItems = items.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));
|
||||
const activeResourceIndex = resourceActiveIndex(activeIndex, visibleItems.length);
|
||||
const pickActiveImport = () => {
|
||||
const item = activeResourceIndex >= 0 ? visibleItems[activeResourceIndex] : null;
|
||||
if (!item?.enabled || !item.onClick) return;
|
||||
void item.onClick();
|
||||
};
|
||||
const keyboard = (event: ReactKeyboardEvent<HTMLElement>) => handleResourceKeyboardEvent(event, {
|
||||
activeIndex: activeResourceIndex,
|
||||
itemCount: visibleItems.length,
|
||||
onActiveIndexChange,
|
||||
onPickActive: pickActiveImport,
|
||||
});
|
||||
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>
|
||||
<>
|
||||
<div className="composer-tools-filter">
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="composer-tools-search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
onKeyDown={keyboard}
|
||||
placeholder="Search imports…"
|
||||
aria-label="Search imports"
|
||||
aria-controls="composer-tools-import-results"
|
||||
aria-activedescendant={
|
||||
activeResourceIndex >= 0 ? resourceOptionDomId('import', activeResourceIndex) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{visibleItems.length === 0 ? (
|
||||
<div className="composer-tools-empty">No import options found for “{query}”.</div>
|
||||
) : (
|
||||
<div className="composer-tools-list" id="composer-tools-import-results">
|
||||
{visibleItems.map((item, index) => (
|
||||
<ImportItem
|
||||
key={item.label}
|
||||
id={resourceOptionDomId('import', index)}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
t={t}
|
||||
enabled={item.enabled}
|
||||
active={index === activeResourceIndex}
|
||||
onActive={() => onActiveIndexChange(index)}
|
||||
onKeyDown={keyboard}
|
||||
onClick={item.enabled && item.onClick ? () => void item.onClick?.() : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportItem({
|
||||
id,
|
||||
icon,
|
||||
label,
|
||||
t,
|
||||
enabled,
|
||||
active,
|
||||
onActive,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
}: {
|
||||
id: string;
|
||||
icon: "upload" | "link" | "grid" | "folder" | "sparkles" | "file";
|
||||
label: string;
|
||||
t: TranslateFn;
|
||||
enabled?: boolean;
|
||||
active: boolean;
|
||||
onActive: () => void;
|
||||
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className={`composer-import-item${enabled ? ' composer-import-item-enabled' : ''}`}
|
||||
className={`composer-tools-row composer-import-item${enabled ? ' composer-import-item-enabled' : ''}`}
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
aria-selected={active}
|
||||
disabled={!enabled}
|
||||
title={enabled ? label : t('chat.importComingSoon')}
|
||||
onMouseEnter={onActive}
|
||||
onFocus={onActive}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={enabled && onClick ? onClick : (e) => e.preventDefault()}
|
||||
>
|
||||
<span className="ico" aria-hidden>
|
||||
|
|
|
|||
|
|
@ -652,10 +652,10 @@
|
|||
transform: translateY(-0.5px);
|
||||
}
|
||||
.composer-mention-trigger.active {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-subtle));
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
color: color-mix(in srgb, var(--accent) 72%, var(--text));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.composer-tools-trigger.active {
|
||||
background: color-mix(in srgb, var(--text) 9%, var(--bg-subtle));
|
||||
|
|
@ -664,55 +664,46 @@
|
|||
}
|
||||
.composer-tools-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 360px;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
z-index: 30;
|
||||
background: color-mix(in srgb, var(--bg-subtle) 70%, var(--bg-panel));
|
||||
height: min(480px, 72vh);
|
||||
min-height: 248px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
box-shadow: 0 18px 52px rgb(0 0 0 / 34%);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.composer-tools-menu-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.composer-tools-menu-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.composer-tools-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 6px 4px;
|
||||
border-bottom: none;
|
||||
background: transparent;
|
||||
min-height: 42px;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--bg-subtle);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.composer-tools-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.composer-tools-tab {
|
||||
flex: 1;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-width: max-content;
|
||||
min-height: 30px;
|
||||
padding: 6px 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 11.5px;
|
||||
line-height: 18px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
|
|
@ -745,18 +736,19 @@
|
|||
align-self: center;
|
||||
}
|
||||
.composer-tools-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 8px 8px;
|
||||
max-height: 360px;
|
||||
padding: 3px 0 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.composer-tools-filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 2px 2px 6px;
|
||||
padding: 6px 8px 4px;
|
||||
}
|
||||
.composer-tools-segments {
|
||||
display: grid;
|
||||
|
|
@ -800,8 +792,8 @@
|
|||
}
|
||||
.composer-tools-search {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel);
|
||||
|
|
@ -809,8 +801,8 @@
|
|||
font-size: 12px;
|
||||
}
|
||||
.composer-tools-search:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text) 12%, transparent);
|
||||
}
|
||||
.composer-tools-empty {
|
||||
padding: 14px 10px;
|
||||
|
|
@ -822,32 +814,33 @@
|
|||
.composer-tools-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
gap: 0;
|
||||
}
|
||||
.composer-tools-section-label {
|
||||
padding: 4px 6px 2px;
|
||||
padding: 6px 8px 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.composer-tools-row {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.composer-tools-row:hover {
|
||||
background: color-mix(in srgb, var(--bg-panel) 72%, var(--bg-subtle));
|
||||
border-color: var(--border);
|
||||
.composer-tools-row:hover,
|
||||
.composer-tools-row[aria-selected="true"] {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
.composer-tools-row.active {
|
||||
|
|
@ -855,9 +848,10 @@
|
|||
border-color: var(--border);
|
||||
}
|
||||
.composer-tools-row-body {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
@ -867,47 +861,29 @@
|
|||
}
|
||||
.composer-tools-row-meta {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-faint);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
/* Plugins panel — each row is a horizontal split: the main button
|
||||
applies the plugin, the side button opens the details modal. */
|
||||
.composer-tools-row--plugin {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
.composer-tools-row--plugin .composer-tools-row-body {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
}
|
||||
.composer-tools-row--plugin .composer-tools-row-meta {
|
||||
text-transform: none;
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.composer-tools-row-main {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
.composer-tools-row-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
padding: 0 6px 0 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.composer-tools-row-main:hover {
|
||||
.composer-tools-row-group:hover,
|
||||
.composer-tools-row-group:has(.composer-tools-row[aria-selected="true"]) {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.composer-tools-row-main:disabled {
|
||||
.composer-tools-row-group .composer-tools-row {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.composer-tools-row:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -915,7 +891,8 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
|
@ -925,13 +902,24 @@
|
|||
}
|
||||
.composer-tools-row-side:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-subtle);
|
||||
background: color-mix(in srgb, var(--bg-panel) 60%, transparent);
|
||||
}
|
||||
.composer-tools-row-side:disabled {
|
||||
color: var(--text-faint);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
.composer-tools-row-pending {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-faint);
|
||||
margin-left: auto;
|
||||
}
|
||||
.composer-tools-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.composer-tools-action-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -950,10 +938,7 @@
|
|||
letter-spacing: 0;
|
||||
}
|
||||
.composer-tools-row-action {
|
||||
border-top: 1px solid var(--border-soft);
|
||||
border-radius: 0;
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.composer-tools-row-action:hover { color: var(--text); }
|
||||
|
|
|
|||
|
|
@ -6,11 +6,9 @@
|
|||
max-height: min(480px, 72vh);
|
||||
margin: 0 0 6px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 12%, transparent),
|
||||
var(--shadow-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -22,8 +20,8 @@
|
|||
gap: 4px;
|
||||
min-height: 42px;
|
||||
padding: 6px;
|
||||
background: color-mix(in srgb, var(--accent) 9%, var(--bg-subtle));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border-soft));
|
||||
background: var(--bg-subtle);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -656,8 +656,9 @@ describe('ChatComposer context pickers', () => {
|
|||
fireEvent.click(screen.getByLabelText('Open resources menu'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Community Deck')).toBeTruthy());
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('Search plugins')));
|
||||
const menu = screen.getByRole('menu');
|
||||
expect(within(menu).getByText('Resources')).toBeTruthy();
|
||||
expect(within(menu).getByRole('tab', { name: 'Plugins' })).toBeTruthy();
|
||||
expect(within(menu).getAllByText('Apply').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('My Export')).toBeNull();
|
||||
|
||||
|
|
@ -671,6 +672,33 @@ describe('ChatComposer context pickers', () => {
|
|||
expect(screen.getByText('Private export workflow')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps Resources search focused and supports arrow-key selection', async () => {
|
||||
plugins = [
|
||||
COMMUNITY_PLUGIN,
|
||||
makePlugin({
|
||||
id: 'brief-writer',
|
||||
title: 'Brief Writer',
|
||||
description: 'Turns source notes into a concise creative brief.',
|
||||
}),
|
||||
];
|
||||
renderComposer();
|
||||
fireEvent.click(screen.getByLabelText('Open resources menu'));
|
||||
|
||||
const search = await screen.findByLabelText('Search plugins');
|
||||
await waitFor(() => expect(document.activeElement).toBe(search));
|
||||
await waitFor(() => expect(screen.getByText('Brief Writer')).toBeTruthy());
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-0');
|
||||
|
||||
fireEvent.keyDown(search, { key: 'ArrowDown' });
|
||||
|
||||
expect(search.getAttribute('aria-activedescendant')).toBe('composer-tools-plugins-option-1');
|
||||
expect(screen.getByText('Brief Writer').closest('[role="menuitem"]')?.getAttribute('aria-selected')).toBe('true');
|
||||
|
||||
fireEvent.keyDown(search, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
|
||||
});
|
||||
|
||||
it('clears absolute anchors when the pet popover switches to fixed positioning', async () => {
|
||||
renderComposer({
|
||||
petConfig: {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ describe('ChatComposer Tools -> Import menu', () => {
|
|||
|
||||
fireEvent.click(screen.getByLabelText('Open resources menu'));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Import' }));
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('Search imports')));
|
||||
|
||||
const folderItem = await screen.findByRole('menuitem', { name: /Link code folder/i });
|
||||
expect(folderItem).toBeTruthy();
|
||||
|
|
|
|||
Loading…
Reference in a new issue