From 1ca465944020043a5d76eac53025ed0fe661f4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 27 May 2026 17:58:21 -0300 Subject: [PATCH] refactor: project sidebar --- packages/kilo-console/src/client.ts | 53 ++++- .../src/components/app-sidebar/AppSidebar.tsx | 189 ++++++++++++++++-- .../routes/projects/ProjectConsoleRoute.tsx | 5 + .../src/shared/terminal-status.ts | 164 +++++++++++++++ .../src/styles/project-console.css | 4 + .../kilo-web-ui/src/styles/console-shell.css | 20 ++ 6 files changed, 415 insertions(+), 20 deletions(-) create mode 100644 packages/kilo-console/src/shared/terminal-status.ts diff --git a/packages/kilo-console/src/client.ts b/packages/kilo-console/src/client.ts index a57988c58a7..dd8603ccdf0 100644 --- a/packages/kilo-console/src/client.ts +++ b/packages/kilo-console/src/client.ts @@ -199,6 +199,38 @@ function model(input: unknown) { return { provider: input.slice(0, index), model: input.slice(index + 1) } } +function norm(input: string) { + const text = input.replace(/\\/g, "/").replace(/\/+$/, "") + return text || "/" +} + +function inside(root: string, input: string) { + const base = norm(root) + const dir = norm(input) + if (dir === base) return true + return dir.startsWith(`${base}/`) +} + +function score(item: ProjectItem, dir: string) { + return [item.worktree, ...item.sandboxes].reduce((best, root) => { + if (!inside(root, dir)) return best + return Math.max(best, norm(root).length) + }, -1) +} + +function owner(items: ProjectItem[], session: Pick) { + const exact = items.find((item) => item.id === session.projectID) + if (exact) return exact + return items.reduce<{ item?: ProjectItem; score: number }>( + (best, item) => { + const next = score(item, session.directory) + if (next <= best.score) return best + return { item, score: next } + }, + { score: -1 }, + ).item +} + async function probe(url: string) { const ctl = new AbortController() const timer = window.setTimeout(() => ctl.abort(), 400) @@ -297,7 +329,9 @@ export async function loadRecentProjects(input: ProjectQuery): Promise() for (const row of rows) { - counts.set(row.projectID, (counts.get(row.projectID) ?? 0) + 1) + const item = owner(items, row) + if (!item) continue + counts.set(item.id, (counts.get(item.id) ?? 0) + 1) } return items @@ -384,6 +418,23 @@ function pending(items: Array) { return new Set(items.map((item) => item.sessionID)) } +export type ProjectLiveStatus = { + busy: boolean + attention: boolean +} + +export async function loadProjectLiveStatus(input: ProjectQuery, dir: string): Promise { + const sdk = client({ url: input.url, dir }) + const [status, permissions, questions] = await Promise.all([ + maybe("Session status", sdk.session.status({ directory: dir })), + maybe("Pending permissions", sdk.permission.list({ directory: dir })), + maybe("Pending questions", sdk.question.list({ directory: dir })), + ]) + const busy = Object.values(status ?? {}).some((s) => s.type !== "idle") + const attention = (permissions ?? []).length > 0 || (questions ?? []).length > 0 + return { busy, attention } +} + function attention(id: string, permissions: Set, questions: Set) { if (permissions.has(id)) return "permission" if (questions.has(id)) return "question" diff --git a/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx b/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx index f169da83da9..42a359772e3 100644 --- a/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx +++ b/packages/kilo-console/src/components/app-sidebar/AppSidebar.tsx @@ -5,13 +5,30 @@ import { forgetCached, healthy, loadRecentProjects, + loadProjectLiveStatus, loadCached, saveCached, + subscribeProjectEvents, + type ProjectConsoleEvent, type RecentProjectItem, type ProjectQuery, } from "../../client" import { type Path } from "../../shared/navigation" import { clean, friendly } from "../../shared/utils" +import { + projectStatus, + projectForDir, + eventTypeName, + eventSessionId, + markError, + clearError, + markAttention, + clearAttention, + markUnread, + clearBusy, + markBusy, + type GlobalEvent, +} from "../../shared/terminal-status" const ports = new Set(["3017", "3018"]) @@ -54,9 +71,11 @@ function tail(input: URLSearchParams) { } function href(item: RecentProjectItem, input: URLSearchParams) { - return `/projects/${encodeURIComponent(item.id)}/settings${tail(input)}` + return `/projects/${encodeURIComponent(item.id)}${tail(input)}` } +// ─── component ──────────────────────────────────────────────────────────────── + type Props = { path: Path } @@ -93,18 +112,26 @@ export function AppSidebar(props: Props) { const discoverable = () => shouldDiscover(params()) const fallback = () => base(params()) const [url, setUrl] = createSignal(fallback()) + const timers = { refetch: undefined as number | undefined } + const query = createMemo(() => { const target = clean(url()) || fallback() if (!target) return undefined return { url: target, dir: "" } }) const [items, { refetch }] = createResource(query, loadRecentProjects) - const settings = () => { - return `/settings${tail(params())}` - } - const selected = (item: RecentProjectItem) => { - return loc.pathname.startsWith(`/projects/${encodeURIComponent(item.id)}/`) - } + + // project currently rendered by ProjectConsoleRoute — it owns unread tracking for its terminals + const activeProject = createMemo(() => { + const match = loc.pathname.match(/^\/projects\/([^/]+)/) + return match ? decodeURIComponent(match[1]) : undefined + }) + + const settings = () => `/settings${tail(params())}` + const selected = (item: RecentProjectItem) => + loc.pathname.startsWith(`/projects/${encodeURIComponent(item.id)}/`) || + loc.pathname === `/projects/${encodeURIComponent(item.id)}` + const nav = () => [ { href: "/projects", label: "Projects", name: "projects", path: "/projects" }, ] as const @@ -113,6 +140,16 @@ export function AppSidebar(props: Props) { { href: settings(), label: "Settings", name: "settings", path: "/settings" }, ] as const + function scheduleRefetch() { + if (timers.refetch) return + timers.refetch = window.setTimeout(() => { + timers.refetch = undefined + void refetch() + }, 150) + } + + // ── server URL tracking ────────────────────────────────────────────────────── + createEffect(() => { const next = params().get("server") if (next && next !== url()) setUrl(next) @@ -153,12 +190,117 @@ export function AppSidebar(props: Props) { }) }) + // ── polling fallback ───────────────────────────────────────────────────────── + createEffect(() => { if (!query()) return const timer = window.setInterval(() => void refetch(), 5000) onCleanup(() => window.clearInterval(timer)) }) + // ── initial status hydration ───────────────────────────────────────────────── + // Called whenever items reload. Fills busy + attention from server state. + // Unread is written by ProjectConsoleRoute (same-project) and SSE turn.close (cross-project). + + createEffect(() => { + const list = items() + const current = query() + if (!list || !current) return + for (const item of list) { + void loadProjectLiveStatus(current, item.worktree) + .then((s) => { + // busy: project has at least one non-idle session + // we don't have per-session IDs here, use a sentinel "__hydrated__" + if (s.busy) markBusy(item.id, "__hydrated__") + else clearBusy(item.id, "__hydrated__") + if (s.attention) markAttention(item.id, "__hydrated__") + else clearAttention(item.id, "__hydrated__") + }) + .catch(() => {}) + } + }) + + // ── SSE event handler ──────────────────────────────────────────────────────── + + createEffect(() => { + const current = query() + if (!current) return + + const stop = subscribeProjectEvents(current, (event) => { + const ge = event as unknown as GlobalEvent + const list = items() ?? [] + + // refresh project list on session lifecycle events + const t = eventTypeName(ge) + if (t.startsWith("session.created") || t.startsWith("session.updated") || t.startsWith("session.deleted")) { + scheduleRefetch() + } + + if (!list.length || !ge.directory) return + const proj = projectForDir(list, ge.directory) + if (!proj) return + + const sid = eventSessionId(ge) ?? "__unknown__" + + // session.turn.close: error | completed + if (t === "session.turn.close") { + const payload = (ge.payload as Record).properties as + | { reason?: string } + | undefined + if (payload?.reason === "error") { + markError(proj.id, sid) + } else { + clearError(proj.id, sid) + if (payload?.reason === "completed") { + // When the user is on this project, ProjectConsoleRoute owns unread tracking + // with per-terminal awareness (it knows which terminal is active). + // AppSidebar only handles cross-project: projects the user is NOT currently viewing. + if (proj.id !== activeProject()) { + markUnread(proj.id, sid) + } + } + } + return + } + + // permission / question → attention + if (t === "permission.asked" || t === "question.asked") { + markAttention(proj.id, sid) + return + } + if ( + t === "permission.replied" || + t === "permission.rejected" || + t === "question.replied" || + t === "question.rejected" + ) { + clearAttention(proj.id, sid) + return + } + + // session.status → busy / idle + if (t === "session.status") { + const sstatus = (ge.payload as Record).properties as + | { status?: { type?: string } } + | undefined + const stype = sstatus?.status?.type + if (stype === "busy" || stype === "retry") { + markBusy(proj.id, sid) + } else if (stype === "idle") { + clearBusy(proj.id, sid) + clearError(proj.id, sid) + } + return + } + }) + + onCleanup(stop) + }) + + onCleanup(() => { + if (timers.refetch) window.clearTimeout(timers.refetch) + }) + return (