diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e629e36..72a5e32 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,21 +4,29 @@ import { Home } from '@/pages/Home'; import { Project } from '@/pages/Project'; import { Session } from '@/pages/Session'; import { Toaster } from '@/components/ui/sonner'; +import { useUserEvents } from '@/hooks/useUserEvents'; + +function AppShell() { + useUserEvents(); + return ( +
+ +
+ + } /> + } /> + } /> + +
+ +
+ ); +} export default function App() { return ( -
- -
- - } /> - } /> - } /> - -
- -
+
); } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 364564b..986bed6 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -7,6 +7,9 @@ import type { SidebarResponse, ListDirResult, ViewFileResult, + Pane, + PaneCreateRequest, + PaneUpdateRequest, } from './types'; export class ApiError extends Error { @@ -113,4 +116,23 @@ export const api = { sidebar: { get: () => request('/api/sidebar'), }, + + panes: { + getForSession: (sessionId: string) => + request<{ panes: Pane[] }>(`/api/sessions/${sessionId}/panes`), + create: (sessionId: string, body: PaneCreateRequest) => + request(`/api/sessions/${sessionId}/panes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + update: (id: string, body: PaneUpdateRequest) => + request(`/api/panes/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + remove: (id: string) => + request(`/api/panes/${id}`, { method: 'DELETE' }), + }, }; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 8348185..322626b 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -64,6 +64,7 @@ export interface SidebarSession { name: string; model: string; updated_at: string; + project_id: string; } export interface SidebarProject { @@ -96,6 +97,36 @@ export interface ViewFileResult { bytes_returned: number; } +export type PaneKind = 'chat' | 'file_browser'; + +export interface FileBrowserPaneState { + open_file?: string | null; + filter?: string; + expanded_dirs?: string[]; +} +export type ChatPaneState = Record; +export type PaneState = ChatPaneState | FileBrowserPaneState; + +interface PaneBase { + id: string; + session_id: string; + position: number; + created_at: string; +} +export type Pane = PaneBase & ( + | { kind: 'chat'; state: ChatPaneState } + | { kind: 'file_browser'; state: FileBrowserPaneState } +); + +export interface PaneCreateRequest { + kind: PaneKind; + position?: number; +} +export interface PaneUpdateRequest { + state?: PaneState; + position?: number; +} + export type WsFrame = | { type: 'snapshot'; messages: Message[] } | { type: 'message_started'; message_id: string; role: MessageRole } diff --git a/apps/web/src/hooks/sessionEvents.ts b/apps/web/src/hooks/sessionEvents.ts index f1db4db..93d687f 100644 --- a/apps/web/src/hooks/sessionEvents.ts +++ b/apps/web/src/hooks/sessionEvents.ts @@ -32,12 +32,34 @@ export interface SessionDeletedEvent { project_id: string; } +export interface SessionUpdatedEvent { + type: 'session_updated'; + session_id: string; + project_id: string; + name: string; + updated_at: string; +} + +export interface SessionLoadedEvent { + type: 'session_loaded'; + session_id: string; + project_id: string; +} + +export interface OpenFileInBrowserEvent { + type: 'open_file_in_browser'; + path: string; // project-relative +} + export type SessionEvent = | SessionRenamedEvent | ProjectCreatedEvent | ProjectDeletedEvent | SessionCreatedEvent - | SessionDeletedEvent; + | SessionDeletedEvent + | SessionUpdatedEvent + | SessionLoadedEvent + | OpenFileInBrowserEvent; type Listener = (event: SessionEvent) => void; const listeners = new Set(); diff --git a/apps/web/src/hooks/useSidebar.ts b/apps/web/src/hooks/useSidebar.ts index 3b53ec4..7bbe20d 100644 --- a/apps/web/src/hooks/useSidebar.ts +++ b/apps/web/src/hooks/useSidebar.ts @@ -13,6 +13,7 @@ let sharedError: string | null = null; let sharedLoading: boolean = true; let initialized = false; let fetchInFlight: Promise | null = null; +let activeSessionProjectId: string | null = null; const subscribers = new Set<() => void>(); function notify(): void { @@ -74,6 +75,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess name: event.session.name, model: event.session.model, updated_at: event.session.updated_at, + project_id: event.project_id, }; return { ...p, @@ -113,7 +115,30 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess }); return changed ? { ...prev, projects } : prev; } - default: + case 'session_updated': { + let changed = false; + const projects = prev.projects.map((p) => { + if (p.id !== event.project_id) return p; + let projectChanged = false; + const recent = p.recent_sessions.map((s) => { + if (s.id !== event.session_id) return s; + projectChanged = true; + return { ...s, name: event.name, updated_at: event.updated_at }; + }); + if (!projectChanged) return p; + changed = true; + const sorted = [...recent].sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ); + return { ...p, recent_sessions: sorted }; + }); + return changed ? { ...prev, projects } : prev; + } + case 'session_loaded': + // activeSessionProjectId is updated in the subscribe callback; no data change here. + return prev; + case 'open_file_in_browser': + // Consumed by Workspace (T7); no sidebar state change needed. return prev; } } @@ -122,6 +147,13 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess // before the initial fetch resolves are dropped; the eventual fetch // result is the source of truth. sessionEvents.subscribe((event) => { + // session_loaded updates activeSessionProjectId regardless of whether + // sharedData is populated yet — notify so subscribers can re-read. + if (event.type === 'session_loaded') { + activeSessionProjectId = event.project_id; + notify(); + return; + } if (!sharedData) return; const next = applyEvent(sharedData, event); if (next === sharedData) return; @@ -133,10 +165,11 @@ interface Snapshot { data: SidebarResponse | null; error: string | null; loading: boolean; + activeSessionProjectId: string | null; } function snapshot(): Snapshot { - return { data: sharedData, error: sharedError, loading: sharedLoading }; + return { data: sharedData, error: sharedError, loading: sharedLoading, activeSessionProjectId }; } export function useSidebar(): { @@ -144,6 +177,7 @@ export function useSidebar(): { error: string | null; loading: boolean; retry: () => void; + activeSessionProjectId: string | null; } { const [state, setState] = useState(snapshot); @@ -165,5 +199,5 @@ export function useSidebar(): { void load(); }; - return { data: state.data, error: state.error, loading: state.loading, retry }; + return { data: state.data, error: state.error, loading: state.loading, retry, activeSessionProjectId: state.activeSessionProjectId }; } diff --git a/apps/web/src/hooks/useUserEvents.ts b/apps/web/src/hooks/useUserEvents.ts new file mode 100644 index 0000000..dce1c99 --- /dev/null +++ b/apps/web/src/hooks/useUserEvents.ts @@ -0,0 +1,58 @@ +import { useEffect } from 'react'; +import { sessionEvents } from './sessionEvents'; + +const RECONNECT_INITIAL_MS = 1000; +const RECONNECT_MAX_MS = 30000; + +export function useUserEvents(): void { + useEffect(() => { + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectDelay = RECONNECT_INITIAL_MS; + let unmounted = false; + + const connect = () => { + if (unmounted) return; + const url = new URL('/api/ws/user', window.location.href); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(url.toString()); + + ws.onopen = () => { + reconnectDelay = RECONNECT_INITIAL_MS; + }; + + ws.onmessage = (ev) => { + try { + const frame = JSON.parse(ev.data); + // The server emits frames whose `type` matches SessionEvent union members + // (project_created, project_deleted, session_created, session_deleted, session_updated). + // Pass through onto the bus. + sessionEvents.emit(frame); + } catch (err) { + console.warn('useUserEvents: failed to parse frame', err); + } + }; + + ws.onclose = () => { + if (unmounted) return; + reconnectTimer = setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); + connect(); + }, reconnectDelay); + }; + + ws.onerror = () => { + // close handler will trigger reconnect + try { ws?.close(); } catch {} + }; + }; + + connect(); + + return () => { + unmounted = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (ws) try { ws.close(); } catch {} + }; + }, []); +}