Files
openchamber/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx
T
Bohdan Triapitsyn 7d7285655d Add i18n foundation and translations (#1027)
* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

* feat(ui): localize context panel, diff/plan views, and context sidebar content

* fix(config): resolve user config home via fs/home before embedded home

* localize header/chat UI and complete model/worktree panel strings

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
2026-04-26 14:03:39 +03:00

251 lines
8.4 KiB
TypeScript

import React from 'react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useI18n } from '@/lib/i18n';
interface AgentInfo {
name: string;
description?: string;
mode?: string | null;
scope?: string;
isBuiltIn?: boolean;
}
export interface AgentMentionAutocompleteHandle {
handleKeyDown: (key: string) => void;
}
type AutocompleteTab = 'commands' | 'agents' | 'files';
const isMentionableAgentMode = (mode?: string | null): boolean => {
if (!mode) return false;
return mode !== 'primary';
};
interface AgentMentionAutocompleteProps {
searchQuery: string;
onAgentSelect: (agentName: string) => void;
onClose: () => void;
showTabs?: boolean;
activeTab?: AutocompleteTab;
onTabSelect?: (tab: AutocompleteTab) => void;
}
export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocompleteHandle, AgentMentionAutocompleteProps>(({
searchQuery,
onAgentSelect,
onClose,
showTabs,
activeTab = 'agents',
onTabSelect,
}, ref) => {
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const ignoreTabClickRef = React.useRef(false);
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
const agentsWithMetadata = useAgentsStore((state) => state.agents);
const loadAgents = useAgentsStore((state) => state.loadAgents);
React.useEffect(() => {
if (agentsWithMetadata.length === 0) {
void loadAgents();
}
}, [loadAgents, agentsWithMetadata.length]);
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
const filtered = visibleAgents
.filter((agent) => isMentionableAgentMode(agent.mode))
.map((agent) => {
const metadata = agentsWithMetadata.find(a => a.name === agent.name) as (AgentWithExtras & { scope?: string }) | undefined;
return {
name: agent.name,
description: agent.description,
mode: agent.mode ?? undefined,
scope: metadata?.scope,
isBuiltIn: metadata ? isAgentBuiltIn(metadata) : false,
};
});
const normalizedQuery = searchQuery.trim();
const matches = normalizedQuery.length
? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery))
: filtered;
matches.sort((a, b) => a.name.localeCompare(b.name));
setAgents(matches);
setSelectedIndex(0);
}, [getVisibleAgents, searchQuery, agentsWithMetadata]);
React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}, [selectedIndex]);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null;
if (!target || !containerRef.current) {
return;
}
if (!containerRef.current.contains(target)) {
onClose();
}
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
};
}, [onClose]);
React.useImperativeHandle(ref, () => ({
handleKeyDown: (key: string) => {
if (key === 'Escape') {
onClose();
return;
}
if (!agents.length) {
return;
}
if (key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % agents.length);
return;
}
if (key === 'ArrowUp') {
setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length);
return;
}
if (key === 'Enter' || key === 'Tab') {
const agent = agents[(selectedIndex + agents.length) % agents.length];
if (agent) {
onAgentSelect(agent.name);
}
}
},
}), [agents, onAgentSelect, onClose, selectedIndex]);
const renderAgent = (agent: AgentInfo, index: number) => {
const isSystem = agent.isBuiltIn;
const isProject = agent.scope === 'project';
return (
<div
key={agent.name}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={cn(
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
index === selectedIndex && 'bg-interactive-selection'
)}
onClick={() => onAgentSelect(agent.name)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">#{agent.name}</span>
{isSystem ? (
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
{t('chat.agentMentionAutocomplete.badge.system')}
</span>
) : agent.scope ? (
<span className={cn(
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0",
isProject
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
)}>
{agent.scope}
</span>
) : null}
</div>
{agent.description && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{agent.description}
</div>
)}
</div>
</div>
);
};
const tabs = React.useMemo(() => ([
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
]), [t]);
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
>
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
}
event.preventDefault();
event.stopPropagation();
ignoreTabClickRef.current = true;
onTabSelect?.(tab.id);
}}
onClick={() => {
if (ignoreTabClickRef.current) {
ignoreTabClickRef.current = false;
return;
}
onTabSelect?.(tab.id);
}}
>
{tab.label}
</button>
))}
</div>
</div>
) : null}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
{agents.length ? (
<div>
{agents.map((agent, index) => renderAgent(agent, index))}
</div>
) : (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
{t('chat.agentMentionAutocomplete.empty')}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
{t('chat.autocomplete.keyboardHint')}
</div>
</div>
);
});
AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete';