diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index c138d18855..3dcddb03cd 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -535,8 +535,14 @@ func (ts *OrchestratorTestServer) Close() { func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentType string, priority int) string { t.Helper() + workspace, err := ts.TaskSvc.CreateWorkspace(context.Background(), &taskservice.CreateWorkspaceRequest{ + Name: "Test Workspace", + }) + require.NoError(t, err) + // Create board first board, err := ts.TaskSvc.CreateBoard(context.Background(), &taskservice.CreateBoardRequest{ + WorkspaceID: workspace.ID, Name: "Test Board", Description: "Test board for orchestrator", }) @@ -553,6 +559,7 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentType string, // Create task with agent type task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{ + WorkspaceID: workspace.ID, BoardID: board.ID, ColumnID: col.ID, Title: "Test Task", diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index 636274fb7f..b2cb385c14 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -15,6 +15,7 @@ func TestTaskStateConstants(t *testing.T) { }{ {"TODO state", v1.TaskStateTODO, "TODO"}, {"IN_PROGRESS state", v1.TaskStateInProgress, "IN_PROGRESS"}, + {"REVIEW state", v1.TaskStateReview, "REVIEW"}, {"BLOCKED state", v1.TaskStateBlocked, "BLOCKED"}, {"COMPLETED state", v1.TaskStateCompleted, "COMPLETED"}, {"FAILED state", v1.TaskStateFailed, "FAILED"}, diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index 99e402510c..dff727ce4a 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -5,6 +5,8 @@ import ( "database/sql" "encoding/json" "fmt" + "os" + "path/filepath" "time" "github.com/google/uuid" @@ -24,7 +26,15 @@ var _ Repository = (*SQLiteRepository)(nil) // NewSQLiteRepository creates a new SQLite repository func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { - db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_journal_mode=WAL") + normalizedPath := normalizeSQLitePath(dbPath) + if err := ensureSQLiteDir(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to prepare database path: %w", err) + } + if err := ensureSQLiteFile(normalizedPath); err != nil { + return nil, fmt.Errorf("failed to create database file: %w", err) + } + dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath) + db, err := sql.Open("sqlite3", dsn) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } @@ -44,6 +54,33 @@ func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) { return repo, nil } +func ensureSQLiteDir(dbPath string) error { + dir := filepath.Dir(dbPath) + if dir == "." || dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} + +func ensureSQLiteFile(dbPath string) error { + file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return err + } + return file.Close() +} + +func normalizeSQLitePath(dbPath string) string { + if dbPath == "" { + return dbPath + } + abs, err := filepath.Abs(dbPath) + if err != nil { + return dbPath + } + return abs +} + // initSchema creates the database tables if they don't exist func (r *SQLiteRepository) initSchema() error { schema := ` @@ -210,14 +247,58 @@ func (r *SQLiteRepository) ensureDefaultWorkspace() error { } if count == 0 { + var boardCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM boards").Scan(&boardCount); err != nil { + return err + } + var taskCount int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM tasks").Scan(&taskCount); err != nil { + return err + } defaultID := uuid.New().String() now := time.Now().UTC() + workspaceName := "Default Workspace" + workspaceDescription := "Default workspace" + if boardCount > 0 || taskCount > 0 { + workspaceName = "Migrated Workspace" + workspaceDescription = "" + } if _, err := r.db.ExecContext(ctx, ` INSERT INTO workspaces (id, name, description, owner_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) - `, defaultID, "Default Workspace", "", "", now, now); err != nil { + `, defaultID, workspaceName, workspaceDescription, "", now, now); err != nil { return err } + + if boardCount == 0 && taskCount == 0 { + boardID := uuid.New().String() + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO boards (id, workspace_id, name, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `, boardID, defaultID, "Dev", "Default development board", now, now); err != nil { + return err + } + + type columnSeed struct { + name string + position int + state string + } + columns := []columnSeed{ + {name: "Todo", position: 0, state: string(v1.TaskStateTODO)}, + {name: "In Progress", position: 1, state: string(v1.TaskStateInProgress)}, + {name: "Review", position: 2, state: string(v1.TaskStateReview)}, + {name: "Done", position: 3, state: string(v1.TaskStateCompleted)}, + } + for _, column := range columns { + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO columns (id, board_id, name, position, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, uuid.New().String(), boardID, column.name, column.position, column.state, now, now); err != nil { + return err + } + } + } } var defaultWorkspaceID string diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 13781a4b46..5c51df71c9 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -48,6 +48,58 @@ func TestSQLiteRepository_Close(t *testing.T) { } } +func TestSQLiteRepository_SeedsDefaultWorkspace(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + workspaces, err := repo.ListWorkspaces(ctx) + if err != nil { + t.Fatalf("failed to list workspaces: %v", err) + } + if len(workspaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(workspaces)) + } + if workspaces[0].Name != "Default Workspace" { + t.Errorf("expected Default Workspace, got %s", workspaces[0].Name) + } + + boards, err := repo.ListBoards(ctx, workspaces[0].ID) + if err != nil { + t.Fatalf("failed to list boards: %v", err) + } + if len(boards) != 1 { + t.Fatalf("expected 1 board, got %d", len(boards)) + } + if boards[0].Name != "Dev" { + t.Errorf("expected Dev board, got %s", boards[0].Name) + } + + columns, err := repo.ListColumns(ctx, boards[0].ID) + if err != nil { + t.Fatalf("failed to list columns: %v", err) + } + if len(columns) != 4 { + t.Fatalf("expected 4 columns, got %d", len(columns)) + } + expected := map[string]v1.TaskState{ + "Todo": v1.TaskStateTODO, + "In Progress": v1.TaskStateInProgress, + "Review": v1.TaskStateReview, + "Done": v1.TaskStateCompleted, + } + for _, column := range columns { + expectedState, ok := expected[column.Name] + if !ok { + t.Errorf("unexpected column name %s", column.Name) + continue + } + if column.State != expectedState { + t.Errorf("expected %s state %s, got %s", column.Name, expectedState, column.State) + } + } +} + // Board CRUD tests func TestSQLiteRepository_BoardCRUD(t *testing.T) { diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 0088af5272..b47da93f06 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -5,6 +5,7 @@ import ( "time" "github.com/google/uuid" + v1 "github.com/kandev/kandev/pkg/api/v1" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" @@ -12,7 +13,6 @@ import ( "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" - v1 "github.com/kandev/kandev/pkg/api/v1" ) // Service provides task business logic diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 904cd49604..4cd9729b9c 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -8,6 +8,7 @@ type TaskState string const ( TaskStateTODO TaskState = "TODO" TaskStateInProgress TaskState = "IN_PROGRESS" + TaskStateReview TaskState = "REVIEW" TaskStateBlocked TaskState = "BLOCKED" TaskStateCompleted TaskState = "COMPLETED" TaskStateFailed TaskState = "FAILED" diff --git a/apps/web/app/page-client.tsx b/apps/web/app/page-client.tsx index b023f1a9ca..0dfc5e7036 100644 --- a/apps/web/app/page-client.tsx +++ b/apps/web/app/page-client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { KanbanBoard } from '@/components/kanban-board'; import { OnboardingDialog } from '@/components/onboarding-dialog'; @@ -8,24 +8,23 @@ import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { STORAGE_KEYS } from '@/lib/settings/constants'; export function PageClient() { - const [showOnboarding, setShowOnboarding] = useState(false); - - useEffect(() => { + const [showOnboarding, setShowOnboarding] = useState(() => { + if (typeof window === 'undefined') return false; const completed = getLocalStorage(STORAGE_KEYS.ONBOARDING_COMPLETED, false); - if (!completed) { - setShowOnboarding(true); - } - }, []); + return !completed; + }); + const [boardKey, setBoardKey] = useState(0); const handleOnboardingComplete = () => { setLocalStorage(STORAGE_KEYS.ONBOARDING_COMPLETED, true); setShowOnboarding(false); + setBoardKey((prev) => prev + 1); }; return ( <> - + ); } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index e4930707af..ae13d8dcdd 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,17 +1,45 @@ import { PageClient } from '@/app/page-client'; import { StateHydrator } from '@/components/state-hydrator'; -import { fetchBoardSnapshot, fetchBoards } from '@/lib/ssr/http'; +import { fetchBoardSnapshot, fetchBoards, fetchWorkspaces } from '@/lib/ssr/http'; import { snapshotToState } from '@/lib/ssr/mapper'; +// Server Component: runs on the server for SSR and data hydration. export default async function Page() { try { - const boards = await fetchBoards(); - const boardId = boards[0]?.id; - if (!boardId) { - return ; + const workspaces = await fetchWorkspaces(); + const activeWorkspaceId = workspaces.workspaces[0]?.id ?? null; + let initialState = { + workspaces: { + items: workspaces.workspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + })), + activeId: activeWorkspaceId, + }, + }; + + if (!activeWorkspaceId) { + return ( + <> + + + + ); } + + const boardList = await fetchBoards(activeWorkspaceId); + const boardId = boardList.boards[0]?.id; + if (!boardId) { + return ( + <> + + + + ); + } + const snapshot = await fetchBoardSnapshot(boardId); - const initialState = snapshotToState(snapshot); + initialState = { ...initialState, ...snapshotToState(snapshot) }; return ( <> diff --git a/apps/web/app/settings/agent/[id]/page.tsx b/apps/web/app/settings/agent/[id]/page.tsx index f1fc90abd4..46406191d0 100644 --- a/apps/web/app/settings/agent/[id]/page.tsx +++ b/apps/web/app/settings/agent/[id]/page.tsx @@ -6,7 +6,6 @@ import { IconTrash } from '@tabler/icons-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; @@ -23,12 +22,6 @@ import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { AgentProfile, AgentType } from '@/lib/settings/types'; -const AGENT_LABELS: Record = { - 'claude-code': 'Claude Code', - 'codex': 'Codex', - 'auggie': 'Auggie', -}; - const MODEL_OPTIONS: Record = { 'claude-code': [ { value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 62c8421280..871f0c3498 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -51,14 +51,6 @@ export default function AgentsSettingsPage() { temperature: 0.7, }); - const handleUpdate = (agent: AgentProfile) => { - setAgents(agents.map((a) => (a.id === agent.id ? agent : a))); - }; - - const handleDelete = (id: string) => { - setAgents(agents.filter((a) => a.id !== id)); - }; - const handleAdd = (e: React.FormEvent) => { e.preventDefault(); const profile: AgentProfile = { diff --git a/apps/web/app/settings/environment/[id]/page.tsx b/apps/web/app/settings/environment/[id]/page.tsx index 6f734581eb..27076b7a23 100644 --- a/apps/web/app/settings/environment/[id]/page.tsx +++ b/apps/web/app/settings/environment/[id]/page.tsx @@ -2,7 +2,7 @@ import { use, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { IconPlus, IconTrash, IconX } from '@tabler/icons-react'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/apps/web/app/settings/environments/page.tsx b/apps/web/app/settings/environments/page.tsx index 2f7c4b788c..701ef157b7 100644 --- a/apps/web/app/settings/environments/page.tsx +++ b/apps/web/app/settings/environments/page.tsx @@ -35,10 +35,6 @@ export default function EnvironmentsSettingsPage() { installedAgents: [], }); - const handleDelete = (id: string) => { - setEnvironments(environments.filter((e) => e.id !== id)); - }; - const handleAdd = (e: React.FormEvent) => { e.preventDefault(); const environment: Environment = { diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx index 9f246d4e94..c99eb5c433 100644 --- a/apps/web/app/settings/general/page.tsx +++ b/apps/web/app/settings/general/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useSyncExternalStore } from 'react'; import { useTheme } from 'next-themes'; import { IconPalette, IconCode, IconBell, IconServer } from '@tabler/icons-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -10,9 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Separator } from '@/components/ui/separator'; import { SettingsSection } from '@/components/settings/settings-section'; import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; -import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; -import { STORAGE_KEYS, DEFAULT_BACKEND_URL } from '@/lib/settings/constants'; -import { isValidBackendUrl } from '@/lib/ws/utils'; +import { getBackendConfig } from '@/lib/config'; import type { Theme, Editor, Notifications } from '@/lib/settings/types'; export default function GeneralSettingsPage() { @@ -24,32 +22,18 @@ export default function GeneralSettingsPage() { const [notifications, setNotifications] = useState( SETTINGS_DATA.general.notifications ); - const [backendUrl, setBackendUrl] = useState(''); - const [backendUrlError, setBackendUrlError] = useState(''); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - const savedBackendUrl = getLocalStorage(STORAGE_KEYS.BACKEND_URL, DEFAULT_BACKEND_URL); - setBackendUrl(savedBackendUrl); - }, []); + const [backendUrl] = useState(() => getBackendConfig().apiBaseUrl); + const mounted = useSyncExternalStore( + () => () => undefined, + () => true, + () => false + ); const handleNotificationToggle = (key: keyof Notifications) => { setNotifications((prev) => ({ ...prev, [key]: !prev[key] })); }; - const handleBackendUrlChange = (value: string) => { - setBackendUrl(value); - setBackendUrlError(''); - - if (value && !isValidBackendUrl(value)) { - setBackendUrlError('Invalid URL format. Must start with http:// or https://'); - return; - } - - setLocalStorage(STORAGE_KEYS.BACKEND_URL, value); - // WebSocket reconnection will be handled by Zustand store later - }; + const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); if (!mounted) { return null; @@ -255,16 +239,14 @@ export default function GeneralSettingsPage() { handleBackendUrlChange(e.target.value)} + value={displayBackendUrl} + readOnly + disabled placeholder="http://localhost:8080" - className={backendUrlError ? 'border-red-500' : ''} + className="cursor-default" /> - {backendUrlError && ( -

{backendUrlError}

- )}

- The URL of the Kandev backend server. WebSocket connection will be implemented with Zustand. + Backend URL is provided at runtime for SSR and WebSocket connections.

diff --git a/apps/web/app/settings/integrations/page.tsx b/apps/web/app/settings/integrations/page.tsx index a5cb3e3f40..7937ebd981 100644 --- a/apps/web/app/settings/integrations/page.tsx +++ b/apps/web/app/settings/integrations/page.tsx @@ -5,14 +5,12 @@ import { IconBrandGithub, IconBrandOpenai, IconBrandGoogle, - IconKey, IconBox, IconCircleDot, } from '@tabler/icons-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; -import { Badge } from '@/components/ui/badge'; type Integration = { id: string; diff --git a/apps/web/app/settings/workspace/[id]/page.tsx b/apps/web/app/settings/workspace/[id]/page.tsx index 8c2210fa7d..1bf3432b89 100644 --- a/apps/web/app/settings/workspace/[id]/page.tsx +++ b/apps/web/app/settings/workspace/[id]/page.tsx @@ -122,8 +122,9 @@ export default function WorkspaceEditPage({ params }: { params: Promise<{ id: st 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 fullPath = files[0].webkitRelativePath; const folderName = fullPath.split('/')[0]; diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index f5c49f2922..b2c60f9d7d 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, 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'; @@ -52,8 +52,9 @@ const INITIAL_CHATS: ChatSession[] = [ export default function TaskPage() { const defaultHorizontalLayout: [number, number] = [75, 25]; - const [horizontalLayout, setHorizontalLayout] = useState(defaultHorizontalLayout); - const [horizontalSeed, setHorizontalSeed] = useState(0); + const [horizontalLayout, setHorizontalLayout] = useState<[number, number]>(() => + getLocalStorage('task-layout-horizontal', defaultHorizontalLayout) + ); const [chats, setChats] = useState(INITIAL_CHATS); const [leftTab, setLeftTab] = useState('chat'); const [selectedDiffPath, setSelectedDiffPath] = useState(null); @@ -61,18 +62,6 @@ export default function TaskPage() { 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; @@ -101,7 +90,6 @@ export default function TaskPage() {
{ diff --git a/apps/web/app/task/[id]/page.tsx b/apps/web/app/task/[id]/page.tsx index 330fdb6d89..5ff8a9bf52 100644 --- a/apps/web/app/task/[id]/page.tsx +++ b/apps/web/app/task/[id]/page.tsx @@ -4,17 +4,20 @@ import { taskToState } from '@/lib/ssr/mapper'; import TaskPageClient from './page-client'; export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) { + let initialState: ReturnType | null = null; + try { const { id } = await params; const task = await fetchTask(id); - const initialState = taskToState(task); - return ( - <> - - - - ); + initialState = taskToState(task); } catch { - return ; + initialState = null; } + + return ( + <> + {initialState ? : null} + + + ); } diff --git a/apps/web/components/connection-status.tsx b/apps/web/components/connection-status.tsx index 64d7f17349..2a01836f41 100644 --- a/apps/web/components/connection-status.tsx +++ b/apps/web/components/connection-status.tsx @@ -3,13 +3,10 @@ import { Badge } from '@/components/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { IconCircleFilled } from '@tabler/icons-react'; -import { getLocalStorage } from '@/lib/local-storage'; -import { STORAGE_KEYS, DEFAULT_BACKEND_URL } from '@/lib/settings/constants'; import { useAppStore } from '@/components/state-provider'; export function ConnectionStatus() { const { status, error } = useAppStore((store) => store.connection); - const backendUrl = getLocalStorage(STORAGE_KEYS.BACKEND_URL, DEFAULT_BACKEND_URL); const getStatusConfig = () => { switch (status) { @@ -29,7 +26,7 @@ export function ConnectionStatus() { const getTooltipContent = () => { const errorLine = error ? `\nError: ${error}` : ''; - return `Status: ${config.text}\nBackend URL: ${backendUrl}${errorLine}`; + return `Status: ${config.text}${errorLine}`; }; return ( diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 0a5937f009..280921c29e 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -12,7 +12,6 @@ import { } from '@dnd-kit/core'; import { KanbanColumn, Column } from './kanban-column'; import { KanbanCardPreview, Task } from './kanban-card'; -import { ThemeToggle } from './theme-toggle'; import { ConnectionStatus } from './connection-status'; import { Button } from '@/components/ui/button'; import { IconPlus, IconSettings } from '@tabler/icons-react'; @@ -26,6 +25,7 @@ import { SelectValue, } from '@/components/ui/select'; import { useRouter } from 'next/navigation'; +import { useAppStore, useAppStoreApi } from '@/components/state-provider'; const VIEWS: Record< string, @@ -78,6 +78,10 @@ export function KanbanBoard() { const [activeTaskId, setActiveTaskId] = useState(null); const [activeViewId, setActiveViewId] = useState('team'); const router = useRouter(); + const kanban = useAppStore((state) => state.kanban); + const workspaceState = useAppStore((state) => state.workspaces); + const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace); + const store = useAppStoreApi(); const isMounted = useSyncExternalStore( () => () => { }, @@ -93,12 +97,31 @@ export function KanbanBoard() { }) ); - const activeTask = useMemo( - () => tasks.find((task) => task.id === activeTaskId) ?? null, - [tasks, activeTaskId] - ); const activeView = VIEWS[activeViewId] ?? VIEWS.team; - const activeColumns = activeView.columns; + const backendColumns = useMemo( + () => + kanban.columns.map((column) => ({ + id: column.id, + title: column.title, + color: 'bg-neutral-400', + })), + [kanban.columns] + ); + const backendTasks = useMemo( + () => + kanban.tasks.map((task) => ({ + id: task.id, + title: task.title, + status: task.columnId, + })), + [kanban.tasks] + ); + const activeColumns = kanban.boardId ? backendColumns : activeView.columns; + const visibleTasks = kanban.boardId ? backendTasks : tasks; + const activeTask = useMemo( + () => visibleTasks.find((task) => task.id === activeTaskId) ?? null, + [visibleTasks, activeTaskId] + ); const handleDragStart = (event: DragStartEvent) => { setActiveTaskId(event.active.id as string); @@ -113,6 +136,18 @@ export function KanbanBoard() { const taskId = active.id as string; const newStatus = over.id as string; + if (kanban.boardId) { + store.getState().hydrate({ + kanban: { + ...kanban, + tasks: kanban.tasks.map((task) => + task.id === taskId ? { ...task, columnId: newStatus } : task + ), + }, + }); + return; + } + setTasks((tasks) => tasks.map((task) => task.id === taskId ? { ...task, status: newStatus } : task @@ -140,7 +175,7 @@ export function KanbanBoard() { }; const getTasksForColumn = (columnId: string) => { - return tasks.filter((task) => task.status === columnId); + return visibleTasks.filter((task) => task.status === columnId); }; if (!isMounted) { @@ -153,6 +188,21 @@ export function KanbanBoard() {

KanDev.ai

+
handleBackendUrlChange(e.target.value)} - placeholder="http://localhost:8080" - className={backendUrlError ? 'border-red-500' : ''} - autoFocus - /> - {backendUrlError &&

{backendUrlError}

} -

- Default: http://localhost:8080 for local development -

-
-
- - - - - - )} - - {step === 3 && ( - <> - -
-
Step 2 of {totalSteps - 1}
-
-
-
-
-
- Next Steps - - You're almost ready! Complete these final configuration steps in Settings. - - -
-
-
-
-
-
-
-
-

Create a Workspace

-

- Set up your first workspace and add project repositories -

-
-
-
-
-
-
-
-

Configure Projects

-

- Add project contexts and custom task columns -

-
-
-
-
-
-
-
-

Set Up Agent Profiles

-

- Configure your AI agents with preferred models and settings -

-
-
-
-
-
- - - - - - )} +
+ + + + + ); diff --git a/apps/web/components/settings/context-card.tsx b/apps/web/components/settings/context-card.tsx index bfad52cd99..a9521013f6 100644 --- a/apps/web/components/settings/context-card.tsx +++ b/apps/web/components/settings/context-card.tsx @@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import type { Context, Column } from '@/lib/settings/types'; +import type { Context } from '@/lib/settings/types'; type ContextCardProps = { context: Context; @@ -90,7 +90,7 @@ export function ContextCard({ context, onUpdate, onDelete }: ContextCardProps) {
- {editedContext.columns.map((column, index) => ( + {editedContext.columns.map((column) => (
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; +};