feat: ws and zustand init

This commit is contained in:
Carlos Florêncio
2026-01-11 12:35:30 +00:00
parent 6753bfa397
commit f7c089d8ba
17 changed files with 715 additions and 167 deletions
+4 -1
View File
@@ -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 (
<html lang="en" suppressHydrationWarning>
<body className="antialiased font-sans">
<ThemeProvider>{children}</ThemeProvider>
<StateProvider>
<ThemeProvider>{children}</ThemeProvider>
</StateProvider>
</body>
</html>
);
+21 -4
View File
@@ -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 <KanbanBoard />;
export default async function Page() {
try {
const boards = await fetchBoards();
const boardId = boards[0]?.id;
if (!boardId) {
return <KanbanBoard />;
}
const snapshot = await fetchBoardSnapshot(boardId);
const initialState = snapshotToState(snapshot);
return (
<>
<StateHydrator initialState={initialState} />
<KanbanBoard />
</>
);
} catch {
return <KanbanBoard />;
}
}
+166
View File
@@ -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<ChatSession[]>(INITIAL_CHATS);
const [leftTab, setLeftTab] = useState('chat');
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(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 = <TaskFilesPanel onSelectDiffPath={handleSelectDiffPath} />;
return (
<TooltipProvider>
<div className="h-screen w-full flex flex-col bg-background">
<TaskTopBar />
<div className="flex-1 min-h-0 px-4 pb-4">
<ResizablePanelGroup
key={horizontalSeed}
direction="horizontal"
className="h-full"
onLayout={(sizes) => {
setHorizontalLayout(sizes as [number, number]);
setLocalStorage('task-layout-horizontal', sizes);
}}
>
<ResizablePanel defaultSize={horizontalLayout[0]} minSize={55}>
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-r-0 mr-[5px]">
<Tabs
value={leftTab}
onValueChange={(value) => setLeftTab(value)}
className="flex-1 min-h-0"
>
<TabsList>
<TabsTrigger value="notes" className="cursor-pointer">
Notes
</TabsTrigger>
<TabsTrigger value="changes" className="cursor-pointer">
All changes
</TabsTrigger>
<TabsTrigger value="chat" className="cursor-pointer">
Chat {activeChat ? `• ${activeChat.title}` : ''}
</TabsTrigger>
</TabsList>
<TabsContent value="notes" className="mt-3 flex-1 min-h-0">
<Textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="Add task notes here..."
className="min-h-0 h-full resize-none"
/>
</TabsContent>
<TabsContent value="changes" className="mt-3 flex-1 min-h-0">
<TaskChangesPanel
selectedDiffPath={selectedDiffPath}
onClearSelected={() => setSelectedDiffPath(null)}
/>
</TabsContent>
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
<TaskChatPanel activeChat={activeChat} agents={AGENTS} onSend={handleSendMessage} />
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
<ResizableHandle className="w-px" />
<ResizablePanel defaultSize={horizontalLayout[1]} minSize={20}>
<TaskRightPanel topPanel={topFilesPanel} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
);
}
+18 -160
View File
@@ -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<ChatSession[]>(INITIAL_CHATS);
const [leftTab, setLeftTab] = useState('chat');
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(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 = <TaskFilesPanel onSelectDiffPath={handleSelectDiffPath} />;
return (
<TooltipProvider>
<div className="h-screen w-full flex flex-col bg-background">
<TaskTopBar />
<div className="flex-1 min-h-0 px-4 pb-4">
<ResizablePanelGroup
key={horizontalSeed}
direction="horizontal"
className="h-full"
onLayout={(sizes) => {
setHorizontalLayout(sizes as [number, number]);
setLocalStorage('task-layout-horizontal', sizes);
}}
>
<ResizablePanel defaultSize={horizontalLayout[0]} minSize={55}>
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-r-0 mr-[5px]">
<Tabs
value={leftTab}
onValueChange={(value) => setLeftTab(value)}
className="flex-1 min-h-0"
>
<TabsList>
<TabsTrigger value="notes" className="cursor-pointer">
Notes
</TabsTrigger>
<TabsTrigger value="changes" className="cursor-pointer">
All changes
</TabsTrigger>
<TabsTrigger value="chat" className="cursor-pointer">
Chat {activeChat ? `• ${activeChat.title}` : ''}
</TabsTrigger>
</TabsList>
<TabsContent value="notes" className="mt-3 flex-1 min-h-0">
<Textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="Add task notes here..."
className="min-h-0 h-full resize-none"
/>
</TabsContent>
<TabsContent value="changes" className="mt-3 flex-1 min-h-0">
<TaskChangesPanel
selectedDiffPath={selectedDiffPath}
onClearSelected={() => setSelectedDiffPath(null)}
/>
</TabsContent>
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
<TaskChatPanel activeChat={activeChat} agents={AGENTS} onSend={handleSendMessage} />
</TabsContent>
</Tabs>
</div>
</ResizablePanel>
<ResizableHandle className="w-px" />
<ResizablePanel defaultSize={horizontalLayout[1]} minSize={20}>
<TaskRightPanel topPanel={topFilesPanel} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
);
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 (
<>
<StateHydrator initialState={initialState} />
<TaskPageClient />
</>
);
} catch {
return <TaskPageClient />;
}
}
+21
View File
@@ -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<AppState>;
};
export function StateHydrator({ initialState }: StateHydratorProps) {
const store = useAppStoreApi();
useEffect(() => {
if (Object.keys(initialState).length) {
store.getState().hydrate(initialState);
}
}, [initialState, store]);
return null;
}
+35
View File
@@ -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<StoreApi<AppState> | null>(null);
export function StateProvider({ children, initialState }: StoreProviderProps) {
const storeRef = useRef<StoreApi<AppState> | null>(null);
if (!storeRef.current) {
storeRef.current = createAppStore(initialState);
}
return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
}
export function useAppStore<T>(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;
}
+23
View File
@@ -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<T>(url: string): Promise<T> {
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<Board[]> {
return fetchJson<Board[]>(`${API_BASE_URL}/api/v1/boards`);
}
export async function fetchBoardSnapshot(boardId: string): Promise<BoardSnapshot> {
return fetchJson<BoardSnapshot>(`${API_BASE_URL}/api/v1/boards/${boardId}/snapshot`);
}
export async function fetchTask(taskId: string): Promise<Task> {
return fetchJson<Task>(`${API_BASE_URL}/api/v1/tasks/${taskId}`);
}
+29
View File
@@ -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<AppState> {
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<AppState> {
return {
tasks: {
activeTaskId: task.id,
},
};
}
+74
View File
@@ -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<AppState>) => void;
setTerminalOutput: (terminalId: string, data: string) => void;
};
export type AppStore = ReturnType<typeof createAppStore>;
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<AppState>) {
return createStore<AppState>()(
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<AppState>;
};
+63
View File
@@ -0,0 +1,63 @@
export type BackendMessageType =
| 'kanban.update'
| 'task.update'
| 'agent.update'
| 'terminal.output'
| 'diff.update'
| 'system.error';
export type BackendMessage<T extends BackendMessageType, P> = {
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>;
};
+42
View File
@@ -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<string, unknown> | null;
};
export type BoardSnapshot = {
board: Board;
columns: Column[];
tasks: Task[];
};
+87
View File
@@ -0,0 +1,87 @@
import type { BackendMessageMap, BackendMessageType } from '@/lib/types/backend';
type MessageHandler<T extends BackendMessageType> = (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<BackendMessageType, Set<MessageHandler<BackendMessageType>>>();
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<T extends BackendMessageType>(type: T, handler: MessageHandler<T>) {
const handlers = this.handlers.get(type) ?? new Set();
handlers.add(handler as MessageHandler<BackendMessageType>);
this.handlers.set(type, handlers);
return () => this.off(type, handler);
}
off<T extends BackendMessageType>(type: T, handler: MessageHandler<T>) {
const handlers = this.handlers.get(type);
if (!handlers) return;
handlers.delete(handler as MessageHandler<BackendMessageType>);
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 = [];
}
}
+55
View File
@@ -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<AppState>) {
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.
},
};
}
+29
View File
@@ -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<AppState>, url: string) {
const clientRef = useRef<WebSocketClient | null>(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;
}
+43 -1
View File
@@ -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
}
}
}
}
}
+3 -1
View File
@@ -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",