feat: add red theme and board background modal

- Introduced a new red theme with custom color variables for a bold aesthetic.
- Updated the theme management to include the new red theme option.
- Added a BoardBackgroundModal component for managing board background settings, including image uploads and opacity controls.
- Enhanced KanbanCard and KanbanColumn components to support new background settings such as opacity and border visibility.
- Updated API client to handle saving and deleting board backgrounds.
- Refactored theme application logic to accommodate the new preview theme functionality.
This commit is contained in:
Cody Seibert
2025-12-12 22:05:16 -05:00
parent 346c38d6da
commit 28328d7d1e
14 changed files with 1736 additions and 409 deletions

View File

@@ -58,6 +58,7 @@ import { KanbanColumn } from "./kanban-column";
import { KanbanCard } from "./kanban-card";
import { AgentOutputModal } from "./agent-output-modal";
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import {
Plus,
RefreshCw,
@@ -86,6 +87,7 @@ import {
Square,
Maximize2,
Shuffle,
ImageIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -206,6 +208,7 @@ export function BoardView() {
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardBackgroundByProject,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
@@ -230,6 +233,8 @@ export function BoardView() {
);
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
@@ -400,7 +405,8 @@ export function BoardView() {
const currentPath = currentProject.path;
const previousPath = prevProjectPathRef.current;
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
const isProjectSwitch =
previousPath !== null && currentPath !== previousPath;
// Get cached features from store (without adding to dependencies)
const cachedFeatures = useAppStore.getState().features;
@@ -556,7 +562,8 @@ export function BoardView() {
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
const eventProjectId =
("projectId" in event && event.projectId) || projectId;
if (event.type === "auto_mode_feature_complete") {
// Reload features when a feature is completed
@@ -585,15 +592,16 @@ export function BoardView() {
loadFeatures();
// Check for authentication errors and show a more helpful message
const isAuthError = event.errorType === "authentication" ||
(event.error && (
event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")
));
const isAuthError =
event.errorType === "authentication" ||
(event.error &&
(event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")));
if (isAuthError) {
toast.error("Authentication Failed", {
description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
description:
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
duration: 10000,
});
} else {
@@ -867,8 +875,11 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "verified") {
moveFeature(featureId, "verified");
// Clear justFinished flag when manually verifying via drag
persistFeatureUpdate(featureId, { status: "verified", justFinished: false });
// Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
@@ -878,8 +889,11 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, "backlog");
// Clear justFinished flag when moving back to backlog
persistFeatureUpdate(featureId, { status: "backlog", justFinished: false });
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: "backlog",
justFinishedAt: undefined,
});
toast.info("Feature moved to backlog", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -1200,8 +1214,11 @@ export function BoardView() {
description: feature.description,
});
moveFeature(feature.id, "verified");
// Clear justFinished flag when manually verifying
persistFeatureUpdate(feature.id, { status: "verified", justFinished: false });
// Clear justFinishedAt timestamp when manually verifying
persistFeatureUpdate(feature.id, {
status: "verified",
justFinishedAt: undefined,
});
toast.success("Feature verified", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1267,11 +1284,11 @@ export function BoardView() {
}
// Move feature back to in_progress before sending follow-up
// Clear justFinished flag since user is now interacting with it
// Clear justFinishedAt timestamp since user is now interacting with it
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
justFinished: false,
justFinishedAt: undefined,
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
@@ -1530,11 +1547,22 @@ export function BoardView() {
}
});
// Sort waiting_approval column: justFinished features go to the top
// Sort waiting_approval column: justFinished features (within 2 minutes) go to the top
map.waiting_approval.sort((a, b) => {
// Features with justFinished=true should appear first
if (a.justFinished && !b.justFinished) return -1;
if (!a.justFinished && b.justFinished) return 1;
// Helper to check if feature is "just finished" (within 2 minutes)
const isJustFinished = (feature: Feature) => {
if (!feature.justFinishedAt) return false;
const finishedTime = new Date(feature.justFinishedAt).getTime();
const now = Date.now();
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
return now - finishedTime < twoMinutes;
};
const aJustFinished = isJustFinished(a);
const bJustFinished = isJustFinished(b);
// Features with justFinishedAt within 2 minutes should appear first
if (aJustFinished && !bJustFinished) return -1;
if (!aJustFinished && bJustFinished) return 1;
return 0; // Keep original order for features with same justFinished status
});
@@ -1639,7 +1667,7 @@ export function BoardView() {
return;
}
const featuresToStart = backlogFeatures.slice(0, availableSlots);
const featuresToStart = backlogFeatures.slice(0, 1);
for (const feature of featuresToStart) {
// Update the feature status with startedAt timestamp
@@ -1848,202 +1876,296 @@ export function BoardView() {
)}
</div>
{/* Kanban Card Detail Level Toggle */}
{/* Board Background & Detail Level Controls */}
{isMounted && (
<TooltipProvider>
<div
className="flex items-center rounded-lg bg-secondary border border-border ml-4"
data-testid="kanban-detail-toggle"
>
<div className="flex items-center gap-2 ml-4">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("minimal")}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
<Button
variant="outline"
size="sm"
onClick={() => setShowBoardBackgroundModal(true)}
className="h-8 px-2"
data-testid="board-background-button"
>
<Minimize2 className="w-4 h-4" />
</button>
<ImageIcon className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
<p>Board Background Settings</p>
</TooltipContent>
</Tooltip>
{/* Kanban Card Detail Level Toggle */}
<div
className="flex items-center rounded-lg bg-secondary border border-border"
data-testid="kanban-detail-toggle"
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("minimal")}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-minimal"
>
<Minimize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Minimal - Title & category only</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("standard")}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-standard"
>
<Square className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Standard - Steps & progress</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setKanbanCardDetailLevel("detailed")}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
)}
data-testid="kanban-toggle-detailed"
>
<Maximize2 className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Detailed - Model, tools & tasks</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
)}
</div>
{/* Kanban Columns */}
<div className="flex-1 overflow-x-auto px-4 pb-4">
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full min-w-max">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
color={column.color}
count={columnFeatures.length}
headerAction={
column.id === "verified" && columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setShowDeleteAllVerifiedDialog(true)}
data-testid="delete-all-verified-button"
>
<Trash2 className="w-3 h-3 mr-1" />
Delete All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={() => setShowSuggestionsDialog(true)}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
{(() => {
// Get background settings for current project
const backgroundSettings = currentProject
? boardBackgroundByProject[currentProject.path] || {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
}
: {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};
// Build background image style if image exists
const backgroundImageStyle = backgroundSettings.imagePath
? {
backgroundImage: `url(${
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(
currentProject?.path || ""
)})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
: {};
return (
<div
className="flex-1 overflow-x-auto px-4 pb-4 relative"
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full min-w-max">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
color={column.color}
count={columnFeatures.length}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() =>
setShowDeleteAllVerifiedDialog(true)
}
data-testid="delete-all-verified-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
</HotkeyButton>
)}
</div>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey = index === 9 ? "0" : String(index + 1);
<Trash2 className="w-3 h-3 mr-1" />
Delete All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={() => setShowSuggestionsDialog(true)}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
data-testid="suggestions-count"
>
{suggestionsCount}
</span>
)}
</Button>
{columnFeatures.length > 0 && (
<HotkeyButton
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
onClick={handleStartNextFeatures}
hotkey={shortcuts.startNext}
hotkeyActive={false}
data-testid="start-next-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
</HotkeyButton>
)}
</div>
) : undefined
}
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => setEditingFeature(feature)}
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
onResume={() => handleResumeFeature(feature)}
onForceStop={() => handleForceStopFeature(feature)}
onManualVerify={() => handleManualVerify(feature)}
onMoveBackToInProgress={() =>
handleMoveBackToInProgress(feature)
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey =
index === 9 ? "0" : String(index + 1);
}
onFollowUp={() => handleOpenFollowUp(feature)}
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
shortcutKey={shortcutKey}
/>
);
})}
</SortableContext>
</KanbanColumn>
);
})}
</div>
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => setEditingFeature(feature)}
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
onResume={() => handleResumeFeature(feature)}
onForceStop={() =>
handleForceStopFeature(feature)
}
onManualVerify={() =>
handleManualVerify(feature)
}
onMoveBackToInProgress={() =>
handleMoveBackToInProgress(feature)
}
onFollowUp={() => handleOpenFollowUp(feature)}
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={
backgroundSettings.cardGlassmorphism
}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
/>
);
})}
</SortableContext>
</KanbanColumn>
);
})}
</div>
<DragOverlay>
{activeFeature && (
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
<CardHeader className="p-3">
<CardTitle className="text-sm">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
)}
</DragOverlay>
</DndContext>
</div>
<DragOverlay>
{activeFeature && (
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
<CardHeader className="p-3">
<CardTitle className="text-sm">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
)}
</DragOverlay>
</DndContext>
</div>
);
})()}
</div>
{/* Board Background Modal */}
<BoardBackgroundModal
open={showBoardBackgroundModal}
onOpenChange={setShowBoardBackgroundModal}
/>
{/* Add Feature Dialog */}
<Dialog
open={showAddDialog}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, memo } from "react";
import { useState, useEffect, useMemo, memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
@@ -110,6 +110,14 @@ interface KanbanCardProps {
contextContent?: string;
/** Feature summary from agent completion */
summary?: string;
/** Opacity percentage (0-100) */
opacity?: number;
/** Whether to use glassmorphism (backdrop-blur) effect */
glassmorphism?: boolean;
/** Whether to show card borders */
cardBorderEnabled?: boolean;
/** Card border opacity percentage (0-100) */
cardBorderOpacity?: number;
}
export const KanbanCard = memo(function KanbanCard({
@@ -131,12 +139,17 @@ export const KanbanCard = memo(function KanbanCard({
shortcutKey,
contextContent,
summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
@@ -148,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({
kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === "detailed";
// Helper to check if "just finished" badge should be shown (within 2 minutes)
const isJustFinished = useMemo(() => {
if (
!feature.justFinishedAt ||
feature.status !== "waiting_approval" ||
feature.error
) {
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
return currentTime - finishedTime < twoMinutes;
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
// Update current time periodically to check if badge should be hidden
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
const timeRemaining = twoMinutes - (currentTime - finishedTime);
if (timeRemaining <= 0) {
// Already past 2 minutes
return;
}
// Update time every second to check if 2 minutes have passed
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => clearInterval(interval);
}, [feature.justFinishedAt, feature.status, currentTime]);
// Load context file for in_progress, waiting_approval, and verified features
useEffect(() => {
const loadContext = async () => {
@@ -184,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({
} else {
// Fallback to direct file read for backward compatibility
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
@@ -241,15 +291,42 @@ export const KanbanCard = memo(function KanbanCard({
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
};
// Calculate border style based on enabled state and opacity
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) {
// Apply border opacity using color-mix to blend the border color with transparent
// The --border variable uses oklch format, so we use color-mix in oklch space
// Ensure border width is set (1px is the default Tailwind border width)
(borderStyle as Record<string, string>).borderWidth = "1px";
(
borderStyle as Record<string, string>
).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
return (
<Card
ref={setNodeRef}
style={style}
style={borderStyle}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
isDragging && "opacity-50 scale-105 shadow-lg",
"cursor-grab active:cursor-grabbing transition-all relative kanban-card-content select-none",
// Apply border class when border is enabled and opacity is 100%
// When opacity is not 100%, we use inline styles for border color
cardBorderEnabled && cardBorderOpacity === 100 && "border-border",
// When border is enabled but opacity is not 100%, we still need border width
cardBorderEnabled && cardBorderOpacity !== 100 && "border",
// Remove default background when using opacity overlay
!isDragging && "bg-transparent",
// Remove default backdrop-blur-sm from Card component when glassmorphism is disabled
!glassmorphism && "backdrop-blur-[0px]!",
isDragging && "scale-105 shadow-lg",
// Special border styles for running/error states override the border opacity
// These need to be applied with higher specificity
isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
feature.error &&
@@ -262,6 +339,16 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity - only affects background, not content */}
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Skip Tests indicator badge */}
{feature.skipTests && !feature.error && (
<div
@@ -292,8 +379,8 @@ export const KanbanCard = memo(function KanbanCard({
<span>Errored</span>
</div>
)}
{/* Just Finished indicator badge - shows when agent just completed work */}
{feature.justFinished && feature.status === "waiting_approval" && !feature.error && (
{/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */}
{isJustFinished && (
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
@@ -304,7 +391,7 @@ export const KanbanCard = memo(function KanbanCard({
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
<span>Done</span>
<span>Fresh Baked</span>
</div>
)}
{/* Branch badge - show when feature has a worktree */}
@@ -317,18 +404,22 @@ export const KanbanCard = memo(function KanbanCard({
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below other badges if present, otherwise use normal position
feature.error || feature.skipTests || (feature.justFinished && feature.status === "waiting_approval")
feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
<span className="truncate max-w-[80px]">
{feature.branchName?.replace("feature/", "")}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">{feature.branchName}</p>
<p className="font-mono text-xs break-all">
{feature.branchName}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -337,9 +428,11 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
// Add extra top padding when badges are present to prevent text overlap
(feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-10",
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
// Add even more top padding when both badges and branch are shown
hasWorktree && (feature.skipTests || feature.error || (feature.justFinished && feature.status === "waiting_approval")) && "pt-14"
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
)}
>
{isCurrentAutoTask && (
@@ -471,7 +564,9 @@ export const KanbanCard = memo(function KanbanCard({
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -565,7 +660,8 @@ export const KanbanCard = memo(function KanbanCard({
todo.status === "completed" &&
"text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-foreground-secondary"
todo.status === "pending" &&
"text-foreground-secondary"
)}
>
{todo.content}
@@ -878,9 +974,13 @@ export const KanbanCard = memo(function KanbanCard({
<Sparkles className="w-5 h-5 text-green-400" />
Implementation Summary
</DialogTitle>
<DialogDescription className="text-sm" title={feature.description || feature.summary || ""}>
<DialogDescription
className="text-sm"
title={feature.description || feature.summary || ""}
>
{(() => {
const displayText = feature.description || feature.summary || "No description";
const displayText =
feature.description || feature.summary || "No description";
return displayText.length > 100
? `${displayText.slice(0, 100)}...`
: displayText;
@@ -916,10 +1016,15 @@ export const KanbanCard = memo(function KanbanCard({
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the feature back to the backlog.
This will discard all changes made by the agent and move the
feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
Branch{" "}
<code className="bg-muted px-1 py-0.5 rounded">
{feature.branchName}
</code>{" "}
will be deleted.
</span>
)}
<span className="block mt-2 text-red-400 font-medium">

View File

@@ -12,6 +12,9 @@ interface KanbanColumnProps {
count: number;
children: ReactNode;
headerAction?: ReactNode;
opacity?: number; // Opacity percentage (0-100) - only affects background
showBorder?: boolean; // Whether to show column border
hideScrollbar?: boolean; // Whether to hide the column scrollbar
}
export const KanbanColumn = memo(function KanbanColumn({
@@ -21,6 +24,9 @@ export const KanbanColumn = memo(function KanbanColumn({
count,
children,
headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -28,13 +34,27 @@ export const KanbanColumn = memo(function KanbanColumn({
<div
ref={setNodeRef}
className={cn(
"flex flex-col h-full rounded-lg bg-card backdrop-blur-sm border border-border transition-colors w-72",
isOver && "bg-accent"
"relative flex flex-col h-full rounded-lg transition-colors w-72",
showBorder && "border border-border"
)}
data-testid={`kanban-column-${id}`}
>
{/* Column Header */}
<div className="flex items-center gap-2 p-3 border-b border-border">
{/* Background layer with opacity - only this layer is affected by opacity */}
<div
className={cn(
"absolute inset-0 rounded-lg backdrop-blur-sm transition-colors",
isOver ? "bg-accent" : "bg-card"
)}
style={{ opacity: opacity / 100 }}
/>
{/* Column Header - positioned above the background */}
<div
className={cn(
"relative z-10 flex items-center gap-2 p-3",
showBorder && "border-b border-border"
)}
>
<div className={cn("w-3 h-3 rounded-full", color)} />
<h3 className="font-medium text-sm flex-1">{title}</h3>
{headerAction}
@@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
</span>
</div>
{/* Column Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{/* Column Content - positioned above the background */}
<div
className={cn(
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2",
hideScrollbar &&
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
)}
>
{children}
</div>
</div>

View File

@@ -29,7 +29,8 @@ export type Theme =
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave";
| "synthwave"
| "red";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";