diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 770fdf3..cc2b673 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,6 +10,7 @@ import { registerProjectRoutes } from './routes/projects.js'; import { registerSessionRoutes } from './routes/sessions.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerMessageRoutes } from './routes/messages.js'; +import { registerSidebarRoutes } from './routes/sidebar.js'; import { registerWebSocket } from './routes/ws.js'; import { registerModelRoutes } from './routes/models.js'; import { createInferenceRunner } from './services/inference.js'; @@ -39,6 +40,7 @@ async function main() { registerSessionRoutes(app, sql, config); registerSettingsRoutes(app, sql); registerModelRoutes(app, config); + registerSidebarRoutes(app, sql); const broker = createBroker(); const inference = createInferenceRunner({ diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 2d272d2..875cf87 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -111,7 +111,7 @@ export function registerSessionRoutes( name = COALESCE(${name ?? null}, name), model = COALESCE(${model ?? null}, model), system_prompt = COALESCE(${system_prompt ?? null}, system_prompt), - updated_at = NOW() + updated_at = clock_timestamp() WHERE id = ${req.params.id} RETURNING id, project_id, name, model, system_prompt, created_at, updated_at `; diff --git a/apps/server/src/routes/sidebar.ts b/apps/server/src/routes/sidebar.ts new file mode 100644 index 0000000..4b543db --- /dev/null +++ b/apps/server/src/routes/sidebar.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; +import type { + SidebarProject, + SidebarResponse, + SidebarSession, +} from '../types/api.js'; + +export function registerSidebarRoutes(app: FastifyInstance, sql: Sql): void { + app.get('/api/sidebar', async (): Promise => { + const projects = await sql<{ id: string; name: string }[]>` + SELECT id, name + FROM projects + ORDER BY added_at DESC + `; + + const enriched: SidebarProject[] = await Promise.all( + projects.map(async (p) => { + const [recent_sessions, countRows] = await Promise.all([ + sql` + SELECT id, name, model, updated_at + FROM sessions + WHERE project_id = ${p.id} + ORDER BY updated_at DESC + LIMIT 6 + `, + sql<{ n: number }[]>` + SELECT COUNT(*)::int AS n + FROM sessions + WHERE project_id = ${p.id} + `, + ]); + return { + id: p.id, + name: p.name, + recent_sessions, + total_sessions: countRows[0]?.n ?? 0, + }; + }) + ); + + return { projects: enriched }; + }); +} diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index 91b4ac4..ee0fc89 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -38,6 +38,8 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER; ALTER TABLE messages ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ; ALTER TABLE messages ADD COLUMN IF NOT EXISTS finished_at TIMESTAMPTZ; +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value JSONB NOT NULL diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts index f30f03b..363646e 100644 --- a/apps/server/src/services/inference.ts +++ b/apps/server/src/services/inference.ts @@ -426,6 +426,7 @@ async function runAssistantTurn( finished_at = clock_timestamp() WHERE id = ${assistantMessageId} `; + await ctx.sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; ctx.publish(sessionId, { type: 'error', message_id: assistantMessageId, @@ -458,6 +459,7 @@ async function runAssistantTurn( WHERE id = ${assistantMessageId} RETURNING tokens_used, ctx_used, ctx_max, finished_at `; + await ctx.sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; for (const tc of toolCalls) { ctx.publish(sessionId, { type: 'tool_call', @@ -529,6 +531,7 @@ async function runAssistantTurn( WHERE id = ${assistantMessageId} RETURNING tokens_used, ctx_used, ctx_max, finished_at `; + await ctx.sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; ctx.publish(sessionId, { type: 'message_complete', message_id: assistantMessageId, diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index a6e08d0..3413f58 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -58,3 +58,21 @@ export interface ModelInfo { id: string; [key: string]: unknown; } + +export interface SidebarSession { + id: string; + name: string; + model: string; + updated_at: string; +} + +export interface SidebarProject { + id: string; + name: string; + recent_sessions: SidebarSession[]; + total_sessions: number; +} + +export interface SidebarResponse { + projects: SidebarProject[]; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 146e861..c4378b3 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -4,6 +4,7 @@ import type { Session, Message, ModelInfo, + SidebarResponse, } from './types'; export class ApiError extends Error { @@ -100,4 +101,8 @@ export const api = { body: JSON.stringify(body), }), }, + + sidebar: { + get: () => request('/api/sidebar'), + }, }; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index da5dacf..0f2ee74 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -59,6 +59,24 @@ export interface ModelInfo { [key: string]: unknown; } +export interface SidebarSession { + id: string; + name: string; + model: string; + updated_at: string; +} + +export interface SidebarProject { + id: string; + name: string; + recent_sessions: SidebarSession[]; + total_sessions: number; +} + +export interface SidebarResponse { + projects: SidebarProject[]; +} + export type WsFrame = | { type: 'snapshot'; messages: Message[] } | { type: 'message_started'; message_id: string; role: MessageRole } diff --git a/apps/web/src/components/AddProjectModal.tsx b/apps/web/src/components/AddProjectModal.tsx index b7a4daf..5f04b27 100644 --- a/apps/web/src/components/AddProjectModal.tsx +++ b/apps/web/src/components/AddProjectModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { api } from '@/api/client'; import type { AvailableProject } from '@/api/types'; +import { sessionEvents } from '@/hooks/sessionEvents'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -42,7 +43,8 @@ export function AddProjectModal({ open, onOpenChange, onAdded }: Props) { setBusy(true); setError(null); try { - await api.projects.add({ path }); + const created = await api.projects.add({ path }); + sessionEvents.emit({ type: 'project_created', project: created }); onAdded(); onOpenChange(false); } catch (err) { diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 2505ede..095f61b 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; -import { NavLink, useNavigate } from 'react-router-dom'; -import { Plus, Folder } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { ChevronRight, Folder, MessageSquare, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { @@ -10,88 +10,225 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { AddProjectModal } from './AddProjectModal'; -import { useProjects } from '@/hooks/useProjects'; +import { api } from '@/api/client'; +import { sessionEvents } from '@/hooks/sessionEvents'; +import { useSidebar } from '@/hooks/useSidebar'; +import type { SidebarProject } from '@/api/types'; + +const EXPANDED_KEY = 'boocode.sidebar.expanded'; +const MAX_VISIBLE_SESSIONS = 5; + +function readExpanded(): Set { + try { + const raw = localStorage.getItem(EXPANDED_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((v): v is string => typeof v === 'string')); + } catch { + return new Set(); + } +} + +function writeExpanded(ids: Set): void { + try { + localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(ids))); + } catch { + /* quota or disabled storage — ignore */ + } +} + +function relTime(iso: string): string { + const now = Date.now(); + const t = Date.parse(iso); + if (Number.isNaN(t)) return ''; + const sec = Math.max(0, Math.floor((now - t) / 1000)); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}d`; + const mo = Math.floor(day / 30); + if (mo < 12) return `${mo}mo`; + return `${Math.floor(mo / 12)}y`; +} + +function activeProjectId(pathname: string, projects: SidebarProject[]): string | null { + const pm = pathname.match(/^\/project\/([^/]+)/); + if (pm?.[1]) return pm[1]; + const sm = pathname.match(/^\/session\/([^/]+)/); + const sid = sm?.[1]; + if (!sid) return null; + return projects.find((p) => p.recent_sessions.some((s) => s.id === sid))?.id ?? null; +} + +function activeSessionId(pathname: string): string | null { + const m = pathname.match(/^\/session\/([^/]+)/); + return m?.[1] ?? null; +} export function ProjectSidebar() { - const { projects, refresh, remove } = useProjects(); + const { data, error, loading, retry } = useSidebar(); const [addOpen, setAddOpen] = useState(false); + const [expanded, setExpanded] = useState>(() => readExpanded()); const navigate = useNavigate(); + const location = useLocation(); + const lastToastedError = useRef(null); + + useEffect(() => { + if (error && !data && error !== lastToastedError.current) { + toast.error(error); + lastToastedError.current = error; + } + if (!error) lastToastedError.current = null; + }, [error, data]); + + const projects = data?.projects ?? []; + const activeProject = useMemo( + () => activeProjectId(location.pathname, projects), + [location.pathname, projects] + ); + const activeSession = useMemo( + () => activeSessionId(location.pathname), + [location.pathname] + ); + + function toggle(id: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + writeExpanded(next); + return next; + }); + } async function handleRemove(id: string) { try { - await remove(id); + await api.projects.remove(id); + sessionEvents.emit({ type: 'project_deleted', project_id: id }); navigate('/'); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to remove project'); } } + const rowCls = (active: boolean) => + active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60'; + return ( ); } diff --git a/apps/web/src/hooks/sessionEvents.ts b/apps/web/src/hooks/sessionEvents.ts index e29b54e..f1db4db 100644 --- a/apps/web/src/hooks/sessionEvents.ts +++ b/apps/web/src/hooks/sessionEvents.ts @@ -1,6 +1,8 @@ // Tiny in-app event bus for session metadata changes that need to propagate // across hooks (e.g. AI rename arriving via WS in the session view needs to -// also refresh the sidebar's session list). One event type for now. +// also refresh the sidebar's session list). + +import type { Project, Session } from '@/api/types'; export interface SessionRenamedEvent { type: 'session_renamed'; @@ -8,7 +10,34 @@ export interface SessionRenamedEvent { name: string; } -type SessionEvent = SessionRenamedEvent; +export interface ProjectCreatedEvent { + type: 'project_created'; + project: Project; +} + +export interface ProjectDeletedEvent { + type: 'project_deleted'; + project_id: string; +} + +export interface SessionCreatedEvent { + type: 'session_created'; + session: Session; + project_id: string; +} + +export interface SessionDeletedEvent { + type: 'session_deleted'; + session_id: string; + project_id: string; +} + +export type SessionEvent = + | SessionRenamedEvent + | ProjectCreatedEvent + | ProjectDeletedEvent + | SessionCreatedEvent + | SessionDeletedEvent; type Listener = (event: SessionEvent) => void; const listeners = new Set(); diff --git a/apps/web/src/hooks/useProjects.ts b/apps/web/src/hooks/useProjects.ts index 5c2103a..6110a36 100644 --- a/apps/web/src/hooks/useProjects.ts +++ b/apps/web/src/hooks/useProjects.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '@/api/client'; import type { Project } from '@/api/types'; +import { sessionEvents } from './sessionEvents'; export function useProjects() { const [projects, setProjects] = useState(null); @@ -32,6 +33,7 @@ export function useProjects() { const remove = useCallback( async (id: string) => { await api.projects.remove(id); + sessionEvents.emit({ type: 'project_deleted', project_id: id }); await refresh(); }, [refresh] diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts new file mode 100644 index 0000000..3b53ec4 --- /dev/null +++ b/apps/web/src/hooks/useSidebar.ts @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/api/client'; +import type { SidebarProject, SidebarResponse, SidebarSession } from '@/api/types'; +import { sessionEvents } from './sessionEvents'; + +const RECENT_SESSIONS_LIMIT = 6; + +// Module-scope shared state — there is at most one sidebar fetch +// for the lifetime of the page, regardless of how many components +// call useSidebar(). +let sharedData: SidebarResponse | null = null; +let sharedError: string | null = null; +let sharedLoading: boolean = true; +let initialized = false; +let fetchInFlight: Promise | null = null; +const subscribers = new Set<() => void>(); + +function notify(): void { + for (const sub of subscribers) { + try { + sub(); + } catch { + // swallow — one bad subscriber shouldn't break others + } + } +} + +function load(): Promise { + if (fetchInFlight) return fetchInFlight; + sharedLoading = true; + sharedError = null; + notify(); + const p = (async () => { + try { + const res = await api.sidebar.get(); + sharedData = res; + sharedError = null; + } catch (err) { + sharedData = null; + sharedError = err instanceof Error ? err.message : 'failed to load sidebar'; + } finally { + sharedLoading = false; + fetchInFlight = null; + notify(); + } + })(); + fetchInFlight = p; + return p; +} + +function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse { + switch (event.type) { + case 'project_created': { + const fresh: SidebarProject = { + id: event.project.id, + name: event.project.name, + recent_sessions: [], + total_sessions: 0, + }; + return { ...prev, projects: [fresh, ...prev.projects] }; + } + case 'project_deleted': { + const next = prev.projects.filter((p) => p.id !== event.project_id); + if (next.length === prev.projects.length) return prev; + return { ...prev, projects: next }; + } + case 'session_created': { + let changed = false; + const projects = prev.projects.map((p) => { + if (p.id !== event.project_id) return p; + changed = true; + const fresh: SidebarSession = { + id: event.session.id, + name: event.session.name, + model: event.session.model, + updated_at: event.session.updated_at, + }; + return { + ...p, + recent_sessions: [fresh, ...p.recent_sessions].slice(0, RECENT_SESSIONS_LIMIT), + total_sessions: p.total_sessions + 1, + }; + }); + return changed ? { ...prev, projects } : prev; + } + case 'session_deleted': { + let changed = false; + const projects = prev.projects.map((p) => { + if (p.id !== event.project_id) return p; + changed = true; + const recent = p.recent_sessions.filter((s) => s.id !== event.session_id); + return { + ...p, + recent_sessions: recent, + total_sessions: Math.max(0, p.total_sessions - 1), + }; + }); + return changed ? { ...prev, projects } : prev; + } + case 'session_renamed': { + let changed = false; + const projects = prev.projects.map((p) => { + let projectChanged = false; + const recent = p.recent_sessions.map((s) => { + if (s.id !== event.session_id) return s; + if (s.name === event.name) return s; + projectChanged = true; + return { ...s, name: event.name }; + }); + if (!projectChanged) return p; + changed = true; + return { ...p, recent_sessions: recent }; + }); + return changed ? { ...prev, projects } : prev; + } + default: + return prev; + } +} + +// One bus subscription for the lifetime of the module. Events arriving +// before the initial fetch resolves are dropped; the eventual fetch +// result is the source of truth. +sessionEvents.subscribe((event) => { + if (!sharedData) return; + const next = applyEvent(sharedData, event); + if (next === sharedData) return; + sharedData = next; + notify(); +}); + +interface Snapshot { + data: SidebarResponse | null; + error: string | null; + loading: boolean; +} + +function snapshot(): Snapshot { + return { data: sharedData, error: sharedError, loading: sharedLoading }; +} + +export function useSidebar(): { + data: SidebarResponse | null; + error: string | null; + loading: boolean; + retry: () => void; +} { + const [state, setState] = useState(snapshot); + + useEffect(() => { + const sub = () => setState(snapshot()); + subscribers.add(sub); + // Sync up if the module state changed between render and effect. + sub(); + if (!initialized) { + initialized = true; + void load(); + } + return () => { + subscribers.delete(sub); + }; + }, []); + + const retry = () => { + void load(); + }; + + return { data: state.data, error: state.error, loading: state.loading, retry }; +} diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index cbf97ac..189399a 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { AddProjectModal } from '@/components/AddProjectModal'; -import { useProjects } from '@/hooks/useProjects'; +import { useSidebar } from '@/hooks/useSidebar'; export function Home() { - const { projects, refresh } = useProjects(); + const { data } = useSidebar(); const [open, setOpen] = useState(false); - const empty = projects && projects.length === 0; + const empty = data ? data.projects.length === 0 : false; return (
@@ -29,7 +29,7 @@ export function Home() { )}
- + {}} /> ); } diff --git a/apps/web/src/pages/Project.tsx b/apps/web/src/pages/Project.tsx index fd4f651..8402fe6 100644 --- a/apps/web/src/pages/Project.tsx +++ b/apps/web/src/pages/Project.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { Plus, MessageSquare, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; import { api } from '@/api/client'; import type { Project as ProjectType } from '@/api/types'; import { Button } from '@/components/ui/button'; +import { sessionEvents } from '@/hooks/sessionEvents'; import { useSessions } from '@/hooks/useSessions'; export function Project() { @@ -26,6 +28,7 @@ export function Project() { setCreating(true); try { const s = await create({}); + sessionEvents.emit({ type: 'session_created', session: s, project_id: id }); navigate(`/session/${s.id}`); } finally { setCreating(false); @@ -73,7 +76,20 @@ export function Project() { variant="ghost" size="icon-sm" aria-label="Delete session" - onClick={() => void remove(s.id)} + onClick={async () => { + try { + await remove(s.id); + sessionEvents.emit({ + type: 'session_deleted', + session_id: s.id, + project_id: id!, + }); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'failed to delete session' + ); + } + }} > diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index a874fbc..b8d6ec5 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -60,6 +60,11 @@ export function Session() { } const updated = await api.sessions.update(id, { name: trimmed }); setSession(updated); + sessionEvents.emit({ + type: 'session_renamed', + session_id: id, + name: trimmed, + }); setEditingName(false); }