refactor: project sidebar

This commit is contained in:
Catriel Müller
2026-05-27 17:58:21 -03:00
parent 7fc3bc09d4
commit 1ca4659440
6 changed files with 415 additions and 20 deletions
+52 -1
View File
@@ -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<KiloSession, "projectID" | "directory">) {
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<RecentPro
const counts = new Map<string, number>()
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<PermissionRequest | QuestionRequest>) {
return new Set(items.map((item) => item.sessionID))
}
export type ProjectLiveStatus = {
busy: boolean
attention: boolean
}
export async function loadProjectLiveStatus(input: ProjectQuery, dir: string): Promise<ProjectLiveStatus> {
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<string>, questions: Set<string>) {
if (permissions.has(id)) return "permission"
if (questions.has(id)) return "question"
@@ -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<ProjectQuery | undefined>(() => {
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<string, unknown>).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<string, unknown>).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 (
<aside class="rail" aria-label="Primary navigation">
<nav class="rail-nav" aria-label="Primary">
@@ -180,18 +322,27 @@ export function AppSidebar(props: Props) {
<div class="rail-favorites" aria-label="Projects with recent sessions">
<For each={items() ?? []}>
{(item) => (
<A
class="favorite-project"
classList={{ active: selected(item) }}
href={href(item, params())}
aria-label={name(item)}
aria-current={selected(item) ? "page" : undefined}
title={name(item)}
>
{mark(item)}
</A>
)}
{(item) => {
const status = () => projectStatus(item.id)
return (
<A
class="favorite-project"
classList={{
active: selected(item),
"status-error": status() === "error",
"status-attention": status() === "attention",
"status-unread": status() === "unread",
"status-busy": status() === "busy",
}}
href={href(item, params())}
aria-label={name(item)}
aria-current={selected(item) ? "page" : undefined}
title={name(item)}
>
{mark(item)}
</A>
)
}}
</For>
</div>
@@ -24,6 +24,7 @@ import {
type Query,
} from "../../client"
import { clean, errMsg, friendly } from "../../shared/utils"
import { markUnread as storeMarkUnread, clearUnread as storeClearUnread, sessionHasUnread } from "../../shared/terminal-status"
import { GhosttyTerminal } from "./terminal/GhosttyTerminal"
const ui = new Set(["3017", "3018"])
@@ -242,6 +243,8 @@ export function ProjectConsoleRoute() {
next.delete(id)
return next
})
const pid = snap()?.project.id
if (pid && id) storeClearUnread(pid, id)
}
function markUnread(id: string) {
@@ -250,6 +253,8 @@ export function ProjectConsoleRoute() {
if (old.has(id)) return old
return new Set([...old, id])
})
const pid = snap()?.project.id
if (pid) storeMarkUnread(pid, id)
}
function scheduleRefetch() {
@@ -0,0 +1,164 @@
/**
* Global reactive store for per-project terminal status.
*
* State is organized as Map<projectId, Set<sessionId>> for each category.
* Any component can read or write to this store — it is the single source of
* truth for the status indicators shown in the rail and in the terminal list.
*
* Population sources:
* - SSE events wired up once in AppSidebar (cross-project + initial hydration)
* - ProjectConsoleRoute (same-project events + message-level unread tracking)
*/
import { createSignal } from "solid-js"
import type { ProjectItem } from "../client"
// ─── types ────────────────────────────────────────────────────────────────────
export type ProjectStatus = "error" | "attention" | "unread" | "busy" | "idle"
const PRIORITY: ProjectStatus[] = ["error", "attention", "unread", "busy", "idle"]
// ─── internal signals (Map<projectId, Set<sessionId>>) ────────────────────────
const [_errors, setErrors] = createSignal(new Map<string, Set<string>>())
const [_attention, setAttention] = createSignal(new Map<string, Set<string>>())
const [_unread, setUnread] = createSignal(new Map<string, Set<string>>())
const [_busy, setBusy] = createSignal(new Map<string, Set<string>>())
// ─── helpers ──────────────────────────────────────────────────────────────────
function addSession(
setter: (fn: (m: Map<string, Set<string>>) => Map<string, Set<string>>) => void,
projectId: string,
sessionId: string,
) {
setter((m) => {
if (m.get(projectId)?.has(sessionId)) return m
const next = new Map(m)
next.set(projectId, new Set([...(m.get(projectId) ?? []), sessionId]))
return next
})
}
function removeSession(
setter: (fn: (m: Map<string, Set<string>>) => Map<string, Set<string>>) => void,
projectId: string,
sessionId: string,
) {
setter((m) => {
const sessions = m.get(projectId)
if (!sessions?.has(sessionId)) return m
const next = new Map(m)
const s = new Set(sessions)
s.delete(sessionId)
if (s.size === 0) next.delete(projectId)
else next.set(projectId, s)
return next
})
}
function removeProject(
setter: (fn: (m: Map<string, Set<string>>) => Map<string, Set<string>>) => void,
projectId: string,
) {
setter((m) => {
if (!m.has(projectId)) return m
const next = new Map(m)
next.delete(projectId)
return next
})
}
// ─── public API ───────────────────────────────────────────────────────────────
// error
export const markError = (p: string, s: string) => addSession(setErrors, p, s)
export const clearError = (p: string, s: string) => removeSession(setErrors, p, s)
export const clearErrorProject = (p: string) => removeProject(setErrors, p)
// attention
export const markAttention = (p: string, s: string) => addSession(setAttention, p, s)
export const clearAttention = (p: string, s: string) => removeSession(setAttention, p, s)
export const clearAttentionProject = (p: string) => removeProject(setAttention, p)
// unread
export const markUnread = (p: string, s: string) => addSession(setUnread, p, s)
export const clearUnread = (p: string, s: string) => removeSession(setUnread, p, s)
export const clearUnreadProject = (p: string) => removeProject(setUnread, p)
// busy
export const markBusy = (p: string, s: string) => addSession(setBusy, p, s)
export const clearBusy = (p: string, s: string) => removeSession(setBusy, p, s)
export const clearBusyProject = (p: string) => removeProject(setBusy, p)
// ─── derived per-project status (reactive) ────────────────────────────────────
export function projectStatus(projectId: string): ProjectStatus {
if ((_errors().get(projectId)?.size ?? 0) > 0) return "error"
if ((_attention().get(projectId)?.size ?? 0) > 0) return "attention"
if ((_unread().get(projectId)?.size ?? 0) > 0) return "unread"
if ((_busy().get(projectId)?.size ?? 0) > 0) return "busy"
return "idle"
}
export function sessionHasUnread(projectId: string, sessionId: string) {
return _unread().get(projectId)?.has(sessionId) ?? false
}
// ─── path helpers (reused from client.ts pattern) ─────────────────────────────
function norm(input: string) {
return input.replace(/\\/g, "/").replace(/\/+$/, "") || "/"
}
function inside(root: string, dir: string) {
const r = norm(root)
const d = norm(dir)
return d === r || d.startsWith(`${r}/`)
}
export function projectForDir(items: ProjectItem[], dir: string): ProjectItem | undefined {
let best: ProjectItem | undefined
let bestLen = -1
for (const item of items) {
for (const root of [item.worktree, ...item.sandboxes]) {
if (inside(root, dir)) {
const len = norm(root).length
if (len > bestLen) {
bestLen = len
best = item
}
}
}
}
return best
}
// ─── event parsing ────────────────────────────────────────────────────────────
export type GlobalEvent = {
directory: string
project?: string
payload: unknown
}
export function eventTypeName(event: GlobalEvent): string {
const payload = event.payload as { type?: string; name?: unknown; syncEvent?: { type?: unknown } }
if (!payload.type) return ""
if (payload.type !== "sync") return payload.type
if (typeof payload.name === "string") return payload.name
if (typeof (payload.syncEvent as Record<string, unknown> | undefined)?.type === "string")
return (payload.syncEvent as { type: string }).type
return ""
}
export function eventSessionId(event: GlobalEvent): string | undefined {
const payload = event.payload as Record<string, unknown>
const props = (payload.properties ?? payload.data) as Record<string, unknown> | undefined
const id = props?.sessionID
return typeof id === "string" ? id : undefined
}
// priority helpers for composing status
export { PRIORITY }
@@ -359,6 +359,10 @@
background: var(--project-terminal-background);
}
.kilo-console .project-terminal-host textarea {
left: -1px !important;
}
.kilo-console .project-terminal-host canvas {
background: var(--project-terminal-background);
display: block;
@@ -226,6 +226,26 @@
color: var(--foreground);
}
.kilo-console .favorite-project.status-error {
border-color: #ef4444;
box-shadow: 0 0 0 0.1875rem color-mix(in oklab, #ef4444 18%, transparent);
}
.kilo-console .favorite-project.status-attention {
border-color: #f97316;
box-shadow: 0 0 0 0.1875rem color-mix(in oklab, #f97316 18%, transparent);
}
.kilo-console .favorite-project.status-unread {
border-color: #22c55e;
box-shadow: 0 0 0 0.1875rem color-mix(in oklab, #22c55e 18%, transparent);
}
.kilo-console .favorite-project.status-busy {
border-color: var(--primary);
box-shadow: 0 0 0 0.1875rem color-mix(in oklab, var(--primary) 18%, transparent);
}
.kilo-console .console-main {
min-width: 0;
min-height: 0;