feat: ws and zustand init
This commit is contained in:
@@ -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
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+43
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user