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.
481 lines
18 KiB
TypeScript
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>
|
|
);
|
|
};
|