Files
openchamber/packages/ui/src/components/sections/commands/CommandsSidebar.tsx
T
Bohdan Triapitsyn 0079347edc refactor(settings): scope the settings project selector to settings
Picking a project in Settings called setActiveProject, which relocates
the chat, the session list, the file tree and the Git surface. Reading
another project's MCP servers or agents moved the user's whole app.

It had to, because the configuration stores resolved the directory
themselves from the active project and held one flat list. Each of them
now takes an explicit directory — omitted still means the active project,
so every caller outside Settings is unchanged — and keys loaded data by
directory next to a flat mirror of the active project. Chat, autocompletes
and pickers keep reading that mirror; a load for another directory writes
only the map. A failed load restores that directory's previous list.

Settings resolves its own directory through useSettingsDirectory, backed
by a session-local settingsProjectPath that follows the active project
until the user picks something else.
2026-08-22 20:31:31 +03:00

481 lines
18 KiB
TypeScript

import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { isMobileDeviceViaCSS } from '@/lib/device';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { selectCommandsForDirectory, useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
interface CommandsSidebarProps {
onItemSelect?: () => void;
}
export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const [renameDialogCommand, setRenameDialogCommand] = React.useState<Command | null>(null);
const [renameNewName, setRenameNewName] = React.useState('');
const [confirmActionCommand, setConfirmActionCommand] = React.useState<Command | null>(null);
const [confirmActionType, setConfirmActionType] = React.useState<'delete' | 'reset' | null>(null);
const [isConfirmActionPending, setIsConfirmActionPending] = React.useState(false);
const [openMenuCommand, setOpenMenuCommand] = React.useState<string | null>(null);
const {
selectedCommandName,
setSelectedCommand,
setCommandDraft,
createCommand,
deleteCommand,
loadCommands,
} = useCommandsStore(useShallow((s) => ({
selectedCommandName: s.selectedCommandName,
setSelectedCommand: s.setSelectedCommand,
setCommandDraft: s.setCommandDraft,
createCommand: s.createCommand,
deleteCommand: s.deleteCommand,
loadCommands: s.loadCommands,
})));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => {
void loadCommands(settingsDirectory);
void loadSkills(settingsDirectory);
}, [loadCommands, loadSkills, settingsDirectory]);
const skillNames = React.useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
const commandOnlyItems = React.useMemo(
() => commands.filter((command) => !skillNames.has(command.name)),
[commands, skillNames],
);
React.useEffect(() => {
if (!selectedCommandName) {
return;
}
if (skillNames.has(selectedCommandName)) {
setSelectedCommand(null);
}
}, [selectedCommandName, setSelectedCommand, skillNames]);
const bgClass = 'bg-background';
const handleCreateNew = () => {
// Generate unique name
const baseName = 'new-command';
let newName = baseName;
let counter = 1;
while (commands.some((c) => c.name === newName)) {
newName = `${baseName}-${counter}`;
counter++;
}
// Set draft and open the page for editing
setCommandDraft({ name: newName, scope: 'user' });
setSelectedCommand(newName);
onItemSelect?.();
};
const handleDeleteCommand = async (command: Command) => {
if (isCommandBuiltIn(command)) {
toast.error(t('settings.commands.sidebar.toast.builtInCannotDelete'));
return;
}
setConfirmActionCommand(command);
setConfirmActionType('delete');
};
const handleResetCommand = async (command: Command) => {
if (!isCommandBuiltIn(command)) {
return;
}
setConfirmActionCommand(command);
setConfirmActionType('reset');
};
const closeConfirmActionDialog = () => {
setConfirmActionCommand(null);
setConfirmActionType(null);
};
const handleConfirmAction = async () => {
if (!confirmActionCommand || !confirmActionType) {
return;
}
setIsConfirmActionPending(true);
const success = await deleteCommand(confirmActionCommand.name, settingsDirectory);
if (success) {
if (confirmActionType === 'delete') {
toast.success(t('settings.commands.sidebar.toast.commandDeleted', { name: confirmActionCommand.name }));
} else {
toast.success(t('settings.commands.sidebar.toast.commandReset', { name: confirmActionCommand.name }));
}
closeConfirmActionDialog();
} else if (confirmActionType === 'delete') {
toast.error(t('settings.commands.sidebar.toast.deleteFailed'));
} else {
toast.error(t('settings.commands.sidebar.toast.resetFailed'));
}
setIsConfirmActionPending(false);
};
const handleDuplicateCommand = (command: Command) => {
const baseName = command.name;
let copyNumber = 1;
let newName = `${baseName}-copy`;
while (commands.some((c) => c.name === newName)) {
copyNumber++;
newName = `${baseName}-copy-${copyNumber}`;
}
// Set draft with prefilled values from source command
setCommandDraft({
name: newName,
scope: command.scope || 'user',
description: command.description,
template: command.template,
agent: command.agent,
model: command.model,
});
setSelectedCommand(newName);
};
const handleOpenRenameDialog = (command: Command) => {
setRenameNewName(command.name);
setRenameDialogCommand(command);
};
const handleRenameCommand = async () => {
if (!renameDialogCommand) return;
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-');
if (!sanitizedName) {
toast.error(t('settings.commands.sidebar.toast.commandNameRequired'));
return;
}
if (sanitizedName === renameDialogCommand.name) {
setRenameDialogCommand(null);
return;
}
if (commands.some((cmd) => cmd.name === sanitizedName)) {
toast.error(t('settings.commands.sidebar.toast.commandExists'));
return;
}
// Create new command with new name and all existing config
const success = await createCommand({
name: sanitizedName,
description: renameDialogCommand.description,
template: renameDialogCommand.template,
agent: renameDialogCommand.agent,
model: renameDialogCommand.model,
}, settingsDirectory);
if (success) {
// Delete old command
const deleteSuccess = await deleteCommand(renameDialogCommand.name, settingsDirectory);
if (deleteSuccess) {
toast.success(`Command renamed to "${sanitizedName}"`);
setSelectedCommand(sanitizedName);
} else {
toast.error(t('settings.commands.sidebar.toast.removeOldAfterRenameFailed'));
}
} else {
toast.error(t('settings.commands.sidebar.toast.renameFailed'));
}
setRenameDialogCommand(null);
};
const builtInCommands = commandOnlyItems.filter(isCommandBuiltIn);
const customCommands = commandOnlyItems.filter((cmd) => !isCommandBuiltIn(cmd));
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.commands.sidebar.title')}</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.commands.sidebar.total', { count: commandOnlyItems.length })}</span>
<Button size="sm"
data-settings-item="commands.create"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={handleCreateNew}
>
<Icon name="add" className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
{commandOnlyItems.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<Icon name="terminal-box" className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">{t('settings.commands.sidebar.empty.title')}</p>
<p className="typography-meta mt-1 opacity-75">{t('settings.commands.sidebar.empty.description')}</p>
</div>
) : (
<>
{builtInCommands.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('settings.commands.sidebar.section.builtIn')}
</div>
{[...builtInCommands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
<CommandListItem
key={command.name}
command={command}
isSelected={selectedCommandName === command.name}
onSelect={() => {
setSelectedCommand(command.name);
onItemSelect?.();
}}
onReset={() => handleResetCommand(command)}
onDuplicate={() => handleDuplicateCommand(command)}
isMenuOpen={openMenuCommand === command.name}
onMenuOpenChange={(open) => setOpenMenuCommand(open ? command.name : null)}
/>
))}
</>
)}
{customCommands.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('settings.commands.sidebar.section.custom')}
</div>
{[...customCommands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
<CommandListItem
key={command.name}
command={command}
isSelected={selectedCommandName === command.name}
onSelect={() => {
setSelectedCommand(command.name);
onItemSelect?.();
}}
onRename={() => handleOpenRenameDialog(command)}
onDelete={() => handleDeleteCommand(command)}
onDuplicate={() => handleDuplicateCommand(command)}
isMenuOpen={openMenuCommand === command.name}
onMenuOpenChange={(open) => setOpenMenuCommand(open ? command.name : null)}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
<Dialog
open={confirmActionCommand !== null && confirmActionType !== null}
onOpenChange={(open) => {
if (!open && !isConfirmActionPending) {
closeConfirmActionDialog();
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{confirmActionType === 'delete' ? t('settings.commands.sidebar.dialog.deleteTitle') : t('settings.commands.sidebar.dialog.resetTitle')}</DialogTitle>
<DialogDescription>
{confirmActionType === 'delete'
? t('settings.commands.sidebar.dialog.deleteDescription', { name: confirmActionCommand?.name ?? '' })
: t('settings.commands.sidebar.dialog.resetDescription', { name: confirmActionCommand?.name ?? '' })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
size="sm"
variant="ghost"
onClick={closeConfirmActionDialog}
disabled={isConfirmActionPending}
>
{t('settings.common.actions.cancel')}
</Button>
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
{confirmActionType === 'delete' ? t('settings.common.actions.delete') : t('settings.common.actions.reset')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rename Dialog */}
<Dialog open={renameDialogCommand !== null} onOpenChange={(open) => !open && setRenameDialogCommand(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.commands.sidebar.renameDialog.title')}</DialogTitle>
<DialogDescription>
{t('settings.commands.sidebar.renameDialog.description', { name: renameDialogCommand?.name ?? '' })}
</DialogDescription>
</DialogHeader>
<Input
value={renameNewName}
onChange={(e) => setRenameNewName(e.target.value)}
placeholder={t('settings.commands.sidebar.renameDialog.placeholder')}
className="text-foreground placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameCommand();
}
}}
/>
<DialogFooter>
<Button
size="sm"
variant="ghost"
onClick={() => setRenameDialogCommand(null)}
>
{t('settings.common.actions.cancel')}
</Button>
<Button size="sm" onClick={handleRenameCommand}>
{t('settings.common.actions.rename')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
interface CommandListItemProps {
command: Command;
isSelected: boolean;
onSelect: () => void;
onDelete?: () => void;
onReset?: () => void;
onRename?: () => void;
onDuplicate: () => void;
isMenuOpen: boolean;
onMenuOpenChange: (open: boolean) => void;
}
const CommandListItem: React.FC<CommandListItemProps> = ({
command,
isSelected,
onSelect,
onDelete,
onReset,
onRename,
onDuplicate,
isMenuOpen,
onMenuOpenChange,
}) => {
const { t } = useI18n();
const isMobile = isMobileDeviceViaCSS();
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const renderMenuItems = (Item: React.ElementType) => (
<>
{onRename && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onRename(); }}>
<Icon name="edit" className="h-4 w-4 mr-px" />
{t('settings.common.actions.rename')}
</Item>
)}
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDuplicate(); }}>
<Icon name="file-copy" className="h-4 w-4 mr-px" />
{t('settings.common.actions.duplicate')}
</Item>
{onReset && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onReset(); }}>
<Icon name="restart" className="h-4 w-4 mr-px" />
{t('settings.common.actions.reset')}
</Item>
)}
{onDelete && (
<Item onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete(); }} className="text-destructive focus:text-destructive">
<Icon name="delete-bin" className="h-4 w-4 mr-px" />
{t('settings.common.actions.delete')}
</Item>
)}
</>
);
return (
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger render={<div className={cn('group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200 select-none', isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover')} onContextMenu={!isMobile ? (e) => { e.preventDefault(); setIsContextMenuOpen(true); } : undefined} />}>
<div className="flex min-w-0 flex-1 items-center">
<button
onClick={onSelect}
className="flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
tabIndex={0}
>
<div className="flex items-center gap-2">
<span className="typography-ui-label font-normal truncate text-foreground">
/{command.name}
</span>
{(command.scope || isCommandBuiltIn(command)) && (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{isCommandBuiltIn(command) ? t('settings.agents.sidebar.badge.system') : command.scope}
</span>
)}
</div>
{command.description && (
<div className="typography-micro text-muted-foreground/60 truncate leading-tight">
{command.description}
</div>
)}
</button>
<DropdownMenu open={isMenuOpen} onOpenChange={(open) => { if (open) setIsContextMenuOpen(false); onMenuOpenChange(open); }}>
<DropdownMenuTrigger asChild>
<Button size="sm"
variant="ghost"
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
>
<Icon name="more-2" className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-20">
{renderMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-fit min-w-20">
{renderMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
);
};