From 81dc7ad82790b4a217ff3ec44ecd566cab47fac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 14 Feb 2026 22:04:00 +0000 Subject: [PATCH] feat: auggie dynamic model list (#223) feat: agent logos --- apps/backend/internal/agent/agents/amp.go | 13 +- apps/backend/internal/agent/agents/auggie.go | 56 ++++++- .../internal/agent/agents/auggie_test.go | 141 ++++++++++++++++++ .../internal/agent/agents/claude_code.go | 27 ++-- apps/backend/internal/agent/agents/gemini.go | 17 ++- .../backend/internal/agent/agents/opencode.go | 20 +-- .../internal/agent/registry/registry.go | 16 +- .../agent/settings/controller/controller.go | 120 ++++++++++++--- .../settings/controller/controller_test.go | 51 ++++++- .../internal/agent/settings/dto/dto.go | 1 + .../agent/settings/handlers/handlers.go | 31 ++++ .../internal/agent/settings/models/models.go | 1 + .../internal/agent/settings/store/sqlite.go | 28 +++- apps/packages/ui/src/scroll-on-overflow.tsx | 83 +++++++++++ apps/web/app/s/[sessionId]/page.tsx | 1 + .../app/settings/agents/[agentId]/page.tsx | 1 + apps/web/app/settings/agents/page.tsx | 3 + apps/web/components/agent-logo.tsx | 44 ++++++ .../settings/agent-profile-page.tsx | 7 +- .../settings/settings-app-sidebar.tsx | 13 +- apps/web/components/task-create-dialog.tsx | 13 +- .../domains/settings/use-settings-data.ts | 1 + apps/web/lib/state/slices/settings/types.ts | 1 + apps/web/lib/ws/handlers/agents.ts | 6 +- 24 files changed, 611 insertions(+), 84 deletions(-) create mode 100644 apps/backend/internal/agent/agents/auggie_test.go create mode 100644 apps/packages/ui/src/scroll-on-overflow.tsx create mode 100644 apps/web/components/agent-logo.tsx diff --git a/apps/backend/internal/agent/agents/amp.go b/apps/backend/internal/agent/agents/amp.go index 8b203eab3b..dfb7f95a9c 100644 --- a/apps/backend/internal/agent/agents/amp.go +++ b/apps/backend/internal/agent/agents/amp.go @@ -97,13 +97,12 @@ func (a *Amp) BuildCommand(opts CommandOptions) Command { Build() } - func (a *Amp) Runtime() *RuntimeConfig { canRecover := true return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", "@sourcegraph/amp@latest", "--execute", "--stream-json", "--stream-json-input").Build(), - WorkingDir: "{workspace}", - Env: map[string]string{}, + Cmd: Cmd("npx", "-y", "@sourcegraph/amp@latest", "--execute", "--stream-json", "--stream-json-input").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, Protocol: agent.ProtocolAmp, @@ -122,8 +121,10 @@ func (a *Amp) PermissionSettings() map[string]PermissionSetting { } var ampPermSettings = map[string]PermissionSetting{ - "auto_approve": {Supported: true, Default: true, Label: "Auto-approve (Dangerously Allow All)", Description: "Automatically approve all tool calls including shell commands", - ApplyMethod: "cli_flag", CLIFlag: "--dangerously-allow-all"}, + "auto_approve": { + Supported: true, Default: true, Label: "Auto-approve (Dangerously Allow All)", Description: "Automatically approve all tool calls including shell commands", + ApplyMethod: "cli_flag", CLIFlag: "--dangerously-allow-all", + }, } func ampStaticModels() []Model { diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go index 5cc90a7261..7999efb678 100644 --- a/apps/backend/internal/agent/agents/auggie.go +++ b/apps/backend/internal/agent/agents/auggie.go @@ -3,6 +3,9 @@ package agents import ( "context" _ "embed" + "fmt" + "regexp" + "strings" "time" "github.com/kandev/kandev/internal/agentctl/server/adapter" @@ -80,7 +83,11 @@ func (a *Auggie) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { func (a *Auggie) DefaultModel() string { return "sonnet4.5" } func (a *Auggie) ListModels(ctx context.Context) (*ModelList, error) { - return &ModelList{Models: auggieStaticModels(), SupportsDynamic: false}, nil + models, err := execAndParse(ctx, 30*time.Second, auggieParseModels, "auggie", "model", "list") + if err != nil { + return &ModelList{Models: auggieStaticModels(), SupportsDynamic: true}, nil + } + return &ModelList{Models: models, SupportsDynamic: true}, nil } func (a *Auggie) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { @@ -96,7 +103,6 @@ func (a *Auggie) BuildCommand(opts CommandOptions) Command { Build() } - func (a *Auggie) Runtime() *RuntimeConfig { canRecover := false return &RuntimeConfig{ @@ -147,3 +153,49 @@ func auggieStaticModels() []Model { {ID: "gpt5", Name: "GPT-5", Description: "OpenAI GPT-5 legacy", Provider: "openai", ContextWindow: 128000, Source: "static"}, } } + +// auggieParseModels parses "auggie model list" output. +// Format: +// +// Available models: +// - Haiku 4.5 [haiku4.5] +// Fast and efficient responses +// - Claude Opus 4.5 [opus4.5] +// Best for complex tasks +var auggieModelLineRe = regexp.MustCompile(`^\s*-\s+(.+?)\s+\[(\S+)\]\s*$`) + +func auggieParseModels(output string) ([]Model, error) { + lines := strings.Split(output, "\n") + models := make([]Model, 0) + defaultModel := "sonnet4.5" + + for i := 0; i < len(lines); i++ { + m := auggieModelLineRe.FindStringSubmatch(lines[i]) + if m == nil { + continue + } + name := m[1] + id := m[2] + + var description string + if i+1 < len(lines) { + desc := strings.TrimSpace(lines[i+1]) + if desc != "" && !strings.HasPrefix(strings.TrimSpace(lines[i+1]), "-") { + description = desc + i++ // skip the description line + } + } + + models = append(models, Model{ + ID: id, + Name: name, + Description: description, + IsDefault: id == defaultModel, + Source: "dynamic", + }) + } + if len(models) == 0 && strings.TrimSpace(output) != "" { + return nil, fmt.Errorf("no models parsed from auggie output") + } + return models, nil +} diff --git a/apps/backend/internal/agent/agents/auggie_test.go b/apps/backend/internal/agent/agents/auggie_test.go new file mode 100644 index 0000000000..eeb4bc1340 --- /dev/null +++ b/apps/backend/internal/agent/agents/auggie_test.go @@ -0,0 +1,141 @@ +package agents + +import ( + "testing" +) + +func TestAuggieParseModels(t *testing.T) { + tests := []struct { + name string + input string + wantLen int + wantErr bool + wantIDs []string + wantDef string // expected default model ID + }{ + { + name: "normal output with descriptions", + input: `Available models: + - Haiku 4.5 [haiku4.5] + Fast and efficient responses + - Claude Opus 4.5 [opus4.5] + Best for complex tasks + - Sonnet 4.5 [sonnet4.5] + Great for everyday tasks`, + wantLen: 3, + wantIDs: []string{"haiku4.5", "opus4.5", "sonnet4.5"}, + wantDef: "sonnet4.5", + }, + { + name: "models without descriptions", + input: `Available models: + - Haiku 4.5 [haiku4.5] + - Sonnet 4.5 [sonnet4.5]`, + wantLen: 2, + wantIDs: []string{"haiku4.5", "sonnet4.5"}, + wantDef: "sonnet4.5", + }, + { + name: "empty output returns empty slice", + input: "", + wantLen: 0, + }, + { + name: "header only returns error", + input: "Available models:\n", + wantErr: true, + }, + { + name: "non-empty garbage returns error", + input: "some unexpected output\nno models here", + wantErr: true, + }, + { + name: "non-matching lines ignored when models exist", + input: `Some random text + - Valid Model [valid-id] + A description +Not a model line +Another random line`, + wantLen: 1, + wantIDs: []string{"valid-id"}, + }, + { + name: "default model gets IsDefault true", + input: ` - Other [other-id] + Some model + - Sonnet 4.5 [sonnet4.5] + Default model`, + wantLen: 2, + wantDef: "sonnet4.5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + models, err := auggieParseModels(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("auggieParseModels() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("auggieParseModels() error = %v", err) + } + if len(models) != tt.wantLen { + t.Fatalf("auggieParseModels() returned %d models, want %d", len(models), tt.wantLen) + } + for i, wantID := range tt.wantIDs { + if i >= len(models) { + break + } + if models[i].ID != wantID { + t.Errorf("model[%d].ID = %q, want %q", i, models[i].ID, wantID) + } + if models[i].Source != "dynamic" { + t.Errorf("model[%d].Source = %q, want %q", i, models[i].Source, "dynamic") + } + } + if tt.wantDef != "" { + found := false + for _, m := range models { + if m.ID == tt.wantDef { + if !m.IsDefault { + t.Errorf("model %q should have IsDefault=true", tt.wantDef) + } + found = true + } else if m.IsDefault { + t.Errorf("model %q should not have IsDefault=true", m.ID) + } + } + if !found { + t.Errorf("default model %q not found in results", tt.wantDef) + } + } + }) + } +} + +func TestAuggieParseModels_Descriptions(t *testing.T) { + input := ` - Haiku 4.5 [haiku4.5] + Fast and efficient responses + - Sonnet 4.5 [sonnet4.5]` + + models, err := auggieParseModels(input) + if err != nil { + t.Fatalf("auggieParseModels() error = %v", err) + } + if len(models) != 2 { + t.Fatalf("got %d models, want 2", len(models)) + } + if models[0].Description != "Fast and efficient responses" { + t.Errorf("model[0].Description = %q, want %q", models[0].Description, "Fast and efficient responses") + } + if models[0].Name != "Haiku 4.5" { + t.Errorf("model[0].Name = %q, want %q", models[0].Name, "Haiku 4.5") + } + if models[1].Description != "" { + t.Errorf("model[1].Description = %q, want empty", models[1].Description) + } +} diff --git a/apps/backend/internal/agent/agents/claude_code.go b/apps/backend/internal/agent/agents/claude_code.go index 4e3489b349..a6d037b610 100644 --- a/apps/backend/internal/agent/agents/claude_code.go +++ b/apps/backend/internal/agent/agents/claude_code.go @@ -94,7 +94,6 @@ func (a *ClaudeCode) BuildCommand(opts CommandOptions) Command { Build() } - func (a *ClaudeCode) Runtime() *RuntimeConfig { canRecover := true return &RuntimeConfig{ @@ -102,9 +101,9 @@ func (a *ClaudeCode) Runtime() *RuntimeConfig { "-p", "--output-format=stream-json", "--input-format=stream-json", "--permission-prompt-tool=stdio", "--disallowedTools=AskUserQuestion", "--setting-sources=user,project", "--verbose").Build(), - WorkingDir: "{workspace}", - RequiredEnv: []string{"ANTHROPIC_API_KEY"}, - Env: map[string]string{}, + WorkingDir: "{workspace}", + RequiredEnv: []string{"ANTHROPIC_API_KEY"}, + Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, Protocol: agent.ProtocolClaudeCode, @@ -122,17 +121,21 @@ func (a *ClaudeCode) PermissionSettings() map[string]PermissionSetting { } var claudeCodePermSettings = map[string]PermissionSetting{ - "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls via stdio protocol", - ApplyMethod: "stdio"}, - "dangerously_skip_permissions": {Supported: true, Default: true, Label: "Skip Permissions", Description: "Bypass all permission checks (dangerous but fast for trusted tasks)", - ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions"}, + "auto_approve": { + Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls via stdio protocol", + ApplyMethod: "stdio", + }, + "dangerously_skip_permissions": { + Supported: true, Default: true, Label: "Skip Permissions", Description: "Bypass all permission checks (dangerous but fast for trusted tasks)", + ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions", + }, } func claudeCodeStaticModels() []Model { return []Model{ - {ID: "claude-sonnet-4-5", Name: "Claude Sonnet 4.5", Description: "Latest Sonnet with improved reasoning", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, - {ID: "claude-opus-4-6", Name: "Claude Opus 4.6", Description: "Latest and most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "claude-opus-4-5", Name: "Claude Opus 4.5", Description: "Most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, - {ID: "claude-haiku-4-5", Name: "Claude Haiku 4.5", Description: "Fast and affordable model for simple tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "claude-sonnet-4-5", Name: "Sonnet 4.5", Description: "Latest Sonnet with improved reasoning", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "claude-opus-4-6", Name: "Opus 4.6", Description: "Latest and most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "claude-opus-4-5", Name: "Opus 4.5", Description: "Most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "claude-haiku-4-5", Name: "Haiku 4.5", Description: "Fast and affordable model for simple tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, } } diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index d54f2e8b82..4718929d54 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -100,13 +100,12 @@ func (a *Gemini) BuildCommand(opts CommandOptions) Command { Build() } - func (a *Gemini) Runtime() *RuntimeConfig { canRecover := false return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", "@google/gemini-cli@0.25.2", "--experimental-acp").Build(), - WorkingDir: "{workspace}", - Env: map[string]string{}, + Cmd: Cmd("npx", "-y", "@google/gemini-cli@0.25.2", "--experimental-acp").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, Protocol: agent.ProtocolACP, @@ -123,13 +122,15 @@ func (a *Gemini) PermissionSettings() map[string]PermissionSetting { } var geminiPermSettings = map[string]PermissionSetting{ - "auto_approve": {Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Automatically approve all tool calls", - ApplyMethod: "cli_flag", CLIFlag: "--yolo --allowed-tools run_shell_command"}, + "auto_approve": { + Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Automatically approve all tool calls", + ApplyMethod: "cli_flag", CLIFlag: "--yolo --allowed-tools run_shell_command", + }, } func geminiStaticModels() []Model { return []Model{ - {ID: "gemini-3-flash-preview", Name: "Gemini 3 Flash", Description: "Fast and efficient model", Provider: "google", ContextWindow: 1000000, IsDefault: true, Source: "static"}, - {ID: "gemini-3-pro-preview", Name: "Gemini 3 Pro", Description: "Most capable model with 2M context", Provider: "google", ContextWindow: 2000000, Source: "static"}, + {ID: "gemini-3-flash-preview", Name: "3 Flash", Description: "Fast and efficient model", Provider: "google", ContextWindow: 1000000, IsDefault: true, Source: "static"}, + {ID: "gemini-3-pro-preview", Name: "3 Pro", Description: "Most capable model with 2M context", Provider: "google", ContextWindow: 2000000, Source: "static"}, } } diff --git a/apps/backend/internal/agent/agents/opencode.go b/apps/backend/internal/agent/agents/opencode.go index b7380966b1..bf59231758 100644 --- a/apps/backend/internal/agent/agents/opencode.go +++ b/apps/backend/internal/agent/agents/opencode.go @@ -84,7 +84,7 @@ func (a *OpenCode) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { return result, nil } -func (a *OpenCode) DefaultModel() string { return "anthropic/claude-sonnet-4-20250514" } +func (a *OpenCode) DefaultModel() string { return "opencode/gpt-5-nano" } func (a *OpenCode) ListModels(ctx context.Context) (*ModelList, error) { models, err := execAndParse(ctx, 30*time.Second, opencodeParseModels, "opencode", "models") @@ -102,13 +102,12 @@ func (a *OpenCode) BuildCommand(opts CommandOptions) Command { return Cmd("npx", "-y", "opencode-ai@1.1.25", "serve", "--hostname", "127.0.0.1", "--port", "0").Build() } - func (a *OpenCode) Runtime() *RuntimeConfig { canRecover := true return &RuntimeConfig{ - Cmd: Cmd("npx", "-y", "opencode-ai@1.1.25", "serve", "--hostname", "127.0.0.1", "--port", "0").Build(), - WorkingDir: "{workspace}", - Env: map[string]string{}, + Cmd: Cmd("npx", "-y", "opencode-ai@1.1.25", "serve", "--hostname", "127.0.0.1", "--port", "0").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, Protocol: agent.ProtocolOpenCode, @@ -125,13 +124,16 @@ func (a *OpenCode) PermissionSettings() map[string]PermissionSetting { } var opencodePermSettings = map[string]PermissionSetting{ - "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls", - ApplyMethod: "env"}, + "auto_approve": { + Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls", + ApplyMethod: "env", + }, } func opencodeStaticModels() []Model { return []Model{ - {ID: "anthropic/claude-sonnet-4-20250514", Name: "Claude Sonnet 4", Description: "Anthropic Claude Sonnet 4", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "opencode/gpt-5-nano", Name: "GPT-5 Nano", Description: "OpenAI GPT-5 Nano", Provider: "openai", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "anthropic/claude-sonnet-4-20250514", Name: "Claude Sonnet 4", Description: "Anthropic Claude Sonnet 4", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, {ID: "anthropic/claude-opus-4-20250514", Name: "Claude Opus 4", Description: "Anthropic Claude Opus 4", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, {ID: "openai/gpt-4.1", Name: "GPT-4.1", Description: "OpenAI GPT-4.1", Provider: "openai", ContextWindow: 200000, Source: "static"}, {ID: "google/gemini-2.5-pro", Name: "Gemini 2.5 Pro", Description: "Google Gemini 2.5 Pro", Provider: "google", ContextWindow: 2000000, Source: "static"}, @@ -143,7 +145,7 @@ func opencodeStaticModels() []Model { func opencodeParseModels(output string) ([]Model, error) { lines := strings.Split(strings.TrimSpace(output), "\n") models := make([]Model, 0, len(lines)) - defaultModel := "anthropic/claude-sonnet-4-20250514" + defaultModel := "opencode/gpt-5-nano" for _, line := range lines { line = strings.TrimSpace(line) diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 9b4fc2899b..8f3fea4c74 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -15,13 +15,15 @@ import ( // Type aliases for backward compatibility during migration. // These allow existing code to reference registry.PermissionSetting etc. -type PermissionSetting = agents.PermissionSetting -type MountTemplate = agents.MountTemplate -type ResourceLimits = agents.ResourceLimits -type SessionConfig = agents.SessionConfig -type PassthroughConfig = agents.PassthroughConfig -type DiscoveryCapabilities = agents.DiscoveryCapabilities -type Model = agents.Model +type ( + PermissionSetting = agents.PermissionSetting + MountTemplate = agents.MountTemplate + ResourceLimits = agents.ResourceLimits + SessionConfig = agents.SessionConfig + PassthroughConfig = agents.PassthroughConfig + DiscoveryCapabilities = agents.DiscoveryCapabilities + Model = agents.Model +) // Registry manages agent configurations type Registry struct { diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index c526353ff9..67ce0857f9 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -40,10 +40,9 @@ var ( ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session") ErrAgentMcpUnsupported = errors.New("mcp not supported by agent") ErrModelRequired = errors.New("model is required for agent profiles") + ErrLogoNotAvailable = errors.New("logo not available for agent") ) -const defaultAgentProfileName = "default" - type Controller struct { repo store.Repository discovery *discovery.Registry @@ -331,27 +330,64 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { if err != nil { return err } - if len(profiles) > 0 { - continue - } - // Read permission settings defaults from agent config - var autoApprove, allowIndexing, skipPermissions bool - permSettings := agentConfig.PermissionSettings() - if permSettings != nil { - if setting, exists := permSettings["auto_approve"]; exists { - autoApprove = setting.Default - } - if setting, exists := permSettings["allow_indexing"]; exists { - allowIndexing = setting.Default - } - if setting, exists := permSettings["dangerously_skip_permissions"]; exists { - skipPermissions = setting.Default - } + autoApprove, allowIndexing, skipPermissions := resolvePermissionDefaults(agentConfig.PermissionSettings()) + + // Fetch model list once per agent (not per profile) to resolve display names + modelList, listErr := agentConfig.ListModels(ctx) + if listErr != nil { + c.logger.Warn("failed to list models during profile sync, using model ID as name", + zap.String("agent", result.Name), zap.Error(listErr)) } + if len(profiles) > 0 { + for _, profile := range profiles { + if profile.UserModified { + continue + } + updated := false + + if profile.AgentDisplayName != displayName { + profile.AgentDisplayName = displayName + updated = true + } + if profile.Model != defaultModel { + profile.Model = defaultModel + updated = true + } + + // Re-resolve profile name from model display name + resolvedName := resolveModelDisplayName(modelList, profile.Model) + if profile.Name != resolvedName { + profile.Name = resolvedName + updated = true + } + + if profile.AutoApprove != autoApprove { + profile.AutoApprove = autoApprove + updated = true + } + if profile.AllowIndexing != allowIndexing { + profile.AllowIndexing = allowIndexing + updated = true + } + if profile.DangerouslySkipPermissions != skipPermissions { + profile.DangerouslySkipPermissions = skipPermissions + updated = true + } + + if updated { + if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { + return err + } + } + } + continue + } + + profileName := resolveModelDisplayName(modelList, defaultModel) defaultProfile := &models.AgentProfile{ AgentID: agent.ID, - Name: defaultAgentProfileName, + Name: profileName, Model: defaultModel, AgentDisplayName: displayName, AutoApprove: autoApprove, @@ -590,6 +626,7 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest DangerouslySkipPermissions: req.DangerouslySkipPermissions, AllowIndexing: req.AllowIndexing, CLIPassthrough: req.CLIPassthrough, + UserModified: true, } if err := c.repo.CreateAgentProfile(ctx, profile); err != nil { return nil, err @@ -635,6 +672,7 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest if req.CLIPassthrough != nil { profile.CLIPassthrough = *req.CLIPassthrough } + profile.UserModified = true if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil { return nil, err } @@ -697,11 +735,55 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { DangerouslySkipPermissions: profile.DangerouslySkipPermissions, AllowIndexing: profile.AllowIndexing, CLIPassthrough: profile.CLIPassthrough, + UserModified: profile.UserModified, CreatedAt: profile.CreatedAt, UpdatedAt: profile.UpdatedAt, } } +// GetAgentLogo returns the SVG logo bytes for the given agent and variant. +func (c *Controller) GetAgentLogo(ctx context.Context, agentName string, variant agents.LogoVariant) ([]byte, error) { + ag, ok := c.agentRegistry.Get(agentName) + if !ok { + return nil, ErrAgentNotFound + } + data := ag.Logo(variant) + if len(data) == 0 { + return nil, ErrLogoNotAvailable + } + return data, nil +} + +// resolvePermissionDefaults extracts the default permission values from agent settings. +func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting) (autoApprove, allowIndexing, skipPermissions bool) { + if permSettings == nil { + return + } + if s, exists := permSettings["auto_approve"]; exists { + autoApprove = s.Default + } + if s, exists := permSettings["allow_indexing"]; exists { + allowIndexing = s.Default + } + if s, exists := permSettings["dangerously_skip_permissions"]; exists { + skipPermissions = s.Default + } + return +} + +// resolveModelDisplayName returns the display name for a model ID from the model list, +// falling back to the model ID itself if not found. +func resolveModelDisplayName(modelList *agents.ModelList, modelID string) string { + if modelList != nil { + for _, m := range modelList.Models { + if m.ID == modelID { + return m.Name + } + } + } + return modelID +} + // detectAgents runs discovery and forces mock-agent available when enabled. func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) { results, err := c.discovery.Detect(ctx) diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index 61feeaf22a..e3e09b2b26 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -24,6 +24,7 @@ type testAgent struct { enabled bool runtime *agents.RuntimeConfig permissionSettings map[string]agents.PermissionSetting + logoData []byte } func (a *testAgent) ID() string { return a.id } @@ -32,7 +33,8 @@ func (a *testAgent) DisplayName() string { return a.displayName } func (a *testAgent) Description() string { return a.description } func (a *testAgent) Enabled() bool { return a.enabled } -func (a *testAgent) Logo(v agents.LogoVariant) []byte { return nil } +func (a *testAgent) Logo(v agents.LogoVariant) []byte { return a.logoData } + func (a *testAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) { return &agents.DiscoveryResult{Available: false}, nil @@ -359,3 +361,50 @@ func TestCommandPreviewResponse_DTO(t *testing.T) { t.Error("CommandPreviewResponse.CommandString should not be empty") } } + +func TestController_GetAgentLogo_Success(t *testing.T) { + logoBytes := []byte("test") + agentList := map[string]agents.Agent{ + "test-agent": &testAgent{ + id: "test-agent", + name: "test-agent", + enabled: true, + logoData: logoBytes, + }, + } + ctrl := newTestController(agentList) + + data, err := ctrl.GetAgentLogo(context.Background(), "test-agent", agents.LogoLight) + if err != nil { + t.Fatalf("GetAgentLogo() error = %v", err) + } + if string(data) != string(logoBytes) { + t.Errorf("GetAgentLogo() = %q, want %q", data, logoBytes) + } +} + +func TestController_GetAgentLogo_AgentNotFound(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{}) + + _, err := ctrl.GetAgentLogo(context.Background(), "nonexistent", agents.LogoLight) + if err != ErrAgentNotFound { + t.Errorf("GetAgentLogo() error = %v, want ErrAgentNotFound", err) + } +} + +func TestController_GetAgentLogo_NoLogoData(t *testing.T) { + agentList := map[string]agents.Agent{ + "test-agent": &testAgent{ + id: "test-agent", + name: "test-agent", + enabled: true, + // logoData is nil + }, + } + ctrl := newTestController(agentList) + + _, err := ctrl.GetAgentLogo(context.Background(), "test-agent", agents.LogoLight) + if err != ErrLogoNotAvailable { + t.Errorf("GetAgentLogo() error = %v, want ErrLogoNotAvailable", err) + } +} diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 36ed52c392..3d687e0ec9 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -16,6 +16,7 @@ type AgentProfileDTO struct { DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` AllowIndexing bool `json:"allow_indexing"` CLIPassthrough bool `json:"cli_passthrough"` + UserModified bool `json:"user_modified"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 816689a7c5..9f262626d8 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/settings/controller" "github.com/kandev/kandev/internal/common/logger" @@ -46,6 +47,7 @@ func (h *Handlers) registerHTTP(router *gin.Engine) { api.PATCH("/agents/:id", h.httpUpdateAgent) api.DELETE("/agents/:id", h.httpDeleteAgent) api.POST("/agents/:id/profiles", h.httpCreateProfile) + api.GET("/agents/:id/logo", h.httpGetAgentLogo) api.GET("/agent-models/:agentName", h.httpGetAgentModels) api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) api.PATCH("/agent-profiles/:id", h.httpUpdateProfile) @@ -422,6 +424,35 @@ func (h *Handlers) httpPreviewAgentCommand(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// httpGetAgentLogo serves agent SVG logos. +// Note: :id here is the agent name (e.g. "claude-code"), not the UUID used by other /agents/:id routes. +func (h *Handlers) httpGetAgentLogo(c *gin.Context) { + agentName := c.Param("id") + if agentName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"}) + return + } + + variant := agents.LogoLight + if c.Query("variant") == "dark" { + variant = agents.LogoDark + } + + data, err := h.controller.GetAgentLogo(c.Request.Context(), agentName, variant) + if err != nil { + if err == controller.ErrAgentNotFound || err == controller.ErrLogoNotAvailable { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + h.logger.Error("failed to get agent logo", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get agent logo"}) + return + } + + c.Header("Cache-Control", "public, max-age=86400") + c.Data(http.StatusOK, "image/svg+xml", data) +} + func (h *Handlers) httpGetAgentModels(c *gin.Context) { agentName := c.Param("agentName") if agentName == "" { diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go index 5faa4038d9..2c39d92549 100644 --- a/apps/backend/internal/agent/settings/models/models.go +++ b/apps/backend/internal/agent/settings/models/models.go @@ -22,6 +22,7 @@ type AgentProfile struct { DangerouslySkipPermissions bool `json:"dangerously_skip_permissions"` AllowIndexing bool `json:"allow_indexing"` CLIPassthrough bool `json:"cli_passthrough"` + UserModified bool `json:"user_modified"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 5f9064f1ba..fa1ef41952 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -59,6 +59,7 @@ func (r *sqliteRepository) initSchema() error { dangerously_skip_permissions INTEGER NOT NULL DEFAULT 0, allow_indexing INTEGER NOT NULL DEFAULT 1, cli_passthrough INTEGER NOT NULL DEFAULT 0, + user_modified INTEGER NOT NULL DEFAULT 0, plan TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, @@ -81,7 +82,15 @@ func (r *sqliteRepository) initSchema() error { CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id); ` _, err := r.db.Exec(schema) - return err + if err != nil { + return err + } + + // Migration: add user_modified column to existing databases. + // Error is intentionally ignored: ALTER TABLE fails with "duplicate column" if it already exists. + _, _ = r.db.Exec(`ALTER TABLE agent_profiles ADD COLUMN user_modified INTEGER NOT NULL DEFAULT 0`) + + return nil } func (r *sqliteRepository) Close() error { @@ -241,10 +250,10 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode profile.CreatedAt = now profile.UpdatedAt = now _, err := r.db.ExecContext(ctx, ` - INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?) + INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?) `, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove), - sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) + sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), sqlite.BoolToInt(profile.UserModified), profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt) return err } @@ -252,10 +261,10 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode profile.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, ` UPDATE agent_profiles - SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, updated_at = ? + SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, user_modified = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL `, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove), - sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), profile.UpdatedAt, profile.ID) + sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), sqlite.BoolToInt(profile.UserModified), profile.UpdatedAt, profile.ID) if err != nil { return err } @@ -283,7 +292,7 @@ func (r *sqliteRepository) DeleteAgentProfile(ctx context.Context, id string) er func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error) { row := r.db.QueryRowContext(ctx, ` - SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at + SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE id = ? AND deleted_at IS NULL `, id) return scanAgentProfile(row) @@ -291,7 +300,7 @@ func (r *sqliteRepository) GetAgentProfile(ctx context.Context, id string) (*mod func (r *sqliteRepository) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at + SELECT id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, user_modified, plan, created_at, updated_at, deleted_at FROM agent_profiles WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC `, agentID) if err != nil { @@ -344,6 +353,7 @@ func scanAgentProfile(scanner interface { var skipPermissions int var allowIndexing int var cliPassthrough int + var userModified int var plan string // unused, kept for backwards compatibility if err := scanner.Scan( &profile.ID, @@ -355,6 +365,7 @@ func scanAgentProfile(scanner interface { &skipPermissions, &allowIndexing, &cliPassthrough, + &userModified, &plan, &profile.CreatedAt, &profile.UpdatedAt, @@ -366,5 +377,6 @@ func scanAgentProfile(scanner interface { profile.DangerouslySkipPermissions = skipPermissions == 1 profile.AllowIndexing = allowIndexing == 1 profile.CLIPassthrough = cliPassthrough == 1 + profile.UserModified = userModified == 1 return profile, nil } diff --git a/apps/packages/ui/src/scroll-on-overflow.tsx b/apps/packages/ui/src/scroll-on-overflow.tsx new file mode 100644 index 0000000000..492113d051 --- /dev/null +++ b/apps/packages/ui/src/scroll-on-overflow.tsx @@ -0,0 +1,83 @@ +'use client'; + +import * as React from 'react'; +import { cn } from './lib/utils'; + +interface ScrollOnOverflowProps { + children: React.ReactNode; + className?: string; + /** Scroll speed in pixels per second. Default 60. */ + speed?: number; +} + +/** + * Container-agnostic wrapper that scrolls its children horizontally on hover + * when they overflow. Apply any styling to the outer element via className. + */ +const ScrollOnOverflow = React.memo( + React.forwardRef(function ScrollOnOverflow( + { children, className, speed = 60 }, + ref, + ) { + const outerRef = React.useRef(null); + const innerRef = React.useRef(null); + const timeoutRef = React.useRef>(undefined); + + React.useEffect(() => { + return () => clearTimeout(timeoutRef.current); + }, []); + + const handleMouseEnter = React.useCallback(() => { + clearTimeout(timeoutRef.current); + const outer = outerRef.current; + const inner = innerRef.current; + if (!outer || !inner) return; + + const overflow = inner.scrollWidth - outer.clientWidth; + if (overflow <= 0) return; + + const duration = Math.max(1, overflow / speed); + inner.style.transition = `transform ${duration}s linear`; + inner.style.transform = `translateX(-${overflow}px)`; + + timeoutRef.current = setTimeout(() => { + inner.style.transition = `transform ${duration}s linear`; + inner.style.transform = 'translateX(0)'; + }, duration * 1000 + 1500); + }, [speed]); + + const handleMouseLeave = React.useCallback(() => { + clearTimeout(timeoutRef.current); + const inner = innerRef.current; + if (!inner) return; + inner.style.transition = ''; + inner.style.transform = ''; + }, []); + + // Merge forwarded ref with internal ref + const mergedRef = React.useCallback( + (node: HTMLSpanElement | null) => { + (outerRef as React.MutableRefObject).current = node; + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }, + [ref], + ); + + return ( + + + {children} + + + ); + }), +); +ScrollOnOverflow.displayName = 'ScrollOnOverflow'; + +export { ScrollOnOverflow }; diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index cbf110a3f1..d979412404 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -155,6 +155,7 @@ export default async function SessionPage({ id: profile.id, label: `${profile.agent_display_name} • ${profile.name}`, agent_id: agent.id, + agent_name: agent.name, })) ), version: 0, diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 86411149f3..9a6a4a8368 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -166,6 +166,7 @@ function AgentSetupForm({ id: profile.id, label: `${profile.agent_display_name} • ${profile.name}`, agent_id: agent.id, + agent_name: agent.name, })) ) ); diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 5c28f8ce30..12c4c4f2a9 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -10,6 +10,7 @@ import { Separator } from '@kandev/ui/separator'; import { useAppStore } from '@/components/state-provider'; import { listAgentDiscovery } from '@/lib/api'; import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; +import { AgentLogo } from '@/components/agent-logo'; import type { AgentDiscovery, Agent, AvailableAgent, AgentProfile } from '@/lib/types/http'; export default function AgentsSettingsPage() { @@ -85,6 +86,7 @@ export default function AgentsSettingsPage() {
+

{availableAgents.find((item: AvailableAgent) => item.name === agent.name)?.display_name ?? agent.name}

@@ -125,6 +127,7 @@ export default function AgentsSettingsPage() {
+ {agent.profiles[0]?.agent_display_name ?? agent.name} diff --git a/apps/web/components/agent-logo.tsx b/apps/web/components/agent-logo.tsx new file mode 100644 index 0000000000..4d6c3b1f4b --- /dev/null +++ b/apps/web/components/agent-logo.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useState, useSyncExternalStore } from 'react'; +import Image from 'next/image'; +import { useTheme } from 'next-themes'; +import { getBackendConfig } from '@/lib/config'; + +interface AgentLogoProps { + agentName: string; + size?: number; + className?: string; +} + +const emptySubscribe = () => () => {}; + +/** Wrapper that re-mounts the inner image when agentName changes, resetting error state. */ +export function AgentLogo({ agentName, size = 16, className }: AgentLogoProps) { + return ; +} + +function AgentLogoImage({ agentName, size = 16, className }: AgentLogoProps) { + const { resolvedTheme } = useTheme(); + const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false); + const [error, setError] = useState(false); + + if (!mounted || error) { + return ; + } + + const variant = resolvedTheme === 'dark' ? 'dark' : 'light'; + const src = `${getBackendConfig().apiBaseUrl}/api/v1/agents/${encodeURIComponent(agentName)}/logo?variant=${variant}`; + + return ( + setError(true)} + /> + ); +} diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 3f7c208829..9cb896e344 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -14,6 +14,7 @@ import { ProfileFormFields } from '@/components/settings/profile-form-fields'; import { deleteAgentProfileAction, updateAgentProfileAction } from '@/app/actions/agents'; import type { Agent, AgentProfile, ModelConfig, PermissionSetting, PassthroughConfig } from '@/lib/types/http'; import { useAppStore } from '@/components/state-provider'; +import { AgentLogo } from '@/components/agent-logo'; import { ProfileMcpConfigCard } from '@/app/settings/agents/[agentId]/profile-mcp-config-card'; import { CommandPreviewCard } from '@/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card'; import type { AgentProfileMcpConfig } from '@/lib/types/http'; @@ -44,6 +45,7 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, passth id: agentProfile.id, label: `${agentProfile.agent_display_name} • ${agentProfile.name}`, agent_id: agentItem.id, + agent_name: agentItem.name, })) ) ); @@ -137,7 +139,10 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, passth
-

{profile.agent_display_name} • {savedProfile.name}

+

+ + {profile.agent_display_name} • {savedProfile.name} +

{profile.agent_display_name} profile settings

diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 35347ae9d9..b6e81c3abb 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -30,8 +30,10 @@ import { useSidebar, } from '@kandev/ui/sidebar'; import { ScrollArea } from '@kandev/ui/scroll-area'; +import { ScrollOnOverflow } from '@kandev/ui/scroll-on-overflow'; import { useAppStore } from '@/components/state-provider'; import { useAvailableAgents } from '@/hooks/domains/settings/use-available-agents'; +import { AgentLogo } from '@/components/agent-logo'; import type { Workspace, Agent, AgentProfile, Environment, Executor } from '@/lib/types/http'; export function SettingsAppSidebar() { @@ -66,7 +68,7 @@ export function SettingsAppSidebar() { - + Settings @@ -192,12 +194,13 @@ export function SettingsAppSidebar() { const profilePath = `/settings/agents/${encodedAgent}/profiles/${profile.id}`; const agentLabel = profile.agent_display_name || agent.name; return ( - + - - + + + {agentLabel} • {profile.name} - + diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 5faceb1f91..5987184d2c 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -22,6 +22,7 @@ import { Textarea } from '@kandev/ui/textarea'; import { Button } from '@kandev/ui/button'; import { Combobox } from './combobox'; import { Badge } from '@kandev/ui/badge'; +import { ScrollOnOverflow } from '@kandev/ui/scroll-on-overflow'; import type { LocalRepository, Task, Workspace, Repository, Environment, Executor, Branch } from '@/lib/types/http'; import type { AgentProfileOption } from '@/lib/state/slices'; import { @@ -46,6 +47,7 @@ import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { STORAGE_KEYS } from '@/lib/settings/constants'; import { linkToSession } from '@/lib/links'; import { getExecutorIcon } from '@/lib/executor-icons'; +import { AgentLogo } from '@/components/agent-logo'; import { useDockviewStore } from '@/lib/state/dockview-store'; import { useContextFilesStore } from '@/lib/state/context-files-store'; @@ -209,7 +211,7 @@ const AgentSelector = memo(function AgentSelector({ emptyMessage="No agent found." disabled={disabled} dropdownLabel="Agent profile" - className={disabled ? undefined : 'cursor-pointer'} + className={`min-w-[380px]${disabled ? '' : ' cursor-pointer'}`} triggerClassName={triggerClassName} /> ); @@ -642,11 +644,14 @@ export function TaskCreateDialog({ label: profile.label, renderLabel: () => ( - {agentLabel} + + + {agentLabel} + {profileLabel ? ( - + {profileLabel} - + ) : null} ), diff --git a/apps/web/hooks/domains/settings/use-settings-data.ts b/apps/web/hooks/domains/settings/use-settings-data.ts index b30e2d10bb..fa5ce2d340 100644 --- a/apps/web/hooks/domains/settings/use-settings-data.ts +++ b/apps/web/hooks/domains/settings/use-settings-data.ts @@ -52,6 +52,7 @@ export function useSettingsData(enabled = true) { id: profile.id, label: `${profile.agent_display_name} • ${profile.name}`, agent_id: agent.id, + agent_name: agent.name, })) ) ); diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 1fbeb1de5c..8d38dc6590 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -35,6 +35,7 @@ export type AgentProfileOption = { id: string; label: string; agent_id: string; + agent_name: string; }; export type AgentProfilesState = { diff --git a/apps/web/lib/ws/handlers/agents.ts b/apps/web/lib/ws/handlers/agents.ts index 591f4c09d7..dcacd252da 100644 --- a/apps/web/lib/ws/handlers/agents.ts +++ b/apps/web/lib/ws/handlers/agents.ts @@ -29,9 +29,10 @@ export function registerAgentsHandlers(store: StoreApi): WsHandlers { 'agent.profile.created': (message) => { store.setState((state) => { const label = `${message.payload.profile.agent_display_name} • ${message.payload.profile.name}`; + const agentName = state.settingsAgents.items.find((a) => a.id === message.payload.profile.agent_id)?.name ?? ''; const nextProfiles = [ ...state.agentProfiles.items.filter((profile) => profile.id !== message.payload.profile.id), - { id: message.payload.profile.id, label, agent_id: message.payload.profile.agent_id }, + { id: message.payload.profile.id, label, agent_id: message.payload.profile.agent_id, agent_name: agentName }, ]; const nextAgents = state.settingsAgents.items.map((item) => item.id === message.payload.profile.agent_id @@ -67,9 +68,10 @@ export function registerAgentsHandlers(store: StoreApi): WsHandlers { 'agent.profile.updated': (message) => { store.setState((state) => { const label = `${message.payload.profile.agent_display_name} • ${message.payload.profile.name}`; + const agentName = state.settingsAgents.items.find((a) => a.id === message.payload.profile.agent_id)?.name ?? ''; const nextProfiles = state.agentProfiles.items.map((profile) => profile.id === message.payload.profile.id - ? { ...profile, label, agent_id: message.payload.profile.agent_id } + ? { ...profile, label, agent_id: message.payload.profile.agent_id, agent_name: agentName } : profile ); const nextAgents = state.settingsAgents.items.map((item) =>