fix(web): align resources picker polish

This commit is contained in:
Aria 2026-05-31 04:32:52 +02:00
parent 0bd6c012d3
commit a46d8e4bc6
5 changed files with 479 additions and 170 deletions

View file

@ -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>

View file

@ -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); }

View file

@ -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;
}

View file

@ -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: {

View file

@ -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();