Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React, { memo, useLayoutEffect, useState } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Feature, useAppStore } from '@/store/app-store';
Expand All @@ -10,6 +9,25 @@ import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';

function getCardBorderStyle(enabled: boolean, opacity: number): React.CSSProperties {
if (!enabled) {
return { borderWidth: '0px', borderColor: 'transparent' };
}
if (opacity !== 100) {
return {
borderWidth: '1px',
borderColor: `color-mix(in oklch, var(--border) ${opacity}%, transparent)`,
};
}
return {};
}

function getCursorClass(isOverlay: boolean | undefined, isDraggable: boolean): string {
if (isOverlay) return 'cursor-grabbing';
if (isDraggable) return 'cursor-grab active:cursor-grabbing';
return 'cursor-default';
}

interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
Expand All @@ -35,6 +53,7 @@ interface KanbanCardProps {
glassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
isOverlay?: boolean;
}

export const KanbanCard = memo(function KanbanCard({
Expand Down Expand Up @@ -62,64 +81,63 @@ export const KanbanCard = memo(function KanbanCard({
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
isOverlay,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
const [isLifted, setIsLifted] = useState(false);

useLayoutEffect(() => {
if (isOverlay) {
requestAnimationFrame(() => {
setIsLifted(true);
});
}
}, [isOverlay]);

const isDraggable =
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: feature.id,
disabled: !isDraggable,
disabled: !isDraggable || isOverlay,
});

const style = {
transform: CSS.Transform.toString(transform),
transition,
const dndStyle = {
opacity: isDragging ? 0.5 : undefined,
};

const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);

const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50'
);

const isInteractive = !isDragging && !isOverlay;
const hasError = feature.error && !isCurrentAutoTask;

const cardElement = (
const innerCardClasses = cn(
'kanban-card-content h-full relative shadow-sm',
'transition-all duration-200 ease-out',
isInteractive && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&
cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg'
);

const renderCardContent = () => (
<Card
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
style={isCurrentAutoTask ? undefined : cardStyle}
className={innerCardClasses}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
{(!isDragging || isOverlay) && (
<div
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
Expand Down Expand Up @@ -185,10 +203,20 @@ export const KanbanCard = memo(function KanbanCard({
</Card>
);

// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}

return cardElement;
return (
<div
ref={setNodeRef}
style={dndStyle}
{...attributes}
{...(isDraggable ? listeners : {})}
className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`}
>
{isCurrentAutoTask ? (
<div className="animated-border-wrapper">{renderCardContent()}</div>
) : (
renderCardContent()
)}
</div>
);
});
40 changes: 26 additions & 14 deletions apps/ui/src/components/views/board-view/kanban-board.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
Expand Down Expand Up @@ -241,19 +240,32 @@ export function KanbanBoard({
}}
>
{activeFeature && (
<Card
className="rotate-2 shadow-2xl shadow-black/25 border-primary/50 bg-card/95 backdrop-blur-sm transition-transform"
style={{ width: `${columnWidth}px` }}
>
<CardHeader className="p-3">
<CardTitle className="text-sm font-medium line-clamp-2">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
<div style={{ width: `${columnWidth}px` }}>
<KanbanCard
feature={activeFeature}
isOverlay
onEdit={() => {}}
onDelete={() => {}}
onViewOutput={() => {}}
onVerify={() => {}}
onResume={() => {}}
onForceStop={() => {}}
onManualVerify={() => {}}
onMoveBackToInProgress={() => {}}
onFollowUp={() => {}}
onImplement={() => {}}
onComplete={() => {}}
onViewPlan={() => {}}
onApprovePlan={() => {}}
onSpawnTask={() => {}}
hasContext={featuresWithContext.has(activeFeature.id)}
isCurrentAutoTask={runningAutoTasks.includes(activeFeature.id)}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
</div>
)}
</DragOverlay>
</DndContext>
Expand Down