batch3 T5: frontend foundation — Pane types, panes API, user-events WS

- Mirror Pane/PaneState/UserStream types
- api.panes.* CRUD methods
- sessionEvents adds session_updated, session_loaded, open_file_in_browser
- useUserEvents hook: single app-level WS to /api/ws/user with reconnect
- useSidebar handles session_updated (in-place patch + re-sort) and
  session_loaded (active-project highlight gap fix); open_file_in_browser
  is a deliberate no-op here, consumed by Workspace later
- App.tsx mounts useUserEvents once

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:24:25 +00:00
parent 015350b2e7
commit 8f0e1245d8
6 changed files with 190 additions and 15 deletions

View File

@@ -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<Listener>();

View File

@@ -13,6 +13,7 @@ let sharedError: string | null = null;
let sharedLoading: boolean = true;
let initialized = false;
let fetchInFlight: Promise<void> | 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>(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 };
}

View File

@@ -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<typeof setTimeout> | 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 {}
};
}, []);
}