diff --git a/.gitignore b/.gitignore
index 3b206d05f9..567ad02ea5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -66,3 +66,5 @@ tmp/
*-secret.yaml
*-secrets.yaml
+apps/backend/bin/
+
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index bcc23374e1..115bc60966 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
+import { StateProvider } from "@/components/state-provider";
export const metadata: Metadata = {
title: "Kandev - AI Kanban",
@@ -15,7 +16,9 @@ export default function RootLayout({
return (
- {children}
+
+ {children}
+
);
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 860576a99e..1bd9554404 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,7 +1,24 @@
-'use client';
-
import { KanbanBoard } from '@/components/kanban-board';
+import { StateHydrator } from '@/components/state-hydrator';
+import { fetchBoardSnapshot, fetchBoards } from '@/lib/ssr/http';
+import { snapshotToState } from '@/lib/ssr/mapper';
-export default function Page() {
- return ;
+export default async function Page() {
+ try {
+ const boards = await fetchBoards();
+ const boardId = boards[0]?.id;
+ if (!boardId) {
+ return ;
+ }
+ const snapshot = await fetchBoardSnapshot(boardId);
+ const initialState = snapshotToState(snapshot);
+ return (
+ <>
+
+
+ >
+ );
+ } catch {
+ return ;
+ }
}
diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx
new file mode 100644
index 0000000000..5ba03a020e
--- /dev/null
+++ b/apps/web/app/task/[id]/page-client.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import '@git-diff-view/react/styles/diff-view.css';
+import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { TooltipProvider } from '@/components/ui/tooltip';
+import { Textarea } from '@/components/ui/textarea';
+import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
+import { TaskChatPanel } from '@/components/task/task-chat-panel';
+import { TaskTopBar } from '@/components/task/task-top-bar';
+import { TaskFilesPanel } from '@/components/task/task-files-panel';
+import { TaskChangesPanel } from '@/components/task/task-changes-panel';
+import { TaskRightPanel } from '@/components/task/task-right-panel';
+import { useAppStoreApi } from '@/components/state-provider';
+import { useWebSocket } from '@/lib/ws/use-websocket';
+
+type ChatMessage = {
+ id: string;
+ role: 'user' | 'agent';
+ content: string;
+};
+
+type ChatSession = {
+ id: string;
+ title: string;
+ messages: ChatMessage[];
+};
+
+const AGENTS = [
+ { id: 'codex', label: 'Codex' },
+ { id: 'claude', label: 'Claude Code' },
+];
+
+const INITIAL_CHATS: ChatSession[] = [
+ {
+ id: 'chat-1',
+ title: 'Build overview',
+ messages: [
+ {
+ id: 'm1',
+ role: 'agent',
+ content: 'I can help with this task. What should I tackle first?',
+ },
+ {
+ id: 'm2',
+ role: 'user',
+ content: 'Please review the changes and summarize the key diffs.',
+ },
+ ],
+ },
+];
+
+
+export default function TaskPage() {
+ const store = useAppStoreApi();
+ useWebSocket(store, 'ws://localhost:8080/ws');
+ const defaultHorizontalLayout: [number, number] = [75, 25];
+ const [horizontalLayout, setHorizontalLayout] = useState(defaultHorizontalLayout);
+ const [horizontalSeed, setHorizontalSeed] = useState(0);
+ const [chats, setChats] = useState(INITIAL_CHATS);
+ const [leftTab, setLeftTab] = useState('chat');
+ const [selectedDiffPath, setSelectedDiffPath] = useState(null);
+ const [notes, setNotes] = useState('');
+
+ const activeChat = chats[0];
+
+ useEffect(() => {
+ const storedHorizontal = getLocalStorage('task-layout-horizontal', defaultHorizontalLayout);
+
+ if (
+ storedHorizontal[0] !== defaultHorizontalLayout[0] ||
+ storedHorizontal[1] !== defaultHorizontalLayout[1]
+ ) {
+ setHorizontalLayout(storedHorizontal);
+ setHorizontalSeed((value) => value + 1);
+ }
+ }, []);
+
+ const handleSendMessage = useCallback((content: string) => {
+ setChats((currentChats) => {
+ const [first, ...rest] = currentChats;
+ if (!first) return currentChats;
+ return [
+ {
+ ...first,
+ messages: [...first.messages, { id: crypto.randomUUID(), role: 'user', content }],
+ },
+ ...rest,
+ ];
+ });
+ }, []);
+
+ const handleSelectDiffPath = useCallback((path: string) => {
+ setSelectedDiffPath(path);
+ setLeftTab('changes');
+ }, []);
+
+ const topFilesPanel = ;
+
+ return (
+
+
+
+
+
+
{
+ setHorizontalLayout(sizes as [number, number]);
+ setLocalStorage('task-layout-horizontal', sizes);
+ }}
+ >
+
+
+ setLeftTab(value)}
+ className="flex-1 min-h-0"
+ >
+
+
+ Notes
+
+
+ All changes
+
+
+ Chat {activeChat ? `• ${activeChat.title}` : ''}
+
+
+
+
+
+
+
+ setSelectedDiffPath(null)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/app/task/[id]/page.tsx b/apps/web/app/task/[id]/page.tsx
index f5c49f2922..330fdb6d89 100644
--- a/apps/web/app/task/[id]/page.tsx
+++ b/apps/web/app/task/[id]/page.tsx
@@ -1,162 +1,20 @@
-'use client';
+import { StateHydrator } from '@/components/state-hydrator';
+import { fetchTask } from '@/lib/ssr/http';
+import { taskToState } from '@/lib/ssr/mapper';
+import TaskPageClient from './page-client';
-import { useCallback, useEffect, useState } from 'react';
-import '@git-diff-view/react/styles/diff-view.css';
-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
-import { TooltipProvider } from '@/components/ui/tooltip';
-import { Textarea } from '@/components/ui/textarea';
-import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
-import { TaskChatPanel } from '@/components/task/task-chat-panel';
-import { TaskTopBar } from '@/components/task/task-top-bar';
-import { TaskFilesPanel } from '@/components/task/task-files-panel';
-import { TaskChangesPanel } from '@/components/task/task-changes-panel';
-import { TaskRightPanel } from '@/components/task/task-right-panel';
-
-type ChatMessage = {
- id: string;
- role: 'user' | 'agent';
- content: string;
-};
-
-type ChatSession = {
- id: string;
- title: string;
- messages: ChatMessage[];
-};
-
-const AGENTS = [
- { id: 'codex', label: 'Codex' },
- { id: 'claude', label: 'Claude Code' },
-];
-
-const INITIAL_CHATS: ChatSession[] = [
- {
- id: 'chat-1',
- title: 'Build overview',
- messages: [
- {
- id: 'm1',
- role: 'agent',
- content: 'I can help with this task. What should I tackle first?',
- },
- {
- id: 'm2',
- role: 'user',
- content: 'Please review the changes and summarize the key diffs.',
- },
- ],
- },
-];
-
-
-export default function TaskPage() {
- const defaultHorizontalLayout: [number, number] = [75, 25];
- const [horizontalLayout, setHorizontalLayout] = useState(defaultHorizontalLayout);
- const [horizontalSeed, setHorizontalSeed] = useState(0);
- const [chats, setChats] = useState(INITIAL_CHATS);
- const [leftTab, setLeftTab] = useState('chat');
- const [selectedDiffPath, setSelectedDiffPath] = useState(null);
- const [notes, setNotes] = useState('');
-
- const activeChat = chats[0];
-
- useEffect(() => {
- const storedHorizontal = getLocalStorage('task-layout-horizontal', defaultHorizontalLayout);
-
- if (
- storedHorizontal[0] !== defaultHorizontalLayout[0] ||
- storedHorizontal[1] !== defaultHorizontalLayout[1]
- ) {
- setHorizontalLayout(storedHorizontal);
- setHorizontalSeed((value) => value + 1);
- }
- }, []);
-
- const handleSendMessage = useCallback((content: string) => {
- setChats((currentChats) => {
- const [first, ...rest] = currentChats;
- if (!first) return currentChats;
- return [
- {
- ...first,
- messages: [...first.messages, { id: crypto.randomUUID(), role: 'user', content }],
- },
- ...rest,
- ];
- });
- }, []);
-
- const handleSelectDiffPath = useCallback((path: string) => {
- setSelectedDiffPath(path);
- setLeftTab('changes');
- }, []);
-
- const topFilesPanel = ;
-
- return (
-
-
-
-
-
-
{
- setHorizontalLayout(sizes as [number, number]);
- setLocalStorage('task-layout-horizontal', sizes);
- }}
- >
-
-
- setLeftTab(value)}
- className="flex-1 min-h-0"
- >
-
-
- Notes
-
-
- All changes
-
-
- Chat {activeChat ? `• ${activeChat.title}` : ''}
-
-
-
-
-
-
-
- setSelectedDiffPath(null)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) {
+ try {
+ const { id } = await params;
+ const task = await fetchTask(id);
+ const initialState = taskToState(task);
+ return (
+ <>
+
+
+ >
+ );
+ } catch {
+ return ;
+ }
}
diff --git a/apps/web/components/state-hydrator.tsx b/apps/web/components/state-hydrator.tsx
new file mode 100644
index 0000000000..2b7d956d04
--- /dev/null
+++ b/apps/web/components/state-hydrator.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { useEffect } from 'react';
+import type { AppState } from '@/lib/state/store';
+import { useAppStoreApi } from '@/components/state-provider';
+
+type StateHydratorProps = {
+ initialState: Partial;
+};
+
+export function StateHydrator({ initialState }: StateHydratorProps) {
+ const store = useAppStoreApi();
+
+ useEffect(() => {
+ if (Object.keys(initialState).length) {
+ store.getState().hydrate(initialState);
+ }
+ }, [initialState, store]);
+
+ return null;
+}
diff --git a/apps/web/components/state-provider.tsx b/apps/web/components/state-provider.tsx
new file mode 100644
index 0000000000..2fd8f662c7
--- /dev/null
+++ b/apps/web/components/state-provider.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { createContext, useContext, useRef } from 'react';
+import type { StoreApi } from 'zustand';
+import { useStore } from 'zustand';
+import type { AppState, StoreProviderProps } from '@/lib/state/store';
+import { createAppStore } from '@/lib/state/store';
+
+const StoreContext = createContext | null>(null);
+
+export function StateProvider({ children, initialState }: StoreProviderProps) {
+ const storeRef = useRef | null>(null);
+
+ if (!storeRef.current) {
+ storeRef.current = createAppStore(initialState);
+ }
+
+ return {children};
+}
+
+export function useAppStore(selector: (state: AppState) => T) {
+ const store = useContext(StoreContext);
+ if (!store) {
+ throw new Error('useAppStore must be used within StateProvider');
+ }
+ return useStore(store, selector);
+}
+
+export function useAppStoreApi() {
+ const store = useContext(StoreContext);
+ if (!store) {
+ throw new Error('useAppStoreApi must be used within StateProvider');
+ }
+ return store;
+}
diff --git a/apps/web/lib/ssr/http.ts b/apps/web/lib/ssr/http.ts
new file mode 100644
index 0000000000..b6a28029a5
--- /dev/null
+++ b/apps/web/lib/ssr/http.ts
@@ -0,0 +1,23 @@
+import type { Board, BoardSnapshot, Task } from '@/lib/types/http';
+
+const API_BASE_URL = process.env.KANDEV_API_BASE_URL ?? 'http://localhost:8080';
+
+async function fetchJson(url: string): Promise {
+ const response = await fetch(url, { cache: 'no-store' });
+ if (!response.ok) {
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`);
+ }
+ return (await response.json()) as T;
+}
+
+export async function fetchBoards(): Promise {
+ return fetchJson(`${API_BASE_URL}/api/v1/boards`);
+}
+
+export async function fetchBoardSnapshot(boardId: string): Promise {
+ return fetchJson(`${API_BASE_URL}/api/v1/boards/${boardId}/snapshot`);
+}
+
+export async function fetchTask(taskId: string): Promise {
+ return fetchJson(`${API_BASE_URL}/api/v1/tasks/${taskId}`);
+}
diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts
new file mode 100644
index 0000000000..2541d391b3
--- /dev/null
+++ b/apps/web/lib/ssr/mapper.ts
@@ -0,0 +1,29 @@
+import type { AppState } from '@/lib/state/store';
+import type { BoardSnapshot, Task } from '@/lib/types/http';
+
+export function snapshotToState(snapshot: BoardSnapshot): Partial {
+ const columnByState = new Map(snapshot.columns.map((column) => [column.state, column.id]));
+ const tasks = snapshot.tasks
+ .map((task) => {
+ const columnId = columnByState.get(task.state);
+ if (!columnId) return null;
+ return { id: task.id, columnId, title: task.title };
+ })
+ .filter((task): task is { id: string; columnId: string; title: string } => Boolean(task));
+
+ return {
+ kanban: {
+ boardId: snapshot.board.id,
+ columns: snapshot.columns.map((column) => ({ id: column.id, title: column.name })),
+ tasks,
+ },
+ };
+}
+
+export function taskToState(task: Task): Partial {
+ return {
+ tasks: {
+ activeTaskId: task.id,
+ },
+ };
+}
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
new file mode 100644
index 0000000000..8d2db2a6d4
--- /dev/null
+++ b/apps/web/lib/state/store.ts
@@ -0,0 +1,74 @@
+import type { ReactNode } from 'react';
+import { createStore } from 'zustand/vanilla';
+import { immer } from 'zustand/middleware/immer';
+
+export type KanbanState = {
+ boardId: string | null;
+ columns: Array<{ id: string; title: string }>;
+ tasks: Array<{ id: string; columnId: string; title: string }>;
+};
+
+export type TaskState = {
+ activeTaskId: string | null;
+};
+
+export type AgentState = {
+ agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>;
+};
+
+export type TerminalState = {
+ terminals: Array<{ id: string; output: string[] }>;
+};
+
+export type DiffState = {
+ files: Array<{ path: string; status: 'A' | 'M' | 'D'; plus: number; minus: number }>;
+};
+
+export type AppState = {
+ kanban: KanbanState;
+ tasks: TaskState;
+ agents: AgentState;
+ terminal: TerminalState;
+ diffs: DiffState;
+ hydrate: (state: Partial) => void;
+ setTerminalOutput: (terminalId: string, data: string) => void;
+};
+
+export type AppStore = ReturnType;
+
+const defaultState: AppState = {
+ kanban: { boardId: null, columns: [], tasks: [] },
+ tasks: { activeTaskId: null },
+ agents: { agents: [] },
+ terminal: { terminals: [] },
+ diffs: { files: [] },
+ hydrate: () => undefined,
+ setTerminalOutput: () => undefined,
+};
+
+export function createAppStore(initialState?: Partial) {
+ return createStore()(
+ immer((set) => ({
+ ...defaultState,
+ ...initialState,
+ hydrate: (state) =>
+ set((draft) => {
+ Object.assign(draft, state);
+ }),
+ setTerminalOutput: (terminalId, data) =>
+ set((draft) => {
+ const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId);
+ if (existing) {
+ existing.output.push(data);
+ } else {
+ draft.terminal.terminals.push({ id: terminalId, output: [data] });
+ }
+ }),
+ }))
+ );
+}
+
+export type StoreProviderProps = {
+ children: ReactNode;
+ initialState?: Partial;
+};
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
new file mode 100644
index 0000000000..eb3ddd1737
--- /dev/null
+++ b/apps/web/lib/types/backend.ts
@@ -0,0 +1,63 @@
+export type BackendMessageType =
+ | 'kanban.update'
+ | 'task.update'
+ | 'agent.update'
+ | 'terminal.output'
+ | 'diff.update'
+ | 'system.error';
+
+export type BackendMessage = {
+ type: T;
+ payload: P;
+ requestId?: string;
+ timestamp?: string;
+};
+
+export type KanbanUpdatePayload = {
+ boardId: string;
+ columns: Array<{ id: string; title: string }>;
+ tasks: Array<{ id: string; columnId: string; title: string }>;
+};
+
+export type TaskUpdatePayload = {
+ taskId: string;
+ status: string;
+ title?: string;
+ description?: string;
+};
+
+export type AgentUpdatePayload = {
+ agentId: string;
+ status: 'idle' | 'running' | 'error';
+ message?: string;
+};
+
+export type TerminalOutputPayload = {
+ terminalId: string;
+ data: string;
+ stream?: 'stdout' | 'stderr';
+};
+
+export type DiffUpdatePayload = {
+ taskId: string;
+ files: Array<{
+ path: string;
+ status: 'A' | 'M' | 'D';
+ plus: number;
+ minus: number;
+ }>;
+};
+
+export type SystemErrorPayload = {
+ message: string;
+ code?: string;
+};
+
+export type BackendMessageMap = {
+ 'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>;
+ 'task.update': BackendMessage<'task.update', TaskUpdatePayload>;
+ 'agent.update': BackendMessage<'agent.update', AgentUpdatePayload>;
+ 'terminal.output': BackendMessage<'terminal.output', TerminalOutputPayload>;
+ 'diff.update': BackendMessage<'diff.update', DiffUpdatePayload>;
+ 'system.error': BackendMessage<'system.error', SystemErrorPayload>;
+};
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
new file mode 100644
index 0000000000..775d652bd6
--- /dev/null
+++ b/apps/web/lib/types/http.ts
@@ -0,0 +1,42 @@
+export type TaskState = 'TODO' | 'IN_PROGRESS' | 'BLOCKED' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
+
+export type Board = {
+ id: string;
+ name: string;
+ description?: string | null;
+ owner_id: string;
+ created_at: string;
+ updated_at: string;
+};
+
+export type Column = {
+ id: string;
+ board_id: string;
+ name: string;
+ position: number;
+ state: TaskState;
+ created_at: string;
+ updated_at: string;
+};
+
+export type Task = {
+ id: string;
+ board_id: string;
+ title: string;
+ description: string;
+ state: TaskState;
+ priority: number;
+ agent_type?: string | null;
+ repository_url?: string | null;
+ branch?: string | null;
+ assigned_agent_id?: string | null;
+ created_at: string;
+ updated_at: string;
+ metadata?: Record | null;
+};
+
+export type BoardSnapshot = {
+ board: Board;
+ columns: Column[];
+ tasks: Task[];
+};
diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts
new file mode 100644
index 0000000000..8b857644cb
--- /dev/null
+++ b/apps/web/lib/ws/client.ts
@@ -0,0 +1,87 @@
+import type { BackendMessageMap, BackendMessageType } from '@/lib/types/backend';
+
+type MessageHandler = (message: BackendMessageMap[T]) => void;
+
+type WebSocketStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error';
+
+export class WebSocketClient {
+ private socket: WebSocket | null = null;
+ private status: WebSocketStatus = 'idle';
+ private handlers = new Map>>();
+ private pendingQueue: string[] = [];
+
+ constructor(private url: string) {}
+
+ getStatus() {
+ return this.status;
+ }
+
+ connect() {
+ if (this.socket) return;
+ this.status = 'connecting';
+ this.socket = new WebSocket(this.url);
+
+ this.socket.onopen = () => {
+ this.status = 'open';
+ this.flushQueue();
+ };
+
+ this.socket.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data) as BackendMessageMap[BackendMessageType];
+ const handlers = this.handlers.get(message.type);
+ if (!handlers) return;
+ handlers.forEach((handler) => handler(message));
+ } catch {
+ // Ignore malformed messages for now.
+ }
+ };
+
+ this.socket.onerror = () => {
+ this.status = 'error';
+ };
+
+ this.socket.onclose = () => {
+ this.status = 'closed';
+ this.socket = null;
+ };
+ }
+
+ disconnect() {
+ if (!this.socket) return;
+ this.socket.close();
+ this.socket = null;
+ this.status = 'closed';
+ }
+
+ send(payload: unknown) {
+ const data = JSON.stringify(payload);
+ if (this.status !== 'open' || !this.socket) {
+ this.pendingQueue.push(data);
+ return;
+ }
+ this.socket.send(data);
+ }
+
+ on(type: T, handler: MessageHandler) {
+ const handlers = this.handlers.get(type) ?? new Set();
+ handlers.add(handler as MessageHandler);
+ this.handlers.set(type, handlers);
+ return () => this.off(type, handler);
+ }
+
+ off(type: T, handler: MessageHandler) {
+ const handlers = this.handlers.get(type);
+ if (!handlers) return;
+ handlers.delete(handler as MessageHandler);
+ if (!handlers.size) {
+ this.handlers.delete(type);
+ }
+ }
+
+ private flushQueue() {
+ if (!this.socket || this.status !== 'open') return;
+ this.pendingQueue.forEach((data) => this.socket?.send(data));
+ this.pendingQueue = [];
+ }
+}
diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts
new file mode 100644
index 0000000000..f958968dda
--- /dev/null
+++ b/apps/web/lib/ws/router.ts
@@ -0,0 +1,55 @@
+import type { StoreApi } from 'zustand';
+import type { AppState } from '@/lib/state/store';
+import type { BackendMessageMap } from '@/lib/types/backend';
+
+export function registerWsHandlers(store: StoreApi) {
+ return {
+ 'kanban.update': (message: BackendMessageMap['kanban.update']) => {
+ store.setState((state) => ({
+ ...state,
+ kanban: {
+ boardId: message.payload.boardId,
+ columns: message.payload.columns,
+ tasks: message.payload.tasks,
+ },
+ }));
+ },
+ 'task.update': (message: BackendMessageMap['task.update']) => {
+ store.setState((state) => ({
+ ...state,
+ tasks: {
+ ...state.tasks,
+ activeTaskId: message.payload.taskId,
+ },
+ }));
+ },
+ 'agent.update': (message: BackendMessageMap['agent.update']) => {
+ store.setState((state) => ({
+ ...state,
+ agents: {
+ agents: state.agents.agents.some((agent) => agent.id === message.payload.agentId)
+ ? state.agents.agents.map((agent) =>
+ agent.id === message.payload.agentId
+ ? { ...agent, status: message.payload.status }
+ : agent
+ )
+ : [...state.agents.agents, { id: message.payload.agentId, status: message.payload.status }],
+ },
+ }));
+ },
+ 'terminal.output': (message: BackendMessageMap['terminal.output']) => {
+ store.getState().setTerminalOutput(message.payload.terminalId, message.payload.data);
+ },
+ 'diff.update': (message: BackendMessageMap['diff.update']) => {
+ store.setState((state) => ({
+ ...state,
+ diffs: {
+ files: message.payload.files,
+ },
+ }));
+ },
+ 'system.error': () => {
+ // TODO: surface as toast/notification once UI is ready.
+ },
+ };
+}
diff --git a/apps/web/lib/ws/use-websocket.tsx b/apps/web/lib/ws/use-websocket.tsx
new file mode 100644
index 0000000000..67a3424ea9
--- /dev/null
+++ b/apps/web/lib/ws/use-websocket.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import type { StoreApi } from 'zustand';
+import { WebSocketClient } from '@/lib/ws/client';
+import { registerWsHandlers } from '@/lib/ws/router';
+import type { AppState } from '@/lib/state/store';
+
+export function useWebSocket(store: StoreApi, url: string) {
+ const clientRef = useRef(null);
+
+ useEffect(() => {
+ const client = new WebSocketClient(url);
+ clientRef.current = client;
+ client.connect();
+
+ const handlers = registerWsHandlers(store);
+ const unsubscribers = Object.entries(handlers).map(([type, handler]) =>
+ client.on(type as keyof typeof handlers, handler as never)
+ );
+
+ return () => {
+ unsubscribers.forEach((unsubscribe) => unsubscribe());
+ client.disconnect();
+ };
+ }, [store, url]);
+
+ return clientRef;
+}
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index 0314ecb22d..efdaea28aa 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -16,6 +16,7 @@
"@tabler/icons-react": "^3.36.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "immer": "^10.1.1",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -24,7 +25,8 @@
"react-resizable-panels": "^2.1.9",
"shadcn": "^3.6.3",
"tailwind-merge": "^3.4.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -7350,6 +7352,17 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -11721,6 +11734,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
+ "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 21a8166773..ee2d582d29 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -17,6 +17,7 @@
"@tabler/icons-react": "^3.36.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "immer": "^10.1.1",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -25,7 +26,8 @@
"react-resizable-panels": "^2.1.9",
"shadcn": "^3.6.3",
"tailwind-merge": "^3.4.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",