From 35f8d02a4d59074a929fb5984827f545e2e73fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 16 Feb 2026 16:26:26 +0000 Subject: [PATCH] feat: improve onboarding dialog (#230) --- apps/backend/internal/agent/agents/agent.go | 1 + apps/backend/internal/agent/agents/amp.go | 3 +- apps/backend/internal/agent/agents/auggie.go | 3 +- .../internal/agent/agents/claude_code.go | 3 +- apps/backend/internal/agent/agents/codex.go | 3 +- apps/backend/internal/agent/agents/copilot.go | 3 +- apps/backend/internal/agent/agents/gemini.go | 3 +- apps/backend/internal/agent/agents/mock.go | 3 +- .../backend/internal/agent/agents/opencode.go | 3 +- .../internal/agent/lifecycle/manager_test.go | 1 + .../internal/agent/registry/registry.go | 7 + .../internal/agent/registry/registry_test.go | 1 + .../settings/controller/controller_test.go | 1 + apps/web/components/agent-logo.tsx | 51 +- apps/web/components/onboarding-dialog.tsx | 533 ++++++++++++++++-- apps/web/lib/api/index.ts | 1 + 16 files changed, 548 insertions(+), 72 deletions(-) diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index b303872c7e..b8029ac780 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -25,6 +25,7 @@ type Agent interface { DisplayName() string Description() string Enabled() bool + DisplayOrder() int // lower = higher priority in listings // --- Assets --- Logo(variant LogoVariant) []byte // nil if unavailable diff --git a/apps/backend/internal/agent/agents/amp.go b/apps/backend/internal/agent/agents/amp.go index dfb7f95a9c..6db77db75d 100644 --- a/apps/backend/internal/agent/agents/amp.go +++ b/apps/backend/internal/agent/agents/amp.go @@ -48,7 +48,8 @@ func (a *Amp) DisplayName() string { return "Amp" } 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) Enabled() bool { return true } +func (a *Amp) DisplayOrder() int { return 6 } func (a *Amp) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go index 7999efb678..a3d1bbbbf6 100644 --- a/apps/backend/internal/agent/agents/auggie.go +++ b/apps/backend/internal/agent/agents/auggie.go @@ -53,7 +53,8 @@ func (a *Auggie) ID() string { return "auggie" } func (a *Auggie) Name() string { return "Augment Coding Agent" } func (a *Auggie) DisplayName() string { return "Auggie" } func (a *Auggie) Description() string { return "Auggie CLI-powered autonomous coding agent." } -func (a *Auggie) Enabled() bool { return true } +func (a *Auggie) Enabled() bool { return true } +func (a *Auggie) DisplayOrder() int { return 3 } func (a *Auggie) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/claude_code.go b/apps/backend/internal/agent/agents/claude_code.go index a6d037b610..d265651ce0 100644 --- a/apps/backend/internal/agent/agents/claude_code.go +++ b/apps/backend/internal/agent/agents/claude_code.go @@ -51,7 +51,8 @@ func (a *ClaudeCode) DisplayName() string { return "Claude" } func (a *ClaudeCode) Description() string { return "Anthropic Claude Code CLI-powered autonomous coding agent using the stream-json protocol." } -func (a *ClaudeCode) Enabled() bool { return true } +func (a *ClaudeCode) Enabled() bool { return true } +func (a *ClaudeCode) DisplayOrder() int { return 1 } func (a *ClaudeCode) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/codex.go b/apps/backend/internal/agent/agents/codex.go index da2ea71a46..113e1d3bb0 100644 --- a/apps/backend/internal/agent/agents/codex.go +++ b/apps/backend/internal/agent/agents/codex.go @@ -49,7 +49,8 @@ func (a *Codex) DisplayName() string { return "Codex" } func (a *Codex) Description() string { return "OpenAI Codex CLI-powered autonomous coding agent using the Codex app-server protocol." } -func (a *Codex) Enabled() bool { return true } +func (a *Codex) Enabled() bool { return true } +func (a *Codex) DisplayOrder() int { return 2 } func (a *Codex) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/copilot.go b/apps/backend/internal/agent/agents/copilot.go index 5321671719..52bc623082 100644 --- a/apps/backend/internal/agent/agents/copilot.go +++ b/apps/backend/internal/agent/agents/copilot.go @@ -49,7 +49,8 @@ func (a *Copilot) DisplayName() string { return "Copilot" } 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) Enabled() bool { return true } +func (a *Copilot) DisplayOrder() int { return 5 } func (a *Copilot) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index 4718929d54..e8e07b1d26 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -51,7 +51,8 @@ func (a *Gemini) DisplayName() string { return "Gemini" } 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) Enabled() bool { return true } +func (a *Gemini) DisplayOrder() int { return 4 } func (a *Gemini) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index c8ff965240..390c153d4c 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -38,7 +38,8 @@ func (a *MockAgent) DisplayName() string { return "Mock" } func (a *MockAgent) Description() string { return "Mock agent for testing. Generates simulated responses with all message types." } -func (a *MockAgent) Enabled() bool { return a.enabled } +func (a *MockAgent) Enabled() bool { return a.enabled } +func (a *MockAgent) DisplayOrder() int { return 99 } func (a *MockAgent) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/agents/opencode.go b/apps/backend/internal/agent/agents/opencode.go index bf59231758..e4f482c9c3 100644 --- a/apps/backend/internal/agent/agents/opencode.go +++ b/apps/backend/internal/agent/agents/opencode.go @@ -52,7 +52,8 @@ func (a *OpenCode) DisplayName() string { return "OpenCode" } 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) Enabled() bool { return true } +func (a *OpenCode) DisplayOrder() int { return 3 } func (a *OpenCode) Logo(v LogoVariant) []byte { if v == LogoDark { diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index 022c7e1d40..4161748b98 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -38,6 +38,7 @@ func (a *testAgent) Name() string { return a.name } 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) DisplayOrder() int { return 0 } func (a *testAgent) Logo(_ agents.LogoVariant) []byte { return nil } func (a *testAgent) IsInstalled(_ context.Context) (*agents.DiscoveryResult, error) { diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 8f3fea4c74..dded77ee5d 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -3,6 +3,7 @@ package registry import ( "fmt" + "slices" "sync" "time" @@ -99,6 +100,9 @@ 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() + }) return result } @@ -113,6 +117,9 @@ func (r *Registry) ListEnabled() []agents.Agent { result = append(result, ag) } } + slices.SortStableFunc(result, func(a, b agents.Agent) int { + return a.DisplayOrder() - b.DisplayOrder() + }) return result } diff --git a/apps/backend/internal/agent/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go index de186a1f25..d5ed84a747 100644 --- a/apps/backend/internal/agent/registry/registry_test.go +++ b/apps/backend/internal/agent/registry/registry_test.go @@ -24,6 +24,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) Logo(agents.LogoVariant) []byte { return nil } func (a *testAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) { return nil, agents.ErrNotSupported diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index e3e09b2b26..2413111462 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -32,6 +32,7 @@ func (a *testAgent) Name() string { return a.name } 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) DisplayOrder() int { return 0 } func (a *testAgent) Logo(v agents.LogoVariant) []byte { return a.logoData } diff --git a/apps/web/components/agent-logo.tsx b/apps/web/components/agent-logo.tsx index 4d6c3b1f4b..016b2ab21c 100644 --- a/apps/web/components/agent-logo.tsx +++ b/apps/web/components/agent-logo.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useState, useSyncExternalStore } from 'react'; -import Image from 'next/image'; +import { useEffect, useState, useSyncExternalStore } from 'react'; import { useTheme } from 'next-themes'; import { getBackendConfig } from '@/lib/config'; @@ -13,6 +12,25 @@ interface AgentLogoProps { const emptySubscribe = () => () => {}; +// Module-level cache: logo URL → object URL (blob). Survives re-renders and remounts. +const logoCache = new Map(); + +function fetchLogo(url: string): Promise { + const cached = logoCache.get(url); + if (cached) return Promise.resolve(cached); + + return fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`Logo fetch failed: ${res.status}`); + return res.blob(); + }) + .then((blob) => { + const objectUrl = URL.createObjectURL(blob); + logoCache.set(url, objectUrl); + return objectUrl; + }); +} + /** Wrapper that re-mounts the inner image when agentName changes, resetting error state. */ export function AgentLogo({ agentName, size = 16, className }: AgentLogoProps) { return ; @@ -21,24 +39,39 @@ export function AgentLogo({ agentName, size = 16, className }: AgentLogoProps) { function AgentLogoImage({ agentName, size = 16, className }: AgentLogoProps) { const { resolvedTheme } = useTheme(); const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false); + const [fetchedSrc, setFetchedSrc] = useState(null); const [error, setError] = useState(false); - if (!mounted || error) { + const variant = resolvedTheme === 'dark' ? 'dark' : 'light'; + const logoUrl = mounted + ? `${getBackendConfig().apiBaseUrl}/api/v1/agents/${encodeURIComponent(agentName)}/logo?variant=${variant}` + : null; + + // Read from cache during render to avoid setState in effect + const cachedSrc = logoUrl ? logoCache.get(logoUrl) ?? null : null; + + useEffect(() => { + if (!logoUrl || logoCache.has(logoUrl)) return; + + fetchLogo(logoUrl) + .then(setFetchedSrc) + .catch(() => setError(true)); + }, [logoUrl]); + + const src = cachedSrc ?? fetchedSrc; + + if (!mounted || error || !src) { 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/onboarding-dialog.tsx b/apps/web/components/onboarding-dialog.tsx index 7f6df21815..57b91dc6df 100644 --- a/apps/web/components/onboarding-dialog.tsx +++ b/apps/web/components/onboarding-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRouter } from 'next/navigation'; +import { Fragment, useCallback, useEffect, useState } from 'react'; import { Dialog, DialogContent, @@ -10,78 +10,501 @@ import { DialogDescription, } from '@kandev/ui/dialog'; import { Button } from '@kandev/ui/button'; -import { IconCheck, IconRocket } from '@tabler/icons-react'; +import { + IconArrowRight, + IconArrowLeft, + IconCheck, + IconFolder, + IconFolders, + IconBrandDocker, + IconX, + IconLoader2, + 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 { listAvailableAgents, listWorkflowTemplates } from '@/lib/api'; +import { listAgentsAction, updateAgentProfileAction } from '@/app/actions/agents'; +import type { AvailableAgent, WorkflowTemplate } from '@/lib/types/http'; + interface OnboardingDialogProps { open: boolean; onComplete: () => void; } +type AgentSetting = { + profileId: string; + model: string; + permissions: Record; + passthrough: boolean; + dirty: boolean; +}; + +const TOTAL_STEPS = 3; + +const RUNTIMES = [ + { + name: 'Local', + description: 'Run agents directly on your machine with full access to your local filesystem.', + icon: IconFolder, + }, + { + name: 'Git Worktree', + description: 'Isolated branch environment under a worktree root for parallel work.', + icon: IconFolders, + }, + { + name: 'Docker', + description: 'Containerized execution for full isolation and reproducibility.', + icon: IconBrandDocker, + }, +]; + export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) { - const router = useRouter(); + const [step, setStep] = useState(0); + const [availableAgents, setAvailableAgents] = useState([]); + const [agentSettings, setAgentSettings] = useState>({}); + const [templates, setTemplates] = useState([]); + const [loadingAgents, setLoadingAgents] = useState(true); + const [loadingTemplates, setLoadingTemplates] = useState(true); + const [prevOpen, setPrevOpen] = useState(false); + + // Reset loading states during render when dialog opens (avoids setState in effect) + if (open && !prevOpen) { + setPrevOpen(true); + setLoadingAgents(true); + setLoadingTemplates(true); + } else if (!open && prevOpen) { + setPrevOpen(false); + } + + useEffect(() => { + if (!open) return; + + Promise.all([ + listAvailableAgents({ cache: 'no-store' }), + listAgentsAction(), + ]) + .then(([availRes, savedRes]) => { + const avail = availRes.agents ?? []; + const saved = savedRes.agents ?? []; + setAvailableAgents(avail); + + // Build initial agentSettings by matching agent names + const settings: Record = {}; + for (const aa of avail) { + const dbAgent = saved.find((a) => a.name === aa.name); + const profile = dbAgent?.profiles?.[0]; + if (profile) { + const perms: Record = {}; + 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; + } + } + } + settings[aa.name] = { + profileId: profile.id, + model: profile.model || aa.model_config.default_model, + permissions: perms, + passthrough: profile.cli_passthrough ?? false, + dirty: false, + }; + } + } + setAgentSettings(settings); + }) + .catch(() => { }) + .finally(() => setLoadingAgents(false)); + + listWorkflowTemplates() + .then((res) => setTemplates(res.templates ?? [])) + .catch(() => { }) + .finally(() => setLoadingTemplates(false)); + }, [open]); + + const saveAgentSettings = useCallback(async () => { + const dirtyEntries = Object.entries(agentSettings).filter(([, s]) => s.dirty); + 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, + }) + ) + ); + }, [agentSettings]); const handleSkip = () => { onComplete(); + setStep(0); }; - const handleGoToSettings = () => { + const handleNext = async () => { + if (step === 0) { + await saveAgentSettings(); + } + if (step < TOTAL_STEPS - 1) { + setStep(step + 1); + } + }; + + const handleBack = () => { + if (step > 0) { + setStep(step - 1); + } + }; + + const handleGetStarted = async () => { + await saveAgentSettings(); onComplete(); - router.push('/settings'); + setStep(0); + }; + + const updateSetting = (agentName: string, patch: Partial) => { + setAgentSettings((prev) => ({ + ...prev, + [agentName]: { + ...prev[agentName], + ...patch, + dirty: true, + }, + })); }; return ( - {}}> - - <> - -
-
- -
-
- Welcome to KanDev.ai - - Your AI-powered development task management system. You are ready to start building. - -
-
-
-
-
-
-
-
-
-

Agents & Environments

-

- Configure agent profiles, environments, and credentials in Settings. -

-
-
-
-
-
-
-
-

Workflow Steps

-

- Customize your workflow steps anytime to match your process. -

-
-
-
+ { }}> + + + + {step === 0 && 'AI Agents'} + {step === 1 && 'Environments'} + {step === 2 && 'Agentic Workflows'} + + + {step === 0 && 'These AI coding agents were discovered on your system.'} + {step === 1 && 'Agents can run in different runtime environments.'} + {step === 2 && 'Workflows define the steps and automation for your tasks.'} + + + +
+ {step === 0 && ( + + )} + {step === 1 && } + {step === 2 && } +
+ + {/* Progress dots */} +
+ {Array.from({ length: TOTAL_STEPS }).map((_, i) => ( +
+ ))} +
+ + +
+ +
+ {step > 0 && ( + + )} + {step < TOTAL_STEPS - 1 ? ( + + ) : ( + + )}
- - - - - +
); } + +/** 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, + loading, + onUpdateSetting, +}: { + availableAgents: AvailableAgent[]; + agentSettings: Record; + loading: boolean; + onUpdateSetting: (agentName: string, patch: Partial) => void; +}) { + const [openAgent, setOpenAgent] = useState(null); + + if (loading) { + return ( +
+ + Discovering agents... +
+ ); + } + + const agents = availableAgents.filter((a) => a.available); + + return ( +
+
+ {agents.map((agent) => { + const settings = agentSettings[agent.name]; + 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; + + return ( + setOpenAgent(isOpen ? agent.name : null)} + > + + + + +
+ {/* Model selector */} +
+ + onUpdateSetting(agent.name, { model: value })} + models={agent.model_config.available_models} + defaultModel={agent.model_config.default_model} + agentName={agent.name} + supportsDynamicModels={agent.model_config.supports_dynamic_models} + /> +
+ + {/* Permission toggles */} + {agent.permission_settings && + Object.entries(agent.permission_settings).map(([key, perm]) => { + if (!perm.supported) return null; + return ( +
+
+ +

+ {perm.description} +

+
+ + onUpdateSetting(agent.name, { + permissions: { + ...settings?.permissions, + [key]: checked === true, + }, + }) + } + /> +
+ ); + })} + + {/* Passthrough toggle */} + {agent.passthrough_config?.supported && ( +
+
+ +

+ {agent.passthrough_config.description} +

+
+ + onUpdateSetting(agent.name, { passthrough: checked === true }) + } + /> +
+ )} +
+
+
+ ); + })} +
+

+ Expand an agent to configure its model and permissions. Changes are saved when you proceed. +

+
+ ); +} + +function StepEnvironments() { + return ( +
+
+ {RUNTIMES.map((runtime) => { + const Icon = runtime.icon; + return ( +
+
+ +
+
+

{runtime.name}

+

{runtime.description}

+
+
+ ); + })} +
+

+ Configure runtime environments in Settings to control where agents execute. +

+
+ ); +} + +function StepWorkflows({ templates, loading }: { templates: WorkflowTemplate[]; loading: boolean }) { + if (loading) { + return ( +
+ + Loading workflow templates... +
+ ); + } + + const defaultTemplate = templates.find((t) => t.id === 'simple'); + const otherTemplates = templates.filter((t) => t.id !== 'simple'); + + return ( +
+
+ {defaultTemplate && ( + + )} + {otherTemplates.length > 0 && ( + <> +

Available templates

+ {otherTemplates.map((template) => ( + + ))} + + )} +
+

+ Workflows control the steps, automation, and agent behavior for your tasks. You can add + more workflows from Settings. +

+
+ ); +} + +function TemplateCard({ template, isDefault }: { template: WorkflowTemplate; isDefault?: boolean }) { + const steps = (template.default_steps ?? []) + .slice() + .sort((a, b) => a.position - b.position); + + return ( +
+
+

{template.name}

+ {isDefault && ( + + + Default + + )} +
+ {template.description && ( +

{template.description}

+ )} + {steps.length > 0 && ( +
+ {steps.map((s, i) => ( + + {i > 0 && →} + + + {s.name} + + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/lib/api/index.ts b/apps/web/lib/api/index.ts index 7660aa1e4e..646e956cdd 100644 --- a/apps/web/lib/api/index.ts +++ b/apps/web/lib/api/index.ts @@ -7,3 +7,4 @@ export * from './domains/session-api'; export * from './domains/workspace-api'; export * from './domains/settings-api'; export * from './domains/process-api'; +export * from './domains/workflow-api';