From 6ec8b31e6b6dfbecd030fd92f800a36886029436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 20 Jan 2026 02:02:34 +0000 Subject: [PATCH] feat: kanban page refactoring (#52) * feat: kanban page refactoring * feat: remove mds --- .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + .../internal/user/handlers/handlers.go | 4 + apps/backend/internal/user/models/models.go | 1 + apps/backend/internal/user/service/service.go | 5 + apps/backend/internal/user/store/sqlite.go | 3 + apps/web/app/page.tsx | 2 + apps/web/components/kanban-board-header.tsx | 80 ------- apps/web/components/kanban-board.tsx | 226 +++++------------- apps/web/components/kanban-card.tsx | 36 ++- apps/web/components/kanban-column.tsx | 19 +- .../components/kanban-display-dropdown.tsx | 48 ++-- apps/web/components/kanban-with-preview.tsx | 158 ++---------- .../components/settings/editors-settings.tsx | 1 + .../components/settings/general-settings.tsx | 3 +- .../settings/settings-app-sidebar.tsx | 3 - apps/web/components/state-provider.tsx | 1 + apps/web/hooks/use-drag-and-drop.ts | 91 +++++++ apps/web/hooks/use-editors.ts | 1 + apps/web/hooks/use-kanban-display-settings.ts | 123 ++++++++++ apps/web/hooks/use-kanban-layout.ts | 54 +++++ apps/web/hooks/use-kanban-preview.ts | 11 - apps/web/hooks/use-task-crud.ts | 69 ++++++ apps/web/hooks/use-task-session.ts | 79 ++++++ apps/web/hooks/use-user-display-settings.ts | 14 +- apps/web/lib/http/index.ts | 1 + apps/web/lib/local-storage.ts | 6 - apps/web/lib/state/store.ts | 2 + apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http.ts | 1 + apps/web/lib/ws/handlers/users.ts | 1 + initial-state-hydration.md | 79 ------ 32 files changed, 590 insertions(+), 537 deletions(-) delete mode 100644 apps/web/components/kanban-board-header.tsx create mode 100644 apps/web/hooks/use-drag-and-drop.ts create mode 100644 apps/web/hooks/use-kanban-display-settings.ts create mode 100644 apps/web/hooks/use-kanban-layout.ts create mode 100644 apps/web/hooks/use-task-crud.ts create mode 100644 apps/web/hooks/use-task-session.ts delete mode 100644 initial-state-hydration.md diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 3680cab6a6..00a11dece7 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -50,6 +50,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS InitialSetupComplete: req.InitialSetupComplete, PreferredShell: req.PreferredShell, DefaultEditorID: req.DefaultEditorID, + EnablePreviewOnClick: req.EnablePreviewOnClick, }) if err != nil { return dto.UserSettingsResponse{}, err diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 8720f5405b..f286afc219 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -21,6 +21,7 @@ type UserSettingsDTO struct { InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` UpdatedAt string `json:"updated_at"` } @@ -46,6 +47,7 @@ type UpdateUserSettingsRequest struct { InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` PreferredShell *string `json:"preferred_shell,omitempty"` DefaultEditorID *string `json:"default_editor_id,omitempty"` + EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` } func FromUser(user *models.User) UserDTO { @@ -66,6 +68,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { InitialSetupComplete: settings.InitialSetupComplete, PreferredShell: settings.PreferredShell, DefaultEditorID: settings.DefaultEditorID, + EnablePreviewOnClick: settings.EnablePreviewOnClick, UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go index d2348596e8..a2dc45918b 100644 --- a/apps/backend/internal/user/handlers/handlers.go +++ b/apps/backend/internal/user/handlers/handlers.go @@ -70,6 +70,7 @@ type httpUpdateUserSettingsRequest struct { InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` PreferredShell *string `json:"preferred_shell,omitempty"` DefaultEditorID *string `json:"default_editor_id,omitempty"` + EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` } func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { @@ -85,6 +86,7 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { InitialSetupComplete: body.InitialSetupComplete, PreferredShell: body.PreferredShell, DefaultEditorID: body.DefaultEditorID, + EnablePreviewOnClick: body.EnablePreviewOnClick, }) if err != nil { h.logger.Error("failed to update user settings", zap.Error(err)) @@ -109,6 +111,7 @@ type wsUpdateUserSettingsRequest struct { InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` PreferredShell *string `json:"preferred_shell,omitempty"` DefaultEditorID *string `json:"default_editor_id,omitempty"` + EnablePreviewOnClick *bool `json:"enable_preview_on_click,omitempty"` } func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -123,6 +126,7 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (* InitialSetupComplete: req.InitialSetupComplete, PreferredShell: req.PreferredShell, DefaultEditorID: req.DefaultEditorID, + EnablePreviewOnClick: req.EnablePreviewOnClick, }) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil) diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 9d2ea040d8..fb71fb749c 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -17,6 +17,7 @@ type UserSettings struct { InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 807723e3d2..6cd4428df3 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -30,6 +30,7 @@ type UpdateUserSettingsRequest struct { InitialSetupComplete *bool PreferredShell *string DefaultEditorID *string + EnablePreviewOnClick *bool } func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { @@ -88,6 +89,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if req.DefaultEditorID != nil { settings.DefaultEditorID = strings.TrimSpace(*req.DefaultEditorID) } + if req.EnablePreviewOnClick != nil { + settings.EnablePreviewOnClick = *req.EnablePreviewOnClick + } settings.UpdatedAt = time.Now().UTC() if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { return nil, err @@ -108,6 +112,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, "default_editor_id": settings.DefaultEditorID, + "enable_preview_on_click": settings.EnablePreviewOnClick, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index c740e1390c..33b911e0cc 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -158,6 +158,7 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, "default_editor_id": settings.DefaultEditorID, + "enable_preview_on_click": settings.EnablePreviewOnClick, }) if err != nil { return err @@ -202,6 +203,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` + EnablePreviewOnClick bool `json:"enable_preview_on_click"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -212,5 +214,6 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.InitialSetupComplete = payload.InitialSetupComplete settings.PreferredShell = payload.PreferredShell settings.DefaultEditorID = payload.DefaultEditorID + settings.EnablePreviewOnClick = payload.EnablePreviewOnClick return settings, nil } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 973a3d634e..11741d33d6 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -29,6 +29,7 @@ export default async function Page({ searchParams }: PageProps) { const settingsWorkspaceId = userSettings?.workspace_id || null; const settingsBoardId = userSettings?.board_id || null; const settingsRepositoryIds = Array.from(new Set(userSettings?.repository_ids ?? [])).sort(); + const settingsEnablePreviewOnClick = userSettings?.enable_preview_on_click ?? false; const activeWorkspaceId = workspaces.workspaces.find((workspace) => workspace.id === workspaceId)?.id ?? workspaces.workspaces.find((workspace) => workspace.id === settingsWorkspaceId)?.id ?? @@ -56,6 +57,7 @@ export default async function Page({ searchParams }: PageProps) { preferredShell: userSettings?.preferred_shell || null, shellOptions: userSettingsResponse?.shell_options ?? [], defaultEditorId: userSettings?.default_editor_id || null, + enablePreviewOnClick: settingsEnablePreviewOnClick, loaded: Boolean(userSettings), }, }; diff --git a/apps/web/components/kanban-board-header.tsx b/apps/web/components/kanban-board-header.tsx deleted file mode 100644 index fd9892b48d..0000000000 --- a/apps/web/components/kanban-board-header.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { Button } from '@kandev/ui/button'; -import { IconPlus, IconSettings } from '@tabler/icons-react'; -import { ConnectionStatus } from './connection-status'; -import { KanbanDisplayDropdown } from './kanban-display-dropdown'; -import type { Repository } from '@/lib/types/http'; - -type KanbanBoardHeaderProps = { - workspaces: Array<{ id: string; name: string }>; - boards: Array<{ id: string; workspaceId: string; name: string }>; - activeWorkspaceId: string | null; - activeBoardId: string | null; - repositories: Repository[]; - repositoriesLoading: boolean; - allRepositoriesSelected: boolean; - selectedRepositoryId: string | null; - enablePreviewOnClick?: boolean; - onWorkspaceChange: (workspaceId: string | null) => void; - onBoardChange: (boardId: string | null) => void; - onRepositoryChange: (repositoryId: string | 'all') => void; - onTogglePreviewOnClick?: (enabled: boolean) => void; - onAddTask: () => void; -}; - -export function KanbanBoardHeader({ - workspaces, - boards, - activeWorkspaceId, - activeBoardId, - repositories, - repositoriesLoading, - allRepositoriesSelected, - selectedRepositoryId, - enablePreviewOnClick, - onWorkspaceChange, - onBoardChange, - onRepositoryChange, - onTogglePreviewOnClick, - onAddTask, -}: KanbanBoardHeaderProps) { - return ( -
-
- - KanDev.ai - - -
-
- - - - - -
-
- ); -} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 47c9941a38..d25c9040ec 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -1,7 +1,6 @@ 'use client'; import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; -import { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; import { Column } from './kanban-column'; import { Task } from './kanban-card'; import { TaskCreateDialog } from './task-create-dialog'; @@ -12,24 +11,23 @@ import { filterTasksByRepositories } from '@/lib/kanban/filters'; import { useUserDisplaySettings } from '@/hooks/use-user-display-settings'; import { useBoards } from '@/hooks/use-boards'; import { useBoardSnapshot } from '@/hooks/use-board-snapshot'; -import { useTaskActions } from '@/hooks/use-task-actions'; -import { KanbanBoardHeader } from './kanban-board-header'; +import { useDragAndDrop } from '@/hooks/use-drag-and-drop'; +import { useTaskCRUD } from '@/hooks/use-task-crud'; import { KanbanBoardGrid } from './kanban-board-grid'; import { getWebSocketClient } from '@/lib/ws/connection'; import { linkToSession } from '@/lib/links'; +import Link from 'next/link'; +import { Button } from '@kandev/ui/button'; +import { IconPlus, IconSettings } from '@tabler/icons-react'; +import { ConnectionStatus } from './connection-status'; +import { KanbanDisplayDropdown } from './kanban-display-dropdown'; interface KanbanBoardProps { onPreviewTask?: (task: Task) => void; onOpenTask?: (task: Task, sessionId: string) => void; - enablePreviewOnClick?: boolean; - onTogglePreviewOnClick?: (enabled: boolean) => void; } -export function KanbanBoard({ onPreviewTask, onOpenTask, enablePreviewOnClick, onTogglePreviewOnClick }: KanbanBoardProps = {}) { - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [activeTaskId, setActiveTaskId] = useState(null); - const [isMovingTask, setIsMovingTask] = useState(false); - const [editingTask, setEditingTask] = useState(null); +export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) { const [taskSessionAvailability, setTaskSessionAvailability] = useState>({}); const router = useRouter(); const kanban = useAppStore((state) => state.kanban); @@ -39,15 +37,16 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, enablePreviewOnClick, o const setActiveBoard = useAppStore((state) => state.setActiveBoard); const setBoards = useAppStore((state) => state.setBoards); const store = useAppStoreApi(); - const { moveTaskById, deleteTaskById } = useTaskActions(); + + // Get preview setting from store + const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick); + useBoards(workspaceState.activeId, true); useBoardSnapshot(boardsState.activeId); + const { settings: userSettings, commitSettings, - repositories, - repositoriesLoading, - allRepositoriesSelected, selectedRepositoryIds, } = useUserDisplaySettings({ workspaceId: workspaceState.activeId, @@ -112,60 +111,10 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, enablePreviewOnClick, o })), [visibleTasks, taskSessionAvailability] ); - const activeTask = useMemo( - () => visibleTasksWithSessions.find((task) => task.id === activeTaskId) ?? null, - [visibleTasksWithSessions, activeTaskId] - ); - const handleDragStart = (event: DragStartEvent) => { - setActiveTaskId(event.active.id as string); - }; - - const handleDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; - - setActiveTaskId(null); - if (!over) return; - - const taskId = active.id as string; - const newStatus = over.id as string; - - if (!kanban.boardId || isMovingTask) { - return; - } - - const targetTasks = kanban.tasks - .filter((task) => task.columnId === newStatus && task.id !== taskId) - .sort((a, b) => a.position - b.position); - const nextPosition = targetTasks.length; - store.getState().hydrate({ - kanban: { - ...kanban, - tasks: kanban.tasks.map((task) => - task.id === taskId - ? { ...task, columnId: newStatus, position: nextPosition } - : task - ), - }, - }); - - try { - setIsMovingTask(true); - await moveTaskById(taskId, { - board_id: kanban.boardId, - column_id: newStatus, - position: nextPosition, - }); - } catch { - // Ignore move errors for now; WS updates or next snapshot will correct. - } finally { - setIsMovingTask(false); - } - }; - - const handleDragCancel = () => { - setActiveTaskId(null); - }; + // Use custom hooks for drag-and-drop and CRUD operations + const { activeTask, handleDragStart, handleDragEnd, handleDragCancel } = useDragAndDrop(visibleTasksWithSessions); + const { isDialogOpen, editingTask, handleCreate, handleEdit, handleDelete, handleDialogOpenChange, setIsDialogOpen, setEditingTask } = useTaskCRUD(); const handleDialogSuccess = (task: BackendTask, mode: 'create' | 'edit') => { if (mode === 'create') { @@ -242,40 +191,32 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, enablePreviewOnClick, o } }; - const handlePreviewTask = async (task: Task) => { - const latestSessionId = await fetchLatestSessionId(task.id); - if (!latestSessionId) { - setEditingTask(task); - setIsDialogOpen(true); - return; - } - if (onPreviewTask) { - onPreviewTask(task); + const handleCardClick = async (task: Task) => { + // Capture the current mode at the start to avoid race conditions + const shouldOpenPreview = enablePreviewOnClick; + + if (shouldOpenPreview) { + // Preview mode - just call the preview handler without fetching session + if (onPreviewTask) { + onPreviewTask(task); + } } else { - // Fallback to opening full page if no preview handler - handleOpenTask(task); + // Navigate mode - fetch session and navigate + const latestSessionId = await fetchLatestSessionId(task.id); + if (!latestSessionId) { + setEditingTask(task); + setIsDialogOpen(true); + return; + } + + if (onOpenTask) { + onOpenTask(task, latestSessionId); + } else { + router.push(linkToSession(latestSessionId)); + } } }; - const handleEditTask = (task: Task) => { - setEditingTask(task); - setIsDialogOpen(true); - }; - - const handleDeleteTask = async (task: Task) => { - if (!kanban.boardId) return; - store.getState().hydrate({ - kanban: { - ...kanban, - tasks: kanban.tasks.filter((item) => item.id !== task.id), - }, - }); - try { - await deleteTaskById(task.id); - } catch { - // Ignore delete errors for now. - } - }; useEffect(() => { @@ -326,72 +267,31 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, enablePreviewOnClick, o return (
- { - setActiveWorkspace(nextWorkspaceId); - if (nextWorkspaceId) { - router.push(`/?workspaceId=${nextWorkspaceId}`); - } else { - router.push('/'); - } - commitSettings({ - workspaceId: nextWorkspaceId, - boardId: null, - repositoryIds: [], - }); - }} - onBoardChange={(nextBoardId) => { - setActiveBoard(nextBoardId); - if (nextBoardId) { - const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId; - const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ''; - router.push(`/?boardId=${nextBoardId}${workspaceParam}`); - } - commitSettings({ - workspaceId: userSettings.workspaceId, - boardId: nextBoardId, - repositoryIds: userSettings.repositoryIds, - }); - }} - onRepositoryChange={(value) => { - if (value === 'all') { - commitSettings({ - workspaceId: userSettings.workspaceId, - boardId: userSettings.boardId, - repositoryIds: [], - }); - return; - } - commitSettings({ - workspaceId: userSettings.workspaceId, - boardId: userSettings.boardId, - repositoryIds: [value], - }); - }} - onAddTask={() => { - setEditingTask(null); - setIsDialogOpen(true); - }} - /> +
+
+ + KanDev.ai + + +
+
+ + + + + +
+
{ - setIsDialogOpen(open); - if (!open) { - setEditingTask(null); - } - }} + onOpenChange={handleDialogOpenChange} workspaceId={workspaceState.activeId} boardId={kanban.boardId} defaultColumnId={activeColumns[0]?.id ?? null} @@ -422,10 +322,10 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, enablePreviewOnClick, o void; onEdit?: (task: Task) => void; onDelete?: (task: Task) => void; @@ -65,35 +66,21 @@ function KanbanCardBody({ ); } -function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: string }) { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const repository = useMemo( - () => Object.values(repositoriesByWorkspace).flat().find((repo) => repo.id === task.repositoryId) ?? null, - [repositoriesByWorkspace, task.repositoryId] - ); - const repoName = getRepositoryDisplayName(repository?.local_path); - +function KanbanCardLayout({ task, repositoryName, className }: KanbanCardProps & { className?: string }) { return ( - + ); } -export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage, showMaximizeButton = true }: KanbanCardProps) { +export function KanbanCard({ task, repositoryName, onClick, onEdit, onDelete, onOpenFullPage, showMaximizeButton = true }: KanbanCardProps) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, }); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const repository = useMemo( - () => Object.values(repositoriesByWorkspace).flat().find((repo) => repo.id === task.repositoryId) ?? null, - [repositoriesByWorkspace, task.repositoryId] - ); - const repoName = getRepositoryDisplayName(repository?.local_path); - const statusIcon = getTaskStateIcon(task.state, 'h-4 w-4'); const style = { @@ -119,7 +106,7 @@ export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage, sh {task.state === 'IN_PROGRESS' && statusIcon} @@ -178,9 +165,18 @@ export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage, sh } export function KanbanCardPreview({ task }: KanbanCardProps) { + // Access store to get repository name for the drag preview + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repository = useMemo( + () => Object.values(repositoriesByWorkspace).flat().find((repo) => repo.id === task.repositoryId) ?? null, + [repositoriesByWorkspace, task.repositoryId] + ); + const repositoryName = repository ? getRepositoryDisplayName(repository.local_path) : null; + return ( ); diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 2c6dc7567e..3a373406d2 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -1,9 +1,11 @@ 'use client'; +import { useMemo } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { KanbanCard, Task } from './kanban-card'; import { Badge } from '@kandev/ui/badge'; -import { cn } from '@/lib/utils'; +import { cn, getRepositoryDisplayName } from '@/lib/utils'; +import { useAppStore } from '@/components/state-provider'; export interface Column { id: string; @@ -26,6 +28,20 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT id: column.id, }); + // Access repositories from store to pass repository names to cards + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repositories = useMemo( + () => Object.values(repositoriesByWorkspace).flat(), + [repositoriesByWorkspace] + ); + + // Helper function to get repository name for a task + const getRepositoryName = (repositoryId?: string): string | null => { + if (!repositoryId) return null; + const repository = repositories.find((repo) => repo.id === repositoryId); + return repository ? getRepositoryDisplayName(repository.local_path) : null; + }; + return (
; - boards: Array<{ id: string; workspaceId: string; name: string }>; - activeWorkspaceId: string | null; - activeBoardId: string | null; - repositories: Repository[]; - repositoriesLoading: boolean; - allRepositoriesSelected: boolean; - selectedRepositoryId: string | null; - enablePreviewOnClick?: boolean; - onWorkspaceChange: (workspaceId: string | null) => void; - onBoardChange: (boardId: string | null) => void; - onRepositoryChange: (repositoryId: string | 'all') => void; - onTogglePreviewOnClick?: (enabled: boolean) => void; -}; +export function KanbanDisplayDropdown() { + const { + workspaces, + boards, + activeWorkspaceId, + activeBoardId, + repositories, + repositoriesLoading, + allRepositoriesSelected, + selectedRepositoryId, + enablePreviewOnClick, + onWorkspaceChange, + onBoardChange, + onRepositoryChange, + onTogglePreviewOnClick, + } = useKanbanDisplaySettings(); -export function KanbanDisplayDropdown({ - workspaces, - boards, - activeWorkspaceId, - activeBoardId, - repositories, - repositoriesLoading, - allRepositoriesSelected, - selectedRepositoryId, - enablePreviewOnClick, - onWorkspaceChange, - onBoardChange, - onRepositoryChange, - onTogglePreviewOnClick, -}: KanbanDisplayDropdownProps) { const repositoryValue = allRepositoriesSelected ? 'all' : selectedRepositoryId ?? 'all'; return ( diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 6915b3d0f1..748828c9d3 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -1,14 +1,15 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useRouter } from "next/navigation"; import { KanbanBoard } from "./kanban-board"; import { TaskPreviewPanel } from "./task-preview-panel"; import { useKanbanPreview } from "@/hooks/use-kanban-preview"; +import { useKanbanLayout } from "@/hooks/use-kanban-layout"; +import { useTaskSession } from "@/hooks/use-task-session"; import { useAppStore } from "@/components/state-provider"; import { Task } from "./kanban-card"; import { PREVIEW_PANEL } from "@/lib/settings/constants"; -import { getWebSocketClient } from "@/lib/ws/connection"; import { linkToSession } from "@/lib/links"; type KanbanWithPreviewProps = { @@ -16,24 +17,18 @@ type KanbanWithPreviewProps = { initialSessionId?: string; }; -export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWithPreviewProps) { +export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) { const router = useRouter(); - const containerRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); - const isResizingRef = useRef(false); // Get tasks from the kanban store const kanbanTasks = useAppStore((state) => state.kanban.tasks); - const taskSessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); const { selectedTaskId, isOpen, previewWidthPx, - enablePreviewOnClick, open, close, - setEnablePreviewOnClick, updatePreviewWidth, } = useKanbanPreview({ initialTaskId, @@ -41,9 +36,13 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit // Cleanup handled by close }, }); - // Use initialSessionId if provided, otherwise fetch from WebSocket - const [selectedTaskSessionId, setSelectedTaskSessionId] = useState(initialSessionId ?? null); - const hasLoadedInitialSession = useRef(false); + + // Use custom hooks for layout and session management + const { containerRef, shouldFloat, kanbanWidth } = useKanbanLayout(isOpen, previewWidthPx); + const { sessionId: selectedTaskSessionId } = useTaskSession(selectedTaskId ?? null); + + // Track resize state + const isResizingRef = useRef(false); // Compute selected task from kanbanTasks and selectedTaskId const selectedTask = useMemo(() => { @@ -63,74 +62,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit }; }, [selectedTaskId, kanbanTasks]); - useEffect(() => { - if (!selectedTaskId) { - return; - } - - // If we have an initial session ID and haven't loaded yet, skip the first fetch - if (initialSessionId && !hasLoadedInitialSession.current) { - hasLoadedInitialSession.current = true; - return; - } - - let isActive = true; - const loadLatestSession = async () => { - const client = getWebSocketClient(); - if (!client) { - if (isActive) { - setSelectedTaskSessionId(null); - } - return; - } - try { - const response = await client.request<{ sessions: Array<{ id: string }> }>( - "task.session.list", - { task_id: selectedTaskId }, - 10000 - ); - if (isActive) { - setSelectedTaskSessionId(response.sessions[0]?.id ?? null); - } - } catch (error) { - console.error("Failed to load task sessions:", error); - if (isActive) { - setSelectedTaskSessionId(null); - } - } - }; - - loadLatestSession(); - - return () => { - isActive = false; - }; - }, [selectedTaskId, initialSessionId]); - - // Measure container width on mount and resize - useEffect(() => { - if (!containerRef.current) return; - - const updateWidth = () => { - if (containerRef.current) { - setContainerWidth(containerRef.current.offsetWidth); - } - }; - - updateWidth(); - const resizeObserver = new ResizeObserver(updateWidth); - resizeObserver.observe(containerRef.current); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - // Calculate if we should be in floating mode - // Float when preview would make kanban less than minimum percentage - const minKanbanWidthPx = (PREVIEW_PANEL.MIN_KANBAN_WIDTH_PERCENT / 100) * containerWidth; - const availableForKanban = containerWidth - previewWidthPx; - const shouldFloat = isOpen && containerWidth > 0 && availableForKanban < minKanbanWidthPx; // Close panel if selected task no longer exists useEffect(() => { @@ -141,10 +72,9 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit const handleNavigateToTask = useCallback( (task: Task, sessionId: string) => { - close(); router.push(linkToSession(sessionId)); }, - [close, router] + [router] ); // Update URL query params when task selection changes @@ -168,59 +98,18 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit window.history.replaceState({}, '', url.toString()); }, [selectedTaskId, selectedTaskSessionId]); - // Preview handler - task data is computed from selectedTaskId + // Preview handler - opens/toggles the preview panel // Toggle behavior: clicking the same task closes the panel const handlePreviewTaskWithData = useCallback( - async (task: Task) => { - if (enablePreviewOnClick) { - // Preview mode: open/toggle preview panel - if (isOpen && selectedTaskId === task.id) { - close(); - } else { - open(task.id); - } + (task: Task) => { + // Toggle preview: if already open for this task, close it; otherwise open + if (isOpen && selectedTaskId === task.id) { + close(); } else { - // Navigate mode: go directly to session details - // First check if we have session data in the store - const sessionsFromStore = taskSessionsByTaskId[task.id]; - const sessionIdFromStore = sessionsFromStore?.[0]?.id; - - if (sessionIdFromStore) { - // We have a session ID in the store - use it directly - handleNavigateToTask(task, sessionIdFromStore); - return; - } - - // No session in store - fetch from backend - const client = getWebSocketClient(); - if (!client) { - // If no WebSocket client, fall back to opening preview - open(task.id); - return; - } - - try { - const response = await client.request<{ sessions: Array<{ id: string }> }>( - "task.session.list", - { task_id: task.id }, - 10000 - ); - const sessionId = response.sessions[0]?.id; - - if (sessionId) { - handleNavigateToTask(task, sessionId); - } else { - // If no session exists, fall back to opening preview - open(task.id); - } - } catch (error) { - console.error("Failed to load task sessions:", error); - // Fall back to opening preview on error - open(task.id); - } + open(task.id); } }, - [enablePreviewOnClick, isOpen, selectedTaskId, taskSessionsByTaskId, open, close, handleNavigateToTask] + [isOpen, selectedTaskId, open, close] ); // Handle Escape key to close preview @@ -265,11 +154,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit window.addEventListener("mouseup", handleMouseUp); }, [previewWidthPx, updatePreviewWidth]); - // Calculate kanban width - keep it at min width when floating to avoid repainting - const kanbanWidth = shouldFloat - ? minKanbanWidthPx - : (isOpen ? containerWidth - previewWidthPx : containerWidth); - const activeSessionId = selectedTaskId ? selectedTaskSessionId : null; return ( @@ -286,8 +170,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
@@ -343,8 +225,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index 5b489d9974..8f3d557dd5 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -357,6 +357,7 @@ export function EditorsSettings() { preferredShell: response.settings.preferred_shell || null, shellOptions: currentUserSettings.shellOptions ?? [], defaultEditorId: response.settings.default_editor_id || null, + enablePreviewOnClick: response.settings.enable_preview_on_click ?? false, loaded: true, }); } diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 43095f87db..7205d45b99 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -37,11 +37,10 @@ export function GeneralSettings() { > - Theme + Color Theme
-