feat: improve agent sort (#231)
This commit is contained in:
@@ -49,7 +49,7 @@ func (a *Amp) Description() string {
|
||||
return "Sourcegraph Amp CLI-powered autonomous coding agent using stream-json protocol."
|
||||
}
|
||||
func (a *Amp) Enabled() bool { return true }
|
||||
func (a *Amp) DisplayOrder() int { return 6 }
|
||||
func (a *Amp) DisplayOrder() int { return 7 }
|
||||
|
||||
func (a *Amp) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
|
||||
@@ -50,7 +50,7 @@ func (a *Copilot) Description() string {
|
||||
return "GitHub Copilot CLI-powered autonomous coding agent using the Copilot SDK protocol."
|
||||
}
|
||||
func (a *Copilot) Enabled() bool { return true }
|
||||
func (a *Copilot) DisplayOrder() int { return 5 }
|
||||
func (a *Copilot) DisplayOrder() int { return 6 }
|
||||
|
||||
func (a *Copilot) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
|
||||
@@ -52,7 +52,7 @@ func (a *Gemini) Description() string {
|
||||
return "Google Gemini CLI-powered autonomous coding agent using ACP protocol."
|
||||
}
|
||||
func (a *Gemini) Enabled() bool { return true }
|
||||
func (a *Gemini) DisplayOrder() int { return 4 }
|
||||
func (a *Gemini) DisplayOrder() int { return 5 }
|
||||
|
||||
func (a *Gemini) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
|
||||
@@ -53,7 +53,7 @@ func (a *OpenCode) Description() string {
|
||||
return "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol."
|
||||
}
|
||||
func (a *OpenCode) Enabled() bool { return true }
|
||||
func (a *OpenCode) DisplayOrder() int { return 3 }
|
||||
func (a *OpenCode) DisplayOrder() int { return 4 }
|
||||
|
||||
func (a *OpenCode) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
@@ -91,6 +92,13 @@ func (r *Registry) GetDefault() (agents.Agent, error) {
|
||||
return nil, fmt.Errorf("no default agent type available")
|
||||
}
|
||||
|
||||
// sortByDisplayOrder sorts agents by their DisplayOrder in ascending order.
|
||||
func sortByDisplayOrder(list []agents.Agent) {
|
||||
slices.SortStableFunc(list, func(a, b agents.Agent) int {
|
||||
return cmp.Compare(a.DisplayOrder(), b.DisplayOrder())
|
||||
})
|
||||
}
|
||||
|
||||
// List returns all registered agents
|
||||
func (r *Registry) List() []agents.Agent {
|
||||
r.mu.RLock()
|
||||
@@ -100,9 +108,7 @@ func (r *Registry) List() []agents.Agent {
|
||||
for _, ag := range r.agents {
|
||||
result = append(result, ag)
|
||||
}
|
||||
slices.SortStableFunc(result, func(a, b agents.Agent) int {
|
||||
return a.DisplayOrder() - b.DisplayOrder()
|
||||
})
|
||||
sortByDisplayOrder(result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -117,9 +123,7 @@ func (r *Registry) ListEnabled() []agents.Agent {
|
||||
result = append(result, ag)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(result, func(a, b agents.Agent) int {
|
||||
return a.DisplayOrder() - b.DisplayOrder()
|
||||
})
|
||||
sortByDisplayOrder(result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
|
||||
// testAgent is a minimal implementation of agents.Agent for testing.
|
||||
type testAgent struct {
|
||||
id string
|
||||
name string
|
||||
description string
|
||||
enabled bool
|
||||
runtime *agents.RuntimeConfig
|
||||
id string
|
||||
name string
|
||||
description string
|
||||
enabled bool
|
||||
displayOrder int
|
||||
runtime *agents.RuntimeConfig
|
||||
}
|
||||
|
||||
func (a *testAgent) ID() string { return a.id }
|
||||
@@ -24,7 +25,7 @@ func (a *testAgent) Name() string { return a.name }
|
||||
func (a *testAgent) DisplayName() string { return a.name }
|
||||
func (a *testAgent) Description() string { return a.description }
|
||||
func (a *testAgent) Enabled() bool { return a.enabled }
|
||||
func (a *testAgent) DisplayOrder() int { return 0 }
|
||||
func (a *testAgent) DisplayOrder() int { return a.displayOrder }
|
||||
func (a *testAgent) Logo(agents.LogoVariant) []byte { return nil }
|
||||
func (a *testAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) {
|
||||
return nil, agents.ErrNotSupported
|
||||
@@ -250,6 +251,58 @@ func TestRegistry_LoadDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_List_OrderedByDisplayOrder(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
// Register agents in reverse display order
|
||||
a3 := validAgentConfig("agent-c", "Agent C")
|
||||
a3.displayOrder = 30
|
||||
a2 := validAgentConfig("agent-b", "Agent B")
|
||||
a2.displayOrder = 20
|
||||
a1 := validAgentConfig("agent-a", "Agent A")
|
||||
a1.displayOrder = 10
|
||||
|
||||
_ = reg.Register(a3)
|
||||
_ = reg.Register(a1)
|
||||
_ = reg.Register(a2)
|
||||
|
||||
list := reg.List()
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("expected 3 agents, got %d", len(list))
|
||||
}
|
||||
if list[0].ID() != "agent-a" || list[1].ID() != "agent-b" || list[2].ID() != "agent-c" {
|
||||
t.Errorf("expected agents ordered by DisplayOrder [agent-a, agent-b, agent-c], got [%s, %s, %s]",
|
||||
list[0].ID(), list[1].ID(), list[2].ID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_ListEnabled_OrderedByDisplayOrder(t *testing.T) {
|
||||
log := newTestLogger()
|
||||
reg := NewRegistry(log)
|
||||
|
||||
a3 := validAgentConfig("agent-c", "Agent C")
|
||||
a3.displayOrder = 30
|
||||
a2 := validAgentConfig("agent-b", "Agent B")
|
||||
a2.displayOrder = 20
|
||||
a2.enabled = false // disabled — should be excluded
|
||||
a1 := validAgentConfig("agent-a", "Agent A")
|
||||
a1.displayOrder = 10
|
||||
|
||||
_ = reg.Register(a3)
|
||||
_ = reg.Register(a1)
|
||||
_ = reg.Register(a2)
|
||||
|
||||
list := reg.ListEnabled()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 enabled agents, got %d", len(list))
|
||||
}
|
||||
if list[0].ID() != "agent-a" || list[1].ID() != "agent-c" {
|
||||
t.Errorf("expected enabled agents ordered [agent-a, agent-c], got [%s, %s]",
|
||||
list[0].ID(), list[1].ID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTypeConfig_ToAPIType(t *testing.T) {
|
||||
ag := validAgentConfig("test-agent", "Test Agent")
|
||||
ag.description = "Test description"
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
ListAgentsResponse,
|
||||
ListAgentDiscoveryResponse,
|
||||
} from '@/lib/types/http';
|
||||
import type { PermissionKey } from '@/lib/agent-permissions';
|
||||
|
||||
type ProfilePermissions = Record<PermissionKey, boolean>;
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
|
||||
@@ -45,14 +48,7 @@ export async function listAgentsAction(): Promise<ListAgentsResponse> {
|
||||
export async function createAgentAction(payload: {
|
||||
name: string;
|
||||
workspace_id?: string | null;
|
||||
profiles?: Array<{
|
||||
name: string;
|
||||
model: string;
|
||||
auto_approve: boolean;
|
||||
dangerously_skip_permissions: boolean;
|
||||
allow_indexing: boolean;
|
||||
cli_passthrough: boolean;
|
||||
}>;
|
||||
profiles?: Array<{ name: string; model: string; cli_passthrough: boolean } & ProfilePermissions>;
|
||||
}): Promise<Agent> {
|
||||
return fetchJson<Agent>(`${apiBaseUrl}/api/v1/agents`, {
|
||||
method: 'POST',
|
||||
@@ -76,14 +72,7 @@ export async function deleteAgentAction(id: string) {
|
||||
|
||||
export async function createAgentProfileAction(
|
||||
agentId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
model: string;
|
||||
auto_approve: boolean;
|
||||
dangerously_skip_permissions: boolean;
|
||||
allow_indexing: boolean;
|
||||
cli_passthrough: boolean;
|
||||
}
|
||||
payload: { name: string; model: string; cli_passthrough: boolean } & ProfilePermissions
|
||||
): Promise<AgentProfile> {
|
||||
return fetchJson<AgentProfile>(`${apiBaseUrl}/api/v1/agents/${agentId}/profiles`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
updateAgentProfileMcpConfigAction,
|
||||
} from '@/app/actions/agents';
|
||||
import type { Agent, AgentDiscovery, AgentProfile, McpServerDef, AvailableAgent, PermissionSetting } from '@/lib/types/http';
|
||||
import { buildDefaultPermissions, arePermissionsDirty, permissionsToProfilePatch } from '@/lib/agent-permissions';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents';
|
||||
@@ -62,9 +63,7 @@ const createDraftProfile = (
|
||||
name: '',
|
||||
agent_display_name: agentDisplayName,
|
||||
model: defaultModel,
|
||||
auto_approve: permissionSettings?.auto_approve?.default ?? false,
|
||||
dangerously_skip_permissions: permissionSettings?.dangerously_skip_permissions?.default ?? false,
|
||||
allow_indexing: permissionSettings?.allow_indexing?.default ?? false,
|
||||
...buildDefaultPermissions(permissionSettings ?? {}),
|
||||
cli_passthrough: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
@@ -137,9 +136,7 @@ function AgentSetupForm({
|
||||
return (
|
||||
draft.name !== saved.name ||
|
||||
draft.model !== saved.model ||
|
||||
draft.auto_approve !== saved.auto_approve ||
|
||||
draft.dangerously_skip_permissions !== saved.dangerously_skip_permissions ||
|
||||
draft.allow_indexing !== saved.allow_indexing ||
|
||||
arePermissionsDirty(draft, saved) ||
|
||||
draft.cli_passthrough !== saved.cli_passthrough
|
||||
);
|
||||
};
|
||||
@@ -278,9 +275,7 @@ function AgentSetupForm({
|
||||
profiles: draftAgent.profiles.map((profile) => ({
|
||||
name: profile.name,
|
||||
model: profile.model,
|
||||
auto_approve: profile.auto_approve,
|
||||
dangerously_skip_permissions: profile.dangerously_skip_permissions,
|
||||
allow_indexing: profile.allow_indexing ?? false,
|
||||
...permissionsToProfilePatch(profile),
|
||||
cli_passthrough: profile.cli_passthrough ?? false,
|
||||
})),
|
||||
});
|
||||
@@ -357,9 +352,7 @@ function AgentSetupForm({
|
||||
const createdProfile = await createAgentProfileAction(savedAgent.id, {
|
||||
name: profile.name,
|
||||
model: profile.model,
|
||||
auto_approve: profile.auto_approve,
|
||||
dangerously_skip_permissions: profile.dangerously_skip_permissions,
|
||||
allow_indexing: profile.allow_indexing ?? false,
|
||||
...permissionsToProfilePatch(profile),
|
||||
cli_passthrough: profile.cli_passthrough ?? false,
|
||||
});
|
||||
if (profile.mcp_config && profile.mcp_config.dirty && profile.mcp_config.servers.trim()) {
|
||||
@@ -380,9 +373,7 @@ function AgentSetupForm({
|
||||
const updatedProfile = await updateAgentProfileAction(profile.id, {
|
||||
name: profile.name,
|
||||
model: profile.model,
|
||||
auto_approve: profile.auto_approve,
|
||||
dangerously_skip_permissions: profile.dangerously_skip_permissions,
|
||||
allow_indexing: profile.allow_indexing ?? false,
|
||||
...permissionsToProfilePatch(profile),
|
||||
});
|
||||
nextProfiles.push(updatedProfile);
|
||||
continue;
|
||||
@@ -470,7 +461,7 @@ function AgentSetupForm({
|
||||
model: profile.model,
|
||||
auto_approve: profile.auto_approve,
|
||||
dangerously_skip_permissions: profile.dangerously_skip_permissions,
|
||||
allow_indexing: profile.allow_indexing,
|
||||
allow_indexing: profile.allow_indexing ?? false,
|
||||
cli_passthrough: profile.cli_passthrough,
|
||||
}}
|
||||
onChange={(patch) => handleProfileChange(profile.id, patch)}
|
||||
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
IconChevronDown,
|
||||
} from '@tabler/icons-react';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@kandev/ui/collapsible';
|
||||
import { Switch } from '@kandev/ui/switch';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { AgentLogo } from '@/components/agent-logo';
|
||||
import { ModelCombobox } from '@/components/settings/model-combobox';
|
||||
import { ProfileFormFields, type ProfileFormData } from '@/components/settings/profile-form-fields';
|
||||
import { profileToPermissionsMap, permissionsToProfilePatch } from '@/lib/agent-permissions';
|
||||
import { listAvailableAgents, listWorkflowTemplates } from '@/lib/api';
|
||||
import { listAgentsAction, updateAgentProfileAction } from '@/app/actions/agents';
|
||||
import type { AvailableAgent, WorkflowTemplate } from '@/lib/types/http';
|
||||
@@ -37,9 +36,7 @@ interface OnboardingDialogProps {
|
||||
|
||||
type AgentSetting = {
|
||||
profileId: string;
|
||||
model: string;
|
||||
permissions: Record<string, boolean>;
|
||||
passthrough: boolean;
|
||||
formData: ProfileFormData;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
@@ -99,21 +96,15 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
const dbAgent = saved.find((a) => a.name === aa.name);
|
||||
const profile = dbAgent?.profiles?.[0];
|
||||
if (profile) {
|
||||
const perms: Record<string, boolean> = {};
|
||||
if (aa.permission_settings) {
|
||||
for (const [key, setting] of Object.entries(aa.permission_settings)) {
|
||||
if (setting.supported) {
|
||||
// Map permission key to profile field
|
||||
const profileValue = getProfilePermissionValue(profile, key);
|
||||
perms[key] = profileValue ?? setting.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
const perms = profileToPermissionsMap(profile, aa.permission_settings ?? {});
|
||||
settings[aa.name] = {
|
||||
profileId: profile.id,
|
||||
model: profile.model || aa.model_config.default_model,
|
||||
permissions: perms,
|
||||
passthrough: profile.cli_passthrough ?? false,
|
||||
formData: {
|
||||
name: profile.name,
|
||||
model: profile.model || aa.model_config.default_model,
|
||||
cli_passthrough: profile.cli_passthrough ?? false,
|
||||
...perms,
|
||||
},
|
||||
dirty: false,
|
||||
};
|
||||
}
|
||||
@@ -134,11 +125,9 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
await Promise.all(
|
||||
dirtyEntries.map(([, s]) =>
|
||||
updateAgentProfileAction(s.profileId, {
|
||||
model: s.model,
|
||||
auto_approve: s.permissions['auto_approve'] ?? false,
|
||||
dangerously_skip_permissions: s.permissions['dangerously_skip_permissions'] ?? false,
|
||||
allow_indexing: s.permissions['allow_indexing'] ?? false,
|
||||
cli_passthrough: s.passthrough,
|
||||
model: s.formData.model,
|
||||
...permissionsToProfilePatch(s.formData),
|
||||
cli_passthrough: s.formData.cli_passthrough,
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -170,12 +159,12 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
setStep(0);
|
||||
};
|
||||
|
||||
const updateSetting = (agentName: string, patch: Partial<AgentSetting>) => {
|
||||
const updateSetting = (agentName: string, formPatch: Partial<ProfileFormData>) => {
|
||||
setAgentSettings((prev) => ({
|
||||
...prev,
|
||||
[agentName]: {
|
||||
...prev[agentName],
|
||||
...patch,
|
||||
formData: { ...prev[agentName].formData, ...formPatch },
|
||||
dirty: true,
|
||||
},
|
||||
}));
|
||||
@@ -253,23 +242,6 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Map a permission settings key to the corresponding AgentProfile boolean field value. */
|
||||
function getProfilePermissionValue(
|
||||
profile: { auto_approve?: boolean; dangerously_skip_permissions?: boolean; allow_indexing?: boolean },
|
||||
key: string
|
||||
): boolean | undefined {
|
||||
switch (key) {
|
||||
case 'auto_approve':
|
||||
return profile.auto_approve;
|
||||
case 'dangerously_skip_permissions':
|
||||
return profile.dangerously_skip_permissions;
|
||||
case 'allow_indexing':
|
||||
return profile.allow_indexing;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function StepAgents({
|
||||
availableAgents,
|
||||
agentSettings,
|
||||
@@ -279,7 +251,7 @@ function StepAgents({
|
||||
availableAgents: AvailableAgent[];
|
||||
agentSettings: Record<string, AgentSetting>;
|
||||
loading: boolean;
|
||||
onUpdateSetting: (agentName: string, patch: Partial<AgentSetting>) => void;
|
||||
onUpdateSetting: (agentName: string, formPatch: Partial<ProfileFormData>) => void;
|
||||
}) {
|
||||
const [openAgent, setOpenAgent] = useState<string | null>(null);
|
||||
|
||||
@@ -299,10 +271,10 @@ function StepAgents({
|
||||
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{agents.map((agent) => {
|
||||
const settings = agentSettings[agent.name];
|
||||
const currentModel = settings?.formData.model || agent.model_config.default_model;
|
||||
const modelName =
|
||||
agent.model_config.available_models.find(
|
||||
(m) => m.id === (settings?.model || agent.model_config.default_model)
|
||||
)?.name ?? settings?.model ?? agent.model_config.default_model;
|
||||
agent.model_config.available_models.find((m) => m.id === currentModel)?.name ??
|
||||
currentModel;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -313,7 +285,7 @@ function StepAgents({
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left cursor-pointer hover:bg-muted/50 transition-colors group"
|
||||
className="flex w-full items-center gap-3 rounded-lg border p-2 text-left cursor-pointer hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<AgentLogo agentName={agent.name} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -330,65 +302,18 @@ function StepAgents({
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border border-t-0 rounded-b-lg px-3 pb-3 pt-2 space-y-3">
|
||||
{/* Model selector */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
<ModelCombobox
|
||||
value={settings?.model || agent.model_config.default_model}
|
||||
onChange={(value) => onUpdateSetting(agent.name, { model: value })}
|
||||
models={agent.model_config.available_models}
|
||||
defaultModel={agent.model_config.default_model}
|
||||
<div className="border border-t-0 rounded-b-lg px-3 pb-3 pt-2">
|
||||
{settings && (
|
||||
<ProfileFormFields
|
||||
variant="compact"
|
||||
hideNameField
|
||||
profile={settings.formData}
|
||||
onChange={(patch) => onUpdateSetting(agent.name, patch)}
|
||||
modelConfig={agent.model_config}
|
||||
permissionSettings={agent.permission_settings ?? {}}
|
||||
passthroughConfig={agent.passthrough_config ?? null}
|
||||
agentName={agent.name}
|
||||
supportsDynamicModels={agent.model_config.supports_dynamic_models}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission toggles */}
|
||||
{agent.permission_settings &&
|
||||
Object.entries(agent.permission_settings).map(([key, perm]) => {
|
||||
if (!perm.supported) return null;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs">{perm.label}</Label>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
{perm.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={settings?.permissions[key] ?? perm.default}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdateSetting(agent.name, {
|
||||
permissions: {
|
||||
...settings?.permissions,
|
||||
[key]: checked === true,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Passthrough toggle */}
|
||||
{agent.passthrough_config?.supported && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs">{agent.passthrough_config.label}</Label>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
{agent.passthrough_config.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={settings?.passthrough ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdateSetting(agent.name, { passthrough: checked === true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
||||
@@ -5,16 +5,14 @@ import { Input } from '@kandev/ui/input';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Switch } from '@kandev/ui/switch';
|
||||
import { ModelCombobox } from '@/components/settings/model-combobox';
|
||||
import { PERMISSION_KEYS, type PermissionKey } from '@/lib/agent-permissions';
|
||||
import type { ModelConfig, PermissionSetting, PassthroughConfig } from '@/lib/types/http';
|
||||
|
||||
export type ProfileFormData = {
|
||||
name: string;
|
||||
model: string;
|
||||
auto_approve: boolean;
|
||||
dangerously_skip_permissions: boolean;
|
||||
allow_indexing?: boolean;
|
||||
cli_passthrough: boolean;
|
||||
};
|
||||
} & Record<PermissionKey, boolean>;
|
||||
|
||||
export type ProfileFormFieldsProps = {
|
||||
profile: ProfileFormData;
|
||||
@@ -25,6 +23,8 @@ export type ProfileFormFieldsProps = {
|
||||
agentName: string;
|
||||
onRemove?: () => void;
|
||||
canRemove?: boolean;
|
||||
variant?: 'default' | 'compact';
|
||||
hideNameField?: boolean;
|
||||
};
|
||||
|
||||
export function ProfileFormFields({
|
||||
@@ -36,27 +36,38 @@ export function ProfileFormFields({
|
||||
agentName,
|
||||
onRemove,
|
||||
canRemove = false,
|
||||
variant = 'default',
|
||||
hideNameField = false,
|
||||
}: ProfileFormFieldsProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>Profile name</Label>
|
||||
<Input
|
||||
value={profile.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
placeholder="Default profile"
|
||||
/>
|
||||
</div>
|
||||
{canRemove && onRemove && (
|
||||
<Button size="sm" variant="ghost" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
const isCompact = variant === 'compact';
|
||||
const switchSize = isCompact ? ('sm' as const) : ('default' as const);
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
return (
|
||||
<div className={isCompact ? 'space-y-3' : 'space-y-4'}>
|
||||
{!hideNameField && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>Profile name</Label>
|
||||
<Input
|
||||
value={profile.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
placeholder="Default profile"
|
||||
/>
|
||||
</div>
|
||||
{canRemove && onRemove && (
|
||||
<Button size="sm" variant="ghost" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={isCompact ? 'space-y-1.5' : 'space-y-2'}>
|
||||
{isCompact ? (
|
||||
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||
) : (
|
||||
<Label>Model</Label>
|
||||
)}
|
||||
<ModelCombobox
|
||||
value={profile.model || modelConfig.default_model}
|
||||
onChange={(value) => onChange({ model: value })}
|
||||
@@ -68,69 +79,81 @@ export function ProfileFormFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{permissionSettings.auto_approve?.supported && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{permissionSettings.auto_approve.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{permissionSettings.auto_approve.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile.auto_approve}
|
||||
onCheckedChange={(checked) => onChange({ auto_approve: checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissionSettings.dangerously_skip_permissions?.supported && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{permissionSettings.dangerously_skip_permissions.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{permissionSettings.dangerously_skip_permissions.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile.dangerously_skip_permissions}
|
||||
onCheckedChange={(checked) => onChange({ dangerously_skip_permissions: checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissionSettings.allow_indexing?.supported && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{permissionSettings.allow_indexing.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{permissionSettings.allow_indexing.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile.allow_indexing ?? false}
|
||||
onCheckedChange={(checked) => onChange({ allow_indexing: checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passthroughConfig?.supported && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{passthroughConfig.label}</Label>
|
||||
{isCompact ? (
|
||||
<>
|
||||
{PERMISSION_KEYS.map((key) => {
|
||||
const setting = permissionSettings[key];
|
||||
if (!setting?.supported) return null;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs">{setting.label}</Label>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
{setting.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
size={switchSize}
|
||||
checked={profile[key]}
|
||||
onCheckedChange={(checked) => onChange({ [key]: checked })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{passthroughConfig.description}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
{passthroughConfig?.supported && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs">{passthroughConfig.label}</Label>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
{passthroughConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
size={switchSize}
|
||||
checked={profile.cli_passthrough}
|
||||
onCheckedChange={(checked) => onChange({ cli_passthrough: checked })}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile.cli_passthrough}
|
||||
onCheckedChange={(checked) => onChange({ cli_passthrough: checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{PERMISSION_KEYS.map((key) => {
|
||||
const setting = permissionSettings[key];
|
||||
if (!setting?.supported) return null;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{setting.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{setting.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile[key]}
|
||||
onCheckedChange={(checked) => onChange({ [key]: checked })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{passthroughConfig?.supported && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{passthroughConfig.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{passthroughConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={profile.cli_passthrough}
|
||||
onCheckedChange={(checked) => onChange({ cli_passthrough: checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
profileToPermissionsMap,
|
||||
permissionsToProfilePatch,
|
||||
buildDefaultPermissions,
|
||||
arePermissionsDirty,
|
||||
} from './agent-permissions';
|
||||
import type { PermissionSetting } from '@/lib/types/http';
|
||||
|
||||
const mockSettings: Record<string, PermissionSetting> = {
|
||||
auto_approve: { supported: true, default: true, label: 'Auto approve', description: '' },
|
||||
dangerously_skip_permissions: { supported: true, default: false, label: 'Skip perms', description: '' },
|
||||
allow_indexing: { supported: true, default: true, label: 'Indexing', description: '' },
|
||||
};
|
||||
|
||||
describe('profileToPermissionsMap', () => {
|
||||
it('uses profile values when present', () => {
|
||||
const result = profileToPermissionsMap(
|
||||
{ auto_approve: false, dangerously_skip_permissions: true, allow_indexing: false },
|
||||
mockSettings
|
||||
);
|
||||
expect(result).toEqual({
|
||||
auto_approve: false,
|
||||
dangerously_skip_permissions: true,
|
||||
allow_indexing: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to setting defaults for missing keys', () => {
|
||||
const result = profileToPermissionsMap({}, mockSettings);
|
||||
expect(result).toEqual({
|
||||
auto_approve: true,
|
||||
dangerously_skip_permissions: false,
|
||||
allow_indexing: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to false when setting is also missing', () => {
|
||||
const result = profileToPermissionsMap({}, {});
|
||||
expect(result).toEqual({
|
||||
auto_approve: false,
|
||||
dangerously_skip_permissions: false,
|
||||
allow_indexing: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('permissionsToProfilePatch', () => {
|
||||
it('returns all permission keys with values', () => {
|
||||
const result = permissionsToProfilePatch({
|
||||
auto_approve: true,
|
||||
dangerously_skip_permissions: true,
|
||||
allow_indexing: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
auto_approve: true,
|
||||
dangerously_skip_permissions: true,
|
||||
allow_indexing: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing keys to false', () => {
|
||||
const result = permissionsToProfilePatch({});
|
||||
expect(result).toEqual({
|
||||
auto_approve: false,
|
||||
dangerously_skip_permissions: false,
|
||||
allow_indexing: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDefaultPermissions', () => {
|
||||
it('builds defaults from permission settings', () => {
|
||||
const result = buildDefaultPermissions(mockSettings);
|
||||
expect(result).toEqual({
|
||||
auto_approve: true,
|
||||
dangerously_skip_permissions: false,
|
||||
allow_indexing: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to false for missing settings', () => {
|
||||
const result = buildDefaultPermissions({});
|
||||
expect(result).toEqual({
|
||||
auto_approve: false,
|
||||
dangerously_skip_permissions: false,
|
||||
allow_indexing: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('arePermissionsDirty', () => {
|
||||
it('returns false when all permissions match', () => {
|
||||
const a = { auto_approve: true, dangerously_skip_permissions: false, allow_indexing: true };
|
||||
expect(arePermissionsDirty(a, { ...a })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when any permission differs', () => {
|
||||
const a = { auto_approve: true, dangerously_skip_permissions: false, allow_indexing: true };
|
||||
const b = { auto_approve: true, dangerously_skip_permissions: true, allow_indexing: true };
|
||||
expect(arePermissionsDirty(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects difference when one side has undefined', () => {
|
||||
const a = { auto_approve: true };
|
||||
const b = { auto_approve: false };
|
||||
expect(arePermissionsDirty(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { AgentProfile, PermissionSetting } from '@/lib/types/http';
|
||||
|
||||
/**
|
||||
* Single source of truth for permission keys.
|
||||
* Adding a key here that doesn't exist on AgentProfile will cause a compile error.
|
||||
*/
|
||||
export const PERMISSION_KEYS = [
|
||||
'auto_approve',
|
||||
'dangerously_skip_permissions',
|
||||
'allow_indexing',
|
||||
] as const;
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
// Compile-time check: every PermissionKey must be a boolean key on AgentProfile.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type _AssertKeysExist = {
|
||||
[K in PermissionKey]: AgentProfile[K] extends boolean ? true : never;
|
||||
};
|
||||
|
||||
/** Extract permission booleans from a profile-like object, using backend defaults for missing values. */
|
||||
export function profileToPermissionsMap(
|
||||
profile: Partial<Pick<AgentProfile, PermissionKey>>,
|
||||
permissionSettings: Record<string, PermissionSetting>
|
||||
): Record<PermissionKey, boolean> {
|
||||
const result = {} as Record<PermissionKey, boolean>;
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
const setting = permissionSettings[key];
|
||||
result[key] = profile[key] ?? setting?.default ?? false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Convert an object containing permission keys to a typed patch for API calls. */
|
||||
export function permissionsToProfilePatch(
|
||||
perms: Partial<Record<PermissionKey, boolean>>
|
||||
): Pick<AgentProfile, PermissionKey> {
|
||||
const result = {} as Pick<AgentProfile, PermissionKey>;
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
result[key] = perms[key] ?? false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Create default permission values from backend metadata. */
|
||||
export function buildDefaultPermissions(
|
||||
permissionSettings: Record<string, PermissionSetting>
|
||||
): Record<PermissionKey, boolean> {
|
||||
const result = {} as Record<PermissionKey, boolean>;
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
result[key] = permissionSettings[key]?.default ?? false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare permission fields between two profile-like objects. */
|
||||
export function arePermissionsDirty(
|
||||
draft: Partial<Pick<AgentProfile, PermissionKey>>,
|
||||
saved: Partial<Pick<AgentProfile, PermissionKey>>
|
||||
): boolean {
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
if (draft[key] !== saved[key]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user