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