feat: kanban page refactoring (#52)
* feat: kanban page refactoring * feat: remove mds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,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,
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user