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:
@@ -4,21 +4,29 @@ import { Home } from '@/pages/Home';
|
|||||||
import { Project } from '@/pages/Project';
|
import { Project } from '@/pages/Project';
|
||||||
import { Session } from '@/pages/Session';
|
import { Session } from '@/pages/Session';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||||
|
|
||||||
|
function AppShell() {
|
||||||
|
useUserEvents();
|
||||||
|
return (
|
||||||
|
<div className="dark h-screen flex bg-background text-foreground">
|
||||||
|
<ProjectSidebar />
|
||||||
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/project/:id" element={<Project />} />
|
||||||
|
<Route path="/session/:id" element={<Session />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<Toaster position="bottom-right" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="dark h-screen flex bg-background text-foreground">
|
<AppShell />
|
||||||
<ProjectSidebar />
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Home />} />
|
|
||||||
<Route path="/project/:id" element={<Project />} />
|
|
||||||
<Route path="/session/:id" element={<Session />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
<Toaster position="bottom-right" />
|
|
||||||
</div>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
SidebarResponse,
|
SidebarResponse,
|
||||||
ListDirResult,
|
ListDirResult,
|
||||||
ViewFileResult,
|
ViewFileResult,
|
||||||
|
Pane,
|
||||||
|
PaneCreateRequest,
|
||||||
|
PaneUpdateRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -113,4 +116,23 @@ export const api = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
get: () => request<SidebarResponse>('/api/sidebar'),
|
get: () => request<SidebarResponse>('/api/sidebar'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
panes: {
|
||||||
|
getForSession: (sessionId: string) =>
|
||||||
|
request<{ panes: Pane[] }>(`/api/sessions/${sessionId}/panes`),
|
||||||
|
create: (sessionId: string, body: PaneCreateRequest) =>
|
||||||
|
request<Pane>(`/api/sessions/${sessionId}/panes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
update: (id: string, body: PaneUpdateRequest) =>
|
||||||
|
request<Pane>(`/api/panes/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
remove: (id: string) =>
|
||||||
|
request<void>(`/api/panes/${id}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface SidebarSession {
|
|||||||
name: string;
|
name: string;
|
||||||
model: string;
|
model: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
project_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarProject {
|
export interface SidebarProject {
|
||||||
@@ -96,6 +97,36 @@ export interface ViewFileResult {
|
|||||||
bytes_returned: number;
|
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<string, never>;
|
||||||
|
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 =
|
export type WsFrame =
|
||||||
| { type: 'snapshot'; messages: Message[] }
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
| { type: 'message_started'; message_id: string; role: MessageRole }
|
| { type: 'message_started'; message_id: string; role: MessageRole }
|
||||||
|
|||||||
@@ -32,12 +32,34 @@ export interface SessionDeletedEvent {
|
|||||||
project_id: string;
|
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 =
|
export type SessionEvent =
|
||||||
| SessionRenamedEvent
|
| SessionRenamedEvent
|
||||||
| ProjectCreatedEvent
|
| ProjectCreatedEvent
|
||||||
| ProjectDeletedEvent
|
| ProjectDeletedEvent
|
||||||
| SessionCreatedEvent
|
| SessionCreatedEvent
|
||||||
| SessionDeletedEvent;
|
| SessionDeletedEvent
|
||||||
|
| SessionUpdatedEvent
|
||||||
|
| SessionLoadedEvent
|
||||||
|
| OpenFileInBrowserEvent;
|
||||||
type Listener = (event: SessionEvent) => void;
|
type Listener = (event: SessionEvent) => void;
|
||||||
|
|
||||||
const listeners = new Set<Listener>();
|
const listeners = new Set<Listener>();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ let sharedError: string | null = null;
|
|||||||
let sharedLoading: boolean = true;
|
let sharedLoading: boolean = true;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let fetchInFlight: Promise<void> | null = null;
|
let fetchInFlight: Promise<void> | null = null;
|
||||||
|
let activeSessionProjectId: string | null = null;
|
||||||
const subscribers = new Set<() => void>();
|
const subscribers = new Set<() => void>();
|
||||||
|
|
||||||
function notify(): void {
|
function notify(): void {
|
||||||
@@ -74,6 +75,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
name: event.session.name,
|
name: event.session.name,
|
||||||
model: event.session.model,
|
model: event.session.model,
|
||||||
updated_at: event.session.updated_at,
|
updated_at: event.session.updated_at,
|
||||||
|
project_id: event.project_id,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
@@ -113,7 +115,30 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
});
|
});
|
||||||
return changed ? { ...prev, projects } : prev;
|
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;
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +147,13 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
// before the initial fetch resolves are dropped; the eventual fetch
|
// before the initial fetch resolves are dropped; the eventual fetch
|
||||||
// result is the source of truth.
|
// result is the source of truth.
|
||||||
sessionEvents.subscribe((event) => {
|
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;
|
if (!sharedData) return;
|
||||||
const next = applyEvent(sharedData, event);
|
const next = applyEvent(sharedData, event);
|
||||||
if (next === sharedData) return;
|
if (next === sharedData) return;
|
||||||
@@ -133,10 +165,11 @@ interface Snapshot {
|
|||||||
data: SidebarResponse | null;
|
data: SidebarResponse | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
activeSessionProjectId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot(): Snapshot {
|
function snapshot(): Snapshot {
|
||||||
return { data: sharedData, error: sharedError, loading: sharedLoading };
|
return { data: sharedData, error: sharedError, loading: sharedLoading, activeSessionProjectId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebar(): {
|
export function useSidebar(): {
|
||||||
@@ -144,6 +177,7 @@ export function useSidebar(): {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
|
activeSessionProjectId: string | null;
|
||||||
} {
|
} {
|
||||||
const [state, setState] = useState<Snapshot>(snapshot);
|
const [state, setState] = useState<Snapshot>(snapshot);
|
||||||
|
|
||||||
@@ -165,5 +199,5 @@ export function useSidebar(): {
|
|||||||
void load();
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
58
apps/web/src/hooks/useUserEvents.ts
Normal file
58
apps/web/src/hooks/useUserEvents.ts
Normal 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 {}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user