diff --git a/packages/kilo-config-ui/src/client.ts b/packages/kilo-config-ui/src/client.ts index 8ff7773b6b7..39797ba9621 100644 --- a/packages/kilo-config-ui/src/client.ts +++ b/packages/kilo-config-ui/src/client.ts @@ -6,7 +6,6 @@ import type { AppAgentsResponse, ConfigOverlayResponse, ConfigModelStateResponse, - ConfigRulesResponse, ConfigSourcesResponse, FormatterStatusResponse, GlobalHealthResponse, @@ -17,6 +16,7 @@ import type { ProviderAuthResponse, ProviderListResponse, ToolIdsResponse, + ToolListResponse, TuiConfigGetResponse, } from "@kilocode/sdk/v2/client" @@ -31,18 +31,21 @@ export type Query = { export type ProjectQuery = Pick export type ProjectItem = KiloProject +export type RecentProjectItem = ProjectItem & { + sessions: number +} export type Snapshot = { health: GlobalHealthResponse effective: EffectiveConfig overlay: ConfigOverlayResponse sources: ConfigSourcesResponse - rules?: ConfigRulesResponse modelState: ConfigModelStateResponse providers: ProviderListResponse authMethods: ProviderAuthResponse tui: TuiConfigGetResponse tools: ToolIdsResponse + toolDetails: ToolListResponse mcp: McpStatusResponse lsp: LspStatusResponse formatter: FormatterStatusResponse @@ -75,6 +78,8 @@ type Result = { } const ports = Array.from({ length: 20 }, (_, index) => 4097 + index) +const day = 24 * 60 * 60 * 1000 +const hidden = new Set(["global"]) const key = "kilo.config.server" const auth = `Basic ${btoa("kilo:kilo")}` @@ -110,6 +115,28 @@ function demand(label: string, result: Result) { return result.data } +async function maybe(label: string, result: Promise>) { + return await result + .then((value) => { + if (value.error) { + console.warn(`${label}: ${message(value.error)}`) + return undefined + } + return value.data + }) + .catch((err) => { + console.warn(`${label}: ${message(err)}`) + return undefined + }) +} + +function model(input: unknown) { + if (typeof input !== "string") return undefined + const index = input.indexOf("/") + if (index <= 0 || index >= input.length - 1) return undefined + return { provider: input.slice(0, index), model: input.slice(index + 1) } +} + async function probe(url: string) { const ctl = new AbortController() const timer = window.setTimeout(() => ctl.abort(), 400) @@ -150,34 +177,34 @@ export async function discover() { export async function load(input: Query): Promise { const sdk = client(input) - const [health, overlay, modelState, providers, authMethods, tui, tools, mcp, lsp, formatter, agents, rules] = - await Promise.all([ - sdk.global.health(), - sdk.config.overlay({ scope: input.scope }), - sdk.config.modelState(), - sdk.provider.list(), - sdk.provider.auth(), - sdk.tui.config.get(), - sdk.tool.ids(), - sdk.mcp.status(), - sdk.lsp.status(), - sdk.formatter.status(), - sdk.app.agents(), - input.scope === "project" ? sdk.config.rules() : Promise.resolve({ data: undefined }), - ]) + const [health, overlay, modelState, providers, authMethods, tui, tools, mcp, lsp, formatter, agents] = await Promise.all([ + sdk.global.health(), + sdk.config.overlay({ scope: input.scope }), + sdk.config.modelState(), + sdk.provider.list(), + sdk.provider.auth(), + sdk.tui.config.get(), + sdk.tool.ids(), + sdk.mcp.status(), + sdk.lsp.status(), + sdk.formatter.status(), + sdk.app.agents(), + ]) const resolved = demand("Config overlay", overlay) + const ref = model(resolved.effective.model) + const info = ref ? await maybe("Tool metadata", sdk.tool.list(ref)) : undefined return { health: demand("Health", health), effective: resolved.effective, overlay: resolved, sources: { sources: resolved.sources }, - rules: input.scope === "project" ? demand("Rules", rules) : undefined, modelState: demand("Model state", modelState), providers: demand("Providers", providers), authMethods: demand("Provider auth methods", authMethods), tui: demand("TUI config", tui), tools: demand("Tools", tools), + toolDetails: info ?? [], mcp: demand("MCP status", mcp), lsp: demand("LSP status", lsp), formatter: demand("Formatter status", formatter), @@ -192,6 +219,29 @@ export async function loadProjects(input: ProjectQuery): Promise return demand("Projects", result) } +export async function loadVisibleProjects(input: ProjectQuery): Promise { + const items = await loadProjects(input) + return items.filter((item) => !hidden.has(item.id)) +} + +export async function loadRecentProjects(input: ProjectQuery): Promise { + const sdk = client(input) + const [items, sessions] = await Promise.all([ + loadVisibleProjects(input), + sdk.experimental.session.list({ archived: false, start: Date.now() - day, limit: 500 }), + ]) + const rows = demand("Recent sessions", sessions) + const counts = new Map() + + for (const row of rows) { + counts.set(row.projectID, (counts.get(row.projectID) ?? 0) + 1) + } + + return items + .map((item) => ({ ...item, sessions: counts.get(item.id) ?? 0 })) + .filter((item) => item.sessions > 0) +} + export async function saveConfig(input: Query, patch: Partial) { const sdk = client(input) const result = await sdk.config.overlayUpdate({ scope: input.scope, set: patch }) @@ -204,12 +254,6 @@ export async function unsetConfig(input: Query, unset: ConfigUnset) { return demand("Update config", result) } -export async function saveRules(input: Query, content: string) { - const sdk = client(input) - const result = await sdk.config.rulesUpdate({ content }) - return demand("Update rules", result) -} - export async function saveModelState(input: Query, favorite: ModelRef[]) { const sdk = client(input) const result = await sdk.config.modelStateUpdate({ favorite }) diff --git a/packages/kilo-config-ui/src/components/app-sidebar/AppSidebar.tsx b/packages/kilo-config-ui/src/components/app-sidebar/AppSidebar.tsx index 7dcd78aa114..4ad47d6ea28 100644 --- a/packages/kilo-config-ui/src/components/app-sidebar/AppSidebar.tsx +++ b/packages/kilo-config-ui/src/components/app-sidebar/AppSidebar.tsx @@ -1,7 +1,62 @@ import { A, useLocation } from "@solidjs/router" -import { For } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, For, onCleanup } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { projects, type Path } from "../../shared/navigation" +import { + discover, + forgetCached, + healthy, + loadRecentProjects, + loadCached, + saveCached, + type RecentProjectItem, + type ProjectQuery, +} from "../../client" +import { type Path } from "../../shared/navigation" +import { clean, friendly } 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 +} + +function repo(input: string) { + const parts = input.split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? "Global" +} + +function name(item: RecentProjectItem) { + return friendly(item.name?.trim() || repo(item.worktree) || item.id.slice(0, 8)) +} + +function mark(item: RecentProjectItem) { + const parts = name(item) + .split(/[\s._/-]+/) + .filter(Boolean) + const text = `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? parts[0]?.[1] ?? ""}` + return text.toUpperCase() || "KG" +} + +function tail(input: URLSearchParams) { + const next = new URLSearchParams(input) + next.delete("directory") + const query = next.toString() + return query ? `?${query}` : "" +} + +function href(item: RecentProjectItem, input: URLSearchParams) { + return `/projects/${encodeURIComponent(item.id)}/settings${tail(input)}` +} type Props = { path: Path @@ -9,28 +64,77 @@ type Props = { export function AppSidebar(props: Props) { const loc = useLocation() - const search = () => { - const params = new URLSearchParams(loc.search) - params.delete("directory") - const query = params.toString() - return query ? `?${query}` : "" - } + const params = createMemo(() => new URLSearchParams(loc.search)) + const discoverable = () => shouldDiscover(params()) + const fallback = () => base(params()) + const [url, setUrl] = createSignal(fallback()) + const query = createMemo(() => { + const target = clean(url()) || fallback() + if (!target) return undefined + return { url: target, dir: "" } + }) + const [items, { refetch }] = createResource(query, loadRecentProjects) const settings = () => { - const suffix = search() - return `/settings${suffix}` + return `/settings${tail(params())}` } + createEffect(() => { + const next = params().get("server") + if (next && next !== url()) setUrl(next) + }) + + createEffect(() => { + if (!discoverable()) return + const cached = loadCached() + void Promise.resolve(cached ? healthy(cached) : false) + .then((ok) => { + if (ok) return cached + forgetCached() + return discover() + }) + .then((value) => { + if (!value) return + saveCached(value) + setUrl(value) + }) + }) + + createEffect(() => { + const current = query() + if (!items() || !current || !discoverable()) return + saveCached(current.url) + }) + + createEffect(() => { + if (!items.error || !discoverable()) return + const cached = loadCached() + if (!cached || cached !== url()) return + forgetCached() + setUrl("") + void discover().then((value) => { + if (!value) return + saveCached(value) + setUrl(value) + }) + }) + + createEffect(() => { + if (!query()) return + const timer = window.setInterval(() => void refetch(), 5000) + onCleanup(() => window.clearInterval(timer)) + }) + return (