mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
refactor: project sidebar
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user