diff --git a/apps/web/app/page-client.tsx b/apps/web/app/page-client.tsx index 0dfc5e7036..5278029402 100644 --- a/apps/web/app/page-client.tsx +++ b/apps/web/app/page-client.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; -import { KanbanBoard } from '@/components/kanban-board'; +import { KanbanWithPreview } from '@/components/kanban-with-preview'; import { OnboardingDialog } from '@/components/onboarding-dialog'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { STORAGE_KEYS } from '@/lib/settings/constants'; @@ -24,7 +24,7 @@ export function PageClient() { return ( <> - + ); } diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index d42fe70b1e..3db79df4eb 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -15,6 +15,7 @@ import { KanbanCardPreview, Task } from './kanban-card'; export type KanbanBoardGridProps = { columns: Column[]; tasks: Task[]; + onPreviewTask: (task: Task) => void; onOpenTask: (task: Task) => void; onEditTask: (task: Task) => void; onDeleteTask: (task: Task) => void; @@ -27,6 +28,7 @@ export type KanbanBoardGridProps = { export function KanbanBoardGrid({ columns, tasks, + onPreviewTask, onOpenTask, onEditTask, onDeleteTask, @@ -72,6 +74,7 @@ export function KanbanBoardGrid({ key={column.id} column={column} tasks={getTasksForColumn(column.id)} + onPreviewTask={onPreviewTask} onOpenTask={onOpenTask} onEditTask={onEditTask} onDeleteTask={onDeleteTask} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 041c428fa3..fd3be48383 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -16,7 +16,12 @@ import { useTaskActions } from '@/hooks/use-task-actions'; import { KanbanBoardHeader } from './kanban-board-header'; import { KanbanBoardGrid } from './kanban-board-grid'; -export function KanbanBoard() { +interface KanbanBoardProps { + onPreviewTask?: (task: Task) => void; + onOpenTask?: (task: Task) => void; +} + +export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) { const [isDialogOpen, setIsDialogOpen] = useState(false); const [activeTaskId, setActiveTaskId] = useState(null); const [isMovingTask, setIsMovingTask] = useState(false); @@ -192,7 +197,20 @@ export function KanbanBoard() { }; const handleOpenTask = (task: Task) => { - router.push(`/task/${task.id}`); + if (onOpenTask) { + onOpenTask(task); + } else { + router.push(`/task/${task.id}`); + } + }; + + const handlePreviewTask = (task: Task) => { + if (onPreviewTask) { + onPreviewTask(task); + } else { + // Fallback to opening full page if no preview handler + handleOpenTask(task); + } }; const handleEditTask = (task: Task) => { @@ -358,6 +376,7 @@ export function KanbanBoard() { void; onEdit?: (task: Task) => void; onDelete?: (task: Task) => void; + onOpenFullPage?: (task: Task) => void; } function KanbanCardBody({ @@ -77,7 +78,7 @@ function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: s ); } -export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps) { +export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage }: KanbanCardProps) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, }); @@ -137,13 +138,29 @@ export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps) actions={
{statusIcon} + {onOpenFullPage && ( + + )} diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 0955bb8b06..6d88beb6a1 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -14,12 +14,13 @@ export interface Column { interface KanbanColumnProps { column: Column; tasks: Task[]; + onPreviewTask: (task: Task) => void; onOpenTask: (task: Task) => void; onEditTask: (task: Task) => void; onDeleteTask: (task: Task) => void; } -export function KanbanColumn({ column, tasks, onOpenTask, onEditTask, onDeleteTask }: KanbanColumnProps) { +export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id: column.id, }); @@ -52,7 +53,8 @@ export function KanbanColumn({ column, tasks, onOpenTask, onEditTask, onDeleteTa diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx new file mode 100644 index 0000000000..542dba75d9 --- /dev/null +++ b/apps/web/components/kanban-with-preview.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } 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 { useAppStore } from "@/components/state-provider"; +import { Task } from "./kanban-card"; +import { PREVIEW_PANEL } from "@/lib/settings/constants"; + +export function KanbanWithPreview() { + const router = useRouter(); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const isResizingRef = useRef(false); + + // Get tasks from the kanban store + const kanbanTasks = useAppStore((state) => state.kanban.tasks); + + const { + selectedTaskId, + isOpen, + previewWidthPx, + open, + close, + updatePreviewWidth, + } = useKanbanPreview({ + onClose: () => { + // Cleanup handled by close + }, + }); + + // Compute selected task from kanbanTasks and selectedTaskId + const selectedTask = useMemo(() => { + if (!selectedTaskId || kanbanTasks.length === 0) return null; + + const task = kanbanTasks.find((t) => t.id === selectedTaskId); + if (!task) return null; + + return { + id: task.id, + title: task.title, + columnId: task.columnId, + state: task.state, + description: task.description, + position: task.position, + repositoryUrl: task.repositoryUrl, + }; + }, [selectedTaskId, kanbanTasks]); + + // 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(() => { + if (isOpen && selectedTaskId && !selectedTask) { + close(); + } + }, [isOpen, selectedTaskId, selectedTask, close]); + + const handleNavigateToTask = useCallback( + (task: Task) => { + close(); + router.push(`/task/${task.id}`); + }, + [close, router] + ); + + // Preview handler - task data is computed from selectedTaskId + // Toggle behavior: clicking the same task closes the panel + const handlePreviewTaskWithData = useCallback( + (task: Task) => { + if (isOpen && selectedTaskId === task.id) { + close(); + } else { + open(task.id); + } + }, + [isOpen, selectedTaskId, open, close] + ); + + // Handle Escape key to close preview + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) { + close(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, close]); + + // Unified resize handler for both inline and floating modes + const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isResizingRef.current = true; + + const startX = e.clientX; + const startWidth = previewWidthPx; + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!isResizingRef.current) return; + + // Calculate new width (moving left increases width) + const deltaX = startX - moveEvent.clientX; + const newWidth = startWidth + deltaX; + + // No maximum limit - can resize all the way to the left + // updatePreviewWidth enforces minimum width + updatePreviewWidth(newWidth); + }; + + const handleMouseUp = () => { + isResizingRef.current = false; + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mousemove", handleMouseMove); + 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); + + return ( +
+ {shouldFloat ? ( + // Floating mode: kanban at min width, preview overlays + <> +
+ +
+ + {/* Backdrop */} +
+ + {/* Floating Panel */} +
+ {/* Resize handle on the left edge */} +
+
+
+
+ + {/* Panel content */} +
+ +
+
+ + ) : ( + // Inline mode: side by side with custom resize +
+ {/* Kanban Board */} +
+ +
+ + {/* Preview Panel */} + {isOpen && ( +
+ {/* Resize handle */} +
+
+
+
+ + {/* Panel content */} +
+ +
+
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/components/task-preview-panel.tsx b/apps/web/components/task-preview-panel.tsx new file mode 100644 index 0000000000..8c90383c04 --- /dev/null +++ b/apps/web/components/task-preview-panel.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { IconX, IconCircleCheck, IconCircleX, IconLoader2, IconAlertTriangle, IconArrowsMaximize } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Badge } from "@kandev/ui/badge"; +import { getRepositoryDisplayName } from "@/lib/utils"; +import type { Task } from "./kanban-card"; + +interface TaskPreviewPanelProps { + task: Task | null; + onClose: () => void; + onMaximize?: (task: Task) => void; +} + +function getStateDisplay(state?: string) { + switch (state) { + case "IN_PROGRESS": + case "SCHEDULING": + return { + label: state === "SCHEDULING" ? "Scheduling" : "In Progress", + icon: , + variant: "default" as const, + }; + case "COMPLETED": + return { + label: "Completed", + icon: , + variant: "default" as const, + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + }; + case "FAILED": + case "CANCELLED": + return { + label: state === "FAILED" ? "Failed" : "Cancelled", + icon: , + variant: "destructive" as const, + }; + case "BLOCKED": + case "WAITING_FOR_INPUT": + return { + label: state === "BLOCKED" ? "Blocked" : "Waiting for Input", + icon: , + variant: "default" as const, + className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", + }; + case "TODO": + return { + label: "To Do", + icon: null, + variant: "secondary" as const, + }; + case "REVIEW": + return { + label: "Review", + icon: null, + variant: "default" as const, + }; + case "CREATED": + default: + return { + label: "Created", + icon: null, + variant: "secondary" as const, + }; + } +} + +export function TaskPreviewPanel({ task, onClose, onMaximize }: TaskPreviewPanelProps) { + if (!task) { + return ( +
+
+

Task Preview

+ +
+
+

Select a task to preview

+
+
+ ); + } + + const repoName = getRepositoryDisplayName(task.repositoryUrl); + const stateDisplay = getStateDisplay(task.state); + + return ( +
+ {/* Header */} +
+

Task Preview

+
+ {onMaximize && ( + + )} + +
+
+ + {/* Content */} +
+
+ {/* Repository */} + {repoName && ( +
+

Repository

+

{repoName}

+
+ )} + + {/* Title */} +
+

Title

+

{task.title}

+
+ + {/* State */} + {task.state && ( +
+

Status

+ + {stateDisplay.icon && {stateDisplay.icon}} + {stateDisplay.label} + +
+ )} + + {/* Description */} + {task.description && ( +
+

Description

+

+ {task.description} +

+
+ )} +
+
+
+ ); +} diff --git a/apps/web/hooks/use-kanban-preview.ts b/apps/web/hooks/use-kanban-preview.ts new file mode 100644 index 0000000000..94a8d6d533 --- /dev/null +++ b/apps/web/hooks/use-kanban-preview.ts @@ -0,0 +1,84 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { getKanbanPreviewState, setKanbanPreviewState } from "@/lib/local-storage"; +import { PREVIEW_PANEL } from "@/lib/settings/constants"; + +interface UseKanbanPreviewOptions { + onClose?: () => void; +} + +export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) { + // Always start with default values to avoid hydration mismatch + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [previewWidthPx, setPreviewWidthPx] = useState(PREVIEW_PANEL.DEFAULT_WIDTH_PX); + const containerRef = useRef(null); + + // Load persisted state from localStorage AFTER hydration + // This is necessary to avoid hydration mismatch between server and client + useEffect(() => { + const savedState = getKanbanPreviewState({ + isOpen: false, + previewWidthPx: PREVIEW_PANEL.DEFAULT_WIDTH_PX, + selectedTaskId: null, + }); + + if (savedState.isOpen) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsOpen(true); + } + if (savedState.previewWidthPx) { + setPreviewWidthPx(Math.max(PREVIEW_PANEL.MIN_WIDTH_PX, savedState.previewWidthPx)); + } + if (savedState.selectedTaskId) { + setSelectedTaskId(savedState.selectedTaskId); + } + }, []); + + // Persist state to localStorage + useEffect(() => { + setKanbanPreviewState({ isOpen }); + }, [isOpen]); + + useEffect(() => { + if (isOpen && previewWidthPx > 0) { + setKanbanPreviewState({ previewWidthPx }); + } + }, [isOpen, previewWidthPx]); + + useEffect(() => { + setKanbanPreviewState({ selectedTaskId }); + }, [selectedTaskId]); + + const open = useCallback((taskId: string) => { + setSelectedTaskId(taskId); + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + setSelectedTaskId(null); + options.onClose?.(); + }, [options]); + + const toggle = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const updatePreviewWidth = useCallback((width: number) => { + setPreviewWidthPx(Math.max(PREVIEW_PANEL.MIN_WIDTH_PX, width)); + }, []); + + return { + selectedTaskId, + isOpen, + previewWidthPx, + open, + close, + toggle, + setSelectedTaskId, + updatePreviewWidth, + containerRef, + }; +} diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 36e910007e..bf0608ab62 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -28,3 +28,50 @@ export function removeLocalStorage(key: string): void { // Ignore removal failures. } } + +// Internal storage keys for kanban preview (not exported - encapsulated) +const KANBAN_PREVIEW_KEYS = { + OPEN: 'kandev.kanban.preview.open', + WIDTH: 'kandev.kanban.preview.width', + SELECTED_TASK: 'kandev.kanban.preview.selectedTask', +} as const; + +// Kanban preview state type +export interface KanbanPreviewState { + isOpen: boolean; + previewWidthPx: number; + selectedTaskId: string | null; +} + +/** + * Get the kanban preview state from localStorage + * @param defaults - Default values to use if not found in localStorage + * @returns The kanban preview state + */ +export function getKanbanPreviewState(defaults: KanbanPreviewState): KanbanPreviewState { + return { + 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), + }; +} + +/** + * Set the kanban preview state in localStorage + * @param state - Partial state to update (only provided fields are updated) + */ +export function setKanbanPreviewState(state: Partial): void { + if (state.isOpen !== undefined) { + setLocalStorage(KANBAN_PREVIEW_KEYS.OPEN, state.isOpen); + } + if (state.previewWidthPx !== undefined) { + setLocalStorage(KANBAN_PREVIEW_KEYS.WIDTH, state.previewWidthPx); + } + if (state.selectedTaskId !== undefined) { + if (state.selectedTaskId === null) { + removeLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK); + } else { + setLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK, state.selectedTaskId); + } + } +} diff --git a/apps/web/lib/settings/constants.ts b/apps/web/lib/settings/constants.ts index 1667f70e20..32a9b442eb 100644 --- a/apps/web/lib/settings/constants.ts +++ b/apps/web/lib/settings/constants.ts @@ -4,3 +4,18 @@ export const STORAGE_KEYS = { } as const; export const DEFAULT_BACKEND_URL = 'http://localhost:8080'; + +// Kanban Preview Panel Settings +export const PREVIEW_PANEL = { + // Minimum width of the preview panel in pixels (prevents panel from being too narrow) + MIN_WIDTH_PX: 300, + + // Default width of the preview panel in pixels when first opened + DEFAULT_WIDTH_PX: 500, + + // Maximum width of the preview panel in viewport width percentage (prevents covering entire screen) + MAX_WIDTH_VW: 95, + + // Minimum width of the kanban board as a percentage of viewport before the panel switches to floating mode + MIN_KANBAN_WIDTH_PERCENT: 50, +} as const;