Initial public release
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { SIDEBAR_SECTION_CONFIG_MAP, SIDEBAR_SECTION_DESCRIPTIONS } from '@/constants/sidebar';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
|
||||
interface SectionPlaceholderProps {
|
||||
sectionId: SidebarSection;
|
||||
variant: 'sidebar' | 'page';
|
||||
}
|
||||
|
||||
export const SectionPlaceholder: React.FC<SectionPlaceholderProps> = ({ sectionId, variant }) => {
|
||||
const config = SIDEBAR_SECTION_CONFIG_MAP[sectionId];
|
||||
const Icon = config.icon;
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-3 text-muted-foreground">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{config.label}</h3>
|
||||
<p className="typography-meta max-w-xs text-muted-foreground">
|
||||
{SIDEBAR_SECTION_DESCRIPTIONS[sectionId]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div className="rounded-full bg-accent/40 p-4 text-muted-foreground">
|
||||
<Icon className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">{config.label}</h2>
|
||||
<p className="typography-body max-w-md text-muted-foreground">
|
||||
{SIDEBAR_SECTION_DESCRIPTIONS[sectionId]}
|
||||
</p>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/60">Coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,649 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
import { useAgentsStore, type AgentConfig } from '@/stores/useAgentsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiInformationLine, RiRobot2Line, RiRobotLine, RiSaveLine, RiSubtractLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useAvailableTools } from '@/hooks/useAvailableTools';
|
||||
|
||||
export const AgentsPage: React.FC = () => {
|
||||
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents } = useAgentsStore();
|
||||
useConfigStore();
|
||||
const { tools: availableTools } = useAvailableTools();
|
||||
|
||||
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
|
||||
const isNewAgent = selectedAgentName && !selectedAgent;
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [mode, setMode] = React.useState<'primary' | 'subagent' | 'all'>('subagent');
|
||||
const [model, setModel] = React.useState('');
|
||||
const [temperature, setTemperature] = React.useState<number | undefined>(undefined);
|
||||
const [topP, setTopP] = React.useState<number | undefined>(undefined);
|
||||
const [prompt, setPrompt] = React.useState('');
|
||||
const [tools, setTools] = React.useState<Record<string, boolean>>({});
|
||||
const [editPermission, setEditPermission] = React.useState<'allow' | 'ask' | 'deny' | 'full'>('allow');
|
||||
const [webfetchPermission, setWebfetchPermission] = React.useState<'allow' | 'ask' | 'deny'>('allow');
|
||||
const [bashPermission, setBashPermission] = React.useState<'allow' | 'ask' | 'deny'>('ask');
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isNewAgent) {
|
||||
|
||||
setName(selectedAgentName || '');
|
||||
setDescription('');
|
||||
setMode('subagent');
|
||||
setModel('');
|
||||
setTemperature(undefined);
|
||||
setTopP(undefined);
|
||||
setPrompt('');
|
||||
setTools({});
|
||||
setEditPermission('allow');
|
||||
setWebfetchPermission('allow');
|
||||
setBashPermission('ask');
|
||||
} else if (selectedAgent) {
|
||||
|
||||
setName(selectedAgent.name);
|
||||
setDescription(selectedAgent.description || '');
|
||||
setMode(selectedAgent.mode || 'subagent');
|
||||
|
||||
if (selectedAgent.model?.providerID && selectedAgent.model?.modelID) {
|
||||
setModel(`${selectedAgent.model.providerID}/${selectedAgent.model.modelID}`);
|
||||
} else {
|
||||
setModel('');
|
||||
}
|
||||
|
||||
setTemperature(selectedAgent.temperature);
|
||||
setTopP(selectedAgent.topP);
|
||||
setPrompt(selectedAgent.prompt || '');
|
||||
setTools(selectedAgent.tools || {});
|
||||
|
||||
if (selectedAgent.permission) {
|
||||
const editMode = selectedAgent.permission.edit;
|
||||
if (editMode === 'allow' || editMode === 'ask' || editMode === 'deny' || editMode === 'full') {
|
||||
setEditPermission(editMode);
|
||||
}
|
||||
if (selectedAgent.permission.webfetch) {
|
||||
setWebfetchPermission(selectedAgent.permission.webfetch);
|
||||
}
|
||||
if (typeof selectedAgent.permission.bash === 'string') {
|
||||
setBashPermission(selectedAgent.permission.bash as 'allow' | 'ask' | 'deny');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, [selectedAgent, isNewAgent, selectedAgentName, agents]);
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error('Agent name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const trimmedModel = model.trim();
|
||||
const config: AgentConfig = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
mode,
|
||||
model: trimmedModel === '' ? null : trimmedModel,
|
||||
temperature,
|
||||
top_p: topP,
|
||||
prompt: prompt.trim() || undefined,
|
||||
tools: Object.keys(tools).length > 0 ? tools : undefined,
|
||||
permission: {
|
||||
edit: editPermission,
|
||||
webfetch: webfetchPermission,
|
||||
bash: bashPermission,
|
||||
},
|
||||
};
|
||||
|
||||
let success: boolean;
|
||||
if (isNewAgent) {
|
||||
success = await createAgent(config);
|
||||
} else {
|
||||
success = await updateAgent(name, config);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewAgent ? 'Agent created successfully' : 'Agent updated successfully');
|
||||
} else {
|
||||
toast.error(isNewAgent ? 'Failed to create agent' : 'Failed to update agent');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving agent:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTool = (tool: string) => {
|
||||
setTools((prev) => ({
|
||||
...prev,
|
||||
[tool]: !prev[tool],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAllTools = (enabled: boolean) => {
|
||||
const allTools: Record<string, boolean> = {};
|
||||
availableTools.forEach((tool: string) => {
|
||||
allTools[tool] = enabled;
|
||||
});
|
||||
setTools(allTools);
|
||||
};
|
||||
|
||||
if (!selectedAgentName) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiRobot2Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select an agent from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{}
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">
|
||||
{isNewAgent ? 'New Agent' : name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Configure agent identity and behavior mode
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Agent Name
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="my-agent"
|
||||
disabled={!isNewAgent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this agent do?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Mode
|
||||
</label>
|
||||
<div className="flex gap-1 w-fit">
|
||||
<ButtonSmall
|
||||
variant={mode === 'primary' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('primary')}
|
||||
className={cn('gap-2', mode === 'primary' ? undefined : 'text-foreground')}
|
||||
>
|
||||
<RiAiAgentLine className="h-3 w-3" />
|
||||
Primary
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
variant={mode === 'subagent' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('subagent')}
|
||||
className={cn('gap-2', mode === 'subagent' ? undefined : 'text-foreground')}
|
||||
>
|
||||
<RiRobotLine className="h-3 w-3" />
|
||||
Subagent
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
variant={mode === 'all' ? 'default' : 'outline'}
|
||||
onClick={() => setMode('all')}
|
||||
className={cn('gap-2', mode === 'all' ? undefined : 'text-foreground')}
|
||||
>
|
||||
<RiAiAgentFill className="h-3 w-3" />
|
||||
All
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Primary: main agent, Subagent: helper agent, All: both modes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Model Configuration</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Configure model and generation parameters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Model
|
||||
</label>
|
||||
<ModelSelector
|
||||
providerId={model ? model.split('/')[0] : ''}
|
||||
modelId={model ? model.split('/')[1] : ''}
|
||||
onChange={(providerId: string, modelId: string) => {
|
||||
if (providerId && modelId) {
|
||||
setModel(`${providerId}/${modelId}`);
|
||||
} else {
|
||||
setModel('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||
Temperature
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Controls randomness in responses.<br />
|
||||
Higher values make output more creative and unpredictable,<br />
|
||||
lower values make it more focused and deterministic.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="relative w-32">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = temperature !== undefined ? temperature : 0.7;
|
||||
const newValue = Math.max(0, current - 0.1);
|
||||
setTemperature(parseFloat(newValue.toFixed(1)));
|
||||
}}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiSubtractLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={temperature !== undefined ? temperature : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === '') {
|
||||
setTemperature(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
|
||||
setTemperature(parsed);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value !== '') {
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed)) {
|
||||
const clamped = Math.max(0, Math.min(2, parsed));
|
||||
setTemperature(parseFloat(clamped.toFixed(1)));
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="—"
|
||||
className="text-center px-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = temperature !== undefined ? temperature : 0.7;
|
||||
const newValue = Math.min(2, current + 0.1);
|
||||
setTemperature(parseFloat(newValue.toFixed(1)));
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||
Top P
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Controls diversity via nucleus sampling.<br />
|
||||
Lower values focus on most likely tokens,<br />
|
||||
higher values consider more possibilities.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="relative w-32">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = topP !== undefined ? topP : 0.9;
|
||||
const newValue = Math.max(0, current - 0.1);
|
||||
setTopP(parseFloat(newValue.toFixed(1)));
|
||||
}}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiSubtractLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={topP !== undefined ? topP : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === '') {
|
||||
setTopP(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
setTopP(parsed);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value !== '') {
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed)) {
|
||||
const clamped = Math.max(0, Math.min(1, parsed));
|
||||
setTopP(parseFloat(clamped.toFixed(1)));
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="—"
|
||||
className="text-center px-10 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = topP !== undefined ? topP : 0.9;
|
||||
const newValue = Math.min(1, current + 0.1);
|
||||
setTopP(parseFloat(newValue.toFixed(1)));
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-6 w-6 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">System Prompt</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Override the default system prompt for this agent
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Custom system prompt for this agent..."
|
||||
rows={8}
|
||||
className="font-mono typography-meta"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Available Tools</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Select tools this agent can access
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 w-fit">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleAllTools(true)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Enable All
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleAllTools(false)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Disable All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTools.map((tool) => (
|
||||
<button
|
||||
key={tool}
|
||||
type="button"
|
||||
onClick={() => toggleTool(tool)}
|
||||
className={cn(
|
||||
"h-6 px-2 rounded-lg border text-[13px] cursor-pointer transition-colors",
|
||||
tools[tool]
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-border/40 bg-sidebar/30 text-foreground hover:bg-sidebar/50"
|
||||
)}
|
||||
>
|
||||
{tool}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Permissions</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Configure permission levels for different operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Edit Permission
|
||||
</label>
|
||||
<div className="flex gap-1 w-fit">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={editPermission === 'full' ? 'default' : 'outline'}
|
||||
onClick={() => setEditPermission('full')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Full
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={editPermission === 'allow' ? 'default' : 'outline'}
|
||||
onClick={() => setEditPermission('allow')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Allow
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={editPermission === 'ask' ? 'default' : 'outline'}
|
||||
onClick={() => setEditPermission('ask')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={editPermission === 'deny' ? 'default' : 'outline'}
|
||||
onClick={() => setEditPermission('deny')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Controls file editing permissions.
|
||||
</p>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p><strong>Full:</strong> Auto-approves all tool requests</p>
|
||||
<p><strong>Allow:</strong> Allows file editing with standard checks</p>
|
||||
<p><strong>Ask:</strong> Prompts for confirmation before editing</p>
|
||||
<p><strong>Deny:</strong> Blocks all file editing operations</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Bash Permission
|
||||
</label>
|
||||
<div className="flex gap-1 w-fit">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={bashPermission === 'allow' ? 'default' : 'outline'}
|
||||
onClick={() => setBashPermission('allow')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Allow
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={bashPermission === 'ask' ? 'default' : 'outline'}
|
||||
onClick={() => setBashPermission('ask')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={bashPermission === 'deny' ? 'default' : 'outline'}
|
||||
onClick={() => setBashPermission('deny')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Permission for running bash commands
|
||||
</p>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p><strong>Allow:</strong> Run bash commands without confirmation</p>
|
||||
<p><strong>Ask:</strong> Prompt for confirmation before execution</p>
|
||||
<p><strong>Deny:</strong> Block all bash command execution</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
WebFetch Permission
|
||||
</label>
|
||||
<div className="flex gap-1 w-fit">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={webfetchPermission === 'allow' ? 'default' : 'outline'}
|
||||
onClick={() => setWebfetchPermission('allow')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Allow
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={webfetchPermission === 'ask' ? 'default' : 'outline'}
|
||||
onClick={() => setWebfetchPermission('ask')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={webfetchPermission === 'deny' ? 'default' : 'outline'}
|
||||
onClick={() => setWebfetchPermission('deny')}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Permission for fetching web content
|
||||
</p>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p><strong>Allow:</strong> Fetch web content without confirmation</p>
|
||||
<p><strong>Ask:</strong> Prompt for confirmation before fetching</p>
|
||||
<p><strong>Deny:</strong> Block all web content access</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="flex justify-end border-t border-border/40 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="gap-2 h-6 px-2 text-xs w-fit"
|
||||
>
|
||||
<RiSaveLine className="h-3 w-3" />
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { RiAddLine, RiAiAgentFill, RiAiAgentLine, RiDeleteBinLine, RiFileCopyLine, RiMore2Line, RiRobot2Line, RiRobotLine } from '@remixicon/react';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Agent } from '@opencode-ai/sdk';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
export const AgentsSidebar: React.FC = () => {
|
||||
const [newAgentName, setNewAgentName] = React.useState('');
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
|
||||
|
||||
const {
|
||||
selectedAgentName,
|
||||
agents,
|
||||
setSelectedAgent,
|
||||
deleteAgent,
|
||||
loadAgents,
|
||||
} = useAgentsStore();
|
||||
|
||||
const { setSidebarOpen } = useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
React.useEffect(() => {
|
||||
loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
const handleCreateAgent = () => {
|
||||
if (!newAgentName.trim()) {
|
||||
toast.error('Agent name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (agents.some((agent) => agent.name === newAgentName)) {
|
||||
toast.error('An agent with this name already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAgent(newAgentName);
|
||||
setNewAgentName('');
|
||||
setIsCreateDialogOpen(false);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAgent = async (agent: Agent) => {
|
||||
if (agent.builtIn) {
|
||||
toast.error('Built-in agents cannot be deleted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Are you sure you want to delete agent "${agent.name}"?`)) {
|
||||
const success = await deleteAgent(agent.name);
|
||||
if (success) {
|
||||
toast.success(`Agent "${agent.name}" deleted successfully`);
|
||||
} else {
|
||||
toast.error('Failed to delete agent');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateAgent = (agent: Agent) => {
|
||||
const baseName = agent.name;
|
||||
let copyNumber = 1;
|
||||
let newName = `${baseName} Copy`;
|
||||
|
||||
while (agents.some((a) => a.name === newName)) {
|
||||
copyNumber++;
|
||||
newName = `${baseName} Copy ${copyNumber}`;
|
||||
}
|
||||
|
||||
setSelectedAgent(newName);
|
||||
setIsCreateDialogOpen(false);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAgentModeIcon = (mode?: string) => {
|
||||
switch (mode) {
|
||||
case 'primary':
|
||||
return <RiAiAgentLine className="h-3 w-3 text-primary" />;
|
||||
case 'all':
|
||||
return <RiAiAgentFill className="h-3 w-3 text-primary" />;
|
||||
case 'subagent':
|
||||
return <RiRobotLine className="h-3 w-3 text-primary" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const builtInAgents = agents.filter((agent) => agent.builtIn);
|
||||
const customAgents = agents.filter((agent) => !agent.builtIn);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar">
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Agents</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{agents.length}</span>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground">
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
||||
{agents.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiRobot2Line className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No agents configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{builtInAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Built-in Agents
|
||||
</div>
|
||||
{builtInAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
isSelected={selectedAgentName === agent.name}
|
||||
onSelect={() => {
|
||||
setSelectedAgent(agent.name);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDuplicate={() => handleDuplicateAgent(agent)}
|
||||
getAgentModeIcon={getAgentModeIcon}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{customAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Custom Agents
|
||||
</div>
|
||||
{customAgents.map((agent) => (
|
||||
<AgentListItem
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
isSelected={selectedAgentName === agent.name}
|
||||
onSelect={() => {
|
||||
setSelectedAgent(agent.name);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDelete={() => handleDeleteAgent(agent)}
|
||||
onDuplicate={() => handleDuplicateAgent(agent)}
|
||||
getAgentModeIcon={getAgentModeIcon}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a unique name for your new agent
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newAgentName}
|
||||
onChange={(e) => setNewAgentName(e.target.value)}
|
||||
placeholder="Agent name..."
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateAgent();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
className="text-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={handleCreateAgent}>
|
||||
Create
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AgentListItemProps {
|
||||
agent: Agent;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete?: () => void;
|
||||
onDuplicate: () => void;
|
||||
getAgentModeIcon: (mode?: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
agent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
getAgentModeIcon,
|
||||
}) => {
|
||||
return (
|
||||
<div className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
inputMode="none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn(
|
||||
"typography-ui-label font-medium truncate",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
{agent.name}
|
||||
</div>
|
||||
|
||||
{}
|
||||
{getAgentModeIcon(agent.mode)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
{agent.description && (
|
||||
<div className="typography-meta text-muted-foreground truncate mt-0.5">
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!agent.builtIn && onDelete && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiPencilAiLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
|
||||
|
||||
interface ModelSelectorProps {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
onChange: (providerId: string, modelId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
providerId,
|
||||
modelId,
|
||||
onChange,
|
||||
className
|
||||
}) => {
|
||||
const { providers, modelsMetadata } = useConfigStore();
|
||||
const isMobile = useUIStore(state => state.isMobile);
|
||||
const { isMobile: deviceIsMobile } = useDeviceInfo();
|
||||
const isActuallyMobile = isMobile || deviceIsMobile;
|
||||
|
||||
const [isMobilePanelOpen, setIsMobilePanelOpen] = React.useState(false);
|
||||
const [expandedMobileProviders, setExpandedMobileProviders] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const closeMobilePanel = () => setIsMobilePanelOpen(false);
|
||||
const toggleMobileProviderExpansion = (providerId: string) => {
|
||||
setExpandedMobileProviders(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(providerId)) {
|
||||
newSet.delete(providerId);
|
||||
} else {
|
||||
newSet.add(providerId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getModelDisplayName = (model: Record<string, unknown>) => {
|
||||
const name = model?.name || model?.id || '';
|
||||
const nameStr = String(name);
|
||||
if (nameStr.length > 40) {
|
||||
return nameStr.substring(0, 37) + '...';
|
||||
}
|
||||
return nameStr;
|
||||
};
|
||||
|
||||
const getModelMetadata = (providerId: string, modelId: string) => {
|
||||
const key = `${providerId}/${modelId}`;
|
||||
return modelsMetadata.get(key);
|
||||
};
|
||||
|
||||
const handleProviderAndModelChange = (newProviderId: string, newModelId: string) => {
|
||||
onChange(newProviderId, newModelId);
|
||||
};
|
||||
|
||||
const renderMobileModelPanel = () => {
|
||||
if (!isActuallyMobile) return null;
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={isMobilePanelOpen}
|
||||
onClose={closeMobilePanel}
|
||||
title="Select Model"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{providers.map((provider) => {
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
if (providerModels.length === 0) return null;
|
||||
|
||||
const isActiveProvider = provider.id === providerId;
|
||||
const isExpanded = expandedMobileProviders.has(provider.id);
|
||||
|
||||
return (
|
||||
<div key={provider.id} className="rounded-xl border border-border/40 bg-background/95">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-1.5 px-2 py-1.5 text-left"
|
||||
onClick={() => toggleMobileProviderExpansion(provider.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{provider.name}
|
||||
</span>
|
||||
{isActiveProvider && (
|
||||
<span className="typography-micro text-primary/80">Current</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{providerModels.map((modelItem: ProviderModel) => {
|
||||
const isSelectedModel = provider.id === providerId && modelItem.id === modelId;
|
||||
const metadata = getModelMetadata(provider.id as string, modelItem.id as string);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={modelItem.id as string}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between px-2 py-1.5 text-left',
|
||||
'typography-meta',
|
||||
isSelectedModel ? 'bg-primary/10 text-primary' : 'text-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
|
||||
closeMobilePanel();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
|
||||
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelectedModel && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left"
|
||||
onClick={() => {
|
||||
handleProviderAndModelChange('', '');
|
||||
closeMobilePanel();
|
||||
}}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground">No model (optional)</span>
|
||||
</button>
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isActuallyMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobilePanelOpen(true)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{providerId ? (
|
||||
<ProviderLogo
|
||||
providerId={providerId}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
) : (
|
||||
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{providerId && modelId ? `${providerId}/${modelId}` : 'Select model...'}
|
||||
</span>
|
||||
</div>
|
||||
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 px-2 rounded-lg bg-accent/20 border border-border/20 cursor-pointer hover:bg-accent/30 h-6 w-fit',
|
||||
className
|
||||
)}>
|
||||
{providerId ? (
|
||||
<>
|
||||
<ProviderLogo
|
||||
providerId={providerId}
|
||||
className="h-3 w-3 flex-shrink-0"
|
||||
/>
|
||||
<RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" />
|
||||
</>
|
||||
) : (
|
||||
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-micro font-medium whitespace-nowrap">
|
||||
{providerId && modelId ? `${providerId}/${modelId}` : 'Not selected'}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-[300px]">
|
||||
{providers.map((provider) => {
|
||||
const providerModels = Array.isArray(provider.models) ? provider.models : [];
|
||||
|
||||
if (providerModels.length === 0) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
disabled
|
||||
className="typography-meta text-muted-foreground"
|
||||
>
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-3 w-3 flex-shrink-0 mr-2"
|
||||
/>
|
||||
{provider.name} (No models)
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={provider.id}>
|
||||
<DropdownMenuSubTrigger className="typography-meta">
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-3 w-3 flex-shrink-0 mr-2"
|
||||
/>
|
||||
{provider.name}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
className="max-h-[320px] min-w-[200px]"
|
||||
sideOffset={2}
|
||||
collisionPadding={8}
|
||||
avoidCollisions={true}
|
||||
>
|
||||
<ScrollableOverlay
|
||||
outerClassName="max-h-[320px] min-w-[200px]"
|
||||
className="space-y-1 p-1"
|
||||
>
|
||||
{providerModels.map((modelItem: ProviderModel) => {
|
||||
const metadata = getModelMetadata(provider.id as string, modelItem.id as string);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelItem.id as string}
|
||||
className="typography-meta"
|
||||
onSelect={() => {
|
||||
handleProviderAndModelChange(provider.id as string, modelItem.id as string);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{getModelDisplayName(modelItem)}</span>
|
||||
{typeof (metadata as unknown as Record<string, unknown>)?.description === 'string' && (
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{(metadata as unknown as Record<string, unknown>).description as React.ReactNode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</ScrollableOverlay>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuItem
|
||||
className="typography-meta"
|
||||
onSelect={() => handleProviderAndModelChange('', '')}
|
||||
>
|
||||
<span className="text-muted-foreground">No model (optional)</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{renderMobileModelPanel()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { RiArrowDownSLine, RiRobot2Line } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
agentName: string;
|
||||
onChange: (agentName: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
agentName,
|
||||
onChange,
|
||||
className
|
||||
}) => {
|
||||
const { agents, loadAgents } = useAgentsStore();
|
||||
const isMobile = useUIStore(state => state.isMobile);
|
||||
const { isMobile: deviceIsMobile } = useDeviceInfo();
|
||||
const isActuallyMobile = isMobile || deviceIsMobile;
|
||||
|
||||
const [isMobilePanelOpen, setIsMobilePanelOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
const closeMobilePanel = () => setIsMobilePanelOpen(false);
|
||||
|
||||
const handleAgentChange = (newAgentName: string) => {
|
||||
onChange(newAgentName);
|
||||
};
|
||||
|
||||
const renderMobileAgentPanel = () => {
|
||||
if (!isActuallyMobile) return null;
|
||||
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={isMobilePanelOpen}
|
||||
onClose={closeMobilePanel}
|
||||
title="Select Agent"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{agents.map((agent) => {
|
||||
const isSelected = agent.name === agentName;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={agent.name}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
|
||||
isSelected ? 'bg-primary/10 text-primary' : 'text-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
handleAgentChange(agent.name);
|
||||
closeMobilePanel();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="typography-meta font-medium">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left"
|
||||
onClick={() => {
|
||||
handleAgentChange('');
|
||||
closeMobilePanel();
|
||||
}}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground">No agent (optional)</span>
|
||||
</button>
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isActuallyMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobilePanelOpen(true)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiRobot2Line className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="typography-meta font-medium text-foreground">
|
||||
{agentName || 'Select agent...'}
|
||||
</span>
|
||||
</div>
|
||||
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 px-2 rounded-lg bg-accent/20 border border-border/20 cursor-pointer hover:bg-accent/30 h-6 w-fit',
|
||||
className
|
||||
)}>
|
||||
<RiRobot2Line className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
<span className="typography-micro font-medium whitespace-nowrap">
|
||||
{agentName || 'Not selected'}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-[300px]">
|
||||
{agents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.name}
|
||||
className="typography-meta"
|
||||
onSelect={() => handleAgentChange(agent.name)}
|
||||
>
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
className="typography-meta"
|
||||
onSelect={() => handleAgentChange('')}
|
||||
>
|
||||
<span className="text-muted-foreground">No agent (optional)</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{renderMobileAgentPanel()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
import { useCommandsStore, type CommandConfig } from '@/stores/useCommandsStore';
|
||||
import { RiCheckLine, RiInformationLine, RiSaveLine, RiTerminalBoxLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ModelSelector } from '../agents/ModelSelector';
|
||||
import { AgentSelector } from './AgentSelector';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
export const CommandsPage: React.FC = () => {
|
||||
const { selectedCommandName, getCommandByName, createCommand, updateCommand, commands } = useCommandsStore();
|
||||
|
||||
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
|
||||
const isNewCommand = selectedCommandName && !selectedCommand;
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [description, setDescription] = React.useState('');
|
||||
const [agent, setAgent] = React.useState('');
|
||||
const [model, setModel] = React.useState('');
|
||||
const [template, setTemplate] = React.useState('');
|
||||
const [subtask, setSubtask] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isNewCommand) {
|
||||
|
||||
setName(selectedCommandName || '');
|
||||
setDescription('');
|
||||
setAgent('');
|
||||
setModel('');
|
||||
setTemplate('');
|
||||
setSubtask(false);
|
||||
} else if (selectedCommand) {
|
||||
|
||||
setName(selectedCommand.name);
|
||||
setDescription(selectedCommand.description || '');
|
||||
setAgent(selectedCommand.agent || '');
|
||||
setModel(selectedCommand.model || '');
|
||||
setTemplate(selectedCommand.template || '');
|
||||
setSubtask(selectedCommand.subtask || false);
|
||||
}
|
||||
}, [selectedCommand, isNewCommand, selectedCommandName, commands]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('Command name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!template.trim()) {
|
||||
toast.error('Command template is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const trimmedAgent = agent.trim();
|
||||
const trimmedModel = model.trim();
|
||||
const trimmedTemplate = template.trim();
|
||||
const config: CommandConfig = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
agent: trimmedAgent === '' ? null : trimmedAgent,
|
||||
model: trimmedModel === '' ? null : trimmedModel,
|
||||
template: trimmedTemplate,
|
||||
subtask,
|
||||
};
|
||||
|
||||
let success: boolean;
|
||||
if (isNewCommand) {
|
||||
success = await createCommand(config);
|
||||
} else {
|
||||
success = await updateCommand(name, config);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewCommand ? 'Command created successfully' : 'Command updated successfully');
|
||||
} else {
|
||||
toast.error(isNewCommand ? 'Failed to create command' : 'Failed to update command');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving command:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedCommandName) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiTerminalBoxLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a command from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{}
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">
|
||||
{isNewCommand ? 'New Command' : name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Basic Information</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Configure command identity and metadata
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Command Name
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="my-command"
|
||||
disabled={!isNewCommand}
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Used with slash (/) prefix in chat
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this command do?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Model & Agent Configuration</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Configure model and agent for command execution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Agent
|
||||
</label>
|
||||
<AgentSelector
|
||||
agentName={agent}
|
||||
onChange={(agentName: string) => setAgent(agentName)}
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Agent to execute this command (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Model
|
||||
</label>
|
||||
<ModelSelector
|
||||
providerId={model ? model.split('/')[0] : ''}
|
||||
modelId={model ? model.split('/')[1] : ''}
|
||||
onChange={(providerId: string, modelId: string) => {
|
||||
if (providerId && modelId) {
|
||||
setModel(`${providerId}/${modelId}`);
|
||||
} else {
|
||||
setModel('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Default model for this command (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2 cursor-pointer">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtask}
|
||||
onChange={(e) => setSubtask(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded border-2 flex items-center justify-center",
|
||||
subtask
|
||||
? "bg-primary border-primary"
|
||||
: "bg-background border-border hover:border-primary/50"
|
||||
)}>
|
||||
{subtask && <RiCheckLine className="w-3 h-3 text-primary-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
Force Subagent Invocation
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Force command to run in a subagent context
|
||||
</p>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
When enabled, this command will always execute in a subagent context,<br/>
|
||||
even if triggered from main agent.<br/>
|
||||
Useful for isolating command logic and maintaining clean separation of concerns.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Command Template</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Define the prompt template for this command. Use $ARGUMENTS for user input.
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder={`Your command template here...
|
||||
|
||||
Use $ARGUMENTS to reference user input.
|
||||
Use !\`shell command\` to inject shell output.
|
||||
Use @filename to include file contents.`}
|
||||
rows={12}
|
||||
className="font-mono typography-meta"
|
||||
/>
|
||||
<div className="typography-meta text-muted-foreground/80 space-y-1">
|
||||
<p className="font-medium">Template Features:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<code className="bg-muted px-1 rounded">$ARGUMENTS</code>
|
||||
<span>- User input after command</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Replaced with everything the user types after the command name.<br/>
|
||||
Example: "/deploy staging" makes $ARGUMENTS = "staging"
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<code className="bg-muted px-1 rounded">!`command`</code>
|
||||
<span>- Inject shell command output</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Executes shell command and replaces this placeholder with its output.<br/>
|
||||
Example: !`git branch --show-current` gets current branch name
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<code className="bg-muted px-1 rounded">@filename</code>
|
||||
<span>- Include file contents</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3 w-3 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Replaces with the full contents of the specified file.<br/>
|
||||
Example: @package.json includes the package.json content in the prompt
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="flex justify-end border-t border-border/40 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="gap-2 h-6 px-2 text-xs w-fit"
|
||||
>
|
||||
<RiSaveLine className="h-3 w-3" />
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine } from '@remixicon/react';
|
||||
import { useCommandsStore, type Command } from '@/stores/useCommandsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const CommandsSidebar: React.FC = () => {
|
||||
const [newCommandName, setNewCommandName] = React.useState('');
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
|
||||
|
||||
const {
|
||||
selectedCommandName,
|
||||
commands,
|
||||
setSelectedCommand,
|
||||
deleteCommand,
|
||||
loadCommands,
|
||||
} = useCommandsStore();
|
||||
|
||||
const { setSidebarOpen } = useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
React.useEffect(() => {
|
||||
loadCommands();
|
||||
}, [loadCommands]);
|
||||
|
||||
const handleCreateCommand = () => {
|
||||
if (!newCommandName.trim()) {
|
||||
toast.error('Command name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedName = newCommandName.trim().replace(/\s+/g, '-');
|
||||
|
||||
if (commands.some((cmd) => cmd.name === sanitizedName)) {
|
||||
toast.error('A command with this name already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCommand(sanitizedName);
|
||||
setNewCommandName('');
|
||||
setIsCreateDialogOpen(false);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCommand = async (command: Command) => {
|
||||
if (window.confirm(`Are you sure you want to delete command "${command.name}"?`)) {
|
||||
const success = await deleteCommand(command.name);
|
||||
if (success) {
|
||||
toast.success(`Command "${command.name}" deleted successfully`);
|
||||
} else {
|
||||
toast.error('Failed to delete command');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
setSelectedCommand(newName);
|
||||
setIsCreateDialogOpen(false);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar">
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Commands</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{commands.length}</span>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground">
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
|
||||
{commands.length === 0 ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiTerminalBoxLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No commands configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{[...commands].sort((a, b) => a.name.localeCompare(b.name)).map((command) => (
|
||||
<CommandListItem
|
||||
key={command.name}
|
||||
command={command}
|
||||
isSelected={selectedCommandName === command.name}
|
||||
onSelect={() => {
|
||||
setSelectedCommand(command.name);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDelete={() => handleDeleteCommand(command)}
|
||||
onDuplicate={() => handleDuplicateCommand(command)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Command</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a unique name for your new slash command
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newCommandName}
|
||||
onChange={(e) => setNewCommandName(e.target.value)}
|
||||
placeholder="Command name..."
|
||||
className="text-foreground placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateCommand();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
className="text-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={handleCreateCommand}>
|
||||
Create
|
||||
</ButtonLarge>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CommandListItemProps {
|
||||
command: Command;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onDuplicate: () => void;
|
||||
}
|
||||
|
||||
const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
command,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}) => {
|
||||
return (
|
||||
<div className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
inputMode="none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"typography-ui-label font-medium truncate flex-1",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
/{command.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{command.description && (
|
||||
<div className="typography-meta text-muted-foreground truncate mt-0.5">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4 mr-px" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,374 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { useGitIdentitiesStore, type GitIdentityProfile } from '@/stores/useGitIdentitiesStore';
|
||||
import {
|
||||
RiUser3Line,
|
||||
RiSaveLine,
|
||||
RiDeleteBinLine,
|
||||
RiGitBranchLine,
|
||||
RiBriefcaseLine,
|
||||
RiHomeLine,
|
||||
RiGraduationCapLine,
|
||||
RiCodeLine,
|
||||
RiInformationLine
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
const PROFILE_COLORS = [
|
||||
{ key: 'keyword', label: 'Green', cssVar: 'var(--syntax-keyword)' },
|
||||
{ key: 'error', label: 'Red', cssVar: 'var(--status-error)' },
|
||||
{ key: 'string', label: 'Cyan', cssVar: 'var(--syntax-string)' },
|
||||
{ key: 'function', label: 'Orange', cssVar: 'var(--syntax-function)' },
|
||||
{ key: 'type', label: 'Yellow', cssVar: 'var(--syntax-type)' },
|
||||
];
|
||||
|
||||
const PROFILE_ICONS = [
|
||||
{ key: 'branch', Icon: RiGitBranchLine, label: 'Branch' },
|
||||
{ key: 'briefcase', Icon: RiBriefcaseLine, label: 'Work' },
|
||||
{ key: 'house', Icon: RiHomeLine, label: 'Personal' },
|
||||
{ key: 'graduation', Icon: RiGraduationCapLine, label: 'School' },
|
||||
{ key: 'code', Icon: RiCodeLine, label: 'Code' },
|
||||
];
|
||||
|
||||
export const GitIdentitiesPage: React.FC = () => {
|
||||
const {
|
||||
selectedProfileId,
|
||||
getProfileById,
|
||||
createProfile,
|
||||
updateProfile,
|
||||
deleteProfile,
|
||||
} = useGitIdentitiesStore();
|
||||
|
||||
const selectedProfile = React.useMemo(() =>
|
||||
selectedProfileId && selectedProfileId !== 'new' ? getProfileById(selectedProfileId) : null,
|
||||
[selectedProfileId, getProfileById]
|
||||
);
|
||||
const isNewProfile = selectedProfileId === 'new';
|
||||
const isGlobalProfile = selectedProfileId === 'global';
|
||||
|
||||
const [name, setName] = React.useState('');
|
||||
const [userName, setUserName] = React.useState('');
|
||||
const [userEmail, setUserEmail] = React.useState('');
|
||||
const [sshKey, setSshKey] = React.useState('');
|
||||
const [color, setColor] = React.useState('keyword');
|
||||
const [icon, setIcon] = React.useState('branch');
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isNewProfile) {
|
||||
|
||||
setName('');
|
||||
setUserName('');
|
||||
setUserEmail('');
|
||||
setSshKey('');
|
||||
setColor('keyword');
|
||||
setIcon('branch');
|
||||
} else if (selectedProfile) {
|
||||
|
||||
setName(selectedProfile.name);
|
||||
setUserName(selectedProfile.userName);
|
||||
setUserEmail(selectedProfile.userEmail);
|
||||
setSshKey(selectedProfile.sshKey || '');
|
||||
setColor(selectedProfile.color || 'keyword');
|
||||
setIcon(selectedProfile.icon || 'branch');
|
||||
}
|
||||
}, [selectedProfile, isNewProfile, selectedProfileId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!userName.trim() || !userEmail.trim()) {
|
||||
toast.error('User name and email are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const profileData: Omit<GitIdentityProfile, 'id'> & { id?: string } = {
|
||||
name: name.trim() || userName.trim(),
|
||||
userName: userName.trim(),
|
||||
userEmail: userEmail.trim(),
|
||||
sshKey: sshKey.trim() || null,
|
||||
color,
|
||||
icon,
|
||||
};
|
||||
|
||||
let success: boolean;
|
||||
if (isNewProfile) {
|
||||
success = await createProfile(profileData);
|
||||
} else if (selectedProfileId) {
|
||||
success = await updateProfile(selectedProfileId, profileData);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(isNewProfile ? 'Profile created successfully' : 'Profile updated successfully');
|
||||
} else {
|
||||
toast.error(isNewProfile ? 'Failed to create profile' : 'Failed to update profile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving profile:', error);
|
||||
toast.error('An error occurred while saving');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedProfileId || isNewProfile) return;
|
||||
|
||||
if (!confirm('Are you sure you want to delete this profile?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await deleteProfile(selectedProfileId);
|
||||
if (success) {
|
||||
toast.success('Profile deleted successfully');
|
||||
} else {
|
||||
toast.error('Failed to delete profile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting profile:', error);
|
||||
toast.error('An error occurred while deleting');
|
||||
}
|
||||
};
|
||||
|
||||
const currentColorValue = React.useMemo(() => {
|
||||
const colorConfig = PROFILE_COLORS.find(c => c.key === color);
|
||||
return colorConfig?.cssVar || 'var(--syntax-keyword)';
|
||||
}, [color]);
|
||||
|
||||
if (!selectedProfileId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<RiUser3Line className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">Select a profile from the sidebar</p>
|
||||
<p className="typography-meta mt-1 opacity-75">or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{}
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">
|
||||
{isNewProfile ? 'New Git Profile' : isGlobalProfile ? 'Global Identity' : name || 'Edit Profile'}
|
||||
</h1>
|
||||
<p className="typography-body text-muted-foreground mt-1">
|
||||
{isNewProfile
|
||||
? 'Create a new Git identity profile for your repositories'
|
||||
: isGlobalProfile
|
||||
? 'System-wide Git identity from global configuration (read-only)'
|
||||
: 'Configure Git identity settings for this profile'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{!isGlobalProfile && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground">Profile Information</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Basic profile settings and display name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Display Name
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Work Profile, Personal, etc."
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Friendly name to identify this profile (optional, defaults to user name)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PROFILE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
onClick={() => setColor(c.key)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg border-2 transition-all',
|
||||
color === c.key
|
||||
? 'border-foreground scale-110'
|
||||
: 'border-transparent hover:border-border'
|
||||
)}
|
||||
style={{ backgroundColor: c.cssVar }}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
Icon
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PROFILE_ICONS.map((i) => {
|
||||
const IconComponent = i.Icon;
|
||||
return (
|
||||
<button
|
||||
key={i.key}
|
||||
onClick={() => setIcon(i.key)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
|
||||
icon === i.key
|
||||
? 'border-primary bg-accent scale-110'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
title={i.label}
|
||||
>
|
||||
<IconComponent
|
||||
className="w-4 h-4"
|
||||
|
||||
style={{ color: currentColorValue }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="typography-h2 font-semibold text-foreground">Git Configuration</h2>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Git user settings that will be applied to repositories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||
User Name {!isGlobalProfile && <span className="text-destructive">*</span>}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
The name that will appear in Git commit messages.<br/>
|
||||
This is the author name shown in git log and GitHub/GitLab interfaces.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Input
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required={!isGlobalProfile}
|
||||
readOnly={isGlobalProfile}
|
||||
disabled={isGlobalProfile}
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Git user.name configuration value
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||
User Email {!isGlobalProfile && <span className="text-destructive">*</span>}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
The email address for Git commits.<br/>
|
||||
This should match your email in GitHub/GitLab<br/>
|
||||
to ensure proper attribution of commits.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={userEmail}
|
||||
onChange={(e) => setUserEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
required={!isGlobalProfile}
|
||||
readOnly={isGlobalProfile}
|
||||
disabled={isGlobalProfile}
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Git user.email configuration value
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="typography-ui-label font-medium text-foreground flex items-center gap-2">
|
||||
SSH Key Path
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Path to SSH private key used for Git authentication.<br/>
|
||||
This key will be used for HTTPS and SSH Git operations.<br/>
|
||||
Common paths: ~/.ssh/id_rsa, ~/.ssh/id_ed25519
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Input
|
||||
value={sshKey}
|
||||
onChange={(e) => setSshKey(e.target.value)}
|
||||
placeholder="/Users/username/.ssh/id_rsa"
|
||||
readOnly={isGlobalProfile}
|
||||
disabled={isGlobalProfile}
|
||||
/>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Path to SSH private key for authentication (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{}
|
||||
{!isGlobalProfile && (
|
||||
<div className="flex justify-between border-t border-border/40 pt-4">
|
||||
{!isNewProfile && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
className="gap-2 h-6 px-2 text-xs"
|
||||
>
|
||||
<RiDeleteBinLine className="h-3 w-3" />
|
||||
Delete Profile
|
||||
</Button>
|
||||
)}
|
||||
<div className={cn('flex gap-2', isNewProfile && 'ml-auto')}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="gap-2 h-6 px-2 text-xs"
|
||||
>
|
||||
<RiSaveLine className="h-3 w-3" />
|
||||
{isSaving ? 'Saving...' : 'Save Profile'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiGitBranchLine,
|
||||
RiMore2Line,
|
||||
RiDeleteBinLine,
|
||||
RiBriefcaseLine,
|
||||
RiHomeLine,
|
||||
RiGraduationCapLine,
|
||||
RiCodeLine,
|
||||
RiHeartLine,
|
||||
} from '@remixicon/react';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitIdentityProfile } from '@/stores/useGitIdentitiesStore';
|
||||
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>> = {
|
||||
branch: RiGitBranchLine,
|
||||
briefcase: RiBriefcaseLine,
|
||||
house: RiHomeLine,
|
||||
graduation: RiGraduationCapLine,
|
||||
code: RiCodeLine,
|
||||
heart: RiHeartLine,
|
||||
};
|
||||
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
keyword: 'var(--syntax-keyword)',
|
||||
error: 'var(--status-error)',
|
||||
string: 'var(--syntax-string)',
|
||||
function: 'var(--syntax-function)',
|
||||
type: 'var(--syntax-type)',
|
||||
};
|
||||
|
||||
export const GitIdentitiesSidebar: React.FC = () => {
|
||||
const {
|
||||
selectedProfileId,
|
||||
profiles,
|
||||
globalIdentity,
|
||||
setSelectedProfile,
|
||||
deleteProfile,
|
||||
loadProfiles,
|
||||
loadGlobalIdentity,
|
||||
} = useGitIdentitiesStore();
|
||||
|
||||
const { setSidebarOpen } = useUIStore();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
React.useEffect(() => {
|
||||
loadProfiles();
|
||||
loadGlobalIdentity();
|
||||
}, [loadProfiles, loadGlobalIdentity]);
|
||||
|
||||
const handleCreateProfile = () => {
|
||||
setSelectedProfile('new');
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProfile = async (profile: GitIdentityProfile) => {
|
||||
if (window.confirm(`Are you sure you want to delete profile "${profile.name}"?`)) {
|
||||
const success = await deleteProfile(profile.id);
|
||||
if (success) {
|
||||
toast.success(`Profile "${profile.name}" deleted successfully`);
|
||||
} else {
|
||||
toast.error('Failed to delete profile');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar">
|
||||
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">Git Profiles</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="typography-meta text-muted-foreground">{profiles.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={handleCreateProfile}
|
||||
>
|
||||
<RiAddLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
|
||||
{}
|
||||
{globalIdentity && (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
System Default
|
||||
</div>
|
||||
<ProfileListItem
|
||||
profile={globalIdentity}
|
||||
isSelected={selectedProfileId === 'global'}
|
||||
onSelect={() => {
|
||||
setSelectedProfile('global');
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDelete={undefined}
|
||||
isReadOnly
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{}
|
||||
{profiles.length > 0 && (
|
||||
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Custom Profiles
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profiles.length === 0 && !globalIdentity ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<RiGitBranchLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">No profiles configured</p>
|
||||
<p className="typography-meta mt-1 opacity-75">Use the + button above to create one</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{profiles.map((profile) => (
|
||||
<ProfileListItem
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isSelected={selectedProfileId === profile.id}
|
||||
onSelect={() => {
|
||||
setSelectedProfile(profile.id);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onDelete={() => handleDeleteProfile(profile)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ProfileListItemProps {
|
||||
profile: GitIdentityProfile;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete?: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const ProfileListItem: React.FC<ProfileListItemProps> = ({
|
||||
profile,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}) => {
|
||||
|
||||
const IconComponent = ICON_MAP[profile.icon || 'branch'] || RiGitBranchLine;
|
||||
|
||||
const iconColor = COLOR_MAP[profile.color || ''];
|
||||
|
||||
return (
|
||||
<div className="group transition-all duration-200">
|
||||
<div className="relative">
|
||||
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex-1 text-left overflow-hidden"
|
||||
inputMode="none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
style={{ color: iconColor }}
|
||||
/>
|
||||
<div className={cn(
|
||||
"typography-ui-label font-medium truncate flex-1",
|
||||
isSelected
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary/80"
|
||||
)}>
|
||||
{profile.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="typography-meta text-muted-foreground truncate mt-0.5">
|
||||
{profile.userEmail}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!isReadOnly && onDelete && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-px" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '../SectionPlaceholder';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
export const ProvidersPage: React.FC = () => {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<SectionPlaceholder sectionId="providers" variant="page" />
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { SectionPlaceholder } from '../SectionPlaceholder';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
export const ProvidersSidebar: React.FC = () => {
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="h-full" className="px-3 py-2">
|
||||
<SectionPlaceholder sectionId="providers" variant="sidebar" />
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import type { ThemeMode } from '@/types/theme';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
|
||||
interface Option<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
},
|
||||
];
|
||||
|
||||
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
|
||||
{
|
||||
id: 'dynamic',
|
||||
label: 'Dynamic',
|
||||
description: 'New files inline, modified files side-by-side. Responsive inline fallback only in Dynamic mode.',
|
||||
},
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Always inline',
|
||||
description: 'Show all file diffs as a single unified view.',
|
||||
},
|
||||
{
|
||||
id: 'side-by-side',
|
||||
label: 'Always side-by-side',
|
||||
description: 'Compare original and modified files next to each other.',
|
||||
},
|
||||
];
|
||||
|
||||
export const AppearanceSettings: React.FC = () => {
|
||||
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
|
||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
|
||||
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
|
||||
const {
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
} = useThemeSystem();
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-8">
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Theme Mode
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="flex gap-1 w-fit">
|
||||
{THEME_MODE_OPTIONS.map((option) => (
|
||||
<ButtonSmall
|
||||
key={option.value}
|
||||
variant={themeMode === option.value ? 'default' : 'outline'}
|
||||
className={cn(themeMode === option.value ? undefined : 'text-foreground')}
|
||||
onClick={() => setThemeMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</ButtonSmall>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Diff layout (Diff tab)
|
||||
</h3>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Choose the default layout for file diffs. You can still override layout per file from the Diff tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-1 w-fit">
|
||||
{DIFF_LAYOUT_OPTIONS.map((option) => (
|
||||
<ButtonSmall
|
||||
key={option.id}
|
||||
variant={diffLayoutPreference === option.id ? 'default' : 'outline'}
|
||||
className={cn(diffLayoutPreference === option.id ? undefined : 'text-foreground')}
|
||||
onClick={() => setDiffLayoutPreference(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</ButtonSmall>
|
||||
))}
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/80 max-w-xl">
|
||||
{DIFF_LAYOUT_OPTIONS.find((option) => option.id === diffLayoutPreference)?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-primary"
|
||||
checked={showReasoningTraces}
|
||||
onChange={(event) => setShowReasoningTraces(event.target.checked)}
|
||||
/>
|
||||
<span className="typography-ui-header font-semibold text-foreground">
|
||||
Show thinking / reasoning traces
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { AppearanceSettings } from './AppearanceSettings';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
return (
|
||||
<ScrollableOverlay
|
||||
outerClassName="h-full"
|
||||
className="settings-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
|
||||
>
|
||||
<AppearanceSettings />
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user