Add command palette

This commit is contained in:
Jack Kingsman
2026-04-06 20:27:55 -07:00
parent af74663518
commit 2f43420235
9 changed files with 659 additions and 3 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"version": "3.6.3",
"version": "3.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-meshcore-frontend",
"version": "3.6.3",
"version": "3.8.0",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3",
@@ -20,6 +20,7 @@
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4",
@@ -3687,6 +3688,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",

View File

@@ -28,6 +28,7 @@
"@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4",

View File

@@ -88,6 +88,7 @@ export function App() {
useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -457,6 +458,18 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleRepeaterAutoLogin = useCallback(
(publicKey: string, displayName: string) => {
handleSelectConversationWithTargetReset({
type: 'contact',
id: publicKey,
name: displayName,
});
setRepeaterAutoLoginKey(publicKey);
},
[handleSelectConversationWithTargetReset]
);
const handleOpenNewMessage = useCallback(
(event?: MouseEvent<HTMLButtonElement>) => {
setNewMessagePrefillRequest(null);
@@ -587,6 +600,8 @@ export function App() {
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
};
const searchProps = {
contacts,
@@ -720,6 +735,7 @@ export function App() {
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/>
</DistanceUnitProvider>
);

View File

@@ -1,4 +1,4 @@
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
import { useSwipeable } from 'react-swipeable';
import { StatusBar } from './StatusBar';
@@ -8,6 +8,7 @@ import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { CommandPalette } from './CommandPalette';
import { SecurityWarningModal } from './SecurityWarningModal';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
@@ -71,6 +72,7 @@ interface AppShellProps {
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
export function AppShell({
@@ -100,6 +102,7 @@ export function AppShell({
bulkAddChannelResultModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
onRepeaterAutoLogin,
}: AppShellProps) {
const swipeHandlers = useSwipeable({
onSwipedRight: ({ initial }) => {
@@ -119,6 +122,14 @@ export function AppShell({
preventScrollOnSwipe: false,
});
const handleOpenSettings = useCallback(
(section: SettingsSection) => {
onSettingsSectionChange(section);
if (!showSettings) onToggleSettingsView();
},
[onSettingsSectionChange, onToggleSettingsView, showSettings]
);
const searchMounted = useRef(false);
if (conversationPaneProps.activeConversation?.type === 'search') {
searchMounted.current = true;
@@ -323,6 +334,13 @@ export function AppShell({
onClose={onCloseBulkAddResults}
/>
<CommandPalette
contacts={sidebarProps.contacts}
channels={sidebarProps.channels}
onSelectConversation={sidebarProps.onSelectConversation}
onOpenSettings={handleOpenSettings}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} />

View File

@@ -0,0 +1,439 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Hash,
Map,
MessageSquare,
Network,
Radio,
Route,
Search,
Sparkles,
User,
Waypoints,
} from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
import { getContactDisplayName } from '../utils/pubkey';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
SETTINGS_SECTION_ICONS,
type SettingsSection,
} from './settings/settingsConstants';
import type { Channel, Contact, Conversation } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
const MAX_PER_GROUP = 8;
interface CommandPaletteProps {
contacts: Contact[];
channels: Channel[];
onSelectConversation: (conv: Conversation) => void;
onOpenSettings: (section: SettingsSection) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
interface Searchable {
searchText: string;
}
interface SearchableContact extends Searchable {
contact: Contact;
displayName: string;
}
interface SearchableChannel extends Searchable {
channel: Channel;
}
interface ToolItem extends Searchable {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
}
interface SettingItem extends Searchable {
section: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const TOOL_ITEMS: ToolItem[] = [
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
{
id: 'visualizer',
name: 'Network Visualizer',
icon: Network,
type: 'visualizer',
searchText: 'network visualizer',
},
{
id: 'search',
name: 'Message Search',
icon: Search,
type: 'search',
searchText: 'message search',
},
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
];
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
section,
label: SETTINGS_SECTION_LABELS[section],
icon: SETTINGS_SECTION_ICONS[section],
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
}));
function fuzzyMatch(text: string, query: string): boolean {
let qi = 0;
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
if (text[ti] === query[qi]) qi++;
}
return qi === query.length;
}
function filterList<T extends Searchable>(items: T[], query: string): T[] {
if (!query) return items.slice(0, MAX_PER_GROUP);
const results: T[] = [];
for (const item of items) {
if (fuzzyMatch(item.searchText, query)) {
results.push(item);
if (results.length >= MAX_PER_GROUP) break;
}
}
return results;
}
export function CommandPalette({
contacts,
channels,
onSelectConversation,
onOpenSettings,
onRepeaterAutoLogin,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
const select = useCallback((action: () => void) => {
setOpen(false);
action();
}, []);
const {
favContacts,
favRepeaters,
regularContacts,
repeaters,
rooms,
favChannels,
regularChannels,
} = useMemo(() => {
const fc: SearchableContact[] = [];
const fr: SearchableContact[] = [];
const rc: SearchableContact[] = [];
const rp: SearchableContact[] = [];
const rm: SearchableContact[] = [];
for (const c of contacts) {
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
const entry: SearchableContact = {
contact: c,
displayName,
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
};
if (c.type === CONTACT_TYPE_REPEATER) {
(c.favorite ? fr : rp).push(entry);
} else if (c.type === CONTACT_TYPE_ROOM) {
rm.push(entry);
} else {
(c.favorite ? fc : rc).push(entry);
}
}
const fch: SearchableChannel[] = [];
const rch: SearchableChannel[] = [];
for (const ch of channels) {
const entry: SearchableChannel = {
channel: ch,
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
};
(ch.favorite ? fch : rch).push(entry);
}
return {
favContacts: fc,
favRepeaters: fr,
regularContacts: rc,
repeaters: rp,
rooms: rm,
favChannels: fch,
regularChannels: rch,
};
}, [contacts, channels]);
const lq = query.toLowerCase();
const fTools = filterList(TOOL_ITEMS, lq);
const fSettings = filterList(SETTING_ITEMS, lq);
const fFavContacts = filterList(favContacts, lq);
const fFavRepeaters = filterList(favRepeaters, lq);
const fFavChannels = filterList(favChannels, lq);
const fContacts = filterList(regularContacts, lq);
const fRepeaters = filterList(repeaters, lq);
const fRooms = filterList(rooms, lq);
const fChannels = filterList(regularChannels, lq);
const totalResults =
fTools.length +
fSettings.length +
fFavContacts.length +
fFavRepeaters.length +
fFavChannels.length +
fContacts.length +
fRepeaters.length +
fRooms.length +
fChannels.length;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery('');
}}
>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command shouldFilter={false}>
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
<CommandList>
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
{fTools.length > 0 && (
<CommandGroup heading="Tools">
{fTools.map((tool) => (
<CommandItem
key={tool.id}
onSelect={() =>
select(() =>
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
)
}
>
<tool.icon className="text-muted-foreground" />
<span>{tool.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fSettings.length > 0 && (
<CommandGroup heading="Settings">
{fSettings.map((item) => (
<CommandItem
key={item.section}
onSelect={() => select(() => onOpenSettings(item.section))}
>
<item.icon className="text-muted-foreground" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fFavContacts.length > 0 && (
<ContactGroup
heading="Favorite Contacts"
items={fFavContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
showStar
/>
)}
{fFavRepeaters.length > 0 && (
<RepeaterGroup
heading="Favorite Repeaters"
items={fFavRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
showStar
/>
)}
{fFavChannels.length > 0 && (
<CommandGroup heading="Favorite Channels">
{fFavChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
<Sparkles className="ml-auto h-3 w-3 text-yellow-500" />
</CommandItem>
))}
</CommandGroup>
)}
{fContacts.length > 0 && (
<ContactGroup
heading="Contacts"
items={fContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fRepeaters.length > 0 && (
<RepeaterGroup
heading="Repeaters"
items={fRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
)}
{fRooms.length > 0 && (
<ContactGroup
heading="Rooms"
items={fRooms}
icon={MessageSquare}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fChannels.length > 0 && (
<CommandGroup heading="Channels">
{fChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function ContactGroup({
heading,
items,
icon: Icon,
showStar,
onSelect,
onSelectConversation,
}: {
heading: string;
items: SearchableContact[];
icon: React.ComponentType<{ className?: string }>;
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.map(({ contact: c, displayName }) => (
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Icon className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
</CommandItem>
))}
</CommandGroup>
);
}
function RepeaterGroup({
heading,
items,
showStar,
onSelect,
onSelectConversation,
onRepeaterAutoLogin,
}: {
heading: string;
items: SearchableContact[];
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.flatMap(({ contact: c, displayName }) => [
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Waypoints className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
</CommandItem>,
<CommandItem
key={`${c.public_key}-acl`}
onSelect={() =>
onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))
}
>
<Waypoints className="text-muted-foreground" />
<span>
{displayName}{' '}
<span className="text-muted-foreground">(ACL login + load all)</span>
</span>
</CommandItem>,
])}
</CommandGroup>
);
}

View File

@@ -79,6 +79,8 @@ interface ConversationPaneProps {
onToggleNotifications: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
repeaterAutoLoginKey: string | null;
onClearRepeaterAutoLogin: () => void;
}
function LoadingPane({ label }: { label: string }) {
@@ -149,6 +151,8 @@ export function ConversationPane({
onToggleNotifications,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin,
}: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => {
@@ -248,6 +252,8 @@ export function ConversationPane({
onOpenContactInfo={onOpenContactInfo}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
onAutoLoginConsumed={onClearRepeaterAutoLogin}
/>
</Suspense>
);

View File

@@ -48,6 +48,8 @@ interface RepeaterDashboardProps {
onOpenContactInfo?: (publicKey: string) => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
autoLoginAndLoadAll?: boolean;
onAutoLoginConsumed?: () => void;
}
export function RepeaterDashboard({
@@ -67,6 +69,8 @@ export function RepeaterDashboard({
onOpenContactInfo,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
autoLoginAndLoadAll,
onAutoLoginConsumed,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -125,6 +129,15 @@ export function RepeaterDashboard({
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
// Command palette "ACL login + load all" auto-action
const autoLoginConsumedRef = useRef(false);
useEffect(() => {
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
autoLoginConsumedRef.current = true;
onAutoLoginConsumed?.();
void loginAsGuest().then(() => loadAll());
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
const isFav = contact?.favorite ?? false;
const handleRepeaterLogin = async (nextPassword: string) => {

View File

@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
function CommandDialog({
children,
...props
}: React.ComponentProps<typeof Dialog>) {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
}
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -158,6 +158,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onToggleNotifications: vi.fn(),
trackedTelemetryRepeaters: [],
onToggleTrackedTelemetry: vi.fn(async () => {}),
repeaterAutoLoginKey: null,
onClearRepeaterAutoLogin: vi.fn(),
...overrides,
};
}