Merge pull request #11434 from Kilo-Org/zest-canoe

feat: add account profile to Kilo Console
This commit is contained in:
Catriel Müller
2026-06-18 16:03:39 -03:00
committed by GitHub
11 changed files with 1278 additions and 5 deletions
+44
View File
@@ -11,6 +11,7 @@ import type {
GlobalHealthResponse,
GlobalEvent,
KiloEmbeddingModelCatalog,
KiloProfileResponse,
LspStatusResponse,
McpStatusResponse,
Pty as PtyInfo,
@@ -42,6 +43,8 @@ export type Query = {
export type ProjectQuery = Pick<Query, "url" | "dir">
export type KiloProfileData = KiloProfileResponse
export type ProjectItem = KiloProject
export type RecentProjectItem = ProjectItem & {
sessions: number
@@ -210,6 +213,11 @@ function model(input: unknown) {
return { provider: input.slice(0, index), model: input.slice(index + 1) }
}
function directory(input: ProjectQuery) {
const dir = value(input.dir)
return dir ? { directory: dir } : undefined
}
function title(input: string) {
return input
.split("_")
@@ -407,6 +415,42 @@ export async function loadEmbeddingModels(input: Query): Promise<KiloEmbeddingMo
return demand("Kilo embedding models", await client(input).indexing.models())
}
export async function loadKiloProfile(input: ProjectQuery): Promise<KiloProfileData> {
const sdk = client(input)
const result = await sdk.kilo.profile(directory(input))
return demand("Kilo profile", result)
}
export async function setKiloOrganization(input: ProjectQuery, organizationId: string | null) {
const sdk = client(input)
const result = await sdk.kilo.organization.set({ ...directory(input), organizationId })
demand("Switch Kilo account", result)
await sdk.global.dispose()
}
export async function logoutKilo(input: ProjectQuery) {
const sdk = client(input)
const result = await sdk.auth.remove({ providerID: "kilo" })
demand("Log out of Kilo", result)
await sdk.global.dispose()
}
export async function startKiloLogin(input: ProjectQuery): Promise<ProviderAuthAuthorization> {
const sdk = client(input)
const result = await sdk.provider.oauth.authorize({ ...directory(input), providerID: "kilo", method: 0 })
return demand("Start Kilo login", result)
}
export async function completeKiloLogin(input: ProjectQuery, signal?: AbortSignal) {
const sdk = client(input)
const result = await sdk.provider.oauth.callback(
{ ...directory(input), providerID: "kilo", method: 0 },
signal ? { signal } : undefined,
)
demand("Complete Kilo login", result)
await sdk.global.dispose()
}
export async function loadProjects(input: ProjectQuery): Promise<ProjectItem[]> {
const sdk = client(input)
const dir = value(input.dir)
+2
View File
@@ -6,6 +6,7 @@ import "./styles.css"
import { ProjectConsoleRoute } from "./routes/projects/ProjectConsoleRoute"
import { ProjectsRoute } from "./routes/projects/ProjectsRoute"
import { ProfileRoute } from "./routes/profile/ProfileRoute"
import { LoginRoute } from "./routes/profile/LoginRoute"
import { ConfigLayout } from "./layouts/ConfigLayout"
import { configSections } from "./routes/config/sections"
@@ -27,6 +28,7 @@ render(
{routes()}
</Route>
<Route path="/profile" component={ProfileRoute} />
<Route path="/kilo/login" component={LoginRoute} />
<Route path="/settings" component={ConfigLayout}>
{routes()}
</Route>
@@ -0,0 +1,244 @@
import { useLocation, useNavigate } from "@solidjs/router"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { createEffect, createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { LoadingScreen } from "../../components/LoadingScreen"
import { completeKiloLogin, loadKiloProfile, startKiloLogin, type ProjectQuery } from "../../client"
import { errMsg } from "../../shared/utils"
import { markDisconnected, page, parseDeviceCode, safeReturn } from "./profile-utils"
import { useProfileServer } from "./server"
type Status = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled"
type State = {
status: Status
code?: string
url?: string
expiresIn?: number
error?: string
}
function time(input: number) {
const min = Math.floor(input / 60)
const sec = input % 60
return `${min}:${sec.toString().padStart(2, "0")}`
}
export function LoginRoute() {
const loc = useLocation()
const nav = useNavigate()
const params = createMemo(() => new URLSearchParams(loc.search))
const server = useProfileServer(params)
const [auth, setAuth] = createSignal<State>({ status: "idle" })
const [left, setLeft] = createSignal(900)
const [attempt, setAttempt] = createSignal(0)
const [active, setActive] = createSignal<AbortController>()
const [copied, setCopied] = createSignal("")
const ret = () => safeReturn(params().get("return"))
const profile = () => page(params(), "/profile")
function abort() {
active()?.abort()
setActive(undefined)
}
function done() {
window.setTimeout(() => nav(ret(), { replace: true }), 650)
}
function start(input: ProjectQuery | undefined = server.query()) {
if (!input) return
abort()
const ctl = new AbortController()
const rev = attempt() + 1
setAttempt(rev)
setActive(ctl)
setAuth({ status: "initiating" })
void startKiloLogin(input)
.then((info) => {
if (attempt() !== rev) return false
setAuth({
status: "pending",
code: parseDeviceCode(info.instructions),
url: info.url,
expiresIn: 900,
})
return completeKiloLogin(input, ctl.signal).then(() => true)
})
.then((ok) => {
if (!ok || attempt() !== rev) return
setAuth({ status: "success" })
void loadKiloProfile(input)
.then(() => markDisconnected(false))
.catch(() => markDisconnected(false))
.finally(done)
})
.catch((err) => {
if (attempt() !== rev) return
setAuth({ status: "error", error: errMsg(err) })
})
.finally(() => {
if (active() === ctl) setActive(undefined)
})
}
function cancel() {
abort()
setAttempt((value) => value + 1)
setAuth({ status: "cancelled" })
}
function copy(label: string, value: string | undefined) {
if (!value) return
void navigator.clipboard
.writeText(value)
.then(() => {
setCopied(label)
window.setTimeout(() => {
if (copied() === label) setCopied("")
}, 1400)
})
.catch((err) => setAuth({ status: "error", error: errMsg(err) }))
}
function open() {
const url = auth().url
if (!url) return
window.open(url, "_blank", "noopener,noreferrer")
}
createEffect(() => {
const current = server.query()
if (!current || auth().status !== "idle") return
start(current)
})
createEffect(() => {
const state = auth()
if (state.status !== "pending") return
setLeft(state.expiresIn ?? 900)
const timer = window.setInterval(() => setLeft((value) => Math.max(0, value - 1)), 1000)
onCleanup(() => window.clearInterval(timer))
})
createEffect(() => {
if (auth().status === "success") server.remember()
})
onCleanup(() => {
abort()
setAttempt((value) => value + 1)
})
return (
<section class="route-empty">
<div class="profile-login-page">
<header class="profile-login-header">
<p class="eyebrow">Kilo Login</p>
<h1>Login to Kilo</h1>
<p>Authorize this Kilo Console through the same device auth flow used by the editor clients.</p>
</header>
<Show when={!server.query() && server.discoverable()}>
<LoadingScreen variant="fullscreen" />
</Show>
<Show when={!server.query() && !server.discoverable()}>
<Card class="profile-login-card" variant="warning">
<strong>Kilo server not found</strong>
<p>Start a local Kilo server or pass a server URL with ?server=.</p>
<a class="profile-link-button" href={profile()}>
Back to Profile
</a>
</Card>
</Show>
<Show when={server.query()}>
<Card class="profile-login-card">
<Switch>
<Match when={auth().status === "idle" || auth().status === "initiating"}>
<div class="profile-login-state">
<span class="profile-spinner" aria-hidden="true" />
<strong>Starting login...</strong>
<p>Preparing a secure browser authorization request.</p>
</div>
</Match>
<Match when={auth().status === "pending"}>
<div class="profile-login-flow">
<div class="profile-login-step">
<span>Step 1</span>
<strong>Open this URL</strong>
<div class="profile-login-url">
<span>{auth().url}</span>
<Button variant="secondary" type="button" onClick={() => copy("url", auth().url)}>
{copied() === "url" ? "Copied" : "Copy"}
</Button>
</div>
<Button variant="primary" type="button" onClick={open}>
Open Browser
</Button>
</div>
<Show when={auth().code}>
{(code) => (
<div class="profile-login-step">
<span>Step 2</span>
<strong>Enter this code</strong>
<button type="button" class="profile-login-code" onClick={() => copy("code", code())}>
{code()}
<small>{copied() === "code" ? "Copied" : "Click to copy"}</small>
</button>
</div>
)}
</Show>
<div class="profile-login-waiting">
<span class="profile-spinner" aria-hidden="true" />
<span>Waiting for authorization ({time(left())})</span>
</div>
<Button variant="ghost" type="button" onClick={cancel}>
Cancel
</Button>
</div>
</Match>
<Match when={auth().status === "success"}>
<div class="profile-login-state success">
<strong>Login successful</strong>
<p>Redirecting back to your profile.</p>
</div>
</Match>
<Match when={auth().status === "error"}>
<div class="profile-login-state error">
<strong>Login failed</strong>
<p>{auth().error}</p>
<div class="profile-login-actions">
<Button variant="primary" type="button" onClick={() => start()}>
Try Again
</Button>
<a class="profile-link-button" href={profile()}>
Back to Profile
</a>
</div>
</div>
</Match>
<Match when={auth().status === "cancelled"}>
<div class="profile-login-state">
<strong>Login cancelled</strong>
<p>No credentials were saved.</p>
<Button variant="primary" type="button" onClick={() => start()}>
Start Again
</Button>
</div>
</Match>
</Switch>
</Card>
</Show>
</div>
</section>
)
}
@@ -1,9 +1,315 @@
import { A, useLocation, useNavigate } from "@solidjs/router"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { LoadingScreen } from "../../components/LoadingScreen"
import { loadKiloProfile, logoutKilo, setKiloOrganization, type KiloProfileData, type ProjectQuery } from "../../client"
import { errMsg } from "../../shared/utils"
import {
authError,
cloud,
credits,
initials,
markDisconnected,
money,
page,
personal,
usage,
wasDisconnected,
} from "./profile-utils"
import { useProfileServer } from "./server"
type Org = NonNullable<KiloProfileData["profile"]["organizations"]>[number]
type State = { kind: "connected"; data: KiloProfileData } | { kind: "disconnected" }
async function load(input: ProjectQuery): Promise<State> {
if (wasDisconnected()) return { kind: "disconnected" }
try {
const data = await loadKiloProfile(input)
markDisconnected(false)
return { kind: "connected", data }
} catch (err) {
if (authError(err)) {
markDisconnected(true)
return { kind: "disconnected" }
}
throw err
}
}
function org(data: KiloProfileData | undefined) {
if (!data?.currentOrgId) return undefined
return data.profile.organizations?.find((item) => item.id === data.currentOrgId)
}
function account(data: KiloProfileData | undefined) {
return org(data)?.name ?? "Personal Account"
}
function role(input: Org) {
if (input.role === "owner") return "Owner"
if (input.role === "admin") return "Admin"
if (input.role === "billing_manager") return "Billing"
return "Member"
}
export function ProfileRoute() {
const loc = useLocation()
const nav = useNavigate()
const params = createMemo(() => new URLSearchParams(loc.search))
const server = useProfileServer(params)
const [data, actions] = createResource(server.query, load)
const [saving, setSaving] = createSignal<string>()
const [error, setError] = createSignal("")
const profile = createMemo(() => {
const state = data()
if (state?.kind === "connected") return state.data
return undefined
})
const orgs = createMemo(() => profile()?.profile.organizations ?? [])
const active = createMemo(() => org(profile()))
const disconnected = createMemo(() => data()?.kind === "disconnected")
const scope = createMemo(() => {
if (profile()) return account(profile())
if (data.loading) return "Loading..."
return "Not connected"
})
const login = () => page(params(), "/kilo/login")
const overview = () => page(params(), "/profile")
const usageUrl = createMemo(() => usage(profile()?.currentOrgId))
const creditsUrl = createMemo(() => credits(profile()?.currentOrgId))
createEffect(() => {
if (profile()) server.remember()
})
createEffect(() => {
const err = data.error
if (!err || authError(err)) return
server.recover()
})
function refresh() {
setError("")
void actions.refetch()
}
function choose(id: string | null) {
const current = server.query()
if (!current) return
const next = id ?? personal
const selected = profile()?.currentOrgId ?? null
if (selected === id) return
setSaving(next)
setError("")
void setKiloOrganization(current, id)
.then(() => actions.refetch())
.catch((err) => setError(errMsg(err)))
.finally(() => setSaving(undefined))
}
function logout() {
const current = server.query()
if (!current) return
setSaving("logout")
setError("")
void logoutKilo(current)
.then(() => {
markDisconnected(true)
actions.mutate({ kind: "disconnected" })
nav(overview(), { replace: true })
})
.catch((err) => setError(errMsg(err)))
.finally(() => setSaving(undefined))
}
return (
<section class="route-empty">
<p class="eyebrow">Profile</p>
<h1>Profile Settings</h1>
<p>Profile identity, account preferences, and workspace defaults will live here.</p>
<section class="config-shell profile-shell" classList={{ disconnected: disconnected() }}>
<Show when={!disconnected()}>
<aside class="config-sidebar" aria-label="Profile sections">
<div class="config-sidebar-title">
<span>Profile</span>
<span class="config-sidebar-scope">
<span>{scope()}</span>
</span>
</div>
<nav class="config-options">
<A class="config-top-option active" href={overview()} aria-current="page">
<span>Overview</span>
</A>
<a class="config-top-option" href={usageUrl()} target="_blank" rel="noreferrer">
<span>Usage</span>
</a>
<a class="config-top-option" href={creditsUrl()} target="_blank" rel="noreferrer">
<span>Buy Credits</span>
</a>
</nav>
</aside>
</Show>
<section class="content">
<div class="profile-page">
<Show when={!disconnected()}>
<header class="profile-header">
<div>
<p class="eyebrow">Kilo Account</p>
<h1>Your Profile</h1>
<p>Manage your Kilo identity, account context, credits, and billing shortcuts.</p>
</div>
<div class="profile-actions">
<Button variant="secondary" type="button" onClick={refresh} disabled={data.loading || !server.query()}>
Refresh
</Button>
<a
class="profile-primary-link"
data-component="button"
data-size="default"
data-variant="primary"
href={cloud()}
target="_blank"
rel="noreferrer"
>
Open Dashboard
</a>
</div>
</header>
</Show>
<Show when={!server.query() && server.discoverable()}>
<LoadingScreen variant="fullscreen" />
</Show>
<Show when={data.loading && !data()}>
<LoadingScreen variant="fullscreen" />
</Show>
<Show when={disconnected()}>
<Card class="profile-connect-card">
<span class="profile-connect-mark" aria-hidden="true">
KG
</span>
<div class="profile-connect-copy">
<p class="eyebrow">Kilo Account</p>
<h1>Connect your Kilo account</h1>
<p>Sign in to view your credits, organizations, and account details in Kilo Console.</p>
</div>
<A
class="profile-primary-link"
data-component="button"
data-size="default"
data-variant="primary"
href={login()}
>
Connect
</A>
</Card>
</Show>
<Show when={!server.query() && !server.discoverable()}>
<Card class="profile-banner" variant="warning">
<strong>Kilo server not found</strong>
<span>Start Kilo Console from a running Kilo server or pass a server URL with ?server=.</span>
</Card>
</Show>
<Show when={data.error && !authError(data.error)}>
<Card class="profile-banner" variant="error">
<strong>Profile request failed</strong>
<span>{errMsg(data.error)}</span>
</Card>
</Show>
<Show when={error()}>
<Card class="profile-banner" variant="error">
<strong>Account update failed</strong>
<span>{error()}</span>
</Card>
</Show>
<Show when={profile()}>
{(info) => (
<div class="profile-grid">
<Card class="profile-card profile-account-card">
<div class="profile-account-head">
<span class="profile-avatar" aria-hidden="true">
{initials(info().profile.name, info().profile.email)}
</span>
<div class="profile-account-text">
<strong>{info().profile.name || info().profile.email}</strong>
<span>{info().profile.email}</span>
</div>
</div>
<div class="profile-meta-row">
<span>Active account</span>
<strong>{account(info())}</strong>
</div>
<div class="profile-card-actions">
<Button variant="ghost" type="button" onClick={logout} disabled={Boolean(saving())}>
Log Out
</Button>
</div>
</Card>
<Card class="profile-card profile-balance-card">
<span class="profile-card-label">Remaining Credits</span>
<strong>{money(info().balance?.balance)}</strong>
<p>
Credits shown for {account(info())}. Switch accounts below to see organization balances when
available.
</p>
</Card>
<Card class="profile-card profile-wide-card">
<header class="profile-card-head">
<div>
<span class="profile-card-label">Organizations</span>
<h2>Your Accounts</h2>
</div>
<span class="profile-pill">{orgs().length === 1 ? "1 org" : `${orgs().length} orgs`}</span>
</header>
<div class="profile-org-list">
<button
type="button"
class="profile-org-row"
classList={{ active: !info().currentOrgId }}
onClick={() => choose(null)}
disabled={Boolean(saving())}
>
<span class="profile-org-icon" aria-hidden="true">
KG
</span>
<span class="profile-org-body">
<strong>Personal Account</strong>
<span>Your personal Kilo credits and settings</span>
</span>
<span class="profile-org-state">{!info().currentOrgId ? "Current" : "Use"}</span>
</button>
<For each={orgs()}>
{(item) => (
<button
type="button"
class="profile-org-row"
classList={{ active: active()?.id === item.id }}
onClick={() => choose(item.id)}
disabled={Boolean(saving())}
>
<span class="profile-org-icon" aria-hidden="true">
{initials(item.name, item.name)}
</span>
<span class="profile-org-body">
<strong>{item.name}</strong>
<span>{role(item)}</span>
</span>
<span class="profile-org-state">{active()?.id === item.id ? "Current" : "Use"}</span>
</button>
)}
</For>
</div>
</Card>
</div>
)}
</Show>
</div>
</section>
</section>
)
}
@@ -0,0 +1,44 @@
import { expect, test } from "bun:test"
import { authError, credits, initials, money, page, parseDeviceCode, safeReturn, usage } from "./profile-utils"
test("parses device auth codes from instructions", () => {
expect(parseDeviceCode("Open https://app.kilo.ai/device-auth and enter code: ABCD-2345")).toBe("ABCD-2345")
expect(parseDeviceCode("Use ABCD-2345 to continue")).toBe("ABCD-2345")
expect(parseDeviceCode(undefined)).toBeUndefined()
})
test("detects Kilo auth failures", () => {
expect(authError(new Error("Kilo profile: Unauthorized"))).toBe(true)
expect(authError({ status: 401 })).toBe(true)
expect(authError(new Error("Kilo profile: temporary network failure"))).toBe(false)
})
test("formats account display helpers", () => {
expect(money(12.5)).toBe("$12.50")
expect(money(null)).toBe("Unknown")
expect(initials("Jane Developer", "jane@example.com")).toBe("JD")
expect(initials("", "solo@example.com")).toBe("SE")
})
test("keeps login returns internal", () => {
expect(safeReturn("/profile?server=http%3A%2F%2F127.0.0.1%3A4097")).toBe(
"/profile?server=http%3A%2F%2F127.0.0.1%3A4097",
)
expect(safeReturn("https://app.kilo.ai/profile")).toBe("/profile")
expect(safeReturn("//app.kilo.ai/profile")).toBe("/profile")
})
test("builds local links while preserving server", () => {
const params = new URLSearchParams({ server: "http://127.0.0.1:4097", ignored: "1" })
expect(page(params, "/kilo/login", { return: "/profile" })).toBe(
"/kilo/login?server=http%3A%2F%2F127.0.0.1%3A4097&return=%2Fprofile",
)
})
test("builds account-aware cloud links", () => {
const id = "9d4b144c-0a2b-477b-973d-24fa02bebf13"
expect(usage(null)).toBe("https://app.kilo.ai/usage")
expect(usage(id)).toBe(`https://app.kilo.ai/organizations/${id}/usage-details`)
expect(credits(undefined)).toBe("https://app.kilo.ai/profile")
expect(credits(id)).toBe(`https://app.kilo.ai/organizations/${id}`)
})
@@ -0,0 +1,80 @@
export const personal = "personal"
let marked = false
export function markDisconnected(input: boolean) {
marked = input
}
export function wasDisconnected() {
return marked
}
function text(input: unknown) {
if (input instanceof Error) return input.message
if (typeof input === "string") return input
if (input === undefined || input === null) return ""
return JSON.stringify(input)
}
export function parseDeviceCode(input: string | undefined) {
if (!input) return undefined
const code = input.match(/code:\s*([A-Z0-9-]+)/i)?.[1]
if (code) return code.toUpperCase()
return input.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/i)?.[1]?.toUpperCase()
}
export function authError(input: unknown) {
const value = text(input).toLowerCase()
return value.includes("unauthorized") || value.includes("invalid token") || value.includes('status":401')
}
export function money(input: number | null | undefined) {
if (typeof input !== "number" || !Number.isFinite(input)) return "Unknown"
return `$${input.toFixed(2)}`
}
export function initials(name: string | undefined, email: string) {
const parts = (name?.trim() || email)
.split(/[\s._@-]+/)
.filter(Boolean)
.slice(0, 2)
const value = parts.map((part) => part[0] ?? "").join("")
return value.toUpperCase() || "KG"
}
export function safeReturn(input: string | null | undefined) {
if (!input || !input.startsWith("/") || input.startsWith("//")) return "/profile"
try {
const url = new URL(input, "http://localhost")
if (url.origin !== "http://localhost") return "/profile"
return `${url.pathname}${url.search}${url.hash}`
} catch {
return "/profile"
}
}
export function page(params: URLSearchParams, path: string, extra?: Record<string, string | null | undefined>) {
const next = new URLSearchParams()
const server = params.get("server")
if (server) next.set("server", server)
for (const [key, value] of Object.entries(extra ?? {})) {
if (value) next.set(key, value)
}
const query = next.toString()
return `${path}${query ? `?${query}` : ""}`
}
export function cloud(path = "/profile") {
return `https://app.kilo.ai${path}`
}
export function usage(id: string | null | undefined) {
if (!id) return cloud("/usage")
return cloud(`/organizations/${encodeURIComponent(id)}/usage-details`)
}
export function credits(id: string | null | undefined) {
if (!id) return cloud("/profile")
return cloud(`/organizations/${encodeURIComponent(id)}`)
}
@@ -0,0 +1,71 @@
import { createEffect, createMemo, createSignal } from "solid-js"
import {
discover,
forgetCached,
loadCached,
resolveServer,
saveCached,
type ProjectQuery,
} from "../../client"
import { clean } from "../../shared/utils"
const ports = new Set(["3017", "3018"])
function shouldDiscover(input: URLSearchParams) {
if (input.get("server")) return false
return ports.has(window.location.port)
}
function base(input: URLSearchParams) {
const param = input.get("server")
if (param) return param
const cached = shouldDiscover(input) ? loadCached() : ""
if (cached) return cached
if (shouldDiscover(input)) return ""
return window.location.origin
}
export function useProfileServer(params: () => URLSearchParams) {
const [url, setUrl] = createSignal(base(params()))
const discoverable = () => shouldDiscover(params())
const query = createMemo<ProjectQuery | undefined>(() => {
const target = clean(url()) || base(params())
if (!target) return undefined
return { url: target, dir: "" }
})
createEffect(() => {
const next = params().get("server")
if (next && next !== url()) setUrl(next)
})
createEffect(() => {
if (!discoverable()) return
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
function remember() {
const current = query()
if (!current || !discoverable()) return
saveCached(current.url)
}
function recover() {
if (!discoverable()) return
const cached = loadCached()
if (!cached || cached !== url()) return
forgetCached()
setUrl("")
void discover().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
}
return { query, discoverable, remember, recover }
}
@@ -11,6 +11,7 @@ test("classifies routes after stripping the console base", () => {
expect(path("/console/projects/demo/settings/agents", "/console")).toBe("/project")
expect(path("/console/settings/agents", "/console")).toBe("/settings")
expect(path("/console/profile", "/console")).toBe("/profile")
expect(path("/console/kilo/login", "/console")).toBe("/profile")
})
test("builds settings roots without preserving the console base", () => {
@@ -21,7 +21,7 @@ export function settings(input: string, prefix = base()) {
export function path(input: string, prefix = base()): Path {
const route = strip(input, prefix)
if (route === "/profile") return "/profile"
if (route === "/profile" || route.startsWith("/kilo/login")) return "/profile"
if (route.startsWith("/settings") || route.startsWith("/config")) return "/settings"
if (route.startsWith("/projects/")) return "/project"
return "/projects"
+1
View File
@@ -14,6 +14,7 @@
@import "./styles/agents-tools.css";
@import "./styles/projects.css";
@import "./styles/project-console.css";
@import "./styles/profile.css";
@import "./styles/servers.css";
@import "./styles/sources.css";
@import "./styles/responsive.css";
@@ -0,0 +1,480 @@
.kilo-console .profile-page,
.kilo-console .profile-login-page {
display: grid;
gap: 1rem;
max-width: 64rem;
margin: 0 auto;
}
.kilo-console .profile-login-page {
max-width: 34rem;
}
.kilo-console .profile-shell.disconnected {
grid-template-columns: minmax(0, 1fr);
}
.kilo-console .profile-header,
.kilo-console .profile-login-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: start;
padding-bottom: 0.5rem;
}
.kilo-console .profile-login-header {
grid-template-columns: 1fr;
text-align: center;
}
.kilo-console .profile-header h1,
.kilo-console .profile-login-header h1 {
margin: 0;
color: var(--foreground);
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.kilo-console .profile-header p:not(.eyebrow),
.kilo-console .profile-login-header p:not(.eyebrow) {
max-width: 42rem;
margin: 0.625rem 0 0;
color: var(--muted-foreground);
font-size: 0.8125rem;
line-height: 1.45;
}
.kilo-console .profile-actions,
.kilo-console .profile-card-actions,
.kilo-console .profile-login-actions {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
}
.kilo-console .profile-primary-link {
text-decoration: none;
}
.kilo-console .profile-connect-card {
display: grid;
min-height: 18rem;
place-items: center;
align-content: center;
gap: 1.25rem;
text-align: center;
}
.kilo-console .profile-connect-mark {
display: grid;
width: 3.5rem;
height: 3.5rem;
place-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background:
linear-gradient(135deg, color-mix(in oklab, var(--primary) 32%, transparent), transparent),
color-mix(in oklab, var(--muted) 70%, transparent);
color: var(--foreground);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.kilo-console .profile-connect-copy {
display: grid;
max-width: 28rem;
gap: 0.625rem;
}
.kilo-console .profile-connect-copy h1,
.kilo-console .profile-connect-copy p {
margin: 0;
}
.kilo-console .profile-connect-copy h1 {
color: var(--foreground);
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.kilo-console .profile-connect-copy p:not(.eyebrow) {
color: var(--muted-foreground);
font-size: 0.8125rem;
line-height: 1.5;
}
.kilo-console .profile-link-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--input-base);
color: var(--foreground);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.45;
padding: 0.25rem 0.625rem;
text-decoration: none;
transition:
background 120ms ease,
border-color 120ms ease,
color 120ms ease;
}
.kilo-console .profile-link-button:hover,
.kilo-console .profile-link-button:focus-visible {
border-color: var(--ring);
background: var(--muted);
color: var(--foreground);
outline: 0;
}
.kilo-console .profile-banner {
margin-bottom: 0;
}
.kilo-console .profile-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.kilo-console .profile-card {
gap: 0.875rem;
}
.kilo-console .profile-wide-card {
grid-column: 1 / -1;
}
.kilo-console .profile-account-card,
.kilo-console .profile-balance-card {
min-height: 10rem;
}
.kilo-console .profile-account-head {
display: flex;
gap: 0.875rem;
align-items: center;
min-width: 0;
}
.kilo-console .profile-avatar,
.kilo-console .profile-org-icon {
display: grid;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--border);
background:
linear-gradient(135deg, color-mix(in oklab, var(--primary) 28%, transparent), transparent),
color-mix(in oklab, var(--muted) 70%, transparent);
color: var(--foreground);
font-weight: 700;
letter-spacing: 0.06em;
}
.kilo-console .profile-avatar {
width: 3.25rem;
height: 3.25rem;
border-radius: var(--radius-lg);
font-size: 1rem;
}
.kilo-console .profile-org-icon {
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
}
.kilo-console .profile-account-text {
display: grid;
min-width: 0;
gap: 0.125rem;
}
.kilo-console .profile-account-text strong,
.kilo-console .profile-card-head h2,
.kilo-console .profile-login-state strong,
.kilo-console .profile-login-step strong {
margin: 0;
color: var(--foreground);
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.25;
}
.kilo-console .profile-account-text span,
.kilo-console .profile-login-state p,
.kilo-console .profile-login-step > span,
.kilo-console .profile-login-waiting {
color: var(--muted-foreground);
font-size: 0.75rem;
line-height: 1.45;
}
.kilo-console .profile-account-text span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .profile-meta-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in oklab, var(--muted) 35%, transparent);
padding: 0.75rem;
}
.kilo-console .profile-meta-row span,
.kilo-console .profile-card-label {
color: var(--muted-foreground);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.kilo-console .profile-meta-row strong {
color: var(--foreground);
font-size: 0.75rem;
font-weight: 600;
}
.kilo-console .profile-balance-card strong {
color: var(--foreground);
font-size: clamp(2rem, 6vw, 3.25rem);
font-weight: 700;
letter-spacing: -0.05em;
line-height: 0.95;
}
.kilo-console .profile-balance-card p,
.kilo-console .profile-login-state p {
margin: 0;
}
.kilo-console .profile-card-head {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
}
.kilo-console .profile-pill {
display: inline-flex;
align-items: center;
min-height: 1.5rem;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted-foreground);
font-size: 0.6875rem;
font-weight: 600;
padding: 0 0.625rem;
text-decoration: none;
text-transform: uppercase;
}
.kilo-console .profile-org-list {
display: grid;
gap: 0.5rem;
}
.kilo-console .profile-org-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
min-height: 3.5rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
color: var(--foreground);
cursor: pointer;
font: inherit;
padding: 0.75rem;
text-align: left;
transition:
background 120ms ease,
border-color 120ms ease;
}
.kilo-console .profile-org-row:hover,
.kilo-console .profile-org-row:focus-visible,
.kilo-console .profile-org-row.active {
border-color: var(--ring);
background: color-mix(in oklab, var(--muted) 45%, transparent);
outline: 0;
}
.kilo-console .profile-org-row:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.kilo-console .profile-org-body {
display: grid;
min-width: 0;
gap: 0.125rem;
}
.kilo-console .profile-org-body strong {
overflow: hidden;
color: var(--foreground);
font-size: 0.8125rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .profile-org-body span,
.kilo-console .profile-org-state {
color: var(--muted-foreground);
font-size: 0.6875rem;
}
.kilo-console .profile-org-state {
font-weight: 600;
}
.kilo-console .profile-login-card {
min-height: 18rem;
}
.kilo-console .profile-login-state,
.kilo-console .profile-login-flow,
.kilo-console .profile-login-step {
display: grid;
gap: 0.875rem;
}
.kilo-console .profile-login-state {
place-items: center;
min-height: 14rem;
text-align: center;
}
.kilo-console .profile-login-state.error strong {
color: var(--destructive);
}
.kilo-console .profile-login-state.success strong {
color: var(--success, var(--primary));
}
.kilo-console .profile-login-step {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--muted) 25%, transparent);
padding: 1rem;
}
.kilo-console .profile-login-url {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem;
align-items: center;
}
.kilo-console .profile-login-url span {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--input-base);
color: var(--foreground);
font-family: var(--font-family-mono, monospace);
font-size: 0.75rem;
padding: 0.5rem 0.625rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .profile-login-code {
display: grid;
gap: 0.25rem;
place-items: center;
border: 1px solid var(--ring);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--primary) 12%, var(--input-base));
color: var(--foreground);
cursor: pointer;
font-family: var(--font-family-mono, monospace);
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.16em;
padding: 1rem;
}
.kilo-console .profile-login-code small {
color: var(--muted-foreground);
font-family: var(--font-family-sans, system-ui, sans-serif);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0;
}
.kilo-console .profile-login-waiting {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.kilo-console .profile-spinner {
width: 1rem;
height: 1rem;
border: 2px solid color-mix(in oklab, var(--muted-foreground) 30%, transparent);
border-top-color: var(--primary);
border-radius: 999px;
animation: profile-spin 900ms linear infinite;
}
@keyframes profile-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 760px) {
.kilo-console .profile-header,
.kilo-console .profile-grid {
grid-template-columns: 1fr;
}
.kilo-console .profile-actions,
.kilo-console .profile-card-actions,
.kilo-console .profile-login-actions {
justify-content: flex-start;
}
.kilo-console .profile-login-url {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.kilo-console .profile-org-row {
grid-template-columns: auto minmax(0, 1fr);
}
.kilo-console .profile-org-state {
grid-column: 2;
}
.kilo-console .profile-login-code {
font-size: 1.5rem;
}
}