fix: respect unavailable personal Kilo accounts

This commit is contained in:
Mark IJbema
2026-07-07 11:46:34 +02:00
parent 61b9e0935c
commit 81213b9f25
23 changed files with 100 additions and 35 deletions
+1 -1
View File
@@ -3,4 +3,4 @@
"@kilocode/kilo-gateway": patch
---
Use the cloud-selected organization as the active Kilo account when provided.
Use cloud account preferences to select the active Kilo organization and hide unavailable personal accounts.
+2
View File
@@ -26,6 +26,7 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
name?: string
organizations?: Organization[]
selectedOrganizationId?: string | null
hasPersonalAccount?: boolean | null
}
// Backend returns { user: { email, name, ... }, organizations }
// Transform to flat KilocodeProfile structure
@@ -34,6 +35,7 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
name: data.user?.name ?? data.name,
organizations: data.organizations,
selectedOrganizationId: data.selectedOrganizationId ?? undefined,
hasPersonalAccount: data.hasPersonalAccount ?? undefined,
}
}
@@ -102,6 +102,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
name: z.string().optional(),
organizations: z.array(Organization).optional(),
selectedOrganizationId: z.string().optional(),
hasPersonalAccount: z.boolean().optional(),
})
const Balance = z.object({
+11 -6
View File
@@ -56,6 +56,7 @@ export function formatProfileInfo(
export function getOrganizationOptions(
organizations: Organization[],
currentOrgId?: string,
hasPersonalAccount = true,
): Array<{
title: string
value: string | null
@@ -63,12 +64,16 @@ export function getOrganizationOptions(
category: string
}> {
return [
{
title: "Personal Account",
value: null,
description: !currentOrgId ? "→ (current)" : undefined,
category: "Accounts",
},
...(hasPersonalAccount
? [
{
title: "Personal Account",
value: null,
description: !currentOrgId ? "→ (current)" : undefined,
category: "Accounts",
},
]
: []),
...organizations.map((org) => ({
title: org.name,
value: org.id,
+1
View File
@@ -28,6 +28,7 @@ export interface KilocodeProfile {
name?: string
organizations?: Organization[]
selectedOrganizationId?: string
hasPersonalAccount?: boolean
}
export interface KilocodeBalance {
@@ -143,6 +143,7 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
organizations = p.profile.organizations.orEmpty().map { org ->
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
},
hasPersonalAccount = p.profile.hasPersonalAccount ?: true,
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) },
kiloPass = p.kiloPass?.let {
ProfileKiloPassDto(
@@ -790,6 +790,7 @@ class KiloBackendAppServiceTest {
"profile":{
"email":"alice@test.com",
"name":"Alice",
"hasPersonalAccount":false,
"organizations":[{"id":"org_1","name":"Acme","role":"ADMIN"}]
},
"balance":{"balance":42.5},
@@ -804,6 +805,7 @@ class KiloBackendAppServiceTest {
assertEquals("alice@test.com", dto.profile?.email)
assertEquals("Alice", dto.profile?.name)
assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role)
assertFalse(dto.profile?.hasPersonalAccount ?: true)
assertEquals(42.5, dto.profile?.balance?.balance)
assertEquals("org_1", dto.profile?.currentOrgId)
}
@@ -124,7 +124,8 @@ internal class SessionAccountOverlay(
var layout = false
val orgs = prof.organizations
val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) +
val personal = prof.hasPersonalAccount
val next = (if (personal) listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) else emptyList()) +
orgs.map { org -> AccountChoice(org.id, org.name) }
if (next != choices) {
choices = next
@@ -133,7 +134,7 @@ internal class SessionAccountOverlay(
if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId
val activeId = if (switching) target else prof.currentOrgId
val activeId = if (switching) target else prof.currentOrgId ?: if (personal) null else orgs.firstOrNull()?.id
val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull()
val title = "${active?.title ?: " "}"
if (picker.text != title) {
@@ -331,12 +331,11 @@ internal class LoggedInProfileUi(
@RequiresEdt
private fun applyOrganizations(profile: ProfileDto) {
val orgs = profile.organizations
val keys: List<Pair<String?, String>> = listOf(null to KiloBundle.message("profile.personalAccount")) +
val personal = profile.hasPersonalAccount
val keys: List<Pair<String?, String>> = (if (personal) listOf(null to KiloBundle.message("profile.personalAccount")) else emptyList()) +
orgs.map { it.id to it.name }
val target = profile.currentOrgId
?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) }
?: 0
val target = keys.indexOfFirst { it.first == profile.currentOrgId }.takeIf { it >= 0 } ?: 0
currentOrgId = profile.currentOrgId
@@ -42,10 +42,12 @@ class SessionAccountOverlayTest : SessionControllerTestBase() {
organizations: List<ProfileOrganizationDto> = emptyList(),
balance: ProfileBalanceDto? = null,
currentOrgId: String? = null,
hasPersonalAccount: Boolean = true,
) = ProfileDto(
email = email,
name = name,
organizations = organizations,
hasPersonalAccount = hasPersonalAccount,
balance = balance,
currentOrgId = currentOrgId,
)
@@ -106,6 +108,22 @@ class SessionAccountOverlayTest : SessionControllerTestBase() {
}
}
fun `test profile without personal account hides personal choice`() {
val acme = org("org_1", "Acme", "MEMBER")
val prof = profile(
email = "user@example.com",
organizations = listOf(acme),
currentOrgId = "org_1",
hasPersonalAccount = false,
)
show(snap(prof))
edt {
assertEquals("Acme", panel.accountTitle())
assertEquals(1, panel.choiceCount())
assertEquals(0, panel.selectedIndex())
}
}
// --- test 4: programmatic update does not call select callback ---
fun `test programmatic update does not call select callback`() {
@@ -155,6 +155,7 @@ data class ProfileDto(
val email: String,
val name: String? = null,
val organizations: List<ProfileOrganizationDto> = emptyList(),
val hasPersonalAccount: Boolean = true,
val balance: ProfileBalanceDto? = null,
val kiloPass: ProfileKiloPassDto? = null,
val currentOrgId: String? = null,
@@ -32,6 +32,7 @@ export interface KilocodeProfile {
name?: string
organizations?: KilocodeOrganization[]
selectedOrganizationId?: string
hasPersonalAccount?: boolean
}
export interface KilocodeBalance {
@@ -65,20 +65,23 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
const orgOptions = createMemo<OrgOption[]>(() => {
const orgs = props.profileData?.profile.organizations ?? []
if (orgs.length === 0) return []
const personal = props.profileData?.profile.hasPersonalAccount !== false
return [
{ value: PERSONAL, label: language.t("profile.personalAccount") },
...(personal ? [{ value: PERSONAL, label: language.t("profile.personalAccount") }] : []),
...orgs.map((org) => ({ value: org.id, label: org.name, description: org.role })),
]
})
const currentOrg = createMemo(() => {
const id = props.profileData?.currentOrgId ?? PERSONAL
const personal = props.profileData?.profile.hasPersonalAccount !== false
const id = props.profileData?.currentOrgId ?? (personal ? PERSONAL : orgOptions()[0]?.value)
return orgOptions().find((o) => o.value === id)
})
const selectOrg = (option: OrgOption | undefined) => {
if (!option) return
const current = props.profileData?.currentOrgId ?? PERSONAL
const personal = props.profileData?.profile.hasPersonalAccount !== false
const current = props.profileData?.currentOrgId ?? (personal ? PERSONAL : orgOptions()[0]?.value)
if (option.value === current) return
setTarget(option.value)
vscode.postMessage({
@@ -258,7 +261,13 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
</div>
{/* Kilo Pass is part of personal credits, so only show it on the personal account */}
<Show when={(data().currentOrgId ?? null) === null ? data().kiloPass : null}>
<Show
when={
(data().currentOrgId ?? null) === null && data().profile.hasPersonalAccount !== false
? data().kiloPass
: null
}
>
{(pass) => (
<div
style={{
@@ -331,7 +340,13 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
</Show>
{/* No active Kilo Pass on the personal account — nudge to subscribe */}
<Show when={(data().currentOrgId ?? null) === null && !data().kiloPass}>
<Show
when={
(data().currentOrgId ?? null) === null &&
data().profile.hasPersonalAccount !== false &&
!data().kiloPass
}
>
<div
style={{
"border-top": "1px solid var(--border-weak-base)",
@@ -24,8 +24,9 @@ export const AccountSwitcher: Component<{ class?: string }> = (props) => {
const profile = () => server.profileData()
const orgs = () => profile()?.profile.organizations ?? []
const personal = () => profile()?.profile.hasPersonalAccount !== false
const visible = () => !!profile() && orgs().length > 0
const current = () => profile()?.currentOrgId ?? PERSONAL
const current = () => profile()?.currentOrgId ?? (personal() ? PERSONAL : (orgs()[0]?.id ?? PERSONAL))
const selected = createMemo(() => {
const id = current()
@@ -119,15 +120,17 @@ export const AccountSwitcher: Component<{ class?: string }> = (props) => {
<Show when={open() && !switching()}>
<div class="account-switcher-dropdown" role="listbox" aria-label="Account">
<button
type="button"
role="option"
aria-selected={current() === PERSONAL}
class="account-switcher-item"
onClick={() => pick(null)}
>
<span class="account-switcher-item-name">{language.t("profile.personalAccount")}</span>
</button>
<Show when={personal()}>
<button
type="button"
role="option"
aria-selected={current() === PERSONAL}
class="account-switcher-item"
onClick={() => pick(null)}
>
<span class="account-switcher-item-name">{language.t("profile.personalAccount")}</span>
</button>
</Show>
<For each={orgs()}>
{(org) => (
<button
@@ -31,6 +31,7 @@ export interface ProfileData {
name?: string
organizations?: Array<{ id: string; name: string; role: string }>
selectedOrganizationId?: string
hasPersonalAccount?: boolean
}
balance: KilocodeBalance | null
kiloPass: KiloPassState | null
@@ -83,11 +83,12 @@ export function KiloAutoMethod(props: KiloAutoMethodProps) {
await sync.bootstrap()
dialog.replace(() => (
<DialogKiloOrganization
organizations={profile.organizations!}
userEmail={profile.email}
providerID={props.providerID}
useSDK={props.useSDK}
<DialogKiloOrganization
organizations={profile.organizations!}
userEmail={profile.email}
providerID={props.providerID}
hasPersonalAccount={profile.hasPersonalAccount !== false}
useSDK={props.useSDK}
useTheme={props.useTheme}
DialogModel={props.DialogModel}
/>
@@ -21,6 +21,7 @@ interface DialogKiloOrganizationProps {
organizations: Organization[]
userEmail: string
providerID: string
hasPersonalAccount?: boolean
useSDK: () => UseSDK
useTheme: () => UseTheme
DialogModel: DialogModel
@@ -33,7 +34,7 @@ export function DialogKiloOrganization(props: DialogKiloOrganizationProps) {
const toast = useToast()
// Get formatted options with current markers
const options = getOrganizationOptions(props.organizations)
const options = getOrganizationOptions(props.organizations, undefined, props.hasPersonalAccount !== false)
// Pre-select first organization (user requirement)
const defaultSelection = getDefaultOrganizationSelection(props.organizations)
@@ -12,12 +12,17 @@ import { getOrganizationOptions } from "@kilocode/kilo-gateway/tui"
interface DialogKiloTeamSelectProps {
organizations: Organization[]
currentOrgId?: string | null
hasPersonalAccount?: boolean
onSelect: (orgId: string | null) => Promise<void>
}
export function DialogKiloTeamSelect(props: DialogKiloTeamSelectProps) {
// Get formatted options with current markers
const options = getOrganizationOptions(props.organizations, props.currentOrgId || undefined)
const options = getOrganizationOptions(
props.organizations,
props.currentOrgId || undefined,
props.hasPersonalAccount !== false,
)
return (
<DialogSelect
@@ -218,6 +218,7 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
<DialogKiloTeamSelect
organizations={profile.organizations!}
currentOrgId={currentOrgId}
hasPersonalAccount={profile.hasPersonalAccount !== false}
onSelect={async (orgId) => {
try {
// Switch to team immediately using server endpoint
@@ -22,6 +22,7 @@ export const Profile = Schema.Struct({
name: Schema.optional(Schema.String),
organizations: Schema.optional(Schema.Array(Organization)),
selectedOrganizationId: Schema.optional(Schema.String),
hasPersonalAccount: Schema.optional(Schema.Boolean),
})
export const Balance = Schema.Struct({
@@ -190,6 +190,7 @@ describe("Kilo PublicApi OpenAPI contract", () => {
expect(profile?.balance).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
expect(profile?.kiloPass).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
expect(profile?.profile?.properties?.selectedOrganizationId).toEqual({ type: "string" })
expect(profile?.profile?.properties?.hasPersonalAccount).toEqual({ type: "boolean" })
const pass = profile?.kiloPass?.anyOf?.find((item) => item.type === "object")?.properties
expect(pass?.nextBillingAt).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
expect(profile?.currentOrgId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
+1
View File
@@ -10788,6 +10788,7 @@ export type KiloProfileResponses = {
role: string
}>
selectedOrganizationId?: string
hasPersonalAccount?: boolean
}
balance: {
balance: number
+3
View File
@@ -13512,6 +13512,9 @@
},
"selectedOrganizationId": {
"type": "string"
},
"hasPersonalAccount": {
"type": "boolean"
}
},
"required": ["email"],