feat: kanban page refactoring (#52)

* feat: kanban page refactoring

* feat: remove mds
This commit is contained in:
Carlos Florêncio
2026-01-20 02:02:34 +00:00
committed by GitHub
parent 3286fd54c8
commit 6ec8b31e6b
32 changed files with 590 additions and 537 deletions
@@ -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
+3
View File
@@ -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),
}
}
@@ -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)
@@ -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"`
}
@@ -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 {
@@ -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
}
+2
View File
@@ -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),
},
};
@@ -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 (
<header className="flex items-center justify-between p-4 pb-3">
<div className="flex items-center gap-3">
<Link href="/" className="text-2xl font-bold hover:opacity-80">
KanDev.ai
</Link>
<ConnectionStatus />
</div>
<div className="flex items-center gap-3">
<Button onClick={onAddTask} className="cursor-pointer">
<IconPlus className="h-4 w-4" />
Add task
</Button>
<KanbanDisplayDropdown
workspaces={workspaces}
boards={boards}
activeWorkspaceId={activeWorkspaceId}
activeBoardId={activeBoardId}
repositories={repositories}
repositoriesLoading={repositoriesLoading}
allRepositoriesSelected={allRepositoriesSelected}
selectedRepositoryId={selectedRepositoryId}
enablePreviewOnClick={enablePreviewOnClick}
onWorkspaceChange={onWorkspaceChange}
onBoardChange={onBoardChange}
onRepositoryChange={onRepositoryChange}
onTogglePreviewOnClick={onTogglePreviewOnClick}
/>
<Link href="/settings" className="cursor-pointer">
<Button variant="outline" className="cursor-pointer">
<IconSettings className="h-4 w-4 mr-2" />
Settings
</Button>
</Link>
</div>
</header>
);
}
+63 -163
View File
@@ -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<string | null>(null);
const [isMovingTask, setIsMovingTask] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) {
const [taskSessionAvailability, setTaskSessionAvailability] = useState<Record<string, boolean>>({});
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 (
<div className="h-screen w-full flex flex-col bg-background">
<KanbanBoardHeader
workspaces={workspaceState.items}
boards={boardsState.items}
activeWorkspaceId={workspaceState.activeId}
activeBoardId={boardsState.activeId}
repositories={repositories}
repositoriesLoading={repositoriesLoading}
allRepositoriesSelected={allRepositoriesSelected}
selectedRepositoryId={userSettings.repositoryIds[0] ?? null}
enablePreviewOnClick={enablePreviewOnClick}
onTogglePreviewOnClick={onTogglePreviewOnClick}
onWorkspaceChange={(nextWorkspaceId) => {
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);
}}
/>
<header className="flex items-center justify-between p-4 pb-3">
<div className="flex items-center gap-3">
<Link href="/" className="text-2xl font-bold hover:opacity-80">
KanDev.ai
</Link>
<ConnectionStatus />
</div>
<div className="flex items-center gap-3">
<Button onClick={handleCreate} className="cursor-pointer">
<IconPlus className="h-4 w-4" />
Add task
</Button>
<KanbanDisplayDropdown />
<Link href="/settings" className="cursor-pointer">
<Button variant="outline" className="cursor-pointer">
<IconSettings className="h-4 w-4 mr-2" />
Settings
</Button>
</Link>
</div>
</header>
<TaskCreateDialog
key={isDialogOpen ? 'open' : 'closed'}
open={isDialogOpen}
onOpenChange={(open) => {
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
<KanbanBoardGrid
columns={activeColumns}
tasks={visibleTasksWithSessions}
onPreviewTask={handlePreviewTask}
onPreviewTask={handleCardClick}
onOpenTask={handleOpenTask}
onEditTask={handleEditTask}
onDeleteTask={handleDeleteTask}
onEditTask={handleEdit}
onDeleteTask={handleDelete}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
+16 -20
View File
@@ -1,5 +1,6 @@
'use client';
import { useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { IconDots, IconArrowsMaximize } from '@tabler/icons-react';
@@ -10,11 +11,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { useMemo } from 'react';
import { useAppStore } from '@/components/state-provider';
import type { TaskState } from '@/lib/types/http';
import { cn, getRepositoryDisplayName } from '@/lib/utils';
import { getTaskStateIcon } from '@/lib/ui/state-icons';
import { useAppStore } from '@/components/state-provider';
export interface Task {
id: string;
@@ -29,6 +29,7 @@ export interface Task {
interface KanbanCardProps {
task: Task;
repositoryName?: string | null;
onClick?: (task: Task) => 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 (
<Card size="sm" className={cn('w-full py-0', className)}>
<CardContent className="px-2 py-1">
<KanbanCardBody task={task} repoName={repoName} />
<KanbanCardBody task={task} repoName={repositoryName ?? null} />
</CardContent>
</Card>
);
}
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
<CardContent className="px-2 py-1">
<KanbanCardBody
task={task}
repoName={repoName}
repoName={repositoryName ?? null}
actions={
<div className="flex items-center gap-2">
{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 (
<KanbanCardLayout
task={task}
repositoryName={repositoryName}
className="cursor-grabbing shadow-lg ring-0 pointer-events-none border border-border"
/>
);
+18 -1
View File
@@ -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 (
<div
ref={setNodeRef}
@@ -54,6 +70,7 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT
<KanbanCard
key={task.id}
task={task}
repositoryName={getRepositoryName(task.repositoryId)}
onClick={onPreviewTask}
onOpenFullPage={onOpenTask}
onEdit={onEditTask}
+17 -31
View File
@@ -17,39 +17,25 @@ import {
SelectValue,
} from '@kandev/ui/select';
import { IconAdjustmentsHorizontal } from '@tabler/icons-react';
import type { Repository } from '@/lib/types/http';
import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings';
type KanbanDisplayDropdownProps = {
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;
};
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 (
+19 -139
View File
@@ -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<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(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<string | null>(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
<KanbanBoard
onPreviewTask={handlePreviewTaskWithData}
onOpenTask={handleNavigateToTask}
enablePreviewOnClick={enablePreviewOnClick}
onTogglePreviewOnClick={setEnablePreviewOnClick}
/>
</div>
@@ -343,8 +225,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
<KanbanBoard
onPreviewTask={handlePreviewTaskWithData}
onOpenTask={handleNavigateToTask}
enablePreviewOnClick={enablePreviewOnClick}
onTogglePreviewOnClick={setEnablePreviewOnClick}
/>
</div>
@@ -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,
});
}
@@ -37,11 +37,10 @@ export function GeneralSettings() {
>
<Card>
<CardHeader>
<CardTitle className="text-base">Theme</CardTitle>
<CardTitle className="text-base">Color Theme</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="theme">Color Theme</Label>
<Select value={themeValue} onValueChange={(value) => setTheme(value as Theme)}>
<SelectTrigger id="theme">
<SelectValue />
@@ -9,7 +9,6 @@ import {
IconRobot,
IconBell,
IconCode,
IconChevronRight,
IconCpu,
IconMessageCircle,
} from '@tabler/icons-react';
@@ -25,11 +24,9 @@ import {
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarMenuAction,
SidebarHeader,
} from '@kandev/ui/sidebar';
import { ScrollArea } from '@kandev/ui/scroll-area';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@kandev/ui/collapsible';
import { useAppStore } from '@/components/state-provider';
import { useAvailableAgents } from '@/hooks/use-available-agents';
+1
View File
@@ -10,6 +10,7 @@ const StoreContext = createContext<StoreApi<AppState> | null>(null);
export function StateProvider({ children, initialState }: StoreProviderProps) {
const [store] = useState(() => createAppStore(initialState));
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
}
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useTaskActions } from '@/hooks/use-task-actions';
import type { Task } from '@/components/kanban-card';
/**
* Custom hook that extracts drag-and-drop logic from the KanbanBoard component.
* Manages drag state and provides handlers for drag operations.
*
* @param visibleTasks - The list of visible tasks (filtered by repository, etc.)
* @returns Object with drag state and drag operation handlers
*/
export function useDragAndDrop(visibleTasks: Task[]) {
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [isMovingTask, setIsMovingTask] = useState(false);
const { moveTaskById } = useTaskActions();
const kanban = useAppStore((state) => state.kanban);
const store = useAppStoreApi();
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveTaskId(event.active.id as string);
}, []);
const handleDragEnd = useCallback(
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;
// Optimistic update
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);
}
},
[kanban, isMovingTask, moveTaskById, store]
);
const handleDragCancel = useCallback(() => {
setActiveTaskId(null);
}, []);
const activeTask = useMemo(
() => visibleTasks.find((task) => task.id === activeTaskId) ?? null,
[visibleTasks, activeTaskId]
);
return {
activeTaskId,
activeTask,
isMovingTask,
handleDragStart,
handleDragEnd,
handleDragCancel,
};
}
+1
View File
@@ -51,6 +51,7 @@ export function useEditors() {
preferredShell: data.settings.preferred_shell || null,
shellOptions: data.shell_options ?? [],
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
loaded: true,
});
})
@@ -0,0 +1,123 @@
'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useAppStore } from '@/components/state-provider';
import { useUserDisplaySettings } from '@/hooks/use-user-display-settings';
/**
* Custom hook that consolidates all kanban display settings and eliminates prop drilling.
* This hook provides access to workspaces, boards, repositories, and preview settings,
* along with handlers for changing these settings.
*/
export function useKanbanDisplaySettings() {
const router = useRouter();
// Access store directly
const workspaces = useAppStore((state) => state.workspaces.items);
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const boards = useAppStore((state) => state.boards.items);
const activeBoardId = useAppStore((state) => state.boards.activeId);
const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace);
const setActiveBoard = useAppStore((state) => state.setActiveBoard);
// Use existing compound hook for user settings
const {
settings: userSettings,
commitSettings,
repositories,
repositoriesLoading,
allRepositoriesSelected,
} = useUserDisplaySettings({
workspaceId: activeWorkspaceId,
boardId: activeBoardId,
});
// Get preview setting from store
const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick);
// Define handlers
const handleWorkspaceChange = useCallback(
(nextWorkspaceId: string | null) => {
setActiveWorkspace(nextWorkspaceId);
if (nextWorkspaceId) {
router.push(`/?workspaceId=${nextWorkspaceId}`);
} else {
router.push('/');
}
commitSettings({
workspaceId: nextWorkspaceId,
boardId: null,
repositoryIds: [],
});
},
[setActiveWorkspace, router, commitSettings]
);
const handleBoardChange = useCallback(
(nextBoardId: string | null) => {
setActiveBoard(nextBoardId);
if (nextBoardId) {
const workspaceId = boards.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,
});
},
[setActiveBoard, boards, router, commitSettings, userSettings.workspaceId, userSettings.repositoryIds]
);
const handleRepositoryChange = useCallback(
(value: string | 'all') => {
if (value === 'all') {
commitSettings({
workspaceId: userSettings.workspaceId,
boardId: userSettings.boardId,
repositoryIds: [],
});
return;
}
commitSettings({
workspaceId: userSettings.workspaceId,
boardId: userSettings.boardId,
repositoryIds: [value],
});
},
[commitSettings, userSettings.workspaceId, userSettings.boardId]
);
const handleTogglePreviewOnClick = useCallback(
(enabled: boolean) => {
commitSettings({
workspaceId: userSettings.workspaceId,
boardId: userSettings.boardId,
repositoryIds: userSettings.repositoryIds,
enablePreviewOnClick: enabled,
});
},
[commitSettings, userSettings.boardId, userSettings.repositoryIds, userSettings.workspaceId]
);
return {
// Data
workspaces,
boards,
activeWorkspaceId,
activeBoardId,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId: userSettings.repositoryIds[0] ?? null,
enablePreviewOnClick,
// Handlers
onWorkspaceChange: handleWorkspaceChange,
onBoardChange: handleBoardChange,
onRepositoryChange: handleRepositoryChange,
onTogglePreviewOnClick: handleTogglePreviewOnClick,
};
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { PREVIEW_PANEL } from '@/lib/settings/constants';
/**
* Custom hook that extracts complex layout calculations for the Kanban board.
* Handles container measurement, floating mode detection, and width calculations.
*
* @param isPreviewOpen - Whether the preview panel is currently open
* @param previewWidthPx - The width of the preview panel in pixels
* @returns Layout state including containerRef, shouldFloat flag, and calculated widths
*/
export function useKanbanLayout(isPreviewOpen: boolean, previewWidthPx: number) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
// 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 = isPreviewOpen && containerWidth > 0 && availableForKanban < minKanbanWidthPx;
// Calculate kanban width - keep it at min width when floating to avoid repainting
const kanbanWidth = shouldFloat
? minKanbanWidthPx
: (isPreviewOpen ? containerWidth - previewWidthPx : containerWidth);
return {
containerRef,
shouldFloat,
kanbanWidth,
containerWidth,
};
}
-11
View File
@@ -14,7 +14,6 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [previewWidthPx, setPreviewWidthPx] = useState<number>(PREVIEW_PANEL.DEFAULT_WIDTH_PX);
const [enablePreviewOnClick, setEnablePreviewOnClick] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const hasInitialized = useRef(false);
@@ -28,7 +27,6 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
isOpen: false,
previewWidthPx: PREVIEW_PANEL.DEFAULT_WIDTH_PX,
selectedTaskId: null,
enablePreviewOnClick: false,
});
// Prioritize initial task ID from SSR
@@ -44,9 +42,6 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
if (taskIdToUse) {
setSelectedTaskId(taskIdToUse);
}
if (savedState.enablePreviewOnClick !== undefined) {
setEnablePreviewOnClick(savedState.enablePreviewOnClick);
}
}, [options.initialTaskId]);
// Persist state to localStorage
@@ -64,10 +59,6 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
setKanbanPreviewState({ selectedTaskId });
}, [selectedTaskId]);
useEffect(() => {
setKanbanPreviewState({ enablePreviewOnClick });
}, [enablePreviewOnClick]);
const open = useCallback((taskId: string) => {
setSelectedTaskId(taskId);
setIsOpen(true);
@@ -91,12 +82,10 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
selectedTaskId,
isOpen,
previewWidthPx,
enablePreviewOnClick,
open,
close,
toggle,
setSelectedTaskId,
setEnablePreviewOnClick,
updatePreviewWidth,
containerRef,
};
+69
View File
@@ -0,0 +1,69 @@
'use client';
import { useCallback, useState } from 'react';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useTaskActions } from '@/hooks/use-task-actions';
import type { Task } from '@/components/kanban-card';
/**
* Custom hook that extracts task CRUD operations from the KanbanBoard component.
* Manages dialog state and provides handlers for create, edit, and delete operations.
*
* @returns Object with dialog state and task operation handlers
*/
export function useTaskCRUD() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const { deleteTaskById } = useTaskActions();
const kanban = useAppStore((state) => state.kanban);
const store = useAppStoreApi();
const handleCreate = useCallback(() => {
setEditingTask(null);
setIsDialogOpen(true);
}, []);
const handleEdit = useCallback((task: Task) => {
setEditingTask(task);
setIsDialogOpen(true);
}, []);
const handleDelete = useCallback(
async (task: Task) => {
if (!kanban.boardId) return;
// Optimistic update
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.
}
},
[deleteTaskById, kanban, store]
);
const handleDialogOpenChange = useCallback((open: boolean) => {
setIsDialogOpen(open);
if (!open) {
setEditingTask(null);
}
}, []);
return {
isDialogOpen,
setIsDialogOpen,
editingTask,
setEditingTask,
handleCreate,
handleEdit,
handleDelete,
handleDialogOpenChange,
};
}
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
/**
* Custom hook that centralizes task session fetching logic.
* First checks the store, then fetches from the backend if needed.
*
* @param taskId - The task ID to fetch session for (null if no task selected)
* @returns Object with sessionId, hasSession flag, and isLoading state
*/
export function useTaskSession(taskId: string | null) {
const sessionsFromStore = useAppStore((state) =>
taskId ? state.taskSessionsByTask.itemsByTaskId[taskId] : null
);
const [fetchedSessionId, setFetchedSessionId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Derive the session ID from store first, fall back to fetched value
const sessionIdFromStore = useMemo(() => {
return sessionsFromStore?.[0]?.id ?? null;
}, [sessionsFromStore]);
const finalSessionId = sessionIdFromStore ?? fetchedSessionId;
useEffect(() => {
// If we have session in store or no taskId, don't fetch
if (sessionIdFromStore || !taskId) {
return;
}
// Fetch if not in store and we have a taskId
let isActive = true;
const fetchSession = async () => {
const client = getWebSocketClient();
if (!client) {
if (isActive) {
setFetchedSessionId(null);
setIsLoading(false);
}
return;
}
try {
setIsLoading(true);
const response = await client.request<{ sessions: Array<{ id: string }> }>(
'task.session.list',
{ task_id: taskId },
10000
);
if (isActive) {
setFetchedSessionId(response.sessions[0]?.id ?? null);
setIsLoading(false);
}
} catch (error) {
console.error('Failed to load task sessions:', error);
if (isActive) {
setFetchedSessionId(null);
setIsLoading(false);
}
}
};
fetchSession();
return () => {
isActive = false;
};
}, [taskId, sessionIdFromStore]);
return {
sessionId: taskId ? finalSessionId : null,
hasSession: !!taskId && !!finalSessionId,
isLoading: taskId && !sessionIdFromStore ? isLoading : false,
};
}
+12 -2
View File
@@ -12,6 +12,7 @@ type DisplaySettings = {
preferredShell: string | null;
shellOptions: Array<{ value: string; label: string }>;
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
loaded: boolean;
};
@@ -27,6 +28,7 @@ type CommitPayload = {
boardId: string | null;
repositoryIds: string[];
preferredShell?: string | null;
enablePreviewOnClick?: boolean;
};
export function useUserDisplaySettings({
@@ -42,6 +44,7 @@ export function useUserDisplaySettings({
const commitSettings = useCallback(
(next: CommitPayload) => {
const repositoryIds = Array.from(new Set(next.repositoryIds)).sort();
const enablePreviewOnClick = next.enablePreviewOnClick ?? userSettings.enablePreviewOnClick;
const normalized: DisplaySettings = {
workspaceId: next.workspaceId,
boardId: next.boardId,
@@ -49,14 +52,16 @@ export function useUserDisplaySettings({
preferredShell: next.preferredShell ?? userSettings.preferredShell ?? null,
shellOptions: userSettings.shellOptions ?? [],
defaultEditorId: userSettings.defaultEditorId ?? null,
enablePreviewOnClick,
loaded: true,
};
const sameWorkspace = normalized.workspaceId === userSettings.workspaceId;
const sameBoard = normalized.boardId === userSettings.boardId;
const samePreview = normalized.enablePreviewOnClick === userSettings.enablePreviewOnClick;
const sameRepos =
normalized.repositoryIds.length === userSettings.repositoryIds.length &&
normalized.repositoryIds.every((id, index) => id === userSettings.repositoryIds[index]);
if (sameWorkspace && sameBoard && sameRepos && userSettings.loaded) {
if (sameWorkspace && sameBoard && sameRepos && samePreview && userSettings.loaded) {
return;
}
setUserSettings(normalized);
@@ -64,6 +69,7 @@ export function useUserDisplaySettings({
workspace_id: normalized.workspaceId ?? '',
board_id: normalized.boardId ?? '',
repository_ids: normalized.repositoryIds,
enable_preview_on_click: normalized.enablePreviewOnClick,
};
const client = getWebSocketClient();
if (!client) {
@@ -73,7 +79,10 @@ export function useUserDisplaySettings({
return;
}
client.request('user.settings.update', payload).catch(() => {
// Ignore update errors for now; local state stays responsive.
// Fall back to HTTP if WS update fails (e.g. navigation races).
updateUserSettings(payload, { cache: 'no-store' }).catch(() => {
// Ignore update errors for now; local state stays responsive.
});
});
},
[setUserSettings, userSettings]
@@ -92,6 +101,7 @@ export function useUserDisplaySettings({
preferredShell: data.settings.preferred_shell || null,
shellOptions: data.shell_options ?? [],
defaultEditorId: data.settings.default_editor_id || null,
enablePreviewOnClick: data.settings.enable_preview_on_click ?? false,
loaded: true,
});
})
+1
View File
@@ -171,6 +171,7 @@ export async function updateUserSettings(
repository_ids: string[];
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
},
options?: ApiRequestOptions
) {
-6
View File
@@ -34,7 +34,6 @@ const KANBAN_PREVIEW_KEYS = {
OPEN: 'kandev.kanban.preview.open',
WIDTH: 'kandev.kanban.preview.width',
SELECTED_TASK: 'kandev.kanban.preview.selectedTask',
ENABLE_PREVIEW_ON_CLICK: 'kandev.kanban.preview.enablePreviewOnClick',
} as const;
// Kanban preview state type
@@ -42,7 +41,6 @@ export interface KanbanPreviewState {
isOpen: boolean;
previewWidthPx: number;
selectedTaskId: string | null;
enablePreviewOnClick: boolean;
}
/**
@@ -55,7 +53,6 @@ export function getKanbanPreviewState(defaults: KanbanPreviewState): KanbanPrevi
isOpen: getLocalStorage(KANBAN_PREVIEW_KEYS.OPEN, defaults.isOpen),
previewWidthPx: getLocalStorage(KANBAN_PREVIEW_KEYS.WIDTH, defaults.previewWidthPx),
selectedTaskId: getLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK, defaults.selectedTaskId),
enablePreviewOnClick: getLocalStorage(KANBAN_PREVIEW_KEYS.ENABLE_PREVIEW_ON_CLICK, defaults.enablePreviewOnClick),
};
}
@@ -77,7 +74,4 @@ export function setKanbanPreviewState(state: Partial<KanbanPreviewState>): void
setLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK, state.selectedTaskId);
}
}
if (state.enablePreviewOnClick !== undefined) {
setLocalStorage(KANBAN_PREVIEW_KEYS.ENABLE_PREVIEW_ON_CLICK, state.enablePreviewOnClick);
}
}
+2
View File
@@ -138,6 +138,7 @@ export type UserSettingsState = {
preferredShell: string | null;
shellOptions: Array<{ value: string; label: string }>;
defaultEditorId: string | null;
enablePreviewOnClick: boolean;
loaded: boolean;
};
@@ -371,6 +372,7 @@ const defaultState: AppState = {
preferredShell: null,
shellOptions: [],
defaultEditorId: null,
enablePreviewOnClick: false,
loaded: false,
},
terminal: { terminals: [] },
+1
View File
@@ -249,6 +249,7 @@ export type UserSettingsUpdatedPayload = {
initial_setup_complete?: boolean;
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
updated_at?: string;
};
+1
View File
@@ -179,6 +179,7 @@ export type UserSettings = {
initial_setup_complete?: boolean;
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
updated_at: string;
};
+1
View File
@@ -15,6 +15,7 @@ export function registerUsersHandlers(store: StoreApi<AppState>): WsHandlers {
preferredShell: message.payload.preferred_shell || null,
shellOptions: state.userSettings.shellOptions ?? [],
defaultEditorId: message.payload.default_editor_id || null,
enablePreviewOnClick: message.payload.enable_preview_on_click ?? false,
loaded: true,
},
}));
-79
View File
@@ -1,79 +0,0 @@
# Initial State Hydration Refactor (LLD)
## Goal
Eliminate per-component `initial*` props by creating the Zustand store with preloaded data at the layout/route boundary, so SSR content renders immediately without post-hydration flashes.
## Current Behavior
- `StateProvider` is in `app/layout.tsx` and always instantiates the store without initial data.
- `StateHydrator` injects server data via `useEffect`, which runs after first paint.
- Pages pass `initial*` props to avoid empty SSR output.
## Proposed Architecture (Settings)
### 1) Introduce a Settings Store Provider
Create layout-level client providers that accept `initialState` and create the store once:
- `apps/web/components/state-provider.tsx`
- keep existing `StateProvider` API but allow `initialState` to be passed.
- new `SettingsStateProvider` (or reuse `StateProvider` directly in settings layout) that accepts `initialState` and wraps settings pages.
### 2) Move Store Creation From Root Layout (Settings Only)
- Root `app/layout.tsx` should no longer create the store (or should accept and pass an initial state when available).
- For sections that need SSR data, create the store in their layout and pass `initialState`.
### 3) Replace StateHydrator With Initial Store Creation
- For sections using SSR data, remove `StateHydrator` from those layouts/pages.
- Create the store with `createAppStore(initialState)` at layout render time.
### 4) Update Settings Layout (Fix Sidebar Flash)
- `apps/web/app/settings/layout.tsx`
- Build `initialState` exactly as today.
- Wrap children with `<StateProvider initialState={initialState}>` (or new `SettingsStateProvider`).
- Remove `<StateHydrator>` usage.
- **Sidebar fix:** The sidebar reads from `settingsAgents`, `workspaces`, etc. With a settings-level provider created from server data, the sidebar will render populated on first paint (no post-load flash).
### 5) Update Pages Under Settings
- Remove `initial*` props that were only used to avoid flash.
- Components should read from store only (via hooks) and use store `loaded` flags.
- Example: `PromptsSettings` should read prompts from store, no `initialPrompts` prop.
### 6) Decide Provider Boundaries
Two options:
- **Option A (Scoped provider):** Settings layout creates its own store instance. Pros: isolates settings state. Cons: state is not shared with other routes.
- **Option B (Shared store):** Root provider accepts initial state per route (requires root layout to receive data, which Next.js does not support directly). Practically hard with App Router.
**Recommendation:** Option A (Scoped provider) per section (e.g., Settings), so SSR data is injected at first render without a flash.
## Detailed Steps
1) **State Provider Changes**
- Update `StateProvider` to accept `initialState?: Partial<AppState>` (already in types).
- Ensure it uses `useState(() => createAppStore(initialState))` (already does).
2) **Settings Layout**
- Replace `StateHydrator` with `StateProvider` wrapping `SettingsLayoutClient`.
- Remove provider from root layout **only if** it conflicts. Otherwise, nest a provider for settings only.
3) **Settings Layout Client**
- If nested provider is used, ensure `SettingsLayoutClient` uses the nearest provider.
4) **Pages/Components Cleanup**
- Remove `initial*` props (e.g., `initialPrompts`, `initialEditors`, etc.).
- Update hooks/components to rely on store state.
5) **Hooks**
- Keep hooks as-is but remove special-casing for initial props.
6) **SSR Validation**
- Verify that settings pages render with data on first paint.
- Remove `StateHydrator` usages where the new provider is used.
## Migration Plan (Settings Focus)
1) Implement provider shift for Settings only.
2) Remove `StateHydrator` from `apps/web/app/settings/layout.tsx` and create the store with `initialState`.
3) Ensure Settings sidebar uses the settings-level store (no data flash).
4) Update settings pages (prompts/editors/notifications) to remove initial props if desired.
5) Add regression checks for settings pages to ensure no blank state on first render.
6) Optionally expand to other sections later.
## Risks
- Nested providers create isolated state between main app and settings pages.
- Multiple store instances may impact shared data expectations (e.g., websockets updating a different store).
- Must ensure WS connector uses the correct store instance or remains in root if necessary.
## Acceptance Criteria
- Settings pages render data immediately without post-load flash.
- Components do not require `initial*` props to render lists.
- No runtime errors due to missing provider context.