diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx
index c92188c097..87f3818cdc 100644
--- a/apps/web/components/settings/repository-card.tsx
+++ b/apps/web/components/settings/repository-card.tsx
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
-import type { Repository, CustomScript } from '@/lib/settings/types';
+import type { Repository } from '@/lib/settings/types';
type RepositoryCardProps = {
repository: Repository;
@@ -35,8 +35,9 @@ export function RepositoryCard({ repository, onUpdate, onDelete }: RepositoryCar
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
- input.onchange = (e: any) => {
- const files = e.target.files;
+ input.onchange = (event: Event) => {
+ const target = event.target as HTMLInputElement | null;
+ const files = target?.files;
if (files && files[0]) {
const path = files[0].webkitRelativePath.split('/')[0];
setEditedRepo({ ...editedRepo, path: `/${path}` });
diff --git a/apps/web/components/state-provider.tsx b/apps/web/components/state-provider.tsx
index 2fd8f662c7..b38532ab76 100644
--- a/apps/web/components/state-provider.tsx
+++ b/apps/web/components/state-provider.tsx
@@ -1,6 +1,6 @@
'use client';
-import { createContext, useContext, useRef } from 'react';
+import { createContext, useContext, useState } from 'react';
import type { StoreApi } from 'zustand';
import { useStore } from 'zustand';
import type { AppState, StoreProviderProps } from '@/lib/state/store';
@@ -9,13 +9,8 @@ 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};
+ const [store] = useState(() => createAppStore(initialState));
+ return {children};
}
export function useAppStore(selector: (state: AppState) => T) {
diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx
index d2cae8c7a7..52667a1c69 100644
--- a/apps/web/components/task/task-changes-panel.tsx
+++ b/apps/web/components/task/task-changes-panel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { memo, useEffect, useMemo, useState } from 'react';
+import { memo, useMemo, useState } from 'react';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import { IconArrowBackUp, IconCopy, IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react';
import { useTheme } from 'next-themes';
@@ -21,16 +21,11 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
onClearSelected,
}: TaskChangesPanelProps) {
const defaultDiffMode: 'unified' | 'split' = 'unified';
- const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(defaultDiffMode);
+ const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(() =>
+ getLocalStorage('task-diff-view-mode', defaultDiffMode)
+ );
const { resolvedTheme } = useTheme();
- useEffect(() => {
- const storedDiffMode = getLocalStorage('task-diff-view-mode', defaultDiffMode);
- if (storedDiffMode !== defaultDiffMode) {
- setDiffViewMode(storedDiffMode);
- }
- }, []);
-
const diffTargets = useMemo(
() => (selectedDiffPath ? [selectedDiffPath] : CHANGED_FILES.map((file) => file.path)),
[selectedDiffPath]
diff --git a/apps/web/components/task/task-right-panel.tsx b/apps/web/components/task/task-right-panel.tsx
index ac729b72c2..c192a8403a 100644
--- a/apps/web/components/task/task-right-panel.tsx
+++ b/apps/web/components/task/task-right-panel.tsx
@@ -1,7 +1,7 @@
'use client';
import type { ReactNode } from 'react';
-import { memo, useEffect, useState } from 'react';
+import { memo, useState } from 'react';
import { IconChevronDown, IconChevronUp, IconX } from '@tabler/icons-react';
import { Badge } from '@/components/ui/badge';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
@@ -15,20 +15,13 @@ type TaskRightPanelProps = {
const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanelProps) {
const defaultRightLayout: [number, number] = [55, 45];
- const [rightLayout, setRightLayout] = useState(defaultRightLayout);
- const [rightSeed, setRightSeed] = useState(0);
+ const [rightLayout, setRightLayout] = useState<[number, number]>(() =>
+ getLocalStorage('task-layout-right', defaultRightLayout)
+ );
const [activeTerminalId, setActiveTerminalId] = useState(1);
const [terminalIds, setTerminalIds] = useState([1]);
const [isBottomCollapsed, setIsBottomCollapsed] = useState(false);
- useEffect(() => {
- const storedRight = getLocalStorage('task-layout-right', defaultRightLayout);
- if (storedRight[0] !== defaultRightLayout[0] || storedRight[1] !== defaultRightLayout[1]) {
- setRightLayout(storedRight);
- setRightSeed((value) => value + 1);
- }
- }, []);
-
const addTerminal = () => {
setTerminalIds((ids) => {
const nextId = Math.max(0, ...ids) + 1;
@@ -96,8 +89,7 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanel
}
return (
- {
diff --git a/apps/web/components/ws-connector.tsx b/apps/web/components/ws-connector.tsx
index a1d947b7c2..08a3169415 100644
--- a/apps/web/components/ws-connector.tsx
+++ b/apps/web/components/ws-connector.tsx
@@ -1,20 +1,17 @@
'use client';
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo } from 'react';
import { useAppStoreApi } from '@/components/state-provider';
-import { getLocalStorage } from '@/lib/local-storage';
-import { DEFAULT_BACKEND_URL, STORAGE_KEYS } from '@/lib/settings/constants';
+import { getBackendConfig } from '@/lib/config';
import { httpToWebSocketUrl, isValidBackendUrl } from '@/lib/ws/utils';
import { useWebSocket } from '@/lib/ws/use-websocket';
export function WebSocketConnector() {
const store = useAppStoreApi();
- const [backendUrl, setBackendUrl] = useState(DEFAULT_BACKEND_URL);
-
- useEffect(() => {
- const saved = getLocalStorage(STORAGE_KEYS.BACKEND_URL, DEFAULT_BACKEND_URL);
- setBackendUrl(isValidBackendUrl(saved) ? saved : DEFAULT_BACKEND_URL);
+ const backendUrl = useMemo(() => {
+ const configured = getBackendConfig().apiBaseUrl;
+ return isValidBackendUrl(configured) ? configured : 'http://localhost:8080';
}, []);
const wsUrl = useMemo(() => httpToWebSocketUrl(backendUrl), [backendUrl]);
diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts
new file mode 100644
index 0000000000..03246ccd93
--- /dev/null
+++ b/apps/web/lib/config.ts
@@ -0,0 +1,16 @@
+export type AppConfig = {
+ apiBaseUrl: string;
+};
+
+const DEFAULT_API_BASE_URL = 'http://localhost:8080';
+
+export function getBackendConfig(): AppConfig {
+ if (typeof window === 'undefined') {
+ return {
+ apiBaseUrl: process.env.KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
+ };
+ }
+ return {
+ apiBaseUrl: process.env.NEXT_PUBLIC_KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
+ };
+}
diff --git a/apps/web/lib/http/client.ts b/apps/web/lib/http/client.ts
new file mode 100644
index 0000000000..3b86cc0474
--- /dev/null
+++ b/apps/web/lib/http/client.ts
@@ -0,0 +1,36 @@
+import type { BoardSnapshot, ListBoardsResponse, ListWorkspacesResponse, Workspace } from '@/lib/types/http';
+
+async function fetchJson(url: string, options?: RequestInit): Promise {
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(options?.headers ?? {}),
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`Request failed: ${response.status} ${response.statusText}`);
+ }
+ return (await response.json()) as T;
+}
+
+export async function createWorkspace(baseUrl: string, payload: { name: string; description?: string }) {
+ return fetchJson(`${baseUrl}/api/v1/workspaces`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function listWorkspaces(baseUrl: string) {
+ return fetchJson(`${baseUrl}/api/v1/workspaces`);
+}
+
+export async function listBoards(baseUrl: string, workspaceId: string) {
+ const url = new URL(`${baseUrl}/api/v1/boards`);
+ url.searchParams.set('workspace_id', workspaceId);
+ return fetchJson(url.toString());
+}
+
+export async function fetchBoardSnapshot(baseUrl: string, boardId: string) {
+ return fetchJson(`${baseUrl}/api/v1/boards/${boardId}/snapshot`);
+}
diff --git a/apps/web/lib/ssr/http.ts b/apps/web/lib/ssr/http.ts
index b6a28029a5..52ce55159e 100644
--- a/apps/web/lib/ssr/http.ts
+++ b/apps/web/lib/ssr/http.ts
@@ -1,6 +1,7 @@
-import type { Board, BoardSnapshot, Task } from '@/lib/types/http';
+import type { BoardSnapshot, ListBoardsResponse, ListWorkspacesResponse, Task } from '@/lib/types/http';
+import { getBackendConfig } from '@/lib/config';
-const API_BASE_URL = process.env.KANDEV_API_BASE_URL ?? 'http://localhost:8080';
+const { apiBaseUrl: API_BASE_URL } = getBackendConfig();
async function fetchJson(url: string): Promise {
const response = await fetch(url, { cache: 'no-store' });
@@ -10,8 +11,16 @@ async function fetchJson(url: string): Promise {
return (await response.json()) as T;
}
-export async function fetchBoards(): Promise {
- return fetchJson(`${API_BASE_URL}/api/v1/boards`);
+export async function fetchWorkspaces(): Promise {
+ return fetchJson(`${API_BASE_URL}/api/v1/workspaces`);
+}
+
+export async function fetchBoards(workspaceId?: string): Promise {
+ const url = new URL(`${API_BASE_URL}/api/v1/boards`);
+ if (workspaceId) {
+ url.searchParams.set('workspace_id', workspaceId);
+ }
+ return fetchJson(url.toString());
}
export async function fetchBoardSnapshot(boardId: string): Promise {
diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts
index 2541d391b3..266fe8c6f8 100644
--- a/apps/web/lib/ssr/mapper.ts
+++ b/apps/web/lib/ssr/mapper.ts
@@ -2,10 +2,9 @@ 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);
+ const columnId = task.column_id;
if (!columnId) return null;
return { id: task.id, columnId, title: task.title };
})
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index 048f2b6b9a..5257ef071c 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -8,6 +8,11 @@ export type KanbanState = {
tasks: Array<{ id: string; columnId: string; title: string }>;
};
+export type WorkspaceState = {
+ items: Array<{ id: string; name: string }>;
+ activeId: string | null;
+};
+
export type TaskState = {
activeTaskId: string | null;
};
@@ -31,12 +36,15 @@ export type ConnectionState = {
export type AppState = {
kanban: KanbanState;
+ workspaces: WorkspaceState;
tasks: TaskState;
agents: AgentState;
terminal: TerminalState;
diffs: DiffState;
connection: ConnectionState;
hydrate: (state: Partial) => void;
+ setActiveWorkspace: (workspaceId: string | null) => void;
+ setWorkspaces: (workspaces: WorkspaceState['items']) => void;
setTerminalOutput: (terminalId: string, data: string) => void;
setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void;
};
@@ -45,12 +53,15 @@ export type AppStore = ReturnType;
const defaultState: AppState = {
kanban: { boardId: null, columns: [], tasks: [] },
+ workspaces: { items: [], activeId: null },
tasks: { activeTaskId: null },
agents: { agents: [] },
terminal: { terminals: [] },
diffs: { files: [] },
connection: { status: 'disconnected', error: null },
hydrate: () => undefined,
+ setActiveWorkspace: () => undefined,
+ setWorkspaces: () => undefined,
setTerminalOutput: () => undefined,
setConnectionStatus: () => undefined,
};
@@ -64,6 +75,17 @@ export function createAppStore(initialState?: Partial) {
set((draft) => {
Object.assign(draft, state);
}),
+ setActiveWorkspace: (workspaceId) =>
+ set((draft) => {
+ draft.workspaces.activeId = workspaceId;
+ }),
+ setWorkspaces: (workspaces) =>
+ set((draft) => {
+ draft.workspaces.items = workspaces;
+ if (!draft.workspaces.activeId && workspaces.length) {
+ draft.workspaces.activeId = workspaces[0].id;
+ }
+ }),
setTerminalOutput: (terminalId, data) =>
set((draft) => {
const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId);
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 775d652bd6..91d94e8b53 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -1,6 +1,22 @@
-export type TaskState = 'TODO' | 'IN_PROGRESS' | 'BLOCKED' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
+export type TaskState =
+ | 'TODO'
+ | 'IN_PROGRESS'
+ | 'REVIEW'
+ | 'BLOCKED'
+ | 'COMPLETED'
+ | 'FAILED'
+ | 'CANCELLED';
export type Board = {
+ id: string;
+ workspace_id: string;
+ name: string;
+ description?: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type Workspace = {
id: string;
name: string;
description?: string | null;
@@ -21,7 +37,10 @@ export type Column = {
export type Task = {
id: string;
+ workspace_id: string;
board_id: string;
+ column_id: string;
+ position: number;
title: string;
description: string;
state: TaskState;
@@ -40,3 +59,13 @@ export type BoardSnapshot = {
columns: Column[];
tasks: Task[];
};
+
+export type ListBoardsResponse = {
+ boards: Board[];
+ total: number;
+};
+
+export type ListWorkspacesResponse = {
+ workspaces: Workspace[];
+ total: number;
+};