From f3fe3097da1c3aeb6922d633b02331c7c0b1fb14 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 5 May 2026 15:56:26 +0200 Subject: [PATCH 1/6] feat(cloud-agent): add profile skills, MCPs, and agents --- .plans/cloud-agent-profile-skills-and-mcps.md | 276 + apps/web/package.json | 1 + .../sessions/prepare/route.test.ts | 110 +- .../api/cloud-agent/sessions/prepare/route.ts | 78 +- .../cloud-agent/sessions/prepare/schema.ts | 9 +- .../components/cloud-agent-next/ChatInput.tsx | 67 +- .../cloud-agent-next/CloudAgentProvider.tsx | 15 +- .../cloud-agent-next/CloudChatPage.tsx | 45 +- .../cloud-agent-next/MobileToolbarPopover.tsx | 36 +- .../cloud-agent-next/NewSessionPanel.tsx | 321 +- .../src/components/cloud-agent-next/types.ts | 15 +- .../cloud-agent/CloudSessionsPage.tsx | 129 +- .../cloud-agent/ProfilePickerPopover.tsx | 452 + .../cloud-agent/ProfilesListDialog.tsx | 2206 +- .../cloud-agent/RepoProfileBindingsDialog.tsx | 23 +- .../profile-editor/McpServersTab.tsx | 343 + .../profile-editor/ProfileAgentsTab.tsx | 658 + .../cloud-agent/profile-editor/SkillsTab.tsx | 469 + .../src/components/shared/ModeCombobox.tsx | 144 +- apps/web/src/hooks/useCloudAgentProfiles.ts | 291 + .../src/lib/agent/profile-session-config.ts | 167 - apps/web/src/lib/agent/types.ts | 61 - .../lib/app-builder/app-builder-service.ts | 13 +- .../tools/resolve-bot-session-profile.test.ts | 20 +- .../bot/tools/resolve-bot-session-profile.ts | 11 +- .../bot/tools/spawn-cloud-agent-session.ts | 2 +- .../cloud-agent-next/cloud-agent-client.ts | 67 +- .../lib/cloud-agent-sdk/session-manager.ts | 7 +- .../src/lib/cloud-agent/cloud-agent-client.ts | 12 +- apps/web/src/lib/user.test.ts | 63 + apps/web/src/routers/agent-profiles-router.ts | 476 +- .../src/routers/cloud-agent-next-router.ts | 63 +- .../src/routers/cloud-agent-next-schemas.ts | 120 +- apps/web/src/routers/cloud-agent-router.ts | 162 +- apps/web/src/routers/cloud-agent-schemas.ts | 22 +- .../organization-cloud-agent-next-router.ts | 69 +- .../organization-cloud-agent-router.ts | 178 +- .../profile-session-config.test.ts | 303 +- packages/cloud-agent-profile/package.json | 28 + packages/cloud-agent-profile/src/index.ts | 149 + .../src/profile-agents-service.ts | 240 + .../src}/profile-commands-service.ts | 15 +- .../src/profile-mcp-service.ts | 428 + .../src/profile-resolution.test.ts | 124 + .../src/profile-resolution.ts | 65 + .../src}/profile-service.ts | 162 +- .../src/profile-session-config.ts | 296 + .../src/profile-skills-service.ts | 364 + .../cloud-agent-profile/src}/profile-utils.ts | 7 +- .../src}/profile-vars-service.ts | 38 +- .../src}/repo-binding-db.ts | 11 +- .../src}/repo-binding-service.ts | 25 +- packages/cloud-agent-profile/src/types.ts | 131 + packages/cloud-agent-profile/tsconfig.json | 20 + .../migrations/0110_colossal_black_knight.sql | 48 + .../db/src/migrations/meta/0110_snapshot.json | 18947 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema-types.ts | 92 + packages/db/src/schema.ts | 120 + pnpm-lock.yaml | 40 + services/cloud-agent-next/package.json | 2 + services/cloud-agent-next/src/db/pg.ts | 13 + .../src/execution/orchestrator.ts | 79 +- .../cloud-agent-next/src/execution/types.ts | 18 +- services/cloud-agent-next/src/logger.ts | 4 +- .../src/persistence/CloudAgentSession.ts | 186 +- .../src/persistence/async-preparation.test.ts | 2 + .../src/persistence/async-preparation.ts | 27 +- .../src/persistence/schemas.test.ts | 152 +- .../src/persistence/schemas.ts | 173 +- .../cloud-agent-next/src/persistence/types.ts | 84 +- services/cloud-agent-next/src/router.test.ts | 9 + .../src/router/handlers/session-management.ts | 57 +- .../src/router/handlers/session-prepare.ts | 177 +- .../src/router/handlers/session-questions.ts | 8 +- .../cloud-agent-next/src/router/schemas.ts | 91 +- services/cloud-agent-next/src/schema.ts | 37 +- .../src/session-prepare.test.ts | 33 +- .../cloud-agent-next/src/session-profile.ts | 64 + .../src/session-service.test.ts | 63 +- .../cloud-agent-next/src/session-service.ts | 510 +- services/cloud-agent-next/src/types.ts | 6 + .../webhook-agent-ingest/src/db/queries.ts | 97 +- .../src/queue-consumer.ts | 49 +- .../services/profile-resolution-service.ts | 81 - 85 files changed, 28198 insertions(+), 2685 deletions(-) create mode 100644 .plans/cloud-agent-profile-skills-and-mcps.md create mode 100644 apps/web/src/components/cloud-agent/ProfilePickerPopover.tsx create mode 100644 apps/web/src/components/cloud-agent/profile-editor/McpServersTab.tsx create mode 100644 apps/web/src/components/cloud-agent/profile-editor/ProfileAgentsTab.tsx create mode 100644 apps/web/src/components/cloud-agent/profile-editor/SkillsTab.tsx delete mode 100644 apps/web/src/lib/agent/profile-session-config.ts delete mode 100644 apps/web/src/lib/agent/types.ts rename apps/web/src/{lib/agent => tests/cloud-agent-profile}/profile-session-config.test.ts (54%) create mode 100644 packages/cloud-agent-profile/package.json create mode 100644 packages/cloud-agent-profile/src/index.ts create mode 100644 packages/cloud-agent-profile/src/profile-agents-service.ts rename {apps/web/src/lib/agent => packages/cloud-agent-profile/src}/profile-commands-service.ts (84%) create mode 100644 packages/cloud-agent-profile/src/profile-mcp-service.ts create mode 100644 packages/cloud-agent-profile/src/profile-resolution.test.ts create mode 100644 packages/cloud-agent-profile/src/profile-resolution.ts rename {apps/web/src/lib/agent => packages/cloud-agent-profile/src}/profile-service.ts (60%) create mode 100644 packages/cloud-agent-profile/src/profile-session-config.ts create mode 100644 packages/cloud-agent-profile/src/profile-skills-service.ts rename {apps/web/src/lib/agent => packages/cloud-agent-profile/src}/profile-utils.ts (82%) rename {apps/web/src/lib/agent => packages/cloud-agent-profile/src}/profile-vars-service.ts (79%) rename {apps/web/src/lib/agent => packages/cloud-agent-profile/src}/repo-binding-db.ts (92%) rename {apps/web/src/lib/agent => packages/cloud-agent-profile/src}/repo-binding-service.ts (65%) create mode 100644 packages/cloud-agent-profile/src/types.ts create mode 100644 packages/cloud-agent-profile/tsconfig.json create mode 100644 packages/db/src/migrations/0110_colossal_black_knight.sql create mode 100644 packages/db/src/migrations/meta/0110_snapshot.json create mode 100644 services/cloud-agent-next/src/db/pg.ts create mode 100644 services/cloud-agent-next/src/session-profile.ts delete mode 100644 services/webhook-agent-ingest/src/services/profile-resolution-service.ts diff --git a/.plans/cloud-agent-profile-skills-and-mcps.md b/.plans/cloud-agent-profile-skills-and-mcps.md new file mode 100644 index 0000000000..3d31671280 --- /dev/null +++ b/.plans/cloud-agent-profile-skills-and-mcps.md @@ -0,0 +1,276 @@ +# Cloud Agent Next Profile Skills and MCPs Plan (v1) + +## Goal + +Make it easy for a Cloud Agent user to add skills and MCP servers by extending the existing profile system, and fix the painful in-list profile editor UX. + +Personal use case is served by the user's personal default profile. Project-specific tools continue to live in the repo under `.kilo/`. + +## Non-goals (v1) + +- Renaming profiles to "environments". +- Introducing a separate "My toolkit" concept. +- Introducing a separate "Connections" credential-bundle concept. +- Organization-scoped toolkit defaults outside the profile system. +- Pre-clone detection of repo `.kilo/` config in the session form. +- Per-turn skill/MCP changes during a running session. + +These can be reconsidered after v1 ships, based on real usage. + +## Mental model + +Three concepts, responsibilities kept small: + +1. **Profile** (existing, extended): reusable Cloud Agent setup. + - Today: setup commands, environment variables (plain + encrypted), repo bindings, default flag. + - v1 adds: skills and MCP servers. + - Ownership remains exactly one of personal or organization. + - A profile marked as personal default, org default, or bound to a repo continues to behave as today. + +2. **Repo `.kilo/`** (unchanged): project-specific skills, MCPs, commands, agents committed to the repository. The CLI loads these after clone. + +3. **Session** (unchanged flow, richer content): merges layers in the existing order and now also merges skills and MCPs. + +The user's "personal skills and MCPs" case is served by adding items to their personal default profile (or any other personal profile they select for a session). The user's "per-repo" case is served by repo-profile bindings. No new concept is required. + +## Merge behavior + +Keep the existing layer order used by `mergeProfileConfiguration` (`apps/web/src/lib/agent/profile-session-config.ts`) for env vars and setup commands. Skills and MCPs only come from profiles — manual session overrides do not apply to them. + +Env vars / setup commands layer order (unchanged, later wins): + +1. Repo-bound profile for the selected repo, if any. +2. User/org default profile for the current context. +3. Explicitly selected session profile. +4. Manual session overrides. +5. System-provided config (e.g. App Builder image MCPs) continues to win by reserved name for MCPs. + +Skills / MCPs layer order (new, profile-only, later wins): + +1. Repo-bound profile for the selected repo, if any. +2. User/org default profile for the current context. +3. Explicitly selected session profile. +4. System-provided MCPs continue to win by reserved name. + +Merge rules per field: + +- **Env vars**: merge by key. Existing behavior. +- **Setup commands**: append. Existing behavior. +- **MCP servers**: merge by server name across profile layers only. Later profile layer replaces the whole server definition for that name. +- **Skills**: merge by skill name across profile layers only. Later profile layer replaces the skill definition for that name. + +No manual-override UI for skills or MCPs in the session form. If a user wants a one-off skill or MCP, they add it to a profile and select that profile for the session. + +Dedupe identical profile ids across layers so a repo-bound profile that is also the selected profile is counted once. + +`ProfileConfigIndicator` keeps showing which layers contributed; extend it to include skill and MCP counts per profile layer. + +## Current findings + +- Profile tables: `agent_environment_profiles`, `agent_environment_profile_vars`, `agent_environment_profile_commands`, `agent_environment_profile_repo_bindings` in `packages/db/src/schema.ts`. Ownership is exactly one of personal or organization. +- Cloud Agent Next already accepts `mcpServers` at session prepare/update and injects them into `KILO_CONFIG_CONTENT` in `services/cloud-agent-next/src/session-service.ts`. It has no first-class skills input yet; only `writeGlobalRules` and `permission.skill = 'allow'` exist. +- Profile env-var encryption exists via `AGENT_ENV_VARS_PUBLIC_KEY`/`AGENT_ENV_VARS_PRIVATE_KEY`. We reuse this envelope for MCP secret values. +- Marketplace feeds are already wired up via `/api/marketplace/skills` and `/api/marketplace/mcps` (`js-yaml` available). +- Profile selection is currently sent to session preparation by **profile name** (`apps/web/src/lib/agent/profile-session-config.ts`). That is ambiguous in org context when a personal and org profile share a name. Switch manual-session wiring to `profileId` while preserving backward compatibility. +- The pinned `@kilocode/cli` version in `services/cloud-agent-next/Dockerfile` must be verified for skill discovery path and `KILO_CONFIG_CONTENT.skills.paths` support before implementation. +- Existing bug to watch: the prepared-session fast path in `ExecutionOrchestrator.prepareWorkspace` can recreate a sandbox without passing stored MCP config back into `getOrCreateSession`. Fix it while touching this area. + +## Data model + +Generate migrations with `pnpm drizzle generate`; never hand-edit SQL. + +Add child tables under `agent_environment_profiles` (draft names; finalize against existing conventions during implementation): + +1. `agent_environment_profile_mcp_servers` + - `id`, `profile_id`, `name`, `type` (`local` | `remote`), `enabled`, `timeout`, `config` (JSON, redacted CLI-native definition), `created_at`, `updated_at`. + - `name` unique per profile. + - Cascades on profile deletion. + +2. `agent_environment_profile_mcp_secrets` + - `id`, `mcp_server_id`, `location` (`environment` | `headers`), `key`, `value`, `created_at`, `updated_at`. + - `value` encrypted with the existing env-vars encryption envelope. + - Masked in all responses. + - Cascades on MCP server deletion. + +3. `agent_environment_profile_skills` + - `id`, `profile_id`, `name`, `description`, `source_type` (`marketplace` | `custom`), `source_url`, `raw_markdown`, `enabled`, `created_at`, `updated_at`. + - `name` unique per profile. + - Cascades on profile deletion. + +Limits: + +- `MAX_PROFILE_MCP_SERVERS = 20` (match Cloud Agent Next `Limits.MAX_MCP_SERVERS`). +- `MAX_PROFILE_SKILLS = 50`. +- `MAX_SKILL_MARKDOWN_LENGTH` around 100 KB. +- MCP server name max 100 characters; skill name slug-only and max 100 characters. + +## MCP value handling + +Support two value kinds for MCP env/header values in v1: + +- `literal`: non-secret value stored in `agent_environment_profile_mcp_servers.config`. +- `secret`: encrypted value stored in `agent_environment_profile_mcp_secrets`. + +No `connection`/`envVar` reference kind in v1. Reusable credential bundles are a deferred follow-up. + +At session preparation, materialize into Cloud Agent Next CLI-native shapes: + +- Local: `{ type: 'local', command: string[], environment?, enabled?, timeout? }`. +- Remote: `{ type: 'remote', url, headers?, enabled?, timeout? }`. + +Marketplace MCPs (`command` + `args` + `env`) are normalized into this shape at save time. + +## Skill materialization + +Verify before coding: + +- Whether `KILO_CONFIG_CONTENT.skills.paths` is honored by the pinned CLI. +- The exact `SKILL.md` path the pinned CLI reads (e.g. `$HOME/.kilo/skills//SKILL.md`). + +Then in `services/cloud-agent-next/src/session-service.ts`: + +- Add `writeRuntimeSkills` helper analogous to `writeGlobalRules`. +- Write each merged profile skill to `${sessionHome}/.kilo/skills//SKILL.md`. +- Include `skills.paths: ["${sessionHome}/.kilo/skills"]` in `KILO_CONFIG_CONTENT` only when at least one skill is present. +- Do not touch repo-committed `.kilo/skills/` in the cloned repo. +- Log names, counts, hashes only — never content. + +## Backend implementation (apps/web) + +1. Services + - `apps/web/src/lib/agent/profile-mcp-service.ts`: CRUD, encryption/masking, session materialization, ownership checks. + - `apps/web/src/lib/agent/profile-skills-service.ts`: CRUD, validation, marketplace/custom sources, materialization. + +2. Profile detail responses + - Add `mcpServerCount` and `skillCount` to profile summaries. + - Add `mcpServers` and `skills` arrays (with masked secrets) to profile details. + +3. `apps/web/src/routers/agent-profiles-router.ts` + - MCP endpoints: `createMcpFromMarketplace`, `createCustomMcp`, `updateMcp`, `deleteMcp`, `setMcpSecret`, `deleteMcpSecret`, `setMcpEnabled`. + - Skill endpoints: `createSkillFromMarketplace`, `createCustomSkill`, `updateSkill`, `deleteSkill`, `setSkillEnabled`. + - Preserve existing org access checks and profile ownership verification. + - Keep mutation permissions consistent with existing profile var/command mutations (same-role rules). + +4. `apps/web/src/lib/agent/profile-session-config.ts` + - Accept `profileId` alongside the legacy `profileName` fallback; verify ownership by id. + - Merge `mcpServers` and `skills` across the three profile layers only (repo-bound, default, selected); do not apply manual session overrides to skills or MCPs. + - Return merged `mcpServers` (with encrypted secret envelopes) and `skills` alongside existing fields. + +5. Manual-session routers + - `apps/web/src/routers/cloud-agent-next-router.ts` and the organization mirror accept `profileId` going forward. Keep `profileName` accepted for backward compatibility. + - Forward merged MCPs and skills to Cloud Agent Next. + +6. Webhook triggers + - `services/webhook-agent-ingest/src/db/queries.ts`: include profile MCPs and skills in the resolved trigger profile so trigger-launched sessions get the same tools. + +7. GDPR/soft-delete + - New tables cascade from `agent_environment_profiles`, which is already handled by `softDeleteUser`. No extra deletes needed. + - Add tests in `apps/web/src/lib/user.test.ts` proving user-owned profile skills, MCPs, and MCP secrets are removed during soft-delete. + +## Cloud Agent Next Worker implementation (services/cloud-agent-next) + +1. Extend schemas: + - `services/cloud-agent-next/src/router/schemas.ts` + - `services/cloud-agent-next/src/persistence/schemas.ts` + - `services/cloud-agent-next/src/persistence/types.ts` + + Accept optional `runtimeSkills: Array<{ name, rawMarkdown }>` and allow `mcpServers` entries to carry encrypted secret envelopes (reuse the env-vars encrypted envelope type). + +2. Session service + - Decrypt MCP secret values using `AGENT_ENV_VARS_PRIVATE_KEY` at session preparation. + - Add `writeRuntimeSkills` (see Skill materialization). + - Include `skills.paths` in `KILO_CONFIG_CONTENT` only when skills are present. + - `getSession`/listings must never return MCP secret values; return counts only. + +3. Lifecycle coverage + - Prepare, async preparation, initiate-from-prepared, resume, update-prepared, and sanitized session reads all forward `runtimeSkills` and MCP secret metadata. + - Fix `ExecutionOrchestrator.prepareWorkspace` prepared-session fast path so stored MCP config is not dropped when recreating a sandbox. + +4. Limits in `schema.ts` + - `MAX_MCP_SERVERS` stays at 20. + - Add `MAX_RUNTIME_SKILLS = 50`, `MAX_RUNTIME_SKILL_MARKDOWN` ~100 KB. + +## Web UI implementation + +Use existing shadcn/Radix primitives, dark-first Kilo tokens, compact controls, visible labels, and React Query. + +### Profile editor redesign (the main UX change) + +Replace the in-list expand/collapse editor in `apps/web/src/components/cloud-agent/ProfilesListDialog.tsx` with a two-pane layout inside the existing dialog: + +- **Left pane**: list of profiles (ownership badges, default badge, repo-binding badge), with `+ New profile` at the top and `Delete` on hover of a row. Selecting a row selects that profile for editing in the right pane. +- **Right pane**: editor for the selected profile, with a sticky header (name, ownership badge, default toggle, repo-binding shortcut) and a tab bar: + - `Variables` (existing behavior). + - `Setup commands` (existing behavior). + - `MCP servers` (new). + - `Skills` (new). +- Unsaved changes indicator in the right pane; explicit `Save` per tab. No more implicit save-on-collapse. +- Extract the current editor bits from `ProfilesListDialog.tsx` into per-tab components before adding new tabs; the file is already large. + +On narrow widths (`md` and below), collapse to a single pane with back navigation. Use `cmdk` (already in deps) for profile search if the list grows. + +### MCP tab UX + +- `Add MCP` opens a picker with two sources: `Marketplace` (via `/api/marketplace/mcps`) and `Custom` (CLI-native JSON editor). +- For each env/header parameter let the user choose `Plain value` or `Encrypted secret`. +- Normalize marketplace JSON (`command` + `args` + `env`) to CLI-native local/remote shape on save. +- Warn copy on save: `Local MCPs run inside the Cloud Agent sandbox. Only add MCPs you trust.` Additional warning when marketplace entries require Docker or local paths that won't work in the sandbox. +- List rows show: name, type badge (local/remote), enabled toggle, required-secret indicator, last updated. + +### Skills tab UX + +- `Add skill` picker with `Marketplace` (via `/api/marketplace/skills`) and `Custom` (paste `SKILL.md`). +- Validate frontmatter `name`/`description`; preview description before save. +- List rows show: name, source badge, enabled toggle, size, last updated. + +### Session form + +- `ProfileConfigIndicator` and `AdvancedConfig` extend to show per-layer counts for MCP servers and skills. +- Merge explanation copy already covers layer order; add a one-liner: `Cloud Agent also loads any .kilo/ config committed to the repo.` +- Switch to sending `profileId` while preserving `profileName` for backward compatibility. + +## Tests and verification + +- `apps/web/src/routers/agent-profiles-router.test.ts`: MCP/skill CRUD, masking, counts, org access, unauthorized access, marketplace normalization. +- `apps/web/src/lib/agent/profile-mcp-service.test.ts` and `profile-skills-service.test.ts`: encryption/decryption round-trip, marketplace normalization, materialization output. +- `apps/web/src/lib/agent/profile-session-config.test.ts`: three-layer profile-only merge for skills and MCPs, profile id resolution, dedupe, name collisions, and that manual session overrides do NOT affect skills or MCPs. +- `apps/web/src/lib/user.test.ts`: `softDeleteUser` removes user-owned profile skills, MCPs, and MCP secrets. +- `services/cloud-agent-next/src/persistence/schemas.test.ts`: new fields validate. +- `services/cloud-agent-next/src/session-service.test.ts`: MCP secret decryption, `writeRuntimeSkills`, `skills.paths` inclusion, sanitized outputs exclude secret values, prepared-session fast path retains MCP config. +- `services/webhook-agent-ingest` tests: profile resolver includes MCPs and skills. + +Targeted verification commands: + +- `pnpm drizzle generate` after schema changes. +- `pnpm --filter web test -- apps/web/src/routers/agent-profiles-router.test.ts apps/web/src/lib/agent/profile-session-config.test.ts apps/web/src/lib/agent/profile-mcp-service.test.ts apps/web/src/lib/agent/profile-skills-service.test.ts apps/web/src/lib/user.test.ts`. +- `pnpm --filter cloud-agent-next test`. +- `scripts/typecheck-all.sh --changes-only` unless the change breadth requires full typecheck. +- `pnpm format` before any commit. + +## Risks and guardrails + +- MCP secrets must stay encrypted at rest, masked in all responses, and excluded from logs and sanitized session state. +- MCP local servers execute commands inside the sandbox. Communicate trust clearly; flag sandbox-incompatible marketplace entries at add time. +- Skills are prompt-injection surface by design. Users must add them intentionally; org-shared skills are visibly shared via profile ownership. +- Large skill content can bloat DB rows and DO metadata. Enforce count and size limits at the router and before prepare-session calls. +- Use `profileId` for new flows to avoid the current org/personal name ambiguity; keep `profileName` accepted for back-compat. +- Keep the existing four-layer merge semantics for env vars and setup commands. Skills and MCPs only merge across profile layers; no manual-override path. + +## Deferred (after v1) + +- A dedicated "Connections" concept for reusable credentials across many MCPs or profiles. +- A separate "My toolkit" concept outside profiles. +- Organization-wide toolkit defaults outside the profile system. +- Pre-clone repo `.kilo/` detection in the session form. +- Marketplace tarball/resource unpacking beyond `SKILL.md`. +- Per-turn skill/MCP changes during a running session. +- Mobile parity for profile editor changes. + +## Recommended first-PR cut + +To keep review and rollback easy, consider splitting into two PRs: + +1. **Profile editor redesign**: extract tabs and switch to the two-pane layout inside `ProfilesListDialog.tsx` with only today's Variables and Setup commands tabs. No data model changes. Low risk, high UX payoff. +2. **Skills and MCPs on profiles**: add the two new tabs, schemas, routers, Cloud Agent Next plumbing, and tests. + +If we ship them together we still benefit from the redesign landing first in the branch diff so the feature work doesn't touch layout. diff --git a/apps/web/package.json b/apps/web/package.json index c1eaffc679..347ab04ea9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "@chat-adapter/state-redis": "^4.27.0", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", + "@kilocode/cloud-agent-profile": "workspace:*", "@kilocode/db": "workspace:*", "@kilocode/encryption": "workspace:*", "@kilocode/event-service": "workspace:*", diff --git a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts b/apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts index f528e02cba..1f8f47021c 100644 --- a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts +++ b/apps/web/src/app/api/cloud-agent/sessions/prepare/route.test.ts @@ -12,11 +12,6 @@ import { } from '@/lib/cloud-agent/github-integration-helpers'; import { createCloudAgentClient } from '@/lib/cloud-agent/cloud-agent-client'; import { signStreamTicket } from '@/lib/cloud-agent/stream-ticket'; -import { - mergeProfileConfiguration, - ProfileNotFoundError, - type MergeProfileConfigurationResult, -} from '@/lib/agent/profile-session-config'; import type { User } from '@kilocode/db/schema'; jest.mock('@/lib/user.server'); @@ -24,10 +19,6 @@ jest.mock('@/routers/organizations/utils'); jest.mock('@/lib/cloud-agent/github-integration-helpers'); jest.mock('@/lib/cloud-agent/cloud-agent-client'); jest.mock('@/lib/cloud-agent/stream-ticket'); -jest.mock('@/lib/agent/profile-session-config', () => ({ - mergeProfileConfiguration: jest.fn(), - ProfileNotFoundError: class ProfileNotFoundError extends Error {}, -})); const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); const mockedEnsureOrganizationAccess = jest.mocked(ensureOrganizationAccess); @@ -41,7 +32,6 @@ const mockedValidateGitHubRepoAccessForOrganization = jest.mocked( ); const mockedCreateCloudAgentClient = jest.mocked(createCloudAgentClient); const mockedSignStreamTicket = jest.mocked(signStreamTicket); -const mockedMergeProfileConfiguration = jest.mocked(mergeProfileConfiguration); function makeRequest(body: unknown) { return new Request('http://localhost:3000/api/cloud-agent/sessions/prepare', { @@ -132,11 +122,6 @@ describe('POST /api/cloud-agent/sessions/prepare', () => { jest.resetAllMocks(); mockedValidateGitHubRepoAccessForUser.mockResolvedValue(true); mockedValidateGitHubRepoAccessForOrganization.mockResolvedValue(true); - mockedMergeProfileConfiguration.mockResolvedValue({ - envVars: undefined, - setupCommands: undefined, - encryptedSecrets: undefined, - }); mockedSignStreamTicket.mockReturnValue({ ticket: 'test-ticket', expiresAt: 1234567890 }); }); @@ -183,13 +168,14 @@ describe('POST /api/cloud-agent/sessions/prepare', () => { expect(body.details).toContainEqual(expect.objectContaining({ path: 'prompt' })); }); - test('returns 400 when mode is invalid', async () => { + test('returns 400 when mode is not a valid slug', async () => { setUserAuth(); const response = await POST( makeRequest({ ...validInput, - mode: 'invalid-mode', + // Uppercase + space — not a valid slug shape. + mode: 'Invalid Mode!', }) ); @@ -554,12 +540,6 @@ describe('POST /api/cloud-agent/sessions/prepare', () => { autoCommit: true, }; - mockedMergeProfileConfiguration.mockResolvedValueOnce({ - envVars: inputWithOptionals.envVars, - setupCommands: inputWithOptionals.setupCommands, - encryptedSecrets: undefined, - }); - await POST(makeRequest(inputWithOptionals)); expect(mockPrepareSession).toHaveBeenCalledWith( @@ -578,9 +558,11 @@ describe('POST /api/cloud-agent/sessions/prepare', () => { }); }); - describe('profileName integration', () => { - test('merges profile configuration before calling cloud-agent', async () => { - const user = setUserAuth(); + describe('profile forwarding', () => { + const profileId = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; + + test('forwards profileId and inline overrides to cloud-agent-next unchanged', async () => { + setUserAuth(); mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); const mockPrepareSession = jest.fn().mockResolvedValue({ kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', @@ -588,48 +570,37 @@ describe('POST /api/cloud-agent/sessions/prepare', () => { }); mockedCreateCloudAgentClient.mockReturnValue(createMockCloudAgentClient(mockPrepareSession)); - const mergedConfig: MergeProfileConfigurationResult = { - envVars: { FROM_PROFILE: 'value' }, - setupCommands: ['pnpm install'], - encryptedSecrets: undefined, - }; - mockedMergeProfileConfiguration.mockResolvedValueOnce(mergedConfig); - await POST( makeRequest({ ...validInput, - profileName: 'My Default', + profileId, envVars: { INLINE: 'value' }, setupCommands: ['echo inline'], }) ); - expect(mockedMergeProfileConfiguration).toHaveBeenCalledWith({ - profileName: 'My Default', - owner: { type: 'user', id: user.id }, - userId: undefined, - repoFullName: 'owner/repo', - platform: 'github', - envVars: { INLINE: 'value' }, - setupCommands: ['echo inline'], - }); - expect(mockPrepareSession).toHaveBeenCalledWith( expect.objectContaining({ - envVars: mergedConfig.envVars, - setupCommands: mergedConfig.setupCommands, + profileId, + envVars: { INLINE: 'value' }, + setupCommands: ['echo inline'], }) ); }); - test('returns 404 when the profile cannot be found', async () => { + test('returns 404 when cloud-agent reports the profile is not found', async () => { setUserAuth(); - mockedMergeProfileConfiguration.mockRejectedValueOnce(new ProfileNotFoundError('Missing')); + mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); + mockedCreateCloudAgentClient.mockReturnValue( + createMockCloudAgentClient( + jest.fn().mockRejectedValue(new Error(`Profile '${profileId}' not found`)) + ) + ); const response = await POST( makeRequest({ ...validInput, - profileName: 'Missing', + profileId, }) ); @@ -638,46 +609,7 @@ describe('POST /api/cloud-agent/sessions/prepare', () => { expect(body.error).toBe('Profile not found'); expect(body.details).toContainEqual( expect.objectContaining({ - path: 'profileName', - }) - ); - }); - - test('passes encryptedSecrets from profile to cloud-agent worker', async () => { - setUserAuth(); - mockedGetGitHubInstallationIdForUser.mockResolvedValue('12345'); - const mockPrepareSession = jest.fn().mockResolvedValue({ - kiloSessionId: '123e4567-e89b-12d3-a456-426614174000', - cloudAgentSessionId: 'cloud-session-123', - }); - mockedCreateCloudAgentClient.mockReturnValue(createMockCloudAgentClient(mockPrepareSession)); - - const encryptedEnvelope = { - encryptedData: 'base64-encrypted-data', - encryptedDEK: 'base64-encrypted-dek', - algorithm: 'rsa-aes-256-gcm' as const, - version: 1 as const, - }; - - const mergedConfig: MergeProfileConfigurationResult = { - envVars: { PUBLIC_VAR: 'value' }, - setupCommands: ['npm install'], - encryptedSecrets: { SECRET_KEY: encryptedEnvelope }, - }; - mockedMergeProfileConfiguration.mockResolvedValueOnce(mergedConfig); - - await POST( - makeRequest({ - ...validInput, - profileName: 'production', - }) - ); - - expect(mockPrepareSession).toHaveBeenCalledWith( - expect.objectContaining({ - envVars: { PUBLIC_VAR: 'value' }, - encryptedSecrets: { SECRET_KEY: encryptedEnvelope }, - setupCommands: ['npm install'], + path: 'profileId', }) ); }); diff --git a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts b/apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts index 5206000f3d..138f04a169 100644 --- a/apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts +++ b/apps/web/src/app/api/cloud-agent/sessions/prepare/route.ts @@ -21,13 +21,7 @@ import { generateApiToken } from '@/lib/tokens'; import { publicPrepareSessionSchema } from './schema'; import { captureException } from '@sentry/nextjs'; import { TRPCError } from '@trpc/server'; -import type { ProfileOwner } from '@/lib/agent/types'; -import type { EncryptedEnvelope } from '@/lib/encryption'; import { signStreamTicket } from '@/lib/cloud-agent/stream-ticket'; -import { - mergeProfileConfiguration, - ProfileNotFoundError, -} from '@/lib/agent/profile-session-config'; import { PLATFORM } from '@/lib/integrations/core/constants'; function handleTRPCError(error: unknown): NextResponse { @@ -177,50 +171,6 @@ export async function POST(request: Request) { ); } - let mergedEnvVars: Record | undefined; - let mergedSetupCommands: string[] | undefined; - let encryptedSecrets: Record | undefined; - - try { - const owner: ProfileOwner = input.organizationId - ? { type: 'organization', id: input.organizationId } - : { type: 'user', id: user.id }; - - const repoFullName = input.githubRepo ?? input.gitlabProject; - const platform = input.gitlabProject ? PLATFORM.GITLAB : PLATFORM.GITHUB; - - const merged = await mergeProfileConfiguration({ - profileName: input.profileName, - owner, - // In org context, pass userId to enable fallback to personal profiles - userId: input.organizationId ? user.id : undefined, - repoFullName, - platform, - envVars: input.envVars, - setupCommands: input.setupCommands, - }); - - mergedEnvVars = merged.envVars; - mergedSetupCommands = merged.setupCommands; - encryptedSecrets = merged.encryptedSecrets; - } catch (error) { - if (error instanceof ProfileNotFoundError) { - return NextResponse.json( - { - error: 'Profile not found', - details: [ - { - path: 'profileName', - message: error.message, - }, - ], - }, - { status: 404 } - ); - } - throw error; - } - try { const authToken = generateApiToken(user); const client = createCloudAgentClient(authToken); @@ -239,9 +189,12 @@ export async function POST(request: Request) { platform: input.gitlabProject ? PLATFORM.GITLAB : PLATFORM.GITHUB, // Common params kilocodeOrganizationId, - envVars: mergedEnvVars, - encryptedSecrets, - setupCommands: mergedSetupCommands, + // Profile resolution happens in cloud-agent-next — forward profileId + // and any inline overrides. CAN merges profile-derived values with + // the inline fields using the same precedence the web used to apply. + profileId: input.profileId, + envVars: input.envVars, + setupCommands: input.setupCommands, mcpServers: input.mcpServers, autoCommit: input.autoCommit, upstreamBranch: input.upstreamBranch, @@ -260,6 +213,25 @@ export async function POST(request: Request) { ...ticketResult, }); } catch (error) { + // Profile resolution failures are surfaced by CAN as 404s. Forward them + // through without mapping to a generic "Failed to prepare session" + // response so the caller sees the same shape we used before this + // refactor. + if (error instanceof Error && /Profile '.+' not found/i.test(error.message)) { + return NextResponse.json( + { + error: 'Profile not found', + details: [ + { + path: 'profileId', + message: error.message, + }, + ], + }, + { status: 404 } + ); + } + captureException(error, { tags: { source: 'cloud-agent-prepare-session', step: 'forward-to-cloud-agent' }, extra: { diff --git a/apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts b/apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts index adcd6dc42b..0074ef1968 100644 --- a/apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts +++ b/apps/web/src/app/api/cloud-agent/sessions/prepare/schema.ts @@ -49,10 +49,11 @@ export const publicPrepareSessionSchema = z organizationId: z.string().uuid('Invalid organization ID format').optional(), // Optional environment profile - // If provided, envVars and setupCommands from the profile will be used. - // Any inline envVars/setupCommands will be merged (inline takes precedence). - // If organizationId is provided, looks up org profile; otherwise looks up user profile. - profileName: z.string().max(100, 'Profile name must be at most 100 characters').optional(), + // If provided, envVars/setupCommands/MCP servers/skills/secrets from the + // profile are merged with inline values (inline takes precedence). + // When omitted, the effective default profile for the caller is used + // (org default wins over personal default in an org context). + profileId: z.string().uuid('Invalid profile ID format').optional(), // Optional configuration envVars: z diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.tsx b/apps/web/src/components/cloud-agent-next/ChatInput.tsx index f0cd3db2be..1d7f31acc9 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatInput.tsx @@ -9,8 +9,9 @@ import { Send, Square, Paperclip, Upload } from 'lucide-react'; import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; import { cn } from '@/lib/utils'; import { BrowseCommandsDialog } from './BrowseCommandsDialog'; -import { ModeCombobox, NEXT_MODE_OPTIONS } from '@/components/shared/ModeCombobox'; +import { ModeCombobox, NEXT_MODE_OPTIONS, type ModeOption } from '@/components/shared/ModeCombobox'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { VariantCombobox } from '@/components/shared/VariantCombobox'; import { MobileToolbarPopover } from './MobileToolbarPopover'; import { ImagePreviewStrip } from '@/components/shared/ImagePreviewStrip'; @@ -53,6 +54,12 @@ type ChatInputProps = { showToolbar?: boolean; /** Pre-populate the textarea (e.g. to restore text after a failed send) */ initialValue?: string; + /** Custom modes exposed by the session's profile stack (shown in picker) */ + customModeOptions?: ModeOption[]; + /** When true, the model picker is rendered read-only (e.g. agent has a model override). */ + modelPickerDisabled?: boolean; + /** Explanatory tooltip shown alongside the locked model picker. */ + modelPickerTooltip?: string; }; export function ChatInput({ @@ -74,6 +81,9 @@ export function ChatInput({ showToolbar = false, initialValue, imageUploadOptions, + customModeOptions, + modelPickerDisabled, + modelPickerTooltip, }: ChatInputProps) { const [value, setValue] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); @@ -411,7 +421,10 @@ export function ChatInput({ availableVariants={availableVariants} onVariantChange={onVariantChange} disabled={disabled || isStreaming} + modelPickerDisabled={modelPickerDisabled} + modelPickerTooltip={modelPickerTooltip} className="md:hidden" + customModeOptions={customModeOptions} /> {/* Desktop: individual pickers */}
@@ -419,27 +432,45 @@ export function ChatInput({ value={mode} onValueChange={onModeChange} options={NEXT_MODE_OPTIONS} + customOptions={customModeOptions} variant="compact" disabled={disabled || isStreaming} className="min-w-0" /> - - {availableVariants.length > 0 && onVariantChange && ( - + {modelPickerDisabled ? ( + + +
+ {model} +
+
+ {modelPickerTooltip && ( + + {modelPickerTooltip} + + )} +
+ ) : ( + <> + + {availableVariants.length > 0 && onVariantChange && ( + + )} + )}
diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx index 4e2cf5e6b5..6b4bbd2cfa 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -14,21 +14,9 @@ import { type CloudAgentSessionId, } from '@/lib/cloud-agent-sdk'; import { CLOUD_AGENT_NEXT_WS_URL, SESSION_INGEST_WS_URL } from '@/lib/constants'; -import type { AgentMode } from './types'; import { usePostHog } from 'posthog-js/react'; const ManagerContext = createContext(null); -const CLOUD_AGENT_NEXT_MODES = [ - 'code', - 'plan', - 'debug', - 'orchestrator', - 'ask', -] satisfies AgentMode[]; - -function isCloudAgentNextMode(mode: string | undefined): mode is AgentMode { - return CLOUD_AGENT_NEXT_MODES.some(validMode => validMode === mode); -} type CloudAgentProviderProps = { children: ReactNode; @@ -122,7 +110,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi api: { send: async payload => { - const mode = isCloudAgentNextMode(payload.mode) ? payload.mode : 'code'; + const mode = payload.mode ?? 'code'; if (payload.model === undefined) { throw new Error('Cloud Agent model is required'); } @@ -282,6 +270,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi isPreparingAsync: Boolean(rs && !rs.preparedAt), prompt: rs?.prompt ?? null, initialMessageId: rs?.initialMessageId ?? null, + runtimeAgents: rs?.runtimeAgents, }; }, diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 466109606e..4905750276 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -12,6 +12,7 @@ import { useManager } from './CloudAgentProvider'; import { MobileSidebarToggle } from './MobileSidebarToggle'; import { ChatHeader } from './ChatHeader'; import { ChatInput } from './ChatInput'; +import type { ModeOption } from '@/components/shared/ModeCombobox'; import { MessageErrorBoundary } from './MessageErrorBoundary'; import { MessageBubble } from './MessageBubble'; import { SessionStatusIndicator } from './SessionStatusIndicator'; @@ -275,11 +276,15 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { const handleSendMessage = useCallback( async (prompt: string, images?: Images) => { setChatUI({ shouldAutoScroll: true }); + const selectedRuntimeAgentForSend = sessionConfig?.runtimeAgents?.find( + a => a.slug === sessionConfig?.mode + ); + const agentModelOverrideForSend = selectedRuntimeAgentForSend?.model?.trim() || undefined; const acceptedPromise = manager.send({ prompt, mode: sessionConfig?.mode ?? 'code', - model: sessionConfig?.model ?? '', - variant: sessionConfig?.variant ?? undefined, + model: agentModelOverrideForSend ?? sessionConfig?.model ?? '', + variant: agentModelOverrideForSend ? undefined : (sessionConfig?.variant ?? undefined), images, }); scheduleScrollToBottom(); @@ -328,6 +333,31 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { [manager] ); + // Expose the session's custom agents to the chat picker. Slug + name only; + // the full config stays server-side. `GetSessionOutput.runtimeAgents` + // already filters to enabled & non-hidden at send time, so we just pass + // through. + const customModeOptions: ModeOption[] | undefined = sessionConfig?.runtimeAgents + ?.length + ? sessionConfig.runtimeAgents.map(a => ({ + value: a.slug as AgentMode, + label: a.name, + description: '', + })) + : undefined; + + // If the selected custom agent carries a model override, the chat model + // picker must reflect + lock that value. + const selectedRuntimeAgent = sessionConfig?.runtimeAgents?.find( + a => a.slug === sessionConfig?.mode + ); + const agentModelOverride = selectedRuntimeAgent?.model?.trim() || undefined; + const displayModel = agentModelOverride ?? sessionConfig?.model; + const modelPickerLocked = !!agentModelOverride; + const modelPickerTooltip = modelPickerLocked + ? `Model is locked by agent "${selectedRuntimeAgent?.name}"` + : undefined; + const handleModeChange = useCallback( (mode: AgentMode) => { if (sessionConfig) setSessionConfig({ ...sessionConfig, mode }); @@ -507,16 +537,21 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) { placeholder={placeholder} slashCommands={availableCommands} mode={sessionConfig?.mode as AgentMode | undefined} - model={sessionConfig?.model} + model={displayModel} modelOptions={modelOptions} isLoadingModels={isLoadingModels} onModeChange={handleModeChange} onModelChange={handleModelChange} - variant={sessionConfig?.variant ?? undefined} + variant={ + modelPickerLocked ? undefined : (sessionConfig?.variant ?? undefined) + } onVariantChange={handleVariantChange} - availableVariants={availableVariants} + availableVariants={modelPickerLocked ? [] : availableVariants} showToolbar={Boolean(sessionIdFromParams)} initialValue={failedPrompt ?? undefined} + customModeOptions={customModeOptions} + modelPickerDisabled={modelPickerLocked} + modelPickerTooltip={modelPickerTooltip} imageUploadOptions={{ messageUuid: imageMessageUuid, organizationId, diff --git a/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx b/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx index 0030d1a0df..a013f9d749 100644 --- a/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx +++ b/apps/web/src/components/cloud-agent-next/MobileToolbarPopover.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ChevronsUpDown } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { ModeCombobox, NEXT_MODE_OPTIONS } from '@/components/shared/ModeCombobox'; +import { ModeCombobox, NEXT_MODE_OPTIONS, type ModeOption } from '@/components/shared/ModeCombobox'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; import { VariantCombobox } from '@/components/shared/VariantCombobox'; import { formatShortModelDisplayName } from '@/lib/format-model-name'; @@ -22,7 +22,11 @@ type MobileToolbarPopoverProps = { availableVariants?: string[]; onVariantChange?: (variant: string) => void; disabled?: boolean; + /** When set, the model picker is rendered as read-only with this explanatory tooltip. */ + modelPickerDisabled?: boolean; + modelPickerTooltip?: string; className?: string; + customModeOptions?: ModeOption[]; }; export function MobileToolbarPopover({ @@ -36,7 +40,10 @@ export function MobileToolbarPopover({ availableVariants = [], onVariantChange, disabled, + modelPickerDisabled, + modelPickerTooltip, className, + customModeOptions, }: MobileToolbarPopoverProps) { const [open, setOpen] = useState(false); @@ -69,19 +76,30 @@ export function MobileToolbarPopover({ value={mode} onValueChange={onModeChange} options={NEXT_MODE_OPTIONS} + customOptions={customModeOptions} label="Mode" /> )} {onModelChange && ( - +
+ + {modelPickerDisabled ? ( +
+
{model}
+ {modelPickerTooltip &&
{modelPickerTooltip}
} +
+ ) : ( + + )} +
)} - {availableVariants.length > 0 && onVariantChange && ( + {!modelPickerDisabled && availableVariants.length > 0 && onVariantChange && (
= CLOUD_AGENT_IMAGE_MAX_COUNT; // --------------------------------------------------------------------------- - // Session form atoms (profile / env / commands) + // Session form atoms (profile override) // --------------------------------------------------------------------------- - const [manualEnvVars, setManualEnvVars] = useAtom(manualEnvVarsAtom); - const [manualSetupCommands, setManualSetupCommands] = useAtom(manualSetupCommandsAtom); const [selectedProfileId, setSelectedProfileId] = useAtom(selectedProfileIdAtom); - const [hasAutoSelectedDefault, setHasAutoSelectedDefault] = useAtom(hasAutoSelectedDefaultAtom); - const setProfileConfig = useSetAtom(profileConfigAtom); - const effectiveEnvVars = useAtomValue(effectiveEnvVarsAtom); - const effectiveSetupCommands = useAtomValue(effectiveSetupCommandsAtom); const resetSessionForm = useSetAtom(resetSessionFormAtom); // Clear any lingering manual overrides whenever the page loads @@ -271,21 +249,14 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { ); // --------------------------------------------------------------------------- - // Profiles + // Profiles — used for the selector and to clear a stale selection when a + // selected profile is deleted elsewhere. // --------------------------------------------------------------------------- - const { - data: combinedProfilesData, - isLoading: isLoadingCombinedProfiles, - error: combinedProfilesError, - } = useCombinedProfiles({ + const { data: combinedProfilesData } = useCombinedProfiles({ organizationId: organizationId ?? '', enabled: !!organizationId, }); - const { - data: personalProfiles, - isLoading: isLoadingPersonalProfiles, - error: personalProfilesError, - } = useProfiles({ + const { data: personalProfiles } = useProfiles({ organizationId: undefined, enabled: !organizationId, }); @@ -296,101 +267,57 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { ...(combinedProfilesData?.personalProfiles ?? []), ] : (personalProfiles ?? []); - const effectiveDefaultId = organizationId - ? combinedProfilesData?.effectiveDefaultId - : personalProfiles?.find(p => p.isDefault)?.id; - // Auto-select effective default profile on initial load + // If the override profile was deleted, clear the selection useEffect(() => { - if (!hasAutoSelectedDefault && !selectedProfileId && effectiveDefaultId) { - setSelectedProfileId(effectiveDefaultId); - setHasAutoSelectedDefault(true); - } else if (!hasAutoSelectedDefault && allProfiles.length > 0) { - setHasAutoSelectedDefault(true); - } - }, [ - allProfiles.length, - effectiveDefaultId, - hasAutoSelectedDefault, - selectedProfileId, - setSelectedProfileId, - setHasAutoSelectedDefault, - ]); - - // If a profile is deleted from the list, clear the selection - useEffect(() => { - if (!selectedProfileId || allProfiles.length === 0) { - return; - } - const stillPresent = allProfiles.some(p => p.id === selectedProfileId); - if (!stillPresent) { + if (!selectedProfileId || allProfiles.length === 0) return; + if (!allProfiles.some(p => p.id === selectedProfileId)) { setSelectedProfileId(null); - setProfileConfig(null); } - }, [allProfiles, selectedProfileId, setProfileConfig, setSelectedProfileId]); - - const selectedProfileSummary = selectedProfileId - ? allProfiles.find(profile => profile.id === selectedProfileId) - : undefined; - - const { data: repoBindings, error: repoBindingsError } = useRepoBindings({ - organizationId, - enabled: !!selectedRepo, + }, [allProfiles, selectedProfileId, setSelectedProfileId]); + + // Resolve the profile whose custom agents should appear in the mode picker. + // Prefers an explicit selection; otherwise falls back to the effective + // default (org default wins over personal). This mirrors the server-side + // resolution in mergeProfileConfiguration for a useful preview. + const effectiveAgentProfileId = + selectedProfileId ?? + (organizationId + ? combinedProfilesData?.effectiveDefaultId + : (personalProfiles?.find(p => p.isDefault)?.id ?? null)) ?? + null; + const effectiveAgentProfileOrg = + effectiveAgentProfileId && organizationId + ? combinedProfilesData?.orgProfiles.some(p => p.id === effectiveAgentProfileId) + ? organizationId + : undefined + : undefined; + const { data: selectedProfileDetails } = useProfile(effectiveAgentProfileId ?? '', { + organizationId: effectiveAgentProfileOrg, + enabled: !!effectiveAgentProfileId, }); - - const repoBoundProfileName = useMemo(() => { - if (!selectedRepo || !repoBindings) return null; - const binding = repoBindings.find( - repoBinding => - repoBinding.repoFullName.toLowerCase() === selectedRepo.toLowerCase() && - repoBinding.platform === selectedPlatform - ); - return binding?.profileName ?? null; - }, [repoBindings, selectedPlatform, selectedRepo]); - - const isProfilesLoading = organizationId ? isLoadingCombinedProfiles : isLoadingPersonalProfiles; - const profilesError = organizationId ? combinedProfilesError : personalProfilesError; - const profileIndicatorState = buildProfileConfigIndicatorState({ - selectedProfileName: selectedProfileSummary?.name ?? null, - repoBoundProfileName, - hasManualEnvVars: Object.keys(manualEnvVars).length > 0, - hasManualSetupCommands: manualSetupCommands.length > 0, - hasSelectedProfileId: !!selectedProfileId, - isProfilesLoading, - hasProfileError: !!profilesError, - hasRepoBindingError: !!selectedRepo && !!repoBindingsError, - }); - - // Fetch selected profile data - const { data: selectedProfile } = useProfile(selectedProfileId || '', { - organizationId, - enabled: !!selectedProfileId, - }); - - // Update profile config atom when profile data is loaded - useEffect(() => { - if (selectedProfile) { - setProfileConfig({ - vars: selectedProfile.vars.map(v => ({ - key: v.key, - value: v.value, - isSecret: v.isSecret, - })), - commands: selectedProfile.commands - .sort((a, b) => a.sequence - b.sequence) - .map(c => c.command), - }); - } else { - setProfileConfig(null); - } - }, [selectedProfile, setProfileConfig]); - - const handleProfileSelect = useCallback( - (profileId: string | null) => { - setSelectedProfileId(profileId); - }, - [setSelectedProfileId] + // Expose only agents that would actually surface in the chat picker: + // enabled, not disabled, not hidden, and not subagent-only. Matches the + // extension's `session.agents().filter(a => a.mode !== 'subagent' && !a.hidden)`. + const visibleCustomAgents = (selectedProfileDetails?.agents ?? []).filter( + a => a.enabled && !a.config.disable && !a.config.hidden && a.config.mode !== 'subagent' ); + const customModeOptions: ModeOption[] = visibleCustomAgents.map(a => ({ + value: a.slug as AgentMode, + label: a.name, + description: a.config.description ?? '', + })); + + // When a custom agent with a `model` override is selected, the override wins + // over the user's model combobox selection. We surface this by forcing the + // pickers to display the override and disabling them. + const selectedCustomAgent = visibleCustomAgents.find(a => a.slug === mode); + const agentModelOverride = selectedCustomAgent?.config.model?.trim() || undefined; + const hasAgentModelOverride = !!agentModelOverride; + const displayModel = agentModelOverride ?? model; + // Variants don't travel with the override (the agent config doesn't carry a + // variant); suppress the variant picker when an override is in effect. + const displayVariants = hasAgentModelOverride ? [] : availableVariants; // --------------------------------------------------------------------------- // Repositories (GitHub + GitLab) @@ -607,7 +534,6 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { // Repo popover state (must be declared before early returns to satisfy Rules of Hooks) // --------------------------------------------------------------------------- const [repoPopoverOpen, setRepoPopoverOpen] = useState(false); - const [settingsPopoverOpen, setSettingsPopoverOpen] = useState(false); const recentFullNames = useMemo(() => new Set(recentRepos.map(r => r.fullName)), [recentRepos]); const githubRepos = unifiedRepositories.filter( @@ -656,11 +582,9 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { const baseInput = { prompt: prompt.trim(), mode, - model, - variant, - envVars: Object.keys(manualEnvVars).length > 0 ? manualEnvVars : undefined, - setupCommands: manualSetupCommands.length > 0 ? manualSetupCommands : undefined, - profileName: selectedProfile?.name, + model: displayModel, + variant: hasAgentModelOverride ? undefined : variant, + profileId: selectedProfileId ?? undefined, autoCommit: true, autoInitiate: true, initialMessageId, @@ -696,8 +620,10 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { } } - setLastUsedModel(model, organizationId); - if (variant) { + if (!hasAgentModelOverride) { + setLastUsedModel(model, organizationId); + } + if (!hasAgentModelOverride && variant) { setLastUsedVariant(model, variant, organizationId); } @@ -723,8 +649,8 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { } }, [ imageUpload, - manualEnvVars, - manualSetupCommands, + displayModel, + hasAgentModelOverride, model, mode, organizationId, @@ -732,8 +658,8 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { queryClient, router, selectedPlatform, + selectedProfileId, selectedRepo, - selectedProfile, trpc.cliSessionsV2.list, trpcClient, variant, @@ -922,15 +848,22 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { {/* Desktop: individual pickers */}
@@ -938,27 +871,43 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { value={mode} onValueChange={setMode} options={NEXT_MODE_OPTIONS} + customOptions={customModeOptions} variant="compact" disabled={isPreparing} className="min-w-0" /> - - {availableVariants.length > 0 && ( - + {hasAgentModelOverride ? ( + + +
+ {displayModel} +
+
+ + Model is locked by agent “{selectedCustomAgent?.name}” + +
+ ) : ( + <> + + {availableVariants.length > 0 && ( + + )} + )}
@@ -1106,52 +1055,14 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) { - {/* Settings — bottom right */} -
- setSettingsPopoverOpen(true)} - /> - {profileIndicatorState && } - - - - - -
-

Advanced settings

-
- -
- -
-
-
-
+ {/* Profile chip — bottom right */} +
diff --git a/apps/web/src/components/cloud-agent-next/types.ts b/apps/web/src/components/cloud-agent-next/types.ts index 6151bb9534..a5ff3753de 100644 --- a/apps/web/src/components/cloud-agent-next/types.ts +++ b/apps/web/src/components/cloud-agent-next/types.ts @@ -241,10 +241,19 @@ export function isMessageStreaming(message: StoredMessage): boolean { // ============================================================================ /** - * Valid mode values for cloud agent sessions. - * Uses new modes for cloud-agent-next. + * Valid mode values for cloud agent sessions. Includes the cloud-agent-next + * built-in slugs plus any custom slug from a session's profile-scoped + * `runtimeAgents`. `(string & {})` keeps the literal completions while still + * accepting custom slugs. */ -export type AgentMode = 'code' | 'plan' | 'debug' | 'orchestrator' | 'ask'; +export type AgentMode = + | 'code' + | 'plan' + | 'debug' + | 'orchestrator' + | 'ask' + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + | (string & {}); // ============================================================================ // Stream Event Types diff --git a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx index 618dd7a9ae..d68fb1f961 100644 --- a/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx +++ b/apps/web/src/components/cloud-agent/CloudSessionsPage.tsx @@ -33,18 +33,9 @@ import { type DemoConfig, } from './demo-config'; import type { AgentMode } from './types'; -import { useProfile, useProfiles, useCombinedProfiles } from '@/hooks/useCloudAgentProfiles'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { - manualEnvVarsAtom, - manualSetupCommandsAtom, - selectedProfileIdAtom, - hasAutoSelectedDefaultAtom, - profileConfigAtom, - effectiveEnvVarsAtom, - effectiveSetupCommandsAtom, - resetSessionFormAtom, -} from './store/session-form-atoms'; +import { useProfiles, useCombinedProfiles } from '@/hooks/useCloudAgentProfiles'; +import { useAtom, useSetAtom } from 'jotai'; +import { selectedProfileIdAtom, resetSessionFormAtom } from './store/session-form-atoms'; import { useOrganizationDefaults } from '@/app/api/organizations/hooks'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; import { @@ -53,7 +44,7 @@ import { type RepositoryPlatform, } from '@/components/shared/RepositoryCombobox'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; -import { AdvancedConfig } from '@/components/shared/AdvancedConfig'; +import { ProfilePickerPopover } from '@/components/cloud-agent/ProfilePickerPopover'; import { cn } from '@/lib/utils'; import { CLOUD_AGENT_PROMPT_MAX_LENGTH } from '@/lib/cloud-agent/constants'; import { MODES } from './ResumeConfigModal'; @@ -110,14 +101,8 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { const [isDemoActionLoading, setIsDemoActionLoading] = useState(false); const [highlightedDemoId, setHighlightedDemoId] = useState(null); - // Session form atoms (profile/env/commands) - const [manualEnvVars, setManualEnvVars] = useAtom(manualEnvVarsAtom); - const [manualSetupCommands, setManualSetupCommands] = useAtom(manualSetupCommandsAtom); + // Profile override selection (base profile resolved server-side from repo binding / default) const [selectedProfileId, setSelectedProfileId] = useAtom(selectedProfileIdAtom); - const [hasAutoSelectedDefault, setHasAutoSelectedDefault] = useAtom(hasAutoSelectedDefaultAtom); - const setProfileConfig = useSetAtom(profileConfigAtom); - const effectiveEnvVars = useAtomValue(effectiveEnvVarsAtom); - const effectiveSetupCommands = useAtomValue(effectiveSetupCommandsAtom); const resetSessionForm = useSetAtom(resetSessionFormAtom); // Clear any lingering manual overrides whenever the page loads @@ -181,71 +166,13 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { ...(combinedProfilesData?.personalProfiles ?? []), ] : (personalProfiles ?? []); - const effectiveDefaultId = organizationId - ? combinedProfilesData?.effectiveDefaultId - : personalProfiles?.find(p => p.isDefault)?.id; - - // Auto-select effective default profile on initial load - useEffect(() => { - if (!hasAutoSelectedDefault && !selectedProfileId && effectiveDefaultId) { - setSelectedProfileId(effectiveDefaultId); - setHasAutoSelectedDefault(true); - } else if (!hasAutoSelectedDefault && allProfiles.length > 0) { - // Mark as auto-selected even if no default exists - setHasAutoSelectedDefault(true); - } - }, [ - allProfiles.length, - effectiveDefaultId, - hasAutoSelectedDefault, - selectedProfileId, - setSelectedProfileId, - setHasAutoSelectedDefault, - ]); - // If a profile is deleted from the list, clear the selection so derived counts reset + // If override profile was deleted, clear the selection useEffect(() => { - if (!selectedProfileId || allProfiles.length === 0) { - return; - } + if (!selectedProfileId || allProfiles.length === 0) return; const stillPresent = allProfiles.some(p => p.id === selectedProfileId); - if (!stillPresent) { - setSelectedProfileId(null); - setProfileConfig(null); - } - }, [allProfiles, selectedProfileId, setProfileConfig, setSelectedProfileId]); - - // Fetch selected profile data - const { data: selectedProfile } = useProfile(selectedProfileId || '', { - organizationId, - enabled: !!selectedProfileId, - }); - - // Update profile config atom when profile data is loaded - useEffect(() => { - if (selectedProfile) { - setProfileConfig({ - vars: selectedProfile.vars.map(v => ({ - key: v.key, - value: v.value, - isSecret: v.isSecret, - })), - commands: selectedProfile.commands - .sort((a, b) => a.sequence - b.sequence) - .map(c => c.command), - }); - } else { - setProfileConfig(null); - } - }, [selectedProfile, setProfileConfig]); - - // Profile selection handler - const handleProfileSelect = useCallback( - (profileId: string | null) => { - setSelectedProfileId(profileId); - }, - [setSelectedProfileId] - ); + if (!stillPresent) setSelectedProfileId(null); + }, [allProfiles, selectedProfileId, setSelectedProfileId]); // Fetch GitHub repositories const { @@ -419,17 +346,13 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { setIsPreparing(true); try { - // Call prepareSession to create DB entry and cloud-agent DO - // If a profile is selected, pass the profile name so the backend - // can resolve encrypted secrets from the profile - // Build the base input without the repo field + // Call prepareSession to create DB entry and cloud-agent DO. + // profileId is unambiguous across org/personal. const baseInput = { prompt: prompt.trim(), mode, model, - envVars: Object.keys(manualEnvVars).length > 0 ? manualEnvVars : undefined, - setupCommands: manualSetupCommands.length > 0 ? manualSetupCommands : undefined, - profileName: selectedProfile?.name, + profileId: selectedProfileId ?? undefined, autoCommit: true, }; @@ -491,8 +414,6 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { setIsPreparing(false); } }, [ - manualEnvVars, - manualSetupCommands, model, mode, organizationId, @@ -501,7 +422,7 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { router, selectedPlatform, selectedRepo, - selectedProfile, + selectedProfileId, trpc.unifiedSessions.list, trpcClient, ]); @@ -687,20 +608,16 @@ export function CloudSessionsPage({ organizationId }: CloudSessionsPageProps) { isLoadingModels={!modelsData} /> - {/* Advanced Configuration */} - + {/* Profile picker — sits below mode/model row */} +
+ +
{/* Submit Button */} void; + repoFullName?: string; + platform?: 'github' | 'gitlab'; +}; + +export function ProfilePickerPopover({ + organizationId, + selectedOverrideProfileId, + onOverrideProfileSelect, + repoFullName, + platform, +}: ProfilePickerPopoverProps) { + const [open, setOpen] = useState(false); + const [pickingOverride, setPickingOverride] = useState(false); + const [showManageProfiles, setShowManageProfiles] = useState(false); + const [openToNewProfile, setOpenToNewProfile] = useState(false); + const [editProfileId, setEditProfileId] = useState(null); + + // Reset override-picker mode whenever the popover closes. + useEffect(() => { + if (!open) setPickingOverride(false); + }, [open]); + + const openEditDialog = (profileId: string) => { + setOpen(false); + setOpenToNewProfile(false); + setEditProfileId(profileId); + setShowManageProfiles(true); + }; + + const { data: combinedData } = useCombinedProfiles({ + organizationId: organizationId ?? '', + enabled: !!organizationId, + }); + const { data: personalProfilesData } = useProfiles({ + organizationId: undefined, + enabled: !organizationId, + }); + + const allProfiles: ProfileSummaryWithOwner[] = useMemo( + () => + organizationId + ? (combinedData?.allProfiles ?? []) + : (personalProfilesData ?? []).map(p => ({ ...p, ownerType: 'user' as const })), + [organizationId, combinedData, personalProfilesData] + ); + + const effectiveDefaultProfileId = organizationId + ? (combinedData?.effectiveDefaultId ?? null) + : (personalProfilesData?.find(p => p.isDefault)?.id ?? null); + + const { data: repoBindings } = useRepoBindings({ organizationId }); + + const repoBindingProfileId = useMemo(() => { + if (!repoFullName || !repoBindings) return null; + const binding = repoBindings.find( + b => + b.repoFullName.toLowerCase() === repoFullName.toLowerCase() && + (!platform || b.platform === platform) + ); + return binding?.profileId ?? null; + }, [repoFullName, repoBindings, platform]); + + // Shared resolution logic — same rules as the server-side merge. + const layers = useMemo( + () => + resolveProfileLayers({ + repoBindingProfileId, + effectiveDefaultProfileId, + explicitOverrideProfileId: selectedOverrideProfileId, + }), + [repoBindingProfileId, effectiveDefaultProfileId, selectedOverrideProfileId] + ); + + const baseProfile = useMemo( + () => + layers.automatic + ? (allProfiles.find(p => p.id === layers.automatic?.profileId) ?? null) + : null, + [allProfiles, layers.automatic] + ); + const baseSource = layers.automatic?.source ?? null; + + const overrideProfile = useMemo( + () => (layers.explicit ? (allProfiles.find(p => p.id === layers.explicit) ?? null) : null), + [allProfiles, layers.explicit] + ); + + // Profiles offered as override candidates. The automatic profile is omitted + // because picking it as override would be a no-op (deduped in + // resolveProfileLayers). + const overrideCandidates = useMemo( + () => allProfiles.filter(p => p.id !== layers.automatic?.profileId), + [allProfiles, layers.automatic] + ); + + const chipName = overrideProfile?.name ?? baseProfile?.name ?? null; + + const chipCounts = useMemo(() => { + const vars = Math.max(baseProfile?.varCount ?? 0, overrideProfile?.varCount ?? 0); + const mcps = (baseProfile?.mcpServerCount ?? 0) + (overrideProfile?.mcpServerCount ?? 0); + const skills = (baseProfile?.skillCount ?? 0) + (overrideProfile?.skillCount ?? 0); + return [vars > 0 && `${vars} vars`, mcps > 0 && `${mcps} MCP`, skills > 0 && `${skills} skills`] + .filter(Boolean) + .join(' · '); + }, [baseProfile, overrideProfile]); + + function formatCounts(profile: ProfileSummaryWithOwner) { + return [ + profile.varCount > 0 && `${profile.varCount} vars`, + profile.mcpServerCount > 0 && `${profile.mcpServerCount} MCP`, + profile.skillCount > 0 && `${profile.skillCount} skills`, + ] + .filter(Boolean) + .join(' · '); + } + + const hasOverride = !!overrideProfile; + + return ( + <> + + + + + + + {pickingOverride ? ( + { + onOverrideProfileSelect(id); + setPickingOverride(false); + }} + onBack={() => setPickingOverride(false)} + /> + ) : ( + setPickingOverride(true)} + onRemoveOverride={() => onOverrideProfileSelect(null)} + /> + )} + +
+ +
+ + +
+ + + + { + setShowManageProfiles(open); + if (!open) { + setOpenToNewProfile(false); + setEditProfileId(null); + } + }} + onProfileSelect={id => { + onOverrideProfileSelect(id); + setShowManageProfiles(false); + }} + openToNewProfile={openToNewProfile} + initialSelectedProfileId={editProfileId} + /> + + ); +} + +type PickerRowProps = { + label: string; + meta?: string; + isSelected: boolean; + onClick: () => void; +}; + +function PickerRow({ label, meta, isSelected, onClick }: PickerRowProps) { + return ( + + ); +} + +type ActiveProfileViewProps = { + baseProfile: ProfileSummaryWithOwner | null; + baseSource: 'repo-binding' | 'default' | null; + overrideProfile: ProfileSummaryWithOwner | null; + allProfilesCount: number; + overrideCandidatesCount: number; + formatCounts: (p: ProfileSummaryWithOwner) => string; + onEditProfile: (id: string) => void; + onStartPickOverride: () => void; + onRemoveOverride: () => void; +}; + +function ActiveProfileView({ + baseProfile, + baseSource, + overrideProfile, + allProfilesCount, + overrideCandidatesCount, + formatCounts, + onEditProfile, + onStartPickOverride, + onRemoveOverride, +}: ActiveProfileViewProps) { + // Nothing selected at all and no profiles exist. + if (!baseProfile && !overrideProfile && allProfilesCount === 0) { + return ( +

+ No profiles yet. Create one to add environment variables, MCP servers, and skills. +

+ ); + } + + // No base and no override, but profiles exist — let the user pick one. + if (!baseProfile && !overrideProfile) { + return ( + <> +

+ No profile selected +

+ + + ); + } + + // The profile the user sees as "active" — override takes precedence over base. + const active = overrideProfile ?? baseProfile; + if (!active) return null; + + const isOverride = !!overrideProfile; + // Only badge non-default sources — default is the expected case. + const badgeLabel = isOverride ? 'override' : baseSource === 'repo-binding' ? 'repo' : null; + + return ( + <> +

+ Active profile +

+ +
+
+ + + {badgeLabel && ( + + {badgeLabel} + + )} + {isOverride && ( + + )} +
+ + {/* Show the base profile underneath when an override is active, so the + user knows what it's layered on top of. */} + {isOverride && baseProfile && ( + + )} +
+ + {!isOverride && overrideCandidatesCount > 0 && ( +
+ +
+ )} + + ); +} + +type OverridePickerViewProps = { + candidates: ProfileSummaryWithOwner[]; + selectedOverrideProfileId: string | null; + formatCounts: (p: ProfileSummaryWithOwner) => string; + onSelect: (id: string | null) => void; + onBack: () => void; +}; + +function OverridePickerView({ + candidates, + selectedOverrideProfileId, + formatCounts, + onSelect, + onBack, +}: OverridePickerViewProps) { + return ( + <> +
+ +

+ Pick override +

+
+ + {candidates.length > 0 ? ( +
+ onSelect(null)} + /> + {candidates.map(profile => ( + onSelect(profile.id === selectedOverrideProfileId ? null : profile.id)} + /> + ))} +
+ ) : ( +

No other profiles available.

+ )} + + ); +} diff --git a/apps/web/src/components/cloud-agent/ProfilesListDialog.tsx b/apps/web/src/components/cloud-agent/ProfilesListDialog.tsx index 8d52a1eed2..902f56e8ba 100644 --- a/apps/web/src/components/cloud-agent/ProfilesListDialog.tsx +++ b/apps/web/src/components/cloud-agent/ProfilesListDialog.tsx @@ -1,7 +1,8 @@ -/** Dialog to list and manage all environment profiles with inline editing. */ +/** Dialog to list and manage all environment profiles with list+detail layout. */ 'use client'; import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; import { FolderCog, @@ -9,49 +10,64 @@ import { Plus, Loader2, AlertCircle, - ChevronDown, - ChevronRight, Eye, EyeOff, Lock, + Unlock, + Check, Trash2, Key, Terminal, Building, User, + GitBranch, + Link2Off, + Link2, + ChevronDown, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; -import { Checkbox } from '@/components/ui/checkbox'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { InlineDeleteConfirmation } from '@/components/ui/inline-delete-confirmation'; +import { cn } from '@/lib/utils'; +import { useTRPC } from '@/lib/trpc/utils'; import { useProfiles, useCombinedProfiles, useProfile, useProfileMutations, useCombinedProfileMutations, + useRepoBindings, + useBindRepoMutation, + useUnbindRepoMutation, type ProfileSummaryWithOwner, type ProfileVar, - type ProfileOwnerType, } from '@/hooks/useCloudAgentProfiles'; +import type { ProfileOwnerType } from '@kilocode/cloud-agent-profile'; +import { McpServersTab } from './profile-editor/McpServersTab'; +import { SkillsTab } from './profile-editor/SkillsTab'; +import { ProfileAgentsTab } from './profile-editor/ProfileAgentsTab'; type ProfilesListDialogProps = { organizationId?: string; open: boolean; onOpenChange: (open: boolean) => void; onProfileSelect?: (profileId: string) => void; + openToNewProfile?: boolean; + /** If provided, the dialog opens with this profile pre-selected for editing. */ + initialSelectedProfileId?: string | null; }; export function ProfilesListDialog({ @@ -59,8 +75,9 @@ export function ProfilesListDialog({ open, onOpenChange, onProfileSelect, + openToNewProfile, + initialSelectedProfileId, }: ProfilesListDialogProps) { - // Use combined profiles when in org context const combinedQuery = useCombinedProfiles({ organizationId: organizationId ?? '', enabled: open && !!organizationId, @@ -73,43 +90,52 @@ export function ProfilesListDialog({ const isLoading = organizationId ? combinedQuery.isLoading : regularQuery.isLoading; const error = organizationId ? combinedQuery.error : regularQuery.error; - // Get profiles based on context const orgProfiles = organizationId ? (combinedQuery.data?.orgProfiles ?? []) : []; const personalProfiles = organizationId ? (combinedQuery.data?.personalProfiles ?? []) : (regularQuery.data ?? []).map(p => ({ ...p, ownerType: 'user' as const })); const effectiveDefaultId = organizationId ? combinedQuery.data?.effectiveDefaultId : null; + const allProfiles: ProfileSummaryWithOwner[] = [...orgProfiles, ...personalProfiles]; - // Use combined mutations when in org context - const combinedMutations = useCombinedProfileMutations({ - organizationId: organizationId ?? '', - }); - const regularMutations = useProfileMutations({ - organizationId: undefined, - }); + const combinedMutations = useCombinedProfileMutations({ organizationId: organizationId ?? '' }); + const regularMutations = useProfileMutations({ organizationId: undefined }); + const mutations = organizationId ? combinedMutations : regularMutations; - // State for creating new profile + const [selectedId, setSelectedId] = useState(null); const [isCreating, setIsCreating] = useState(false); const [newProfileName, setNewProfileName] = useState(''); const [newProfileDescription, setNewProfileDescription] = useState(''); - const [newProfileOwnerType, setNewProfileOwnerType] = useState( - organizationId ? 'organization' : 'user' + const [newProfileOwnerType, setNewProfileOwnerType] = useState<'personal' | 'organization'>( + 'personal' ); + const [savingNew, setSavingNew] = useState(false); + const [deletingId, setDeletingId] = useState(null); - // State for expanded profile (accordion - only one at a time) - const [expandedProfileId, setExpandedProfileId] = useState(null); + // Open in "new profile" mode if requested + useEffect(() => { + if (open && openToNewProfile) { + setIsCreating(true); + setSelectedId(null); + setNewProfileName(''); + setNewProfileDescription(''); + setNewProfileOwnerType('personal'); + } + }, [open, openToNewProfile]); - // Loading states - const [deletingId, setDeletingId] = useState(null); - const [togglingDefaultId, setTogglingDefaultId] = useState(null); - const [savingId, setSavingId] = useState(null); + // When the caller asks us to open to a specific profile, honor it on open. + useEffect(() => { + if (open && !openToNewProfile && initialSelectedProfileId) { + setSelectedId(initialSelectedProfileId); + setIsCreating(false); + } + }, [open, openToNewProfile, initialSelectedProfileId]); - // Reset owner type when dialog opens in org context + // Select first profile when list loads and nothing is selected useEffect(() => { - if (open && organizationId) { - setNewProfileOwnerType('organization'); + if (!isCreating && !selectedId && allProfiles.length > 0) { + setSelectedId(allProfiles[0]?.id ?? null); } - }, [open, organizationId]); + }, [allProfiles, selectedId, isCreating]); const handleDelete = async (profile: ProfileSummaryWithOwner) => { setDeletingId(profile.id); @@ -127,57 +153,24 @@ export function ProfilesListDialog({ }); } toast.success(`Profile "${profile.name}" deleted`); - if (expandedProfileId === profile.id) { - setExpandedProfileId(null); - } - } catch (err) { - console.error('Failed to delete profile:', err); + if (selectedId === profile.id) setSelectedId(null); + } catch { toast.error('Failed to delete profile'); } finally { setDeletingId(null); } }; - const handleToggleDefault = async (profile: ProfileSummaryWithOwner) => { - setTogglingDefaultId(profile.id); - try { - const profileOrgId = profile.ownerType === 'organization' ? organizationId : undefined; - const mutations = organizationId ? combinedMutations : regularMutations; - - if (profile.isDefault) { - await mutations.clearDefault.mutateAsync({ - profileId: profile.id, - organizationId: profileOrgId, - }); - toast.success(`"${profile.name}" is no longer the default`); - } else { - await mutations.setAsDefault.mutateAsync({ - profileId: profile.id, - organizationId: profileOrgId, - }); - toast.success(`"${profile.name}" is now the default profile`); - } - } catch (err) { - console.error('Failed to toggle default:', err); - toast.error('Failed to update default profile'); - } finally { - setTogglingDefaultId(null); - } - }; - const handleCreateProfile = async () => { if (!newProfileName.trim()) { toast.error('Profile name is required'); return; } - - setSavingId('new'); + setSavingNew(true); try { - // Determine which organizationId to use based on owner type selection - const createOrgId = newProfileOwnerType === 'organization' ? organizationId : undefined; - - const mutations = organizationId ? combinedMutations : regularMutations; - const createdProfile = await mutations.createProfile.mutateAsync({ + const createOrgId = + organizationId && newProfileOwnerType === 'organization' ? organizationId : undefined; + const created = await mutations.createProfile.mutateAsync({ name: newProfileName.trim(), description: newProfileDescription.trim() || undefined, organizationId: createOrgId, @@ -186,895 +179,1440 @@ export function ProfilesListDialog({ setIsCreating(false); setNewProfileName(''); setNewProfileDescription(''); - // Expand the newly created profile for editing - if (createdProfile?.id) { - setExpandedProfileId(createdProfile.id); - } - } catch (err) { - console.error('Failed to create profile:', err); + if (created?.id) setSelectedId(created.id); + } catch { toast.error('Failed to create profile'); } finally { - setSavingId(null); + setSavingNew(false); } }; - const handleToggleExpand = (profileId: string) => { - setExpandedProfileId(prev => (prev === profileId ? null : profileId)); - }; - - const handleProfileUse = (profileId: string) => { - if (!onProfileSelect) { - return; - } - onProfileSelect(profileId); - onOpenChange(false); - }; - - const hasNoProfiles = orgProfiles.length === 0 && personalProfiles.length === 0 && !isCreating; + const isEffectiveDefault = (profile: ProfileSummaryWithOwner) => + organizationId ? profile.id === effectiveDefaultId : profile.isDefault; return ( - - - - + + + + Manage Profiles - - Create and manage environment profiles with variables and setup commands. - -
- {isLoading ? ( -
- -
- ) : error ? ( -
- - Failed to load profiles -
- ) : hasNoProfiles ? ( -
-

No profiles yet.

-

- Create a profile to save environment variables and setup commands. -

-
- ) : ( -
- {/* Organization profiles section */} - {organizationId && orgProfiles.length > 0 && ( -
-

- - Organization Profiles -

-
- {orgProfiles.map(profile => ( - handleToggleExpand(profile.id)} - onDelete={() => handleDelete(profile)} - onToggleDefault={() => handleToggleDefault(profile)} - isDeleting={deletingId === profile.id} - isTogglingDefault={togglingDefaultId === profile.id} - onProfileSelect={handleProfileUse} - isEffectiveDefault={profile.id === effectiveDefaultId} - /> - ))} -
+
+ {/* Left panel — profile list */} +
+
+ {isLoading ? ( +
+
- )} - - {/* Personal profiles section */} - {personalProfiles.length > 0 && ( -
-

- - {organizationId ? 'Personal Profiles' : 'Your Profiles'} -

-
- {personalProfiles.map(profile => ( - handleToggleExpand(profile.id)} - onDelete={() => handleDelete(profile)} - onToggleDefault={() => handleToggleDefault(profile)} - isDeleting={deletingId === profile.id} - isTogglingDefault={togglingDefaultId === profile.id} - onProfileSelect={handleProfileUse} - isEffectiveDefault={ - organizationId ? profile.id === effectiveDefaultId : profile.isDefault - } - /> - ))} -
+ ) : error ? ( +
+ + Failed to load
- )} - - {/* Create new profile form */} - {isCreating && ( -
-
- {/* Owner type selection (only in org context) */} - {organizationId && ( -
- - setNewProfileOwnerType(v as ProfileOwnerType)} - className="flex gap-4" - > -
- - -
-
- - -
-
-
- )} -
- - setNewProfileName(e.target.value)} - placeholder="New profile name" - autoFocus={!organizationId} - /> -
-
- -