mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
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:
@@ -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";
|
||||
@@ -213,6 +215,7 @@ export function BoardView() {
|
||||
aiProfiles,
|
||||
kanbanCardDetailLevel,
|
||||
setKanbanCardDetailLevel,
|
||||
boardBackgroundByProject,
|
||||
} = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||
@@ -237,6 +240,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);
|
||||
@@ -407,7 +412,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;
|
||||
@@ -563,7 +569,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
|
||||
@@ -592,15 +599,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 {
|
||||
@@ -874,8 +882,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,
|
||||
@@ -885,8 +896,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,
|
||||
@@ -1207,8 +1221,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 ? "..." : ""
|
||||
@@ -1274,11 +1291,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);
|
||||
@@ -1537,11 +1554,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
|
||||
});
|
||||
|
||||
@@ -1646,7 +1674,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
|
||||
@@ -1855,202 +1883,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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,7 +29,8 @@ export type Theme =
|
||||
| "gruvbox"
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave";
|
||||
| "synthwave"
|
||||
| "red";
|
||||
|
||||
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user