feat: improve kanban board ui (#155)
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { IconCircleFilled } from '@tabler/icons-react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
|
||||
export function ConnectionStatus() {
|
||||
const status = useAppStore((store) => store.connection.status);
|
||||
const error = useAppStore((store) => store.connection.error);
|
||||
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return { color: 'text-emerald-500', text: 'Connected', variant: 'default' as const, pulse: false };
|
||||
case 'connecting':
|
||||
return { color: 'text-amber-500', text: 'Connecting', variant: 'secondary' as const, pulse: true };
|
||||
case 'reconnecting':
|
||||
return { color: 'text-orange-500', text: 'Reconnecting', variant: 'secondary' as const, pulse: true };
|
||||
case 'error':
|
||||
return { color: 'text-red-500', text: 'Error', variant: 'destructive' as const, pulse: false };
|
||||
case 'disconnected':
|
||||
default:
|
||||
return { color: 'text-yellow-500', text: 'Disconnected', variant: 'secondary' as const, pulse: true };
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
|
||||
const getTooltipContent = () => {
|
||||
const errorLine = error ? `\nError: ${error}` : '';
|
||||
return `Status: ${config.text}${errorLine}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
title={getTooltipContent()}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 text-xs cursor-default ${
|
||||
config.pulse ? 'animate-pulse' : ''
|
||||
}`}
|
||||
>
|
||||
<IconCircleFilled className={`h-2 w-2 ${config.color}`} />
|
||||
<span>{config.text}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function KanbanBoardGrid({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2 rounded-lg overflow-hidden h-full p-[2px]"
|
||||
className="grid gap-2 rounded-lg h-full"
|
||||
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
|
||||
@@ -64,7 +64,7 @@ function KanbanCardBody({
|
||||
{actions}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-3">
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-1">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -49,15 +49,15 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex flex-col h-full min-w-0 p-4 min-h-[600px] rounded-sm border border-border',
|
||||
'flex flex-col h-full min-w-0 p-3 min-h-[300px] rounded-sm border border-border',
|
||||
isOver && 'ring-2 ring-primary'
|
||||
)}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/70 pb-3 mb-4 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('w-3 h-3 rounded-full', column.color)} />
|
||||
<h2 className="font-semibold text-base">{column.title}</h2>
|
||||
<div className={cn('w-2 h-2 rounded-full', column.color)} />
|
||||
<h2 className="font-semibold text-sm">{column.title}</h2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tasks.length}
|
||||
</Badge>
|
||||
@@ -65,24 +65,21 @@ export function KanbanColumn({ column, tasks, onPreviewTask, onOpenTask, onEditT
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden space-y-2 px-1 pt-4">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center mt-8">No tasks yet</p>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
repositoryName={getRepositoryName(task.repositoryId)}
|
||||
onClick={onPreviewTask}
|
||||
onOpenFullPage={onOpenTask}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden space-y-2 px-1">
|
||||
{tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
repositoryName={getRepositoryName(task.repositoryId)}
|
||||
onClick={onPreviewTask}
|
||||
onOpenFullPage={onOpenTask}
|
||||
onEdit={onEditTask}
|
||||
onDelete={onDeleteTask}
|
||||
showMaximizeButton={showMaximizeButton}
|
||||
isDeleting={deletingTaskId === task.id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { IconAdjustmentsHorizontal } from '@tabler/icons-react';
|
||||
import { useKanbanDisplaySettings } from '@/hooks/use-kanban-display-settings';
|
||||
import type { Workspace, Repository } from '@/lib/types/http';
|
||||
import type { BoardState } from '@/lib/state/slices';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
|
||||
export function KanbanDisplayDropdown() {
|
||||
const {
|
||||
@@ -126,7 +127,10 @@ export function KanbanDisplayDropdown() {
|
||||
onTogglePreviewOnClick?.(!!checked);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Open preview on click</span>
|
||||
<span className="text-sm">
|
||||
Open preview on click {" "}
|
||||
<Badge variant="secondary" className="mr-1">beta</Badge>
|
||||
</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.
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '@kandev/ui/button';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@kandev/ui/toggle-group';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { IconPlus, IconSettings, IconList, IconLayoutKanban } from '@tabler/icons-react';
|
||||
import { ConnectionStatus } from '../connection-status';
|
||||
import { KanbanDisplayDropdown } from '../kanban-display-dropdown';
|
||||
import { TaskSearchInput } from './task-search-input';
|
||||
import { linkToTasks } from '@/lib/links';
|
||||
@@ -35,9 +34,8 @@ export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban'
|
||||
<header className="relative 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
|
||||
KanDev
|
||||
</Link>
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
{onSearchChange && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2">
|
||||
|
||||
@@ -398,7 +398,17 @@ export function TaskCreateDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId) return;
|
||||
if (!repositoryId && !selectedLocalRepo && repositories.length === 1) {
|
||||
if (repositoryId || selectedLocalRepo) return;
|
||||
|
||||
// Priority 1: Last used repository
|
||||
const lastUsedRepoId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_REPOSITORY_ID, null);
|
||||
if (lastUsedRepoId && repositories.some((repo: Repository) => repo.id === lastUsedRepoId)) {
|
||||
setRepositoryId(lastUsedRepoId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: Auto-select if only one repository
|
||||
if (repositories.length === 1) {
|
||||
setRepositoryId(repositories[0].id);
|
||||
}
|
||||
}, [open, repositories, repositoryId, selectedLocalRepo, workspaceId]);
|
||||
@@ -436,11 +446,22 @@ export function TaskCreateDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!repositoryId) return;
|
||||
if (!branch) {
|
||||
const preferredBranch = selectPreferredBranch(branches);
|
||||
if (preferredBranch) {
|
||||
setBranch(preferredBranch);
|
||||
}
|
||||
if (branch) return;
|
||||
|
||||
// Priority 1: Last used branch
|
||||
const lastUsedBranch = getLocalStorage<string | null>(STORAGE_KEYS.LAST_BRANCH, null);
|
||||
if (lastUsedBranch && branches.some((b: Branch) => {
|
||||
const displayName = b.type === 'remote' && b.remote ? `${b.remote}/${b.name}` : b.name;
|
||||
return displayName === lastUsedBranch;
|
||||
})) {
|
||||
setBranch(lastUsedBranch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: Preferred branch (main/master)
|
||||
const preferredBranch = selectPreferredBranch(branches);
|
||||
if (preferredBranch) {
|
||||
setBranch(preferredBranch);
|
||||
}
|
||||
}, [branch, branches, repositoryId]);
|
||||
|
||||
@@ -458,6 +479,7 @@ export function TaskCreateDialog({
|
||||
const workspaceRepo = repositories.find((repo: Repository) => repo.id === value);
|
||||
if (workspaceRepo) {
|
||||
setRepositoryId(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value);
|
||||
setDiscoveredRepoPath('');
|
||||
setSelectedLocalRepo(null);
|
||||
setLocalBranches([]);
|
||||
@@ -473,6 +495,12 @@ export function TaskCreateDialog({
|
||||
setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value);
|
||||
}, []);
|
||||
|
||||
// Memoized callback for BranchSelector to save selection to localStorage
|
||||
const handleBranchChange = useCallback((value: string) => {
|
||||
setBranch(value);
|
||||
setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value);
|
||||
}, []);
|
||||
|
||||
const hasRepositorySelection = Boolean(repositoryId || selectedLocalRepo);
|
||||
const branchOptionsRaw = repositoryId
|
||||
? branches
|
||||
@@ -592,6 +620,18 @@ export function TaskCreateDialog({
|
||||
useEffect(() => {
|
||||
if (repositoryId || localBranches.length === 0) return;
|
||||
if (branch) return;
|
||||
|
||||
// Priority 1: Last used branch
|
||||
const lastUsedBranch = getLocalStorage<string | null>(STORAGE_KEYS.LAST_BRANCH, null);
|
||||
if (lastUsedBranch && localBranches.some((b: Branch) => {
|
||||
const displayName = b.type === 'remote' && b.remote ? `${b.remote}/${b.name}` : b.name;
|
||||
return displayName === lastUsedBranch;
|
||||
})) {
|
||||
setBranch(lastUsedBranch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: Preferred branch (main/master)
|
||||
const preferredBranch = selectPreferredBranch(localBranches);
|
||||
if (preferredBranch) {
|
||||
setBranch(preferredBranch);
|
||||
@@ -931,34 +971,34 @@ export function TaskCreateDialog({
|
||||
disabled={!workspaceId || repositoriesLoading || discoverReposLoading || isEditMode}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<BranchSelector
|
||||
options={branchOptions}
|
||||
value={branch}
|
||||
onValueChange={setBranch}
|
||||
placeholder={
|
||||
!hasRepositorySelection
|
||||
? 'Select repository first'
|
||||
: repositoryId
|
||||
? branchesLoading
|
||||
? 'Loading branches...'
|
||||
: 'Select branch'
|
||||
: localBranchesLoading
|
||||
? 'Loading branches...'
|
||||
: branchOptions.length > 0
|
||||
? 'Select branch'
|
||||
: 'No branches found'
|
||||
}
|
||||
searchPlaceholder="Search branches..."
|
||||
emptyMessage="No branch found."
|
||||
disabled={
|
||||
isEditMode ||
|
||||
!hasRepositorySelection ||
|
||||
(repositoryId && branchesLoading) ||
|
||||
(!repositoryId && (localBranchesLoading || branchOptions.length === 0))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<BranchSelector
|
||||
options={branchOptions}
|
||||
value={branch}
|
||||
onValueChange={handleBranchChange}
|
||||
placeholder={
|
||||
!hasRepositorySelection
|
||||
? 'Select repository first'
|
||||
: repositoryId
|
||||
? branchesLoading
|
||||
? 'Loading branches...'
|
||||
: 'Select branch'
|
||||
: localBranchesLoading
|
||||
? 'Loading branches...'
|
||||
: branchOptions.length > 0
|
||||
? 'Select branch'
|
||||
: 'No branches found'
|
||||
}
|
||||
searchPlaceholder="Search branches..."
|
||||
emptyMessage="No branch found."
|
||||
disabled={
|
||||
isEditMode ||
|
||||
!hasRepositorySelection ||
|
||||
(repositoryId && branchesLoading) ||
|
||||
(!repositoryId && (localBranchesLoading || branchOptions.length === 0))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{startAgent && (
|
||||
<div>
|
||||
{agentProfiles.length === 0 && !agentProfilesLoading ? (
|
||||
@@ -969,19 +1009,19 @@ export function TaskCreateDialog({
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<AgentSelector
|
||||
options={agentProfileOptions}
|
||||
value={agentProfileId}
|
||||
onValueChange={handleAgentProfileChange}
|
||||
placeholder={
|
||||
agentProfilesLoading
|
||||
? 'Loading agents...'
|
||||
: agentProfiles.length === 0
|
||||
? 'No agents available'
|
||||
: 'Select agent'
|
||||
}
|
||||
disabled={agentProfilesLoading || isCreatingSession}
|
||||
/>
|
||||
<AgentSelector
|
||||
options={agentProfileOptions}
|
||||
value={agentProfileId}
|
||||
onValueChange={handleAgentProfileChange}
|
||||
placeholder={
|
||||
agentProfilesLoading
|
||||
? 'Loading agents...'
|
||||
: agentProfiles.length === 0
|
||||
? 'No agents available'
|
||||
: 'Select agent'
|
||||
}
|
||||
disabled={agentProfilesLoading || isCreatingSession}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,8 @@ export const STORAGE_KEYS = {
|
||||
BACKEND_URL: 'kandev.settings.backendUrl',
|
||||
ONBOARDING_COMPLETED: 'kandev.onboarding.completed',
|
||||
LAST_AGENT_PROFILE_ID: 'kandev.dialog.lastAgentProfileId',
|
||||
LAST_REPOSITORY_ID: 'kandev.dialog.lastRepositoryId',
|
||||
LAST_BRANCH: 'kandev.dialog.lastBranch',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_BACKEND_URL = 'http://localhost:8080';
|
||||
|
||||
Reference in New Issue
Block a user