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 {}
+ };
+ }, []);
+}