feat: preview panel option (#49)

* feat: preview panel option

* feat: improved card navigation
This commit is contained in:
Carlos Florêncio
2026-01-19 23:49:06 +00:00
committed by GitHub
parent 92ada0ad22
commit 0ac6a87132
9 changed files with 259 additions and 105 deletions
@@ -23,6 +23,7 @@ export type KanbanBoardGridProps = {
onDragEnd: (event: DragEndEvent) => void;
onDragCancel: () => void;
activeTask: Task | null;
showMaximizeButton?: boolean;
};
export function KanbanBoardGrid({
@@ -36,6 +37,7 @@ export function KanbanBoardGrid({
onDragEnd,
onDragCancel,
activeTask,
showMaximizeButton,
}: KanbanBoardGridProps) {
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -78,6 +80,7 @@ export function KanbanBoardGrid({
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
showMaximizeButton={showMaximizeButton}
/>
))}
</div>
+21 -95
View File
@@ -2,22 +2,9 @@
import Link from 'next/link';
import { Button } from '@kandev/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import { IconAdjustmentsHorizontal, IconPlus, IconSettings } from '@tabler/icons-react';
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 = {
@@ -29,9 +16,11 @@ type KanbanBoardHeaderProps = {
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;
};
@@ -44,13 +33,13 @@ export function KanbanBoardHeader({
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
onWorkspaceChange,
onBoardChange,
onRepositoryChange,
onTogglePreviewOnClick,
onAddTask,
}: KanbanBoardHeaderProps) {
const repositoryValue = allRepositoriesSelected ? 'all' : selectedRepositoryId ?? 'all';
return (
<header className="flex items-center justify-between p-4 pb-3">
<div className="flex items-center gap-3">
@@ -64,84 +53,21 @@ export function KanbanBoardHeader({
<IconPlus className="h-4 w-4" />
Add task
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="cursor-pointer">
<IconAdjustmentsHorizontal className="h-4 w-4 mr-2" />
Display
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] p-3">
<div className="space-y-3">
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Workspace</DropdownMenuLabel>
<Select
value={activeWorkspaceId ?? ''}
onValueChange={(value) => onWorkspaceChange(value || null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select workspace" />
</SelectTrigger>
<SelectContent>
{workspaces.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Board</DropdownMenuLabel>
<Select
value={activeBoardId ?? ''}
onValueChange={(value) => onBoardChange(value || null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select board" />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Repository</DropdownMenuLabel>
<Select
value={repositoryValue}
onValueChange={(value) => onRepositoryChange(value as string | 'all')}
disabled={repositories.length === 0}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
repositoriesLoading
? 'Loading repositories...'
: repositories.length === 0
? 'No repositories'
: 'Select repository'
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
{repositories.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
{repo.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
<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" />
+6 -1
View File
@@ -21,9 +21,11 @@ import { linkToSession } from '@/lib/links';
interface KanbanBoardProps {
onPreviewTask?: (task: Task) => void;
onOpenTask?: (task: Task, sessionId: string) => void;
enablePreviewOnClick?: boolean;
onTogglePreviewOnClick?: (enabled: boolean) => void;
}
export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) {
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);
@@ -333,6 +335,8 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
repositoriesLoading={repositoriesLoading}
allRepositoriesSelected={allRepositoriesSelected}
selectedRepositoryId={userSettings.repositoryIds[0] ?? null}
enablePreviewOnClick={enablePreviewOnClick}
onTogglePreviewOnClick={onTogglePreviewOnClick}
onWorkspaceChange={(nextWorkspaceId) => {
setActiveWorkspace(nextWorkspaceId);
if (nextWorkspaceId) {
@@ -426,6 +430,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
activeTask={activeTask}
showMaximizeButton={enablePreviewOnClick}
/>
</div>
);
+4 -3
View File
@@ -33,6 +33,7 @@ interface KanbanCardProps {
onEdit?: (task: Task) => void;
onDelete?: (task: Task) => void;
onOpenFullPage?: (task: Task) => void;
showMaximizeButton?: boolean;
}
function KanbanCardBody({
@@ -81,7 +82,7 @@ function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: s
);
}
export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage }: KanbanCardProps) {
export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage, showMaximizeButton = true }: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
});
@@ -121,8 +122,8 @@ export function KanbanCard({ task, onClick, onEdit, onDelete, onOpenFullPage }:
repoName={repoName}
actions={
<div className="flex items-center gap-2">
{statusIcon}
{onOpenFullPage && task.hasSession !== false && (
{task.state === 'IN_PROGRESS' && statusIcon}
{showMaximizeButton && onOpenFullPage && task.hasSession !== false && (
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
+3 -1
View File
@@ -18,9 +18,10 @@ interface KanbanColumnProps {
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
showMaximizeButton?: boolean;
}
export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask }: KanbanColumnProps) {
export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditTask, onDeleteTask, showMaximizeButton }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
});
@@ -57,6 +58,7 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT
onOpenFullPage={onOpenTask}
onEdit={onEditTask}
onDelete={onDeleteTask}
showMaximizeButton={showMaximizeButton}
/>
))
)}
@@ -0,0 +1,151 @@
'use client';
import { Button } from '@kandev/ui/button';
import { Checkbox } from '@kandev/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import { IconAdjustmentsHorizontal } from '@tabler/icons-react';
import type { Repository } from '@/lib/types/http';
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({
workspaces,
boards,
activeWorkspaceId,
activeBoardId,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
onWorkspaceChange,
onBoardChange,
onRepositoryChange,
onTogglePreviewOnClick,
}: KanbanDisplayDropdownProps) {
const repositoryValue = allRepositoriesSelected ? 'all' : selectedRepositoryId ?? 'all';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="cursor-pointer">
<IconAdjustmentsHorizontal className="h-4 w-4 mr-2" />
Display
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] p-3">
<div className="space-y-3">
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Workspace</DropdownMenuLabel>
<Select
value={activeWorkspaceId ?? ''}
onValueChange={(value) => onWorkspaceChange(value || null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select workspace" />
</SelectTrigger>
<SelectContent>
{workspaces.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Board</DropdownMenuLabel>
<Select
value={activeBoardId ?? ''}
onValueChange={(value) => onBoardChange(value || null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select board" />
</SelectTrigger>
<SelectContent>
{boards.map((board) => (
<SelectItem key={board.id} value={board.id}>
{board.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Repository</DropdownMenuLabel>
<Select
value={repositoryValue}
onValueChange={(value) => onRepositoryChange(value as string | 'all')}
disabled={repositories.length === 0}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
repositoriesLoading
? 'Loading repositories...'
: repositories.length === 0
? 'No repositories'
: 'Select repository'
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
{repositories.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
{repo.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0">Preview Panel</DropdownMenuLabel>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={enablePreviewOnClick ?? false}
onCheckedChange={(checked) => {
onTogglePreviewOnClick?.(!!checked);
}}
/>
<span className="text-sm">Open preview on click</span>
</label>
<p className="text-xs text-muted-foreground pl-6">
When enabled, clicking a task opens the preview panel. When disabled, clicking navigates directly to the session.
</p>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
+54 -5
View File
@@ -24,13 +24,16 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
// 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,
@@ -168,14 +171,56 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
// 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();
async (task: Task) => {
if (enablePreviewOnClick) {
// Preview mode: open/toggle preview panel
if (isOpen && selectedTaskId === task.id) {
close();
} else {
open(task.id);
}
} else {
open(task.id);
// 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);
}
}
},
[isOpen, selectedTaskId, open, close]
[enablePreviewOnClick, isOpen, selectedTaskId, taskSessionsByTaskId, open, close, handleNavigateToTask]
);
// Handle Escape key to close preview
@@ -241,6 +286,8 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
<KanbanBoard
onPreviewTask={handlePreviewTaskWithData}
onOpenTask={handleNavigateToTask}
enablePreviewOnClick={enablePreviewOnClick}
onTogglePreviewOnClick={setEnablePreviewOnClick}
/>
</div>
@@ -296,6 +343,8 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
<KanbanBoard
onPreviewTask={handlePreviewTaskWithData}
onOpenTask={handleNavigateToTask}
enablePreviewOnClick={enablePreviewOnClick}
onTogglePreviewOnClick={setEnablePreviewOnClick}
/>
</div>
+11
View File
@@ -14,6 +14,7 @@ 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);
@@ -27,6 +28,7 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
isOpen: false,
previewWidthPx: PREVIEW_PANEL.DEFAULT_WIDTH_PX,
selectedTaskId: null,
enablePreviewOnClick: false,
});
// Prioritize initial task ID from SSR
@@ -42,6 +44,9 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
if (taskIdToUse) {
setSelectedTaskId(taskIdToUse);
}
if (savedState.enablePreviewOnClick !== undefined) {
setEnablePreviewOnClick(savedState.enablePreviewOnClick);
}
}, [options.initialTaskId]);
// Persist state to localStorage
@@ -59,6 +64,10 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
setKanbanPreviewState({ selectedTaskId });
}, [selectedTaskId]);
useEffect(() => {
setKanbanPreviewState({ enablePreviewOnClick });
}, [enablePreviewOnClick]);
const open = useCallback((taskId: string) => {
setSelectedTaskId(taskId);
setIsOpen(true);
@@ -82,10 +91,12 @@ export function useKanbanPreview(options: UseKanbanPreviewOptions = {}) {
selectedTaskId,
isOpen,
previewWidthPx,
enablePreviewOnClick,
open,
close,
toggle,
setSelectedTaskId,
setEnablePreviewOnClick,
updatePreviewWidth,
containerRef,
};
+6
View File
@@ -34,6 +34,7 @@ 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
@@ -41,6 +42,7 @@ export interface KanbanPreviewState {
isOpen: boolean;
previewWidthPx: number;
selectedTaskId: string | null;
enablePreviewOnClick: boolean;
}
/**
@@ -53,6 +55,7 @@ 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),
};
}
@@ -74,4 +77,7 @@ 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);
}
}