Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .specs/cloud-agent-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ repository.
16,000 characters.
14. A repository matches every environment that contains it. When exactly one
matches, it MUST apply with no choice.
15. When several match, the new-session form in the web UI MUST require the user
to choose one and MUST NOT start the session until they do. A request that
15. When several match, the new-session form in the web UI MUST require a valid
choice and MUST NOT start the session without one. The form MUST
remember the last choice per repository and personal/organization scope and
restore it only while it still matches the repository. A request that
marks the choice as required and names no environment MUST fail with a
conflict that names the matching environments. A request that names an
environment that does not contain the repository MUST fail.
Expand All @@ -131,9 +133,9 @@ repository.
names one is ignored, but a request that lists additional repositories MUST
fail.
17. A session on a multi-repository environment MUST ignore the repository's
profile binding and the implicit default profile. A profile the user
selects explicitly MUST still apply. The new-session form MUST show the same
profiles that will apply.
profile binding while applying the effective default profile. A profile the
user selects explicitly MUST replace the default. The new-session form MUST
show the same profiles that will apply.
18. A session on a multi-repository environment MUST run in an isolated sandbox.
One repository MUST run at `/workspace/<name>`. Several MUST each be cloned
at `/workspace/<name>`, and the agent and setup MUST start at `/workspace`.
Expand Down Expand Up @@ -661,6 +663,12 @@ The following use SHOULD and are not enforced today:

## Changelog

### 2026-11-03 -- Environment selection preferences

- Remember the environment choice per repository and personal/organization scope.
- Apply the effective default profile to multi-repository environments unless an
explicit profile replaces it. Repository bindings remain ignored.

### 2026-11-03 -- Snapshot notice removal

- Removed the snapshot-reuse notice requirement to match the simplified
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"graphql": "16.14.2",
"jest": "30.3.0",
"knip": "6.39.0",
"linkedom": "0.18.12",
"postcss": "8.5.23",
"tailwindcss": "4.2.4",
"tsconfig-paths": "4.2.0",
Expand Down
10 changes: 3 additions & 7 deletions apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useRouter } from 'next/navigation';
import { useAtom, useSetAtom } from 'jotai';
import { toast } from 'sonner';
import { v4 as uuidv4 } from 'uuid';
import { appliesImplicitProfiles } from '@kilocode/cloud-agent-profile';
import {
AlertCircle,
Brain,
Expand Down Expand Up @@ -538,14 +537,11 @@ export function NewSessionPanel({ organizationId }: NewSessionPanelProps) {
// Prefers an explicit selection; otherwise falls back to the effective
// default (personal default wins over org default). This mirrors the
// server-side resolution in mergeProfileConfiguration for a useful preview.
// A V2 environment applies no implicit default, only an explicit selection.
const effectiveAgentProfileId =
selectedProfileId ??
(!appliesImplicitProfiles(repositoryEnvironment)
? null
: organizationId
? combinedProfilesData?.effectiveDefaultId
: (personalProfiles?.find(p => p.isDefault)?.id ?? null)) ??
(organizationId
? combinedProfilesData?.effectiveDefaultId
: (personalProfiles?.find(p => p.isDefault)?.id ?? null)) ??
null;
const effectiveAgentProfileOrg =
effectiveAgentProfileId && organizationId
Expand Down
125 changes: 111 additions & 14 deletions apps/web/src/components/cloud-agent/ProfilePickerPopover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,31 @@ const PROFILE = {
kiloCommandCount: 3,
};

function renderPicker(
selectedOverrideProfileId: string | null = null,
repoFullName?: string
): string {
const SECOND_PROFILE = { ...PROFILE, id: 'profile-2', name: 'Development' };

type RenderOptions = {
selectedOverrideProfileId?: string | null;
repoFullName?: string;
environment?: { repositories?: unknown } | null;
organizationId?: string;
};

function renderPicker(options: RenderOptions = {}): string {
return renderToStaticMarkup(
createElement(ProfilePickerPopover, {
organizationId: 'org-1',
selectedOverrideProfileId,
repoFullName,
organizationId: 'organizationId' in options ? options.organizationId : 'org-1',
selectedOverrideProfileId: options.selectedOverrideProfileId ?? null,
repoFullName: options.repoFullName,
environment: options.environment,
onOverrideProfileSelect: jest.fn(),
})
);
}

function triggerText(markup: string): string {
return markup.split('</button>')[0].replace(/<[^>]*>/g, '');
}

describe('ProfilePickerPopover trigger', () => {
beforeEach(() => {
mockCombined = {
Expand All @@ -84,10 +95,12 @@ describe('ProfilePickerPopover trigger', () => {
{ selectedOverrideProfileId: 'profile-2', repoFullName: undefined, name: 'Development' },
{ selectedOverrideProfileId: 'profile-2', repoFullName: 'acme/repo', name: 'Development' },
])('shows only $name for override $selectedOverrideProfileId and repo $repoFullName', props => {
const markup = renderPicker(props.selectedOverrideProfileId, props.repoFullName);
const triggerText = markup.split('</button>')[0].replace(/<[^>]*>/g, '');
const markup = renderPicker({
selectedOverrideProfileId: props.selectedOverrideProfileId,
repoFullName: props.repoFullName,
});

expect(triggerText).toBe(props.name);
expect(triggerText(markup)).toBe(props.name);
expect(markup).toContain('2 vars · 2 MCP · 6 skills · 3 cmds');
});

Expand All @@ -99,11 +112,95 @@ describe('ProfilePickerPopover trigger', () => {
refetch: jest.fn(),
};

const triggerText = renderPicker()
.split('</button>')[0]
.replace(/<[^>]*>/g, '');
expect(triggerText(renderPicker())).toBe('No profile');
});
});

describe('ProfilePickerPopover environment profiles', () => {
const v2WithOneRepository = { repositories: [{}] };
const v2WithSeveralRepositories = { repositories: [{}, {}] };

beforeEach(() => {
// The repo binding points at a different profile than the default, so a
// leaked binding would surface as a second active layer.
mockCombined = {
data: { allProfiles: [PROFILE, SECOND_PROFILE], effectiveDefaultId: SECOND_PROFILE.id },
isLoading: false,
error: undefined,
refetch: jest.fn(),
};
mockPersonal = { data: [], isLoading: false, error: undefined, refetch: jest.fn() };
mockRepoBindings = [{ repoFullName: 'acme/repo', platform: 'github', profileId: PROFILE.id }];
});

it.each([
{ repositories: 'one repository', environment: v2WithOneRepository },
{ repositories: 'several repositories', environment: v2WithSeveralRepositories },
])(
'shows the default and no repository binding for a V2 environment with $repositories',
({ environment }) => {
const markup = renderPicker({ environment, repoFullName: 'acme/repo' });

expect(triggerText(markup)).toBe('Development');
expect(markup).toContain('Active profile');
expect(markup).not.toContain('Production');
expect(markup).not.toContain('on top of');
}
);

it('lets an explicit profile replace the default', () => {
const markup = renderPicker({
environment: v2WithOneRepository,
repoFullName: 'acme/repo',
selectedOverrideProfileId: PROFILE.id,
});

expect(triggerText(markup)).toBe('Production');
expect(markup).not.toContain('Development');
expect(markup).not.toContain('on top of');
});

it('restores the default when the explicit profile is cleared', () => {
const markup = renderPicker({
environment: v2WithOneRepository,
repoFullName: 'acme/repo',
selectedOverrideProfileId: null,
});

expect(triggerText(markup)).toBe('Development');
});

it('shows no profile when there is no default and no explicit selection', () => {
mockCombined = {
data: { allProfiles: [PROFILE], effectiveDefaultId: null },
isLoading: false,
error: undefined,
refetch: jest.fn(),
};

const markup = renderPicker({ environment: v2WithOneRepository, repoFullName: 'acme/repo' });

expect(triggerText(markup)).toBe('No profile');
expect(markup).toContain('Pick a profile');
expect(markup).not.toContain('Active profile');
});

it('applies the personal default for a V2 environment', () => {
mockPersonal = {
data: [{ ...PROFILE, isDefault: true }],
isLoading: false,
error: undefined,
refetch: jest.fn(),
};

const markup = renderPicker({
environment: v2WithOneRepository,
repoFullName: 'acme/repo',
organizationId: undefined,
});

expect(triggerText).toBe('No profile');
expect(triggerText(markup)).toBe('Production');
expect(markup).not.toContain('on top of');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type ProfilePickerPopoverProps = {
onOverrideProfileSelect: (id: string | null) => void;
repoFullName?: string;
platform?: 'github' | 'gitlab';
/** The session's environment. A V2 environment skips the binding and the implicit default. */
/** The session's environment. A V2 environment skips the repo binding; the effective default still applies. */
environment?: { repositories?: unknown } | null;
};

Expand Down
16 changes: 7 additions & 9 deletions apps/web/src/hooks/useEnvironmentProfiles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { appliesImplicitProfiles, resolveProfileLayers } from '@kilocode/cloud-agent-profile';
import { appliesRepoBindings, resolveProfileLayers } from '@kilocode/cloud-agent-profile';
import {
useCombinedProfiles,
useProfile,
Expand All @@ -18,7 +18,7 @@ export function useEnvironmentProfiles({
organizationId?: string;
repository?: { platform: string; fullName: string };
selectedProfileId?: string | null;
/** The environment being edited or used. A V2 environment skips the binding and the implicit default. */
/** The environment being edited or used. A V2 environment skips the repo binding; the effective default still applies. */
environment?: { repositories?: unknown } | null;
}) {
const personal = useProfiles({ enabled: !organizationId });
Expand All @@ -28,9 +28,9 @@ export function useEnvironmentProfiles({
});
const bindings = useRepoBindings({ organizationId });
const summaries = organizationId ? combined.data?.allProfiles : personal.data;
const implicitProfiles = appliesImplicitProfiles(environment);
const binds = appliesRepoBindings(environment);
const binding =
!implicitProfiles || repository?.platform === 'bitbucket'
!binds || repository?.platform === 'bitbucket'
? undefined
: bindings.data?.find(
candidate =>
Expand All @@ -39,11 +39,9 @@ export function useEnvironmentProfiles({
);
const layers = resolveProfileLayers({
repoBindingProfileId: binding?.profileId ?? null,
effectiveDefaultProfileId: !implicitProfiles
? null
: organizationId
? (combined.data?.effectiveDefaultId ?? null)
: (personal.data?.find(profile => profile.isDefault)?.id ?? null),
effectiveDefaultProfileId: organizationId
? (combined.data?.effectiveDefaultId ?? null)
: (personal.data?.find(profile => profile.isDefault)?.id ?? null),
explicitOverrideProfileId: selectedProfileId ?? null,
});
const ownerFor = (id: string | undefined) =>
Expand Down
Loading
Loading