Merge origin/main into feature/shared-packages

Resolved conflicts:
- list.ts: Keep @automaker/git-utils import, add worktree-metadata import
- feature-loader.ts: Use Feature type from @automaker/types
- automaker-paths.test.ts: Import from @automaker/platform
- kanban-card.tsx: Accept deletion (split into components/)
- subprocess.test.ts: Keep libs/platform location

Added missing exports to @automaker/platform:
- getGlobalSettingsPath, getCredentialsPath, getProjectSettingsPath, ensureDataDir

Added title and titleGenerating fields to @automaker/types Feature interface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-20 22:20:17 +01:00
108 changed files with 10834 additions and 3489 deletions

View File

@@ -1,7 +1,35 @@
import { useState, useCallback } from "react";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./utils/router";
import { SplashScreen } from "./components/splash-screen";
import { useSettingsMigration } from "./hooks/use-settings-migration";
import "./styles/global.css";
import "./styles/theme-imports";
export default function App() {
return <RouterProvider router={router} />;
const [showSplash, setShowSplash] = useState(() => {
// Only show splash once per session
if (sessionStorage.getItem("automaker-splash-shown")) {
return false;
}
return true;
});
// Run settings migration on startup (localStorage -> file storage)
const migrationState = useSettingsMigration();
if (migrationState.migrated) {
console.log("[App] Settings migrated to file storage");
}
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem("automaker-splash-shown", "true");
setShowSplash(false);
}, []);
return (
<>
<RouterProvider router={router} />
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
</>
);
}

View File

@@ -15,6 +15,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
import { getHttpApiClient } from "@/lib/http-api-client";
import { useBoardBackgroundSettings } from "@/hooks/use-board-background-settings";
import { toast } from "sonner";
const ACCEPTED_IMAGE_TYPES = [
@@ -35,9 +36,8 @@ export function BoardBackgroundModal({
open,
onOpenChange,
}: BoardBackgroundModalProps) {
const { currentProject, boardBackgroundByProject } = useAppStore();
const {
currentProject,
boardBackgroundByProject,
setBoardBackground,
setCardOpacity,
setColumnOpacity,
@@ -47,7 +47,7 @@ export function BoardBackgroundModal({
setCardBorderOpacity,
setHideScrollbar,
clearBoardBackground,
} = useAppStore();
} = useBoardBackgroundSettings();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -139,8 +139,8 @@ export function BoardBackgroundModal({
);
if (result.success && result.path) {
// Update store with the relative path (live update)
setBoardBackground(currentProject.path, result.path);
// Update store and persist to server
await setBoardBackground(currentProject.path, result.path);
toast.success("Background image saved");
} else {
toast.error(result.error || "Failed to save background image");
@@ -214,7 +214,7 @@ export function BoardBackgroundModal({
);
if (result.success) {
clearBoardBackground(currentProject.path);
await clearBoardBackground(currentProject.path);
setPreviewImage(null);
toast.success("Background image cleared");
} else {
@@ -228,59 +228,59 @@ export function BoardBackgroundModal({
}
}, [currentProject, clearBoardBackground]);
// Live update opacity when sliders change
// Live update opacity when sliders change (with persistence)
const handleCardOpacityChange = useCallback(
(value: number[]) => {
async (value: number[]) => {
if (!currentProject) return;
setCardOpacity(currentProject.path, value[0]);
await setCardOpacity(currentProject.path, value[0]);
},
[currentProject, setCardOpacity]
);
const handleColumnOpacityChange = useCallback(
(value: number[]) => {
async (value: number[]) => {
if (!currentProject) return;
setColumnOpacity(currentProject.path, value[0]);
await setColumnOpacity(currentProject.path, value[0]);
},
[currentProject, setColumnOpacity]
);
const handleColumnBorderToggle = useCallback(
(checked: boolean) => {
async (checked: boolean) => {
if (!currentProject) return;
setColumnBorderEnabled(currentProject.path, checked);
await setColumnBorderEnabled(currentProject.path, checked);
},
[currentProject, setColumnBorderEnabled]
);
const handleCardGlassmorphismToggle = useCallback(
(checked: boolean) => {
async (checked: boolean) => {
if (!currentProject) return;
setCardGlassmorphism(currentProject.path, checked);
await setCardGlassmorphism(currentProject.path, checked);
},
[currentProject, setCardGlassmorphism]
);
const handleCardBorderToggle = useCallback(
(checked: boolean) => {
async (checked: boolean) => {
if (!currentProject) return;
setCardBorderEnabled(currentProject.path, checked);
await setCardBorderEnabled(currentProject.path, checked);
},
[currentProject, setCardBorderEnabled]
);
const handleCardBorderOpacityChange = useCallback(
(value: number[]) => {
async (value: number[]) => {
if (!currentProject) return;
setCardBorderOpacity(currentProject.path, value[0]);
await setCardBorderOpacity(currentProject.path, value[0]);
},
[currentProject, setCardBorderOpacity]
);
const handleHideScrollbarToggle = useCallback(
(checked: boolean) => {
async (checked: boolean) => {
if (!currentProject) return;
setHideScrollbar(currentProject.path, checked);
await setHideScrollbar(currentProject.path, checked);
},
[currentProject, setHideScrollbar]
);

View File

@@ -208,13 +208,31 @@ export function FileBrowserDialog({
}
};
const handleSelect = () => {
const handleSelect = useCallback(() => {
if (currentPath) {
addRecentFolder(currentPath);
onSelect(currentPath);
onOpenChange(false);
}
};
}, [currentPath, onSelect, onOpenChange]);
// Handle Command/Ctrl+Enter keyboard shortcut to select current folder
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux)
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (currentPath && !loading) {
handleSelect();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, currentPath, loading, handleSelect]);
// Helper to get folder name from path
const getFolderName = (path: string) => {
@@ -399,9 +417,12 @@ export function FileBrowserDialog({
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading} title="Select current folder (Cmd+Enter / Ctrl+Enter)">
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "⌘" : "Ctrl"}+
</kbd>
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1029,12 +1029,6 @@ export function Sidebar() {
icon: UserCircle,
shortcut: shortcuts.profiles,
},
{
id: "terminal",
label: "Terminal",
icon: Terminal,
shortcut: shortcuts.terminal,
},
];
// Filter out hidden items
@@ -1048,29 +1042,39 @@ export function Sidebar() {
if (item.id === "profiles" && hideAiProfiles) {
return false;
}
if (item.id === "terminal" && hideTerminal) {
return false;
}
return true;
});
// Build project items - Terminal is conditionally included
const projectItems: NavItem[] = [
{
id: "board",
label: "Kanban Board",
icon: LayoutGrid,
shortcut: shortcuts.board,
},
{
id: "agent",
label: "Agent Runner",
icon: Bot,
shortcut: shortcuts.agent,
},
];
// Add Terminal to Project section if not hidden
if (!hideTerminal) {
projectItems.push({
id: "terminal",
label: "Terminal",
icon: Terminal,
shortcut: shortcuts.terminal,
});
}
return [
{
label: "Project",
items: [
{
id: "board",
label: "Kanban Board",
icon: LayoutGrid,
shortcut: shortcuts.board,
},
{
id: "agent",
label: "Agent Runner",
icon: Bot,
shortcut: shortcuts.agent,
},
],
items: projectItems,
},
{
label: "Tools",

View File

@@ -0,0 +1,309 @@
import { useEffect, useState, useMemo } from "react";
const TOTAL_DURATION = 2300; // Total animation duration in ms (tightened from 4000)
const LOGO_ENTER_DURATION = 500; // Tightened from 1200
const PARTICLES_ENTER_DELAY = 100; // Tightened from 400
const EXIT_START = 1800; // Adjusted for shorter duration
interface Particle {
id: number;
x: number;
y: number;
size: number;
delay: number;
angle: number;
distance: number;
opacity: number;
floatDuration: number;
}
function generateParticles(count: number): Particle[] {
return Array.from({ length: count }, (_, i) => {
const angle = (i / count) * 360 + Math.random() * 30;
const distance = 60 + Math.random() * 80; // Increased spread
return {
id: i,
x: Math.cos((angle * Math.PI) / 180) * distance,
y: Math.sin((angle * Math.PI) / 180) * distance,
size: 3 + Math.random() * 6, // Slightly smaller range for more subtle look
delay: Math.random() * 400,
angle,
distance: 300 + Math.random() * 200,
opacity: 0.4 + Math.random() * 0.6,
floatDuration: 3000 + Math.random() * 4000,
};
});
}
export function SplashScreen({ onComplete }: { onComplete: () => void }) {
const [phase, setPhase] = useState<"enter" | "hold" | "exit" | "done">(
"enter"
);
const particles = useMemo(() => generateParticles(50), []);
useEffect(() => {
const timers: NodeJS.Timeout[] = [];
// Phase transitions
timers.push(setTimeout(() => setPhase("hold"), LOGO_ENTER_DURATION));
timers.push(setTimeout(() => setPhase("exit"), EXIT_START));
timers.push(
setTimeout(() => {
setPhase("done");
onComplete();
}, TOTAL_DURATION)
);
return () => timers.forEach(clearTimeout);
}, [onComplete]);
if (phase === "done") return null;
return (
<div
className={`
fixed inset-0 z-[9999] flex items-center justify-center
bg-background
transition-opacity duration-500 ease-out
${phase === "exit" ? "opacity-0" : "opacity-100"}
`}
style={{
pointerEvents: phase === "exit" ? "none" : "auto",
}}
>
<style>{`
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-slow-reverse {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -6px); }
}
`}</style>
{/* Subtle gradient background */}
<div
className="absolute inset-0 opacity-30"
style={{
background:
"radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)",
}}
/>
{/* Particle container 1 - Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow 60s linear infinite" }}
>
{particles.slice(0, 25).map((particle) => (
<div
key={particle.id}
className="absolute"
style={{
transform:
phase === "exit"
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
}}
>
<div
className="rounded-full"
style={{
width: particle.size,
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
}}
/>
</div>
))}
</div>
{/* Particle container 2 - Counter-Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow-reverse 75s linear infinite" }}
>
{particles.slice(25).map((particle) => (
<div
key={particle.id}
className="absolute"
style={{
transform:
phase === "exit"
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
}}
>
<div
className="rounded-full"
style={{
width: particle.size,
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
animationDelay: `${particle.delay}ms`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
}}
/>
</div>
))}
</div>
{/* Logo container */}
<div
className="relative z-10"
style={{
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
transform:
phase === "enter"
? "scale(0.3) rotate(-20deg)"
: phase === "exit"
? "scale(2.5) translateY(-100px)"
: "scale(1) rotate(0deg)",
transition:
phase === "enter"
? `all ${LOGO_ENTER_DURATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`
: phase === "exit"
? "all 600ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
}}
>
{/* Glow effect behind logo */}
<div
className="absolute inset-0 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--brand-500) 0%, transparent 70%)",
transform: "scale(2.5)",
opacity: phase === "hold" ? 0.6 : 0,
transition: "opacity 500ms ease-out",
}}
/>
{/* The logo */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="relative z-10"
style={{
width: 120,
height: 120,
filter: "drop-shadow(0 0 30px var(--brand-500))",
}}
>
<defs>
<linearGradient
id="splash-bg"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: "var(--brand-400)" }} />
<stop offset="100%" style={{ stopColor: "var(--brand-600)" }} />
</linearGradient>
<filter
id="splash-shadow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#splash-bg)"
/>
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#splash-shadow)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</div>
{/* Automaker text that fades in below the logo */}
<div
className="absolute flex items-center gap-1"
style={{
top: "calc(50% + 80px)",
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
transform:
phase === "enter"
? "translateY(20px)"
: phase === "exit"
? "translateY(-30px) scale(1.2)"
: "translateY(0)",
transition:
phase === "enter"
? `all 600ms ease-out ${LOGO_ENTER_DURATION - 200}ms`
: phase === "exit"
? "all 500ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
}}
>
<span className="font-bold text-foreground text-4xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
</div>
</div>
);
}

View File

@@ -11,11 +11,12 @@ function Card({ className, gradient = false, ...props }: CardProps) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-md py-6",
"bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6",
// Premium layered shadow
"shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]",
// Gradient border option
gradient && "relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
gradient &&
"relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
className
)}
{...props}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Tag } from "lucide-react";
import { Autocomplete } from "@/components/ui/autocomplete";
interface CategoryAutocompleteProps {
@@ -9,6 +9,7 @@ interface CategoryAutocompleteProps {
placeholder?: string;
className?: string;
disabled?: boolean;
error?: boolean;
"data-testid"?: string;
}
@@ -19,6 +20,7 @@ export function CategoryAutocomplete({
placeholder = "Select or type a category...",
className,
disabled = false,
error = false,
"data-testid": testId,
}: CategoryAutocompleteProps) {
return (
@@ -27,10 +29,14 @@ export function CategoryAutocomplete({
onChange={onChange}
options={suggestions}
placeholder={placeholder}
searchPlaceholder="Search category..."
searchPlaceholder="Search or type new category..."
emptyMessage="No category found."
className={className}
disabled={disabled}
error={error}
icon={Tag}
allowCreate
createLabel={(v) => `Create "${v}"`}
data-testid={testId}
itemTestIdPrefix="category-option"
/>

View File

@@ -1,4 +1,3 @@
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import {
PointerSensor,
@@ -39,6 +38,7 @@ import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialo
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
import { WorktreePanel } from "./board-view/worktree-panel";
import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types";
import { COLUMNS } from "./board-view/constants";
import {
useBoardFeatures,
@@ -58,6 +58,9 @@ const EMPTY_WORKTREES: ReturnType<
ReturnType<typeof useAppStore.getState>["getWorktrees"]
> = [];
/** Delay before starting a newly created feature to allow state to settle */
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
export function BoardView() {
const {
currentProject,
@@ -271,13 +274,16 @@ export function BoardView() {
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce((counts, feature) => {
if (feature.status !== "completed") {
const branch = feature.branchName ?? "main";
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
}, {} as Record<string, number>);
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== "completed") {
const branch = feature.branchName ?? "main";
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
},
{} as Record<string, number>
);
}, [hookFeatures]);
// Custom collision detection that prioritizes columns over cards
@@ -340,7 +346,7 @@ export function BoardView() {
const worktrees = useMemo(
() =>
currentProject
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
: EMPTY_WORKTREES,
[currentProject, worktreesByProject]
);
@@ -412,9 +418,124 @@ export function BoardView() {
outputFeature,
projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: (newWorktree) => {
if (!currentProject) return;
// Check if worktree already exists in the store (by branch name)
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find(
(w) => w.branch === newWorktree.branch
);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
},
currentWorktreeBranch,
});
// Handler for addressing PR comments - creates a feature and starts it automatically
const handleAddressPRComments = useCallback(
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
// Use a simple prompt that instructs the agent to read and address PR feedback
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
const prNumber = prInfo.number;
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
// Create the feature
const featureData = {
category: "PR Review",
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
branchName: worktree.branch,
priority: 1, // High priority for PR feedback
planningMode: "skip" as const,
requirePlanApproval: false,
};
await handleAddFeature(featureData);
// Find the newly created feature and start it
// We need to wait a moment for the feature to be created
setTimeout(async () => {
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.description.includes(`PR #${prNumber}`)
);
if (newFeature) {
await handleStartImplementation(newFeature);
}
}, FEATURE_CREATION_SETTLE_DELAY_MS);
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for resolving conflicts - creates a feature to pull from origin/main and resolve conflicts
const handleResolveConflicts = useCallback(
async (worktree: WorktreeInfo) => {
const description = `Pull latest from origin/main and resolve conflicts. Merge origin/main into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
// Create the feature
const featureData = {
category: "Maintenance",
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
branchName: worktree.branch,
priority: 1, // High priority for conflict resolution
planningMode: "skip" as const,
requirePlanApproval: false,
};
await handleAddFeature(featureData);
// Find the newly created feature and start it
setTimeout(async () => {
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.description.includes("Pull latest from origin/main")
);
if (newFeature) {
await handleStartImplementation(newFeature);
}
}, FEATURE_CREATION_SETTLE_DELAY_MS);
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning);
@@ -835,6 +956,7 @@ export function BoardView() {
<BoardHeader
projectName={currentProject.name}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
@@ -874,6 +996,8 @@ export function BoardView() {
setSelectedWorktreeForAction(worktree);
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks}
branchCardCounts={branchCardCounts}
@@ -1153,7 +1277,25 @@ export function BoardView() {
open={showCreatePRDialog}
onOpenChange={setShowCreatePRDialog}
worktree={selectedWorktreeForAction}
onCreated={() => {
projectPath={currentProject?.path || null}
onCreated={(prUrl) => {
// If a PR was created and we have the worktree branch, update all features on that branch with the PR URL
if (prUrl && selectedWorktreeForAction?.branch) {
const branchName = selectedWorktreeForAction.branch;
const featuresToUpdate = hookFeatures.filter((f) => f.branchName === branchName);
// Update local state synchronously
featuresToUpdate.forEach((feature) => {
updateFeature(feature.id, { prUrl });
});
// Persist changes asynchronously and in parallel
Promise.all(
featuresToUpdate.map((feature) =>
persistFeatureUpdate(feature.id, { prUrl })
)
).catch(console.error);
}
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}

View File

@@ -4,12 +4,13 @@ import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Users } from "lucide-react";
import { Plus, Bot } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
interface BoardHeaderProps {
projectName: string;
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
@@ -21,6 +22,7 @@ interface BoardHeaderProps {
export function BoardHeader({
projectName,
maxConcurrency,
runningAgentsCount,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
@@ -41,7 +43,8 @@ export function BoardHeader({
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
data-testid="concurrency-slider-container"
>
<Users className="w-4 h-4 text-muted-foreground" />
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agents</span>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
@@ -52,10 +55,10 @@ export function BoardHeader({
data-testid="concurrency-slider"
/>
<span
className="text-sm text-muted-foreground min-w-[2ch] text-center"
className="text-sm text-muted-foreground min-w-[5ch] text-center"
data-testid="concurrency-value"
>
{maxConcurrency}
{runningAgentsCount} / {maxConcurrency}
</span>
</div>
)}

View File

@@ -1,2 +1,2 @@
export { KanbanCard } from "./kanban-card";
export { KanbanCard } from "./kanban-card/kanban-card";
export { KanbanColumn } from "./kanban-column";

View File

@@ -0,0 +1,283 @@
import { useEffect, useState } from "react";
import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store";
import {
AgentTaskInfo,
parseAgentContext,
formatModelName,
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { cn } from "@/lib/utils";
import {
Cpu,
Brain,
ListTodo,
Sparkles,
Expand,
CheckCircle2,
Circle,
Loader2,
Wrench,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { SummaryDialog } from "./summary-dialog";
/**
* Formats thinking level for compact display
*/
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
if (!level || level === "none") return "";
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
};
return labels[level];
}
interface AgentInfoPanelProps {
feature: Feature;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
}
export function AgentInfoPanel({
feature,
contextContent,
summary,
isCurrentAutoTask,
}: AgentInfoPanelProps) {
const { kanbanCardDetailLevel } = useAppStore();
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === "detailed";
useEffect(() => {
const loadContext = async () => {
if (contextContent) {
const info = parseAgentContext(contextContent);
setAgentInfo(info);
return;
}
if (feature.status === "backlog") {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
} else {
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
const result = await api.readFile(contextPath);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
// eslint-disable-next-line no-undef
console.debug("[KanbanCard] No context file for feature:", feature.id);
}
};
loadContext();
if (isCurrentAutoTask) {
// eslint-disable-next-line no-undef
const interval = setInterval(loadContext, 3000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === "backlog") {
return (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatThinkingLevel(feature.thinkingLevel)}
</span>
</div>
)}
</div>
</div>
);
}
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
return (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
agentInfo.currentPhase === "planning" &&
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
agentInfo.currentPhase === "action" &&
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
agentInfo.currentPhase === "verification" &&
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
)}
>
{agentInfo.currentPhase}
</div>
)}
</div>
{/* Task List Progress */}
{agentInfo.todos.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === "completed").length}
/{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-[10px]"
>
{todo.status === "completed" ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === "in_progress" ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-muted-foreground/60 line-through",
todo.status === "in_progress" &&
"text-[var(--status-warning)]",
todo.status === "pending" && "text-muted-foreground/80"
)}
>
{todo.content}
</span>
</div>
))}
{agentInfo.todos.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-4">
+{agentInfo.todos.length - 3} more
</p>
)}
</div>
</div>
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate font-medium">Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
onPointerDown={(e) => e.stopPropagation()}
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{
agentInfo.todos.filter((t) => t.status === "completed")
.length
}{" "}
tasks done
</span>
)}
</div>
)}
</>
)}
</div>
);
}
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
// This ensures the dialog can be opened from the expand button
return (
<>
{showAgentInfo && (
<SummaryDialog
feature={feature}
agentInfo={agentInfo}
summary={summary}
isOpen={isSummaryDialogOpen}
onOpenChange={setIsSummaryDialogOpen}
/>
)}
</>
);
}

View File

@@ -0,0 +1,337 @@
import { Feature } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import {
Edit,
PlayCircle,
RotateCcw,
StopCircle,
CheckCircle2,
FileText,
Eye,
Wand2,
Archive,
} from "lucide-react";
interface CardActionsProps {
feature: Feature;
isCurrentAutoTask: boolean;
hasContext?: boolean;
shortcutKey?: string;
onEdit: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onFollowUp?: () => void;
onImplement?: () => void;
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
}
export function CardActions({
feature,
isCurrentAutoTask,
hasContext,
shortcutKey,
onEdit,
onViewOutput,
onVerify,
onResume,
onForceStop,
onManualVerify,
onFollowUp,
onImplement,
onComplete,
onViewPlan,
onApprovePlan,
}: CardActionsProps) {
return (
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-running-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Approve Plan</span>
</Button>
)}
{onViewOutput && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
{shortcutKey && (
<span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</span>
)}
</Button>
)}
{onForceStop && (
<Button
variant="destructive"
size="sm"
className="h-7 text-[11px] px-2 shrink-0"
onClick={(e) => {
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
onClick={(e) => {
e.stopPropagation();
onApprovePlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`approve-plan-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1" />
Approve Plan
</Button>
)}
{feature.skipTests && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`manual-verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : hasContext && onResume ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onResume();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`resume-feature-${feature.id}`}
>
<RotateCcw className="w-3 h-3 mr-1" />
Resume
</Button>
) : onVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
onClick={(e) => {
e.stopPropagation();
onVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-feature-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Resume
</Button>
) : null}
{onViewOutput && !feature.skipTests && (
<Button
variant="secondary"
size="sm"
className="h-7 text-[11px] px-2"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-inprogress-${feature.id}`}
>
<FileText className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
<>
{/* Logs button */}
{onViewOutput && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-verified-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
</Button>
)}
{/* Complete button */}
{onComplete && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs min-w-0 bg-brand-500 hover:bg-brand-600"
onClick={(e) => {
e.stopPropagation();
onComplete();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`complete-${feature.id}`}
>
<Archive className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Complete</span>
</Button>
)}
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{/* Refine prompt button */}
{onFollowUp && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px] min-w-0"
onClick={(e) => {
e.stopPropagation();
onFollowUp();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`follow-up-${feature.id}`}
>
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Refine</span>
</Button>
)}
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
{feature.prUrl && onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`verify-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Verify
</Button>
) : onManualVerify ? (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onManualVerify();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`mark-as-verified-${feature.id}`}
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Mark as Verified
</Button>
) : null}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
<>
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-backlog-${feature.id}`}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
{feature.planSpec?.content && onViewPlan && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
onViewPlan();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-plan-${feature.id}`}
title="View Plan"
>
<Eye className="w-3 h-3" />
</Button>
)}
{onImplement && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onImplement();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`make-${feature.id}`}
>
<PlayCircle className="w-3 h-3 mr-1" />
Make
</Button>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { useEffect, useMemo, useState } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AlertCircle, Lock, Hand, Sparkles } from "lucide-react";
import { getBlockingDependencies } from "@/lib/dependency-resolver";
interface CardBadgeProps {
children: React.ReactNode;
className?: string;
"data-testid"?: string;
title?: string;
}
/**
* Shared badge component matching the "Just Finished" badge style
* Used for priority badges and other card badges
*/
function CardBadge({
children,
className,
"data-testid": dataTestId,
title,
}: CardBadgeProps) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
className
)}
data-testid={dataTestId}
title={title}
>
{children}
</div>
);
}
interface CardBadgesProps {
feature: Feature;
}
export function CardBadges({ feature }: CardBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== "backlog") {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
// Status badges row (error, blocked)
const showStatusBadges =
feature.error ||
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog");
if (!showStatusBadges) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
{/* Error badge */}
{feature.error && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]"
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Blocked badge */}
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog" && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold",
"bg-orange-500/20 border-orange-500/50 text-orange-500"
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{" "}
{blockingDependencies.length === 1
? "dependency"
: "dependencies"}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(", ")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
interface PriorityBadgesProps {
feature: Feature;
}
export function PriorityBadges({ feature }: PriorityBadgesProps) {
const [currentTime, setCurrentTime] = useState(() => Date.now());
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;
return currentTime - finishedTime < twoMinutes;
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000;
const timeRemaining = twoMinutes - (currentTime - finishedTime);
if (timeRemaining <= 0) {
return;
}
// eslint-disable-next-line no-undef
const interval = setInterval(() => {
setCurrentTime(Date.now());
}, 1000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}, [feature.justFinishedAt, feature.status, currentTime]);
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === "backlog") ||
isJustFinished;
if (!showPriorityBadges) {
return null;
}
return (
<div className="absolute top-2 left-2 flex items-center gap-1.5">
{/* Priority badge */}
{feature.priority && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
className={cn(
"bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5", // badge style from example
feature.priority === 1 &&
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]",
feature.priority === 2 &&
"bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]",
feature.priority === 3 &&
"bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]"
)}
data-testid={`priority-badge-${feature.id}`}
>
{feature.priority === 1 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
H
</span>
) : feature.priority === 2 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
M
</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">
L
</span>
)}
</CardBadge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
{feature.priority === 1
? "High Priority"
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === "backlog" && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
className="bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]"
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
</CardBadge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Manual verification required</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished badge */}
{isJustFinished && (
<CardBadge
className="bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
</CardBadge>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { Feature } from "@/store/app-store";
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from "lucide-react";
interface CardContentSectionsProps {
feature: Feature;
useWorktrees: boolean;
showSteps: boolean;
}
export function CardContentSections({
feature,
useWorktrees,
showSteps,
}: CardContentSectionsProps) {
return (
<>
{/* Target Branch Display */}
{useWorktrees && feature.branchName && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<GitBranch className="w-3 h-3 shrink-0" />
<span className="font-mono truncate" title={feature.branchName}>
{feature.branchName}
</span>
</div>
)}
{/* PR URL Display */}
{typeof feature.prUrl === "string" &&
/^https?:\/\//i.test(feature.prUrl) &&
(() => {
const prNumber = feature.prUrl.split("/").pop();
return (
<div className="mb-2">
<a
href={feature.prUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-[11px] text-purple-500 hover:text-purple-400 transition-colors"
title={feature.prUrl}
data-testid={`pr-url-${feature.id}`}
>
<GitPullRequest className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[150px]">
{prNumber ? `Pull Request #${prNumber}` : "Pull Request"}
</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
</a>
</div>
);
})()}
{/* Steps Preview */}
{showSteps && feature.steps && feature.steps.length > 0 && (
<div className="mb-3 space-y-1.5">
{feature.steps.slice(0, 3).map((step, index) => (
<div
key={index}
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === "verified" ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
</span>
</div>
))}
{feature.steps.length > 3 && (
<p className="text-[10px] text-muted-foreground/60 pl-5">
+{feature.steps.length - 3} more
</p>
)}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,330 @@
import { useState } from "react";
import { Feature } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
GripVertical,
Edit,
Loader2,
Trash2,
FileText,
MoreVertical,
ChevronDown,
ChevronUp,
Cpu,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { formatModelName, DEFAULT_MODEL } from "@/lib/agent-context-parser";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
interface CardHeaderProps {
feature: Feature;
isDraggable: boolean;
isCurrentAutoTask: boolean;
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
}
export function CardHeaderSection({
feature,
isDraggable,
isCurrentAutoTask,
onEdit,
onDelete,
onViewOutput,
}: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDeleteDialogOpen(true);
};
const handleConfirmDelete = () => {
onDelete();
};
return (
<CardHeader className="p-3 pb-2 block">
{/* Running task header */}
{isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
className="text-[var(--status-in-progress)] text-[10px]"
/>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-running-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-running-${feature.id}`}
className="text-xs"
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === "backlog" && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-backlog-${feature.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
}-${feature.id}`}
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{onViewOutput && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
}-${feature.id}`}
title="Logs"
>
<FileText className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
}-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</>
)}
{/* In progress header */}
{!isCurrentAutoTask && feature.status === "in_progress" && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-feature-${feature.id}`}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`menu-${feature.id}`}
>
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
data-testid={`edit-feature-${feature.id}`}
className="text-xs"
>
<Edit className="w-3 h-3 mr-2" />
Edit
</DropdownMenuItem>
{onViewOutput && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
data-testid={`view-logs-${feature.id}`}
className="text-xs"
>
<FileText className="w-3 h-3 mr-2" />
View Logs
</DropdownMenuItem>
)}
{/* Model info in dropdown */}
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
{/* Title and description */}
<div className="flex items-start gap-2">
{isDraggable && (
<div
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
data-testid={`drag-handle-${feature.id}`}
>
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground italic">
Generating title...
</span>
</div>
) : feature.title ? (
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
{feature.title}
</CardTitle>
) : null}
<CardDescription
className={cn(
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
!isDescriptionExpanded && "line-clamp-3"
)}
>
{feature.description || feature.summary || feature.id}
</CardDescription>
{(feature.description || feature.summary || "").length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
setIsDescriptionExpanded(!isDescriptionExpanded);
}}
onPointerDown={(e) => e.stopPropagation()}
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1.5 transition-colors"
data-testid={`toggle-description-${feature.id}`}
>
{isDescriptionExpanded ? (
<>
<ChevronUp className="w-3 h-3" />
<span>Less</span>
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
<span>More</span>
</>
)}
</button>
)}
</div>
</div>
{/* Delete Confirmation Dialog */}
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="Delete Feature"
description="Are you sure you want to delete this feature? This action cannot be undone."
testId="delete-confirmation-dialog"
confirmTestId="confirm-delete-button"
/>
</CardHeader>
);
}

View File

@@ -0,0 +1,214 @@
import React, { memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Feature, useAppStore } from "@/store/app-store";
import { CardBadges, PriorityBadges } from "./card-badges";
import { CardHeaderSection } from "./card-header";
import { CardContentSections } from "./card-content-sections";
import { AgentInfoPanel } from "./agent-info-panel";
import { CardActions } from "./card-actions";
interface KanbanCardProps {
feature: Feature;
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onImplement?: () => void;
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
contextContent?: string;
summary?: string;
opacity?: number;
glassmorphism?: boolean;
cardBorderEnabled?: boolean;
cardBorderOpacity?: number;
}
export const KanbanCard = memo(function KanbanCard({
feature,
onEdit,
onDelete,
onViewOutput,
onVerify,
onResume,
onForceStop,
onManualVerify,
onMoveBackToInProgress: _onMoveBackToInProgress,
onFollowUp,
onImplement,
onComplete,
onViewPlan,
onApprovePlan,
hasContext,
isCurrentAutoTask,
shortcutKey,
contextContent,
summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
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({
id: feature.id,
disabled: !isDraggable,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
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 cardElement = (
<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}`}
onDoubleClick={onEdit}
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Status Badges Row */}
<CardBadges feature={feature} />
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">
{feature.category}
</span>
</div>
{/* Priority and Manual Verification badges */}
<PriorityBadges feature={feature} />
{/* Card Header */}
<CardHeaderSection
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
/>
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections
feature={feature}
useWorktrees={useWorktrees}
showSteps={showSteps}
/>
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}
/>
{/* Actions */}
<CardActions
feature={feature}
isCurrentAutoTask={!!isCurrentAutoTask}
hasContext={hasContext}
shortcutKey={shortcutKey}
onEdit={onEdit}
onViewOutput={onViewOutput}
onVerify={onVerify}
onResume={onResume}
onForceStop={onForceStop}
onManualVerify={onManualVerify}
onFollowUp={onFollowUp}
onImplement={onImplement}
onComplete={onComplete}
onViewPlan={onViewPlan}
onApprovePlan={onApprovePlan}
/>
</CardContent>
</Card>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
});

View File

@@ -0,0 +1,75 @@
import { Feature } from "@/store/app-store";
import { AgentTaskInfo } from "@/lib/agent-context-parser";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/ui/markdown";
import { Sparkles } from "lucide-react";
interface SummaryDialogProps {
feature: Feature;
agentInfo: AgentTaskInfo | null;
summary?: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export function SummaryDialog({
feature,
agentInfo,
summary,
isOpen,
onOpenChange,
}: SummaryDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
data-testid={`summary-dialog-${feature.id}`}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[var(--status-success)]" />
Implementation Summary
</DialogTitle>
<DialogDescription
className="text-sm"
title={feature.description || feature.summary || ""}
>
{(() => {
const displayText =
feature.description || feature.summary || "No description";
return displayText.length > 100
? `${displayText.slice(0, 100)}...`
: displayText;
})()}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
<Markdown>
{feature.summary ||
summary ||
agentInfo?.summary ||
"No summary available"}
</Markdown>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="close-summary-button"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,6 +11,7 @@ import {
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import {
@@ -58,6 +59,7 @@ interface AddFeatureDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (feature: {
title: string;
category: string;
description: string;
steps: string[];
@@ -99,6 +101,7 @@ export function AddFeatureDialog({
const navigate = useNavigate();
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
const [newFeature, setNewFeature] = useState({
title: "",
category: "",
description: "",
steps: [""],
@@ -126,16 +129,25 @@ export function AddFeatureDialog({
enhancementModel,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
useWorktrees,
} = useAppStore();
// Sync defaults when dialog opens
useEffect(() => {
if (open) {
// Find the default profile if one is set
const defaultProfile = defaultAIProfileId
? aiProfiles.find((p) => p.id === defaultAIProfileId)
: null;
setNewFeature((prev) => ({
...prev,
skipTests: defaultSkipTests,
branchName: defaultBranch || "",
// Use default profile's model/thinkingLevel if set, else fallback to defaults
model: defaultProfile?.model ?? "opus",
thinkingLevel: defaultProfile?.thinkingLevel ?? "none",
}));
setUseCurrentBranch(true);
setPlanningMode(defaultPlanningMode);
@@ -147,6 +159,8 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
]);
const handleAdd = () => {
@@ -175,6 +189,7 @@ export function AddFeatureDialog({
: newFeature.branchName || "";
onAdd({
title: newFeature.title,
category,
description: newFeature.description,
steps: newFeature.steps.filter((s) => s.trim()),
@@ -191,6 +206,7 @@ export function AddFeatureDialog({
// Reset form
setNewFeature({
title: "",
category: "",
description: "",
steps: [""],
@@ -339,6 +355,17 @@ export function AddFeatureDialog({
error={descriptionError}
/>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
value={newFeature.title}
onChange={(e) =>
setNewFeature({ ...newFeature, title: e.target.value })
}
placeholder="Leave blank to auto-generate"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -29,13 +29,15 @@ interface CreatePRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCreated: () => void;
projectPath: string | null;
onCreated: (prUrl?: string) => void;
}
export function CreatePRDialog({
open,
onOpenChange,
worktree,
projectPath,
onCreated,
}: CreatePRDialogProps) {
const [title, setTitle] = useState("");
@@ -96,6 +98,7 @@ export function CreatePRDialog({
return;
}
const result = await api.worktree.createPR(worktree.path, {
projectPath: projectPath || undefined,
commitMessage: commitMessage || undefined,
prTitle: title || worktree.branch,
prBody: body || `Changes from branch ${worktree.branch}`,
@@ -108,13 +111,25 @@ export function CreatePRDialog({
setPrUrl(result.result.prUrl);
// Mark operation as completed for refresh on close
operationCompletedRef.current = true;
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
// Show different message based on whether PR already existed
if (result.result.prAlreadyExisted) {
toast.success("Pull request found!", {
description: `PR already exists for ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
} else {
toast.success("Pull request created!", {
description: `PR created from ${result.result.branch}`,
action: {
label: "View PR",
onClick: () => window.open(result.result!.prUrl!, "_blank"),
},
});
}
// Don't call onCreated() here - keep dialog open to show success message
// onCreated() will be called when user closes the dialog
} else {
@@ -200,7 +215,8 @@ export function CreatePRDialog({
// Only call onCreated() if an actual operation completed
// This prevents unnecessary refreshes when user cancels
if (operationCompletedRef.current) {
onCreated();
// Pass the PR URL if one was created
onCreated(prUrl || undefined);
}
onOpenChange(false);
// State reset is handled by useEffect when open becomes false

View File

@@ -11,6 +11,7 @@ import {
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
import {
@@ -61,6 +62,7 @@ interface EditFeatureDialogProps {
onUpdate: (
featureId: string,
updates: {
title: string;
category: string;
description: string;
steps: string[];
@@ -159,6 +161,7 @@ export function EditFeatureDialog({
: editingFeature.branchName || "";
const updates = {
title: editingFeature.title ?? "",
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
@@ -311,6 +314,21 @@ export function EditFeatureDialog({
data-testid="edit-feature-description"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-title">Title (optional)</Label>
<Input
id="edit-title"
value={editingFeature.title ?? ""}
onChange={(e) =>
setEditingFeature({
...editingFeature,
title: e.target.value,
})
}
placeholder="Leave blank to auto-generate"
data-testid="edit-feature-title"
/>
</div>
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -41,6 +41,7 @@ interface UseBoardActionsProps {
outputFeature: Feature | null;
projectPath: string | null;
onWorktreeCreated?: () => void;
onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void;
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
}
@@ -68,6 +69,7 @@ export function useBoardActions({
outputFeature,
projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
currentWorktreeBranch,
}: UseBoardActionsProps) {
const {
@@ -87,6 +89,7 @@ export function useBoardActions({
const handleAddFeature = useCallback(
async (featureData: {
title: string;
category: string;
description: string;
steps: string[];
@@ -114,15 +117,20 @@ export function useBoardActions({
currentProject.path,
finalBranchName
);
if (result.success) {
if (result.success && result.worktree) {
console.log(
`[Board] Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? "created" : "already exists"
}`
);
// Auto-select the worktree when creating a feature for it
onWorktreeAutoSelect?.({
path: result.worktree.path,
branch: result.worktree.branch,
});
// Refresh worktree list in UI
onWorktreeCreated?.();
} else {
} else if (!result.success) {
console.error(
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
result.error
@@ -141,8 +149,14 @@ export function useBoardActions({
}
}
// Check if we need to generate a title
const needsTitleGeneration =
!featureData.title.trim() && featureData.description.trim();
const newFeatureData = {
...featureData,
title: featureData.title,
titleGenerating: needsTitleGeneration,
status: "backlog" as const,
branchName: finalBranchName,
};
@@ -150,14 +164,56 @@ export function useBoardActions({
// Must await to ensure feature exists on server before user can drag it
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
// Generate title in the background if needed (non-blocking)
if (needsTitleGeneration) {
const api = getElectronAPI();
if (api?.features?.generateTitle) {
api.features
.generateTitle(featureData.description)
.then((result) => {
if (result.success && result.title) {
const titleUpdates = {
title: result.title,
titleGenerating: false,
};
updateFeature(createdFeature.id, titleUpdates);
persistFeatureUpdate(createdFeature.id, titleUpdates);
} else {
// Clear generating flag even if failed
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
persistFeatureUpdate(createdFeature.id, titleUpdates);
}
})
.catch((error) => {
console.error("[Board] Error generating title:", error);
// Clear generating flag on error
const titleUpdates = { titleGenerating: false };
updateFeature(createdFeature.id, titleUpdates);
persistFeatureUpdate(createdFeature.id, titleUpdates);
});
}
}
},
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
[
addFeature,
persistFeatureCreate,
persistFeatureUpdate,
updateFeature,
saveCategory,
useWorktrees,
currentProject,
onWorktreeCreated,
onWorktreeAutoSelect,
]
);
const handleUpdateFeature = useCallback(
async (
featureId: string,
updates: {
title: string;
category: string;
description: string;
steps: string[];
@@ -212,6 +268,7 @@ export function useBoardActions({
const finalUpdates = {
...updates,
title: updates.title,
branchName: finalBranchName,
};
@@ -222,7 +279,15 @@ export function useBoardActions({
}
setEditingFeature(null);
},
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
[
updateFeature,
persistFeatureUpdate,
saveCategory,
setEditingFeature,
useWorktrees,
currentProject,
onWorktreeCreated,
]
);
const handleDeleteFeature = useCallback(

View File

@@ -194,7 +194,6 @@ export function KanbanBoard({
onMoveBackToInProgress(feature)
}
onFollowUp={() => onFollowUp(feature)}
onCommit={() => onCommit(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}

View File

@@ -19,9 +19,11 @@ import {
Play,
Square,
Globe,
MessageSquare,
GitMerge,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, DevServerInfo } from "../types";
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -40,6 +42,8 @@ interface WorktreeActionsDropdownProps {
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
@@ -63,11 +67,16 @@ export function WorktreeActionsDropdown({
onOpenInEditor,
onCommit,
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeActionsDropdownProps) {
// Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr;
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
@@ -154,6 +163,15 @@ export function WorktreeActionsDropdown({
</span>
)}
</DropdownMenuItem>
{!worktree.isMain && (
<DropdownMenuItem
onClick={() => onResolveConflicts(worktree)}
className="text-xs text-purple-500 focus:text-purple-600"
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree)}
@@ -170,12 +188,50 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
)}
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
{(!worktree.isMain || worktree.hasChanges) && (
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
Create Pull Request
</DropdownMenuItem>
)}
{/* Show PR info and Address Comments button if PR exists */}
{!worktree.isMain && hasPR && worktree.pr && (
<>
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, "_blank");
}}
className="text-xs"
>
<GitPullRequest className="w-3 h-3 mr-2" />
PR #{worktree.pr.number}
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
{worktree.pr.state}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
// Convert stored PR info to the full PRInfo format for the handler
// The handler will fetch full comments from GitHub
const prInfo: PRInfo = {
number: worktree.pr!.number,
title: worktree.pr!.title,
url: worktree.pr!.url,
state: worktree.pr!.state,
author: "", // Will be fetched
body: "", // Will be fetched
comments: [],
reviewComments: [],
};
onAddressPRComments(worktree, prInfo);
}}
className="text-xs text-blue-500 focus:text-blue-600"
>
<MessageSquare className="w-3.5 h-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
</>
)}
{!worktree.isMain && (
<>
<DropdownMenuSeparator />

View File

@@ -1,14 +1,22 @@
import { Button } from "@/components/ui/button";
import { RefreshCw, Globe, Loader2 } from "lucide-react";
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
import { cn } from "@/lib/utils";
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
interface WorktreeTabProps {
worktree: WorktreeInfo;
cardCount?: number; // Number of unarchived cards for this branch
hasChanges?: boolean; // Whether the worktree has uncommitted changes
changedFilesCount?: number; // Number of files with uncommitted changes
isSelected: boolean;
isRunning: boolean;
isActivating: boolean;
@@ -36,6 +44,8 @@ interface WorktreeTabProps {
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
@@ -45,6 +55,8 @@ interface WorktreeTabProps {
export function WorktreeTab({
worktree,
cardCount,
hasChanges,
changedFilesCount,
isSelected,
isRunning,
isActivating,
@@ -72,13 +84,101 @@ export function WorktreeTab({
onOpenInEditor,
onCommit,
onCreatePR,
onAddressPRComments,
onResolveConflicts,
onDeleteWorktree,
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? "open";
const prStateClasses = (() => {
// When selected (active tab), use high contrast solid background (paper-like)
if (isSelected) {
return "bg-background text-foreground border-transparent shadow-sm";
}
// When not selected, use the colored variants
switch (prState) {
case "open":
case "reopened":
return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25";
case "draft":
return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25";
case "merged":
return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25";
case "closed":
return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25";
default:
return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80";
}
})();
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`;
// Helper to get status icon color for the selected state
const getStatusColorClass = () => {
if (!isSelected) return "";
switch (prState) {
case "open":
case "reopened":
return "text-emerald-600 dark:text-emerald-500";
case "draft":
return "text-amber-600 dark:text-amber-500";
case "merged":
return "text-purple-600 dark:text-purple-500";
case "closed":
return "text-rose-600 dark:text-rose-500";
default:
return "text-muted-foreground";
}
};
prBadge = (
<span
role="button"
tabIndex={0}
className={cn(
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
"cursor-pointer hover:opacity-80 active:opacity-70",
prStateClasses
)}
title={`${prLabel} - Click to open`}
aria-label={`${prLabel} - Click to open pull request`}
onClick={(e) => {
e.stopPropagation(); // Prevent triggering worktree selection
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
}
}}
onKeyDown={(e) => {
// Prevent event from bubbling to parent button
e.stopPropagation();
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (worktree.pr?.url) {
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
}
}
}}
>
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
PR #{worktree.pr.number}
</span>
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
{prState}
</span>
</span>
);
}
return (
<div className="flex items-center">
<div className="flex items-center rounded-md">
{worktree.isMain ? (
<>
<Button
@@ -103,6 +203,27 @@ export function WorktreeTab({
{cardCount}
</span>
)}
{hasChanges && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{prBadge}
</Button>
<BranchSwitchDropdown
worktree={worktree}
@@ -146,6 +267,27 @@ export function WorktreeTab({
{cardCount}
</span>
)}
{hasChanges && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
isSelected
? "bg-amber-500 text-amber-950 border-amber-400"
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
)}>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? "!"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{prBadge}
</Button>
)}
@@ -183,6 +325,8 @@ export function WorktreeTab({
onOpenInEditor={onOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { pathsEqual } from "@/lib/utils";
@@ -20,9 +20,12 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback(async () => {
const fetchWorktrees = useCallback(async (options?: { silent?: boolean }) => {
if (!projectPath) return;
setIsLoading(true);
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(true);
}
try {
const api = getElectronAPI();
if (!api?.worktree?.listAll) {
@@ -40,7 +43,9 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
console.error("Failed to fetch worktrees:", error);
return undefined;
} finally {
setIsLoading(false);
if (!silent) {
setIsLoading(false);
}
}
}, [projectPath, setWorktreesInStore]);
@@ -58,14 +63,25 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
}
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
// Use a ref to track the current worktree to avoid running validation
// when selection changes (which could cause a race condition with stale worktrees list)
const currentWorktreeRef = useRef(currentWorktree);
useEffect(() => {
currentWorktreeRef.current = currentWorktree;
}, [currentWorktree]);
// Validation effect: only runs when worktrees list changes (not on selection change)
// This prevents a race condition where the selection is reset because the
// local worktrees state hasn't been updated yet from the async fetch
useEffect(() => {
if (worktrees.length > 0) {
const currentPath = currentWorktree?.path;
const current = currentWorktreeRef.current;
const currentPath = current?.path;
const currentWorktreeExists = currentPath === null
? true
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
if (current == null || (currentPath !== null && !currentWorktreeExists)) {
// Find the primary worktree and get its branch name
// Fallback to "main" only if worktrees haven't loaded yet
const mainWorktree = worktrees.find((w) => w.isMain);
@@ -73,7 +89,7 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
setCurrentWorktree(projectPath, null, mainBranch);
}
}
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
}, [worktrees, projectPath, setCurrentWorktree]);
const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => {

View File

@@ -1,3 +1,11 @@
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
export interface WorktreeInfo {
path: string;
branch: string;
@@ -6,6 +14,7 @@ export interface WorktreeInfo {
hasWorktree: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
pr?: WorktreePRInfo;
}
export interface BranchInfo {
@@ -25,6 +34,31 @@ export interface FeatureInfo {
branchName?: string;
}
export interface PRInfo {
number: number;
title: string;
url: string;
state: string;
author: string;
body: string;
comments: Array<{
id: number;
author: string;
body: string;
createdAt: string;
isReviewComment: boolean;
}>;
reviewComments: Array<{
id: number;
author: string;
body: string;
path?: string;
line?: number;
createdAt: string;
isReviewComment: boolean;
}>;
}
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
@@ -32,6 +66,8 @@ export interface WorktreePanelProps {
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[];
features?: FeatureInfo[];

View File

@@ -1,6 +1,12 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { GitBranch, Plus, RefreshCw } from "lucide-react";
import {
GitBranch,
Plus,
RefreshCw,
PanelLeftOpen,
PanelLeftClose,
} from "lucide-react";
import { cn, pathsEqual } from "@/lib/utils";
import type { WorktreePanelProps, WorktreeInfo } from "./types";
import {
@@ -13,6 +19,8 @@ import {
} from "./hooks";
import { WorktreeTab } from "./components";
const WORKTREE_PANEL_COLLAPSED_KEY = "worktree-panel-collapsed";
export function WorktreePanel({
projectPath,
onCreateWorktree,
@@ -20,6 +28,8 @@ export function WorktreePanel({
onCommit,
onCreatePR,
onCreateBranch,
onAddressPRComments,
onResolveConflicts,
onRemovedWorktrees,
runningFeatureIds = [],
features = [],
@@ -79,6 +89,45 @@ export function WorktreePanel({
features,
});
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === "true";
});
useEffect(() => {
localStorage.setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
}, [isCollapsed]);
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
// Periodic interval check (1 second) to detect branch changes on disk
const intervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
fetchWorktrees({ silent: true });
}, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [fetchWorktrees]);
// Get the currently selected worktree for collapsed view
const selectedWorktree = worktrees.find((w) => {
if (
currentWorktree === null ||
currentWorktree === undefined ||
currentWorktree.path === null
) {
return w.isMain;
}
return pathsEqual(w.path, currentWorktreePath);
});
const isWorktreeSelected = (worktree: WorktreeInfo) => {
return worktree.isMain
? currentWorktree === null ||
@@ -87,99 +136,208 @@ export function WorktreePanel({
: pathsEqual(worktree.path, currentWorktreePath);
};
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleBranchDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
resetBranchFilter();
}
};
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
const handleActionsDropdownOpenChange =
(worktree: WorktreeInfo) => (open: boolean) => {
if (open) {
fetchBranches(worktree.path);
}
};
if (!useWorktreesEnabled) {
return null;
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Collapsed view - just show current branch and toggle
if (isCollapsed) {
return (
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Expand worktree panel"
>
<PanelLeftOpen className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Branch:</span>
<span className="text-sm font-mono font-medium">
{selectedWorktree?.branch ?? "main"}
</span>
{selectedWorktree?.hasChanges && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
{selectedWorktree.changedFilesCount ?? "!"}
</span>
)}
</div>
);
}
// Expanded view - full worktree panel
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={toggleCollapsed}
title="Collapse worktree panel"
>
<PanelLeftClose className="w-4 h-4" />
</Button>
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
<div className="flex items-center gap-1 flex-wrap">
{worktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
<div className="flex items-center gap-2">
{mainWorktree && (
<WorktreeTab
key={mainWorktree.path}
worktree={mainWorktree}
cardCount={branchCardCounts?.[mainWorktree.branch]}
hasChanges={mainWorktree.hasChanges}
changedFilesCount={mainWorktree.changedFilesCount}
isSelected={isWorktreeSelected(mainWorktree)}
isRunning={hasRunningFeatures(mainWorktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
mainWorktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
mainWorktree
)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
</Button>
)}
</div>
{/* Worktrees section - only show if enabled */}
{useWorktreesEnabled && (
<>
<div className="w-px h-5 bg-border mx-2" />
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
<div className="flex items-center gap-2 flex-wrap">
{nonMainWorktrees.map((worktree) => {
const cardCount = branchCardCounts?.[worktree.branch];
return (
<WorktreeTab
key={worktree.path}
worktree={worktree}
cardCount={cardCount}
hasChanges={worktree.hasChanges}
changedFilesCount={worktree.changedFilesCount}
isSelected={isWorktreeSelected(worktree)}
isRunning={hasRunningFeatures(worktree)}
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount}
behindCount={behindCount}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
worktree
)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
worktree
)}
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
);
})}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (
removedWorktrees &&
removedWorktrees.length > 0 &&
onRemovedWorktrees
) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
/>
</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -43,6 +43,9 @@ export function SettingsView() {
setDefaultPlanningMode,
defaultRequirePlanApproval,
setDefaultRequirePlanApproval,
defaultAIProfileId,
setDefaultAIProfileId,
aiProfiles,
} = useAppStore();
// Convert electron Project to settings-view Project type
@@ -127,12 +130,15 @@ export function SettingsView() {
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
/>
);
case "danger":

View File

@@ -2,7 +2,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
Zap, ClipboardList, FileText, ScrollText, ShieldCheck
Zap, ClipboardList, FileText, ScrollText, ShieldCheck, User
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AIProfile } from "@/store/app-store";
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -22,12 +23,15 @@ interface FeatureDefaultsSectionProps {
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
aiProfiles: AIProfile[];
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void;
}
export function FeatureDefaultsSection({
@@ -37,13 +41,20 @@ export function FeatureDefaultsSection({
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange,
}: FeatureDefaultsSectionProps) {
// Find the selected profile name for display
const selectedProfile = defaultAIProfileId
? aiProfiles.find((p) => p.id === defaultAIProfileId)
: null;
return (
<div
className={cn(
@@ -169,6 +180,49 @@ export function FeatureDefaultsSection({
{/* Separator */}
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
{/* Default AI Profile */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
<User className="w-5 h-5 text-brand-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">
Default AI Profile
</Label>
<Select
value={defaultAIProfileId ?? "none"}
onValueChange={(v: string) => onDefaultAIProfileIdChange(v === "none" ? null : v)}
>
<SelectTrigger
className="w-[180px] h-8"
data-testid="default-ai-profile-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">None (pick manually)</span>
</SelectItem>
{aiProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
<span>{profile.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
{selectedProfile
? `New features will use the "${selectedProfile.name}" profile (${selectedProfile.model}, ${selectedProfile.thinkingLevel} thinking).`
: "Pre-select an AI profile when creating new features. Choose \"None\" to pick manually each time."}
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

View File

@@ -1,8 +1,8 @@
import { useSetupStore } from "@/store/setup-store";
import { StepIndicator } from "./setup-view/components";
import {
WelcomeStep,
ThemeStep,
CompleteStep,
ClaudeSetupStep,
GitHubSetupStep,
@@ -11,20 +11,17 @@ import { useNavigate } from "@tanstack/react-router";
// Main Setup View
export function SetupView() {
const {
currentStep,
setCurrentStep,
completeSetup,
setSkipClaudeSetup,
} = useSetupStore();
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } =
useSetupStore();
const navigate = useNavigate();
const steps = ["welcome", "claude", "github", "complete"] as const;
const steps = ["welcome", "theme", "claude", "github", "complete"] as const;
type StepName = (typeof steps)[number];
const getStepName = (): StepName => {
if (currentStep === "claude_detect" || currentStep === "claude_auth")
return "claude";
if (currentStep === "welcome") return "welcome";
if (currentStep === "theme") return "theme";
if (currentStep === "github") return "github";
return "complete";
};
@@ -39,6 +36,10 @@ export function SetupView() {
);
switch (from) {
case "welcome":
console.log("[Setup Flow] Moving to theme step");
setCurrentStep("theme");
break;
case "theme":
console.log("[Setup Flow] Moving to claude_detect step");
setCurrentStep("claude_detect");
break;
@@ -56,9 +57,12 @@ export function SetupView() {
const handleBack = (from: string) => {
console.log("[Setup Flow] handleBack called from:", from);
switch (from) {
case "claude":
case "theme":
setCurrentStep("welcome");
break;
case "claude":
setCurrentStep("theme");
break;
case "github":
setCurrentStep("claude_detect");
break;
@@ -98,42 +102,47 @@ export function SetupView() {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-8">
<div className="w-full max-w-2xl mx-auto">
<div className="mb-8">
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
<div className="flex-1 overflow-y-auto min-h-0 flex items-center justify-center">
<div className="w-full max-w-2xl mx-auto px-8">
<div className="mb-8">
<StepIndicator
currentStep={currentIndex}
totalSteps={steps.length}
/>
</div>
<div>
{currentStep === "welcome" && (
<WelcomeStep onNext={() => handleNext("welcome")} />
)}
{currentStep === "theme" && (
<ThemeStep
onNext={() => handleNext("theme")}
onBack={() => handleBack("theme")}
/>
</div>
)}
<div className="py-8">
{currentStep === "welcome" && (
<WelcomeStep onNext={() => handleNext("welcome")} />
)}
{(currentStep === "claude_detect" ||
currentStep === "claude_auth") && (
<ClaudeSetupStep
onNext={() => handleNext("claude")}
onBack={() => handleBack("claude")}
onSkip={handleSkipClaude}
/>
)}
{(currentStep === "claude_detect" ||
currentStep === "claude_auth") && (
<ClaudeSetupStep
onNext={() => handleNext("claude")}
onBack={() => handleBack("claude")}
onSkip={handleSkipClaude}
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "github" && (
<GitHubSetupStep
onNext={() => handleNext("github")}
onBack={() => handleBack("github")}
onSkip={handleSkipGithub}
/>
)}
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}
</div>
{currentStep === "complete" && (
<CompleteStep onFinish={handleFinish} />
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
// Re-export all setup step components for easier imports
export { WelcomeStep } from "./welcome-step";
export { ThemeStep } from "./theme-step";
export { CompleteStep } from "./complete-step";
export { ClaudeSetupStep } from "./claude-setup-step";
export { GitHubSetupStep } from "./github-setup-step";

View File

@@ -0,0 +1,90 @@
import { Button } from "@/components/ui/button";
import { ArrowRight, ArrowLeft, Check } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
interface ThemeStepProps {
onNext: () => void;
onBack: () => void;
}
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
const { theme, setTheme, setPreviewTheme } = useAppStore();
const handleThemeHover = (themeValue: string) => {
setPreviewTheme(themeValue as typeof theme);
};
const handleThemeLeave = () => {
setPreviewTheme(null);
};
const handleThemeClick = (themeValue: string) => {
setTheme(themeValue as typeof theme);
setPreviewTheme(null);
};
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-3xl font-bold text-foreground mb-3">
Choose Your Theme
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Pick a theme that suits your style. Hover to preview, click to select.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{themeOptions.map((option) => {
const Icon = option.Icon;
const isSelected = theme === option.value;
return (
<button
key={option.value}
data-testid={option.testId}
onMouseEnter={() => handleThemeHover(option.value)}
onMouseLeave={handleThemeLeave}
onClick={() => handleThemeClick(option.value)}
className={cn(
"relative flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all duration-200",
"hover:scale-105 hover:shadow-lg",
isSelected
? "border-brand-500 bg-brand-500/10"
: "border-border hover:border-brand-400 bg-card"
)}
>
{isSelected && (
<div className="absolute top-2 right-2">
<Check className="w-4 h-4 text-brand-500" />
</div>
)}
<Icon className="w-6 h-6 text-foreground" />
<span className="text-sm font-medium text-foreground">
{option.label}
</span>
</button>
);
})}
</div>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
size="lg"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
onClick={onNext}
data-testid="theme-continue-button"
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getHttpApiClient } from "@/lib/http-api-client";
import { toast } from "sonner";
/**
* Hook for managing board background settings with automatic persistence to server
*/
export function useBoardBackgroundSettings() {
const store = useAppStore();
const httpClient = getHttpApiClient();
// Helper to persist settings to server
const persistSettings = useCallback(
async (projectPath: string, settingsToUpdate: Record<string, unknown>) => {
try {
const result = await httpClient.settings.updateProject(
projectPath,
{
boardBackground: settingsToUpdate,
}
);
if (!result.success) {
console.error("Failed to persist settings:", result.error);
toast.error("Failed to save settings");
}
} catch (error) {
console.error("Failed to persist settings:", error);
toast.error("Failed to save settings");
}
},
[httpClient]
);
// Get current background settings for a project
const getCurrentSettings = useCallback(
(projectPath: string) => {
const current = store.boardBackgroundByProject[projectPath];
return current || {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};
},
[store.boardBackgroundByProject]
);
// Persisting wrappers for store actions
const setBoardBackground = useCallback(
async (projectPath: string, imagePath: string | null) => {
// Get current settings first
const current = getCurrentSettings(projectPath);
// Prepare the updated settings
const toUpdate = {
...current,
imagePath,
imageVersion: imagePath ? Date.now() : undefined,
};
// Update local store
store.setBoardBackground(projectPath, imagePath);
// Persist to server
await persistSettings(projectPath, toUpdate);
},
[store, persistSettings, getCurrentSettings]
);
const setCardOpacity = useCallback(
async (projectPath: string, opacity: number) => {
const current = getCurrentSettings(projectPath);
store.setCardOpacity(projectPath, opacity);
await persistSettings(projectPath, { ...current, cardOpacity: opacity });
},
[store, persistSettings, getCurrentSettings]
);
const setColumnOpacity = useCallback(
async (projectPath: string, opacity: number) => {
const current = getCurrentSettings(projectPath);
store.setColumnOpacity(projectPath, opacity);
await persistSettings(projectPath, { ...current, columnOpacity: opacity });
},
[store, persistSettings, getCurrentSettings]
);
const setColumnBorderEnabled = useCallback(
async (projectPath: string, enabled: boolean) => {
const current = getCurrentSettings(projectPath);
store.setColumnBorderEnabled(projectPath, enabled);
await persistSettings(projectPath, {
...current,
columnBorderEnabled: enabled,
});
},
[store, persistSettings, getCurrentSettings]
);
const setCardGlassmorphism = useCallback(
async (projectPath: string, enabled: boolean) => {
const current = getCurrentSettings(projectPath);
store.setCardGlassmorphism(projectPath, enabled);
await persistSettings(projectPath, {
...current,
cardGlassmorphism: enabled,
});
},
[store, persistSettings, getCurrentSettings]
);
const setCardBorderEnabled = useCallback(
async (projectPath: string, enabled: boolean) => {
const current = getCurrentSettings(projectPath);
store.setCardBorderEnabled(projectPath, enabled);
await persistSettings(projectPath, {
...current,
cardBorderEnabled: enabled,
});
},
[store, persistSettings, getCurrentSettings]
);
const setCardBorderOpacity = useCallback(
async (projectPath: string, opacity: number) => {
const current = getCurrentSettings(projectPath);
store.setCardBorderOpacity(projectPath, opacity);
await persistSettings(projectPath, {
...current,
cardBorderOpacity: opacity,
});
},
[store, persistSettings, getCurrentSettings]
);
const setHideScrollbar = useCallback(
async (projectPath: string, hide: boolean) => {
const current = getCurrentSettings(projectPath);
store.setHideScrollbar(projectPath, hide);
await persistSettings(projectPath, { ...current, hideScrollbar: hide });
},
[store, persistSettings, getCurrentSettings]
);
const clearBoardBackground = useCallback(
async (projectPath: string) => {
store.clearBoardBackground(projectPath);
// Clear the boardBackground settings
await persistSettings(projectPath, {
imagePath: null,
imageVersion: undefined,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
});
},
[store, persistSettings]
);
return {
setBoardBackground,
setCardOpacity,
setColumnOpacity,
setColumnBorderEnabled,
setCardGlassmorphism,
setCardBorderEnabled,
setCardBorderOpacity,
setHideScrollbar,
clearBoardBackground,
getCurrentSettings,
};
}

View File

@@ -0,0 +1,324 @@
/**
* Settings Migration Hook and Sync Functions
*
* Handles migrating user settings from localStorage to persistent file-based storage
* on app startup. Also provides utility functions for syncing individual setting
* categories to the server.
*
* Migration flow:
* 1. useSettingsMigration() hook checks server for existing settings files
* 2. If none exist, collects localStorage data and sends to /api/settings/migrate
* 3. After successful migration, clears deprecated localStorage keys
* 4. Maintains automaker-storage in localStorage as fast cache for Zustand
*
* Sync functions for incremental updates:
* - syncSettingsToServer: Writes global settings to file
* - syncCredentialsToServer: Writes API keys to file
* - syncProjectSettingsToServer: Writes project-specific overrides
*/
import { useEffect, useState, useRef } from "react";
import { getHttpApiClient } from "@/lib/http-api-client";
import { isElectron } from "@/lib/electron";
/**
* State returned by useSettingsMigration hook
*/
interface MigrationState {
/** Whether migration check has completed */
checked: boolean;
/** Whether migration actually occurred */
migrated: boolean;
/** Error message if migration failed (null if success/no-op) */
error: string | null;
}
/**
* localStorage keys that may contain settings to migrate
*
* These keys are collected and sent to the server for migration.
* The automaker-storage key is handled specially as it's still used by Zustand.
*/
const LOCALSTORAGE_KEYS = [
"automaker-storage",
"automaker-setup",
"worktree-panel-collapsed",
"file-browser-recent-folders",
"automaker:lastProjectDir",
] as const;
/**
* localStorage keys to remove after successful migration
*
* automaker-storage is intentionally NOT in this list because Zustand still uses it
* as a cache. These other keys have been migrated and are no longer needed.
*/
const KEYS_TO_CLEAR_AFTER_MIGRATION = [
"worktree-panel-collapsed",
"file-browser-recent-folders",
"automaker:lastProjectDir",
// Legacy keys from older versions
"automaker_projects",
"automaker_current_project",
"automaker_trashed_projects",
] as const;
/**
* React hook to handle settings migration from localStorage to file-based storage
*
* Runs automatically once on component mount. Returns state indicating whether
* migration check is complete, whether migration occurred, and any errors.
*
* Only runs in Electron mode (isElectron() must be true). Web mode uses different
* storage mechanisms.
*
* The hook uses a ref to ensure it only runs once despite multiple mounts.
*
* @returns MigrationState with checked, migrated, and error fields
*/
export function useSettingsMigration(): MigrationState {
const [state, setState] = useState<MigrationState>({
checked: false,
migrated: false,
error: null,
});
const migrationAttempted = useRef(false);
useEffect(() => {
// Only run once
if (migrationAttempted.current) return;
migrationAttempted.current = true;
async function checkAndMigrate() {
// Only run migration in Electron mode (web mode uses different storage)
if (!isElectron()) {
setState({ checked: true, migrated: false, error: null });
return;
}
try {
const api = getHttpApiClient();
// Check if server has settings files
const status = await api.settings.getStatus();
if (!status.success) {
console.error("[Settings Migration] Failed to get status:", status);
setState({
checked: true,
migrated: false,
error: "Failed to check settings status",
});
return;
}
// If settings files already exist, no migration needed
if (!status.needsMigration) {
console.log(
"[Settings Migration] Settings files exist, no migration needed"
);
setState({ checked: true, migrated: false, error: null });
return;
}
// Check if we have localStorage data to migrate
const automakerStorage = localStorage.getItem("automaker-storage");
if (!automakerStorage) {
console.log(
"[Settings Migration] No localStorage data to migrate"
);
setState({ checked: true, migrated: false, error: null });
return;
}
console.log("[Settings Migration] Starting migration...");
// Collect all localStorage data
const localStorageData: Record<string, string> = {};
for (const key of LOCALSTORAGE_KEYS) {
const value = localStorage.getItem(key);
if (value) {
localStorageData[key] = value;
}
}
// Send to server for migration
const result = await api.settings.migrate(localStorageData);
if (result.success) {
console.log("[Settings Migration] Migration successful:", {
globalSettings: result.migratedGlobalSettings,
credentials: result.migratedCredentials,
projects: result.migratedProjectCount,
});
// Clear old localStorage keys (but keep automaker-storage for Zustand)
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
localStorage.removeItem(key);
}
setState({ checked: true, migrated: true, error: null });
} else {
console.warn(
"[Settings Migration] Migration had errors:",
result.errors
);
setState({
checked: true,
migrated: false,
error: result.errors.join(", "),
});
}
} catch (error) {
console.error("[Settings Migration] Migration failed:", error);
setState({
checked: true,
migrated: false,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
checkAndMigrate();
}, []);
return state;
}
/**
* Sync current global settings to file-based server storage
*
* Reads the current Zustand state from localStorage and sends all global settings
* to the server to be written to {dataDir}/settings.json.
*
* Call this when important global settings change (theme, UI preferences, profiles, etc.)
* Safe to call from store subscribers or change handlers.
*
* Only functions in Electron mode. Returns false if not in Electron or on error.
*
* @returns Promise resolving to true if sync succeeded, false otherwise
*/
export async function syncSettingsToServer(): Promise<boolean> {
if (!isElectron()) return false;
try {
const api = getHttpApiClient();
const automakerStorage = localStorage.getItem("automaker-storage");
if (!automakerStorage) {
return false;
}
const parsed = JSON.parse(automakerStorage);
const state = parsed.state || parsed;
// Extract settings to sync
const updates = {
theme: state.theme,
sidebarOpen: state.sidebarOpen,
chatHistoryOpen: state.chatHistoryOpen,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
maxConcurrency: state.maxConcurrency,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
projects: state.projects,
trashedProjects: state.trashedProjects,
projectHistory: state.projectHistory,
projectHistoryIndex: state.projectHistoryIndex,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
};
const result = await api.settings.updateGlobal(updates);
return result.success;
} catch (error) {
console.error("[Settings Sync] Failed to sync settings:", error);
return false;
}
}
/**
* Sync API credentials to file-based server storage
*
* Sends API keys (partial update supported) to the server to be written to
* {dataDir}/credentials.json. Credentials are kept separate from settings for security.
*
* Call this when API keys are added or updated in settings UI.
* Only requires providing the keys that have changed.
*
* Only functions in Electron mode. Returns false if not in Electron or on error.
*
* @param apiKeys - Partial credential object with optional anthropic, google, openai keys
* @returns Promise resolving to true if sync succeeded, false otherwise
*/
export async function syncCredentialsToServer(apiKeys: {
anthropic?: string;
google?: string;
openai?: string;
}): Promise<boolean> {
if (!isElectron()) return false;
try {
const api = getHttpApiClient();
const result = await api.settings.updateCredentials({ apiKeys });
return result.success;
} catch (error) {
console.error("[Settings Sync] Failed to sync credentials:", error);
return false;
}
}
/**
* Sync project-specific settings to file-based server storage
*
* Sends project settings (theme, worktree config, board customization) to the server
* to be written to {projectPath}/.automaker/settings.json.
*
* These settings override global settings for specific projects.
* Supports partial updates - only include fields that have changed.
*
* Call this when project settings are modified in the board or settings UI.
* Only functions in Electron mode. Returns false if not in Electron or on error.
*
* @param projectPath - Absolute path to project directory
* @param updates - Partial ProjectSettings with optional theme, worktree, and board settings
* @returns Promise resolving to true if sync succeeded, false otherwise
*/
export async function syncProjectSettingsToServer(
projectPath: string,
updates: {
theme?: string;
useWorktrees?: boolean;
boardBackground?: Record<string, unknown>;
currentWorktree?: { path: string | null; branch: string };
worktrees?: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
}
): Promise<boolean> {
if (!isElectron()) return false;
try {
const api = getHttpApiClient();
const result = await api.settings.updateProject(projectPath, updates);
return result.success;
} catch (error) {
console.error(
"[Settings Sync] Failed to sync project settings:",
error
);
return false;
}
}

View File

@@ -203,6 +203,9 @@ export interface FeaturesAPI {
projectPath: string,
featureId: string
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
generateTitle: (
description: string
) => Promise<{ success: boolean; title?: string; error?: string }>;
}
export interface AutoModeAPI {
@@ -505,7 +508,15 @@ const mockFileSystem: Record<string, string> = {};
// Check if we're in Electron (for UI indicators only)
export const isElectron = (): boolean => {
return typeof window !== "undefined" && window.isElectron === true;
if (typeof window === "undefined") {
return false;
}
if ((window as any).isElectron === true) {
return true;
}
return window.electronAPI?.isElectron === true;
};
// Check if backend server is available
@@ -1353,6 +1364,17 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
getPRInfo: async (worktreePath: string, branchName: string) => {
console.log("[Mock] Getting PR info:", { worktreePath, branchName });
return {
success: true,
result: {
hasPR: false,
ghCliAvailable: false,
},
};
},
};
}
@@ -2595,6 +2617,14 @@ function createMockFeaturesAPI(): FeaturesAPI {
const content = mockFileSystem[agentOutputPath];
return { success: true, content: content || null };
},
generateTitle: async (description: string) => {
console.log("[Mock] Generating title for:", description.substring(0, 50));
// Mock title generation - just take first few words
const words = description.split(/\s+/).slice(0, 6).join(" ");
const title = words.length > 40 ? words.substring(0, 40) + "..." : words;
return { success: true, title: `Add ${title}` };
},
};
}

View File

@@ -512,6 +512,8 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/features/delete", { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post("/api/features/agent-output", { projectPath, featureId }),
generateTitle: (description: string) =>
this.post("/api/features/generate-title", { description }),
};
// Auto Mode API
@@ -672,6 +674,8 @@ export class HttpApiClient implements ElectronAPI {
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
};
// Git API
@@ -833,6 +837,135 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
};
// Settings API - persistent file-based settings
settings = {
// Get settings status (check if migration needed)
getStatus: (): Promise<{
success: boolean;
hasGlobalSettings: boolean;
hasCredentials: boolean;
dataDir: string;
needsMigration: boolean;
}> => this.get("/api/settings/status"),
// Global settings
getGlobal: (): Promise<{
success: boolean;
settings?: {
version: number;
theme: string;
sidebarOpen: boolean;
chatHistoryOpen: boolean;
kanbanCardDetailLevel: string;
maxConcurrency: number;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
useWorktrees: boolean;
showProfilesOnly: boolean;
defaultPlanningMode: string;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
muteDoneSound: boolean;
enhancementModel: string;
keyboardShortcuts: Record<string, string>;
aiProfiles: unknown[];
projects: unknown[];
trashedProjects: unknown[];
projectHistory: string[];
projectHistoryIndex: number;
lastProjectDir?: string;
recentFolders: string[];
worktreePanelCollapsed: boolean;
lastSelectedSessionByProject: Record<string, string>;
};
error?: string;
}> => this.get("/api/settings/global"),
updateGlobal: (updates: Record<string, unknown>): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/global", updates),
// Credentials (masked for security)
getCredentials: (): Promise<{
success: boolean;
credentials?: {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.get("/api/settings/credentials"),
updateCredentials: (updates: {
apiKeys?: { anthropic?: string; google?: string; openai?: string };
}): Promise<{
success: boolean;
credentials?: {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.put("/api/settings/credentials", updates),
// Project settings
getProject: (projectPath: string): Promise<{
success: boolean;
settings?: {
version: number;
theme?: string;
useWorktrees?: boolean;
currentWorktree?: { path: string | null; branch: string };
worktrees?: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
boardBackground?: {
imagePath: string | null;
imageVersion?: number;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
};
lastSelectedSessionId?: string;
};
error?: string;
}> => this.post("/api/settings/project", { projectPath }),
updateProject: (
projectPath: string,
updates: Record<string, unknown>
): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/project", { projectPath, updates }),
// Migration from localStorage
migrate: (data: {
"automaker-storage"?: string;
"automaker-setup"?: string;
"worktree-panel-collapsed"?: string;
"file-browser-recent-folders"?: string;
"automaker:lastProjectDir"?: string;
}): Promise<{
success: boolean;
migratedGlobalSettings: boolean;
migratedCredentials: boolean;
migratedProjectCount: number;
errors: string[];
}> => this.post("/api/settings/migrate", { data }),
};
// Sessions API
sessions = {
list: (

View File

@@ -304,6 +304,20 @@ function createWindow(): void {
// App lifecycle
app.whenReady().then(async () => {
// Ensure userData path is consistent across dev/prod so files land in Automaker dir
try {
const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker");
if (app.getPath("userData") !== desiredUserDataPath) {
app.setPath("userData", desiredUserDataPath);
console.log("[Electron] userData path set to:", desiredUserDataPath);
}
} catch (error) {
console.warn(
"[Electron] Failed to set userData path:",
(error as Error).message
);
}
if (process.platform === "darwin" && app.dock) {
const iconPath = getIconPath();
if (iconPath) {

View File

@@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI } from "@/lib/electron";
import { Toaster } from "sonner";
import { ThemeOption, themeOptions } from "@/config/theme-options";
@@ -16,9 +17,13 @@ function RootLayoutContent() {
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { setupComplete } = useSetupStore();
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const [setupHydrated, setSetupHydrated] = useState(() =>
useSetupStore.persist?.hasHydrated?.() ?? false
);
const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key
@@ -61,6 +66,35 @@ function RootLayoutContent() {
setIsMounted(true);
}, []);
// Wait for setup store hydration before enforcing routing rules
useEffect(() => {
if (useSetupStore.persist?.hasHydrated?.()) {
setSetupHydrated(true);
return;
}
const unsubscribe = useSetupStore.persist?.onFinishHydration?.(() => {
setSetupHydrated(true);
});
return () => {
if (typeof unsubscribe === "function") {
unsubscribe();
}
};
}, []);
// Redirect first-run users (or anyone who reopened the wizard) to /setup
useEffect(() => {
if (!setupHydrated) return;
if (!setupComplete && location.pathname !== "/setup") {
navigate({ to: "/setup" });
} else if (setupComplete && location.pathname === "/setup") {
navigate({ to: "/" });
}
}, [setupComplete, setupHydrated, location.pathname, navigate]);
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
}, [openFileBrowser]);

View File

@@ -280,6 +280,8 @@ export interface AIProfile {
export interface Feature {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps: string[];
@@ -305,6 +307,7 @@ export interface Feature {
planningMode?: PlanningMode; // Planning mode for this feature
planSpec?: PlanSpec; // Generated spec/plan data
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
prUrl?: string; // Pull request URL when a PR has been created for this feature
}
// Parsed task from spec (for spec and full planning modes)
@@ -494,6 +497,7 @@ export interface AppState {
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
// Plan Approval State
// When a plan requires user approval, this holds the pending approval details
@@ -742,6 +746,7 @@ export interface AppActions {
setDefaultPlanningMode: (mode: PlanningMode) => void;
setDefaultRequirePlanApproval: (require: boolean) => void;
setDefaultAIProfileId: (profileId: string | null) => void;
// Plan Approval actions
setPendingPlanApproval: (approval: {
@@ -841,6 +846,7 @@ const initialState: AppState = {
specCreatingForProject: null,
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
pendingPlanApproval: null,
};
@@ -1510,6 +1516,10 @@ export const useAppStore = create<AppState & AppActions>()(
// Only allow removing non-built-in profiles
const profile = get().aiProfiles.find((p) => p.id === id);
if (profile && !profile.isBuiltIn) {
// Clear default if this profile was selected
if (get().defaultAIProfileId === id) {
set({ defaultAIProfileId: null });
}
set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) });
}
},
@@ -2265,6 +2275,7 @@ export const useAppStore = create<AppState & AppActions>()(
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
setDefaultAIProfileId: (profileId) => set({ defaultAIProfileId: profileId }),
// Plan Approval actions
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
@@ -2340,7 +2351,210 @@ export const useAppStore = create<AppState & AppActions>()(
boardBackgroundByProject: state.boardBackgroundByProject,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
}),
}
)
);
// ============================================================================
// Settings Sync to Server (file-based storage)
// ============================================================================
// Debounced sync function to avoid excessive server calls
let syncTimeoutId: NodeJS.Timeout | null = null;
const SYNC_DEBOUNCE_MS = 2000; // Wait 2 seconds after last change before syncing
/**
* Schedule a sync of current settings to the server
* This is debounced to avoid excessive API calls
*/
function scheduleSyncToServer() {
// Only sync in Electron mode
if (typeof window === "undefined") return;
// Clear any pending sync
if (syncTimeoutId) {
clearTimeout(syncTimeoutId);
}
// Schedule new sync
syncTimeoutId = setTimeout(async () => {
try {
// Dynamic import to avoid circular dependencies
const { syncSettingsToServer } = await import(
"@/hooks/use-settings-migration"
);
await syncSettingsToServer();
} catch (error) {
console.error("[AppStore] Failed to sync settings to server:", error);
}
}, SYNC_DEBOUNCE_MS);
}
// Subscribe to store changes and sync to server
// Only sync when important settings change (not every state change)
let previousState: Partial<AppState> | null = null;
let previousProjectSettings: Record<
string,
{
theme?: string;
boardBackground?: typeof initialState.boardBackgroundByProject[string];
currentWorktree?: typeof initialState.currentWorktreeByProject[string];
worktrees?: typeof initialState.worktreesByProject[string];
}
> = {};
// Track pending project syncs (debounced per project)
const projectSyncTimeouts: Record<string, NodeJS.Timeout> = {};
const PROJECT_SYNC_DEBOUNCE_MS = 2000;
/**
* Schedule sync of project settings to server
*/
function scheduleProjectSettingsSync(
projectPath: string,
updates: Record<string, unknown>
) {
// Only sync in Electron mode
if (typeof window === "undefined") return;
// Clear any pending sync for this project
if (projectSyncTimeouts[projectPath]) {
clearTimeout(projectSyncTimeouts[projectPath]);
}
// Schedule new sync
projectSyncTimeouts[projectPath] = setTimeout(async () => {
try {
const { syncProjectSettingsToServer } = await import(
"@/hooks/use-settings-migration"
);
await syncProjectSettingsToServer(projectPath, updates);
} catch (error) {
console.error(
`[AppStore] Failed to sync project settings for ${projectPath}:`,
error
);
}
delete projectSyncTimeouts[projectPath];
}, PROJECT_SYNC_DEBOUNCE_MS);
}
useAppStore.subscribe((state) => {
// Skip if this is the initial load
if (!previousState) {
previousState = {
theme: state.theme,
projects: state.projects,
trashedProjects: state.trashedProjects,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
maxConcurrency: state.maxConcurrency,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
};
// Initialize project settings tracking
for (const project of state.projects) {
previousProjectSettings[project.path] = {
theme: project.theme,
boardBackground: state.boardBackgroundByProject[project.path],
currentWorktree: state.currentWorktreeByProject[project.path],
worktrees: state.worktreesByProject[project.path],
};
}
return;
}
// Check if any important global settings changed
const importantSettingsChanged =
state.theme !== previousState.theme ||
state.projects !== previousState.projects ||
state.trashedProjects !== previousState.trashedProjects ||
state.keyboardShortcuts !== previousState.keyboardShortcuts ||
state.aiProfiles !== previousState.aiProfiles ||
state.maxConcurrency !== previousState.maxConcurrency ||
state.defaultSkipTests !== previousState.defaultSkipTests ||
state.enableDependencyBlocking !== previousState.enableDependencyBlocking ||
state.useWorktrees !== previousState.useWorktrees ||
state.showProfilesOnly !== previousState.showProfilesOnly ||
state.muteDoneSound !== previousState.muteDoneSound ||
state.enhancementModel !== previousState.enhancementModel ||
state.defaultPlanningMode !== previousState.defaultPlanningMode ||
state.defaultRequirePlanApproval !== previousState.defaultRequirePlanApproval ||
state.defaultAIProfileId !== previousState.defaultAIProfileId;
if (importantSettingsChanged) {
// Update previous state
previousState = {
theme: state.theme,
projects: state.projects,
trashedProjects: state.trashedProjects,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
maxConcurrency: state.maxConcurrency,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
defaultAIProfileId: state.defaultAIProfileId,
};
// Schedule sync to server
scheduleSyncToServer();
}
// Check for per-project settings changes
for (const project of state.projects) {
const projectPath = project.path;
const prev = previousProjectSettings[projectPath] || {};
const updates: Record<string, unknown> = {};
// Check if project theme changed
if (project.theme !== prev.theme) {
updates.theme = project.theme;
}
// Check if board background changed
const currentBg = state.boardBackgroundByProject[projectPath];
if (currentBg !== prev.boardBackground) {
updates.boardBackground = currentBg;
}
// Check if current worktree changed
const currentWt = state.currentWorktreeByProject[projectPath];
if (currentWt !== prev.currentWorktree) {
updates.currentWorktree = currentWt;
}
// Check if worktrees list changed
const worktrees = state.worktreesByProject[projectPath];
if (worktrees !== prev.worktrees) {
updates.worktrees = worktrees;
}
// If any project settings changed, sync them
if (Object.keys(updates).length > 0) {
scheduleProjectSettingsSync(projectPath, updates);
// Update tracking
previousProjectSettings[projectPath] = {
theme: project.theme,
boardBackground: currentBg,
currentWorktree: currentWt,
worktrees: worktrees,
};
}
}
});

View File

@@ -53,6 +53,7 @@ export interface InstallProgress {
export type SetupStep =
| "welcome"
| "theme"
| "claude_detect"
| "claude_auth"
| "github"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
/**
* Bundles all individual theme styles so the build pipeline
* doesn't tree-shake their CSS when imported dynamically.
*/
import "./themes/dark.css";
import "./themes/light.css";
import "./themes/retro.css";
import "./themes/dracula.css";
import "./themes/nord.css";
import "./themes/monokai.css";
import "./themes/tokyonight.css";
import "./themes/solarized.css";
import "./themes/gruvbox.css";
import "./themes/catppuccin.css";
import "./themes/onedark.css";
import "./themes/synthwave.css";
import "./themes/red.css";
import "./themes/cream.css";
import "./themes/sunset.css";
import "./themes/gray.css";

View File

@@ -0,0 +1,144 @@
/* Catppuccin Theme */
.catppuccin {
--background: oklch(0.18 0.02 260); /* #1e1e2e base */
--background-50: oklch(0.18 0.02 260 / 0.5);
--background-80: oklch(0.18 0.02 260 / 0.8);
--foreground: oklch(0.9 0.01 280); /* #cdd6f4 text */
--foreground-secondary: oklch(0.75 0.02 280); /* #bac2de subtext1 */
--foreground-muted: oklch(0.6 0.03 280); /* #a6adc8 subtext0 */
--card: oklch(0.22 0.02 260); /* #313244 surface0 */
--card-foreground: oklch(0.9 0.01 280);
--popover: oklch(0.2 0.02 260);
--popover-foreground: oklch(0.9 0.01 280);
--primary: oklch(0.75 0.15 280); /* #cba6f7 mauve */
--primary-foreground: oklch(0.18 0.02 260);
--brand-400: oklch(0.8 0.15 280);
--brand-500: oklch(0.75 0.15 280); /* Mauve */
--brand-600: oklch(0.7 0.17 280);
--secondary: oklch(0.26 0.02 260); /* #45475a surface1 */
--secondary-foreground: oklch(0.9 0.01 280);
--muted: oklch(0.26 0.02 260);
--muted-foreground: oklch(0.6 0.03 280);
--accent: oklch(0.3 0.03 260); /* #585b70 surface2 */
--accent-foreground: oklch(0.9 0.01 280);
--destructive: oklch(0.65 0.2 15); /* #f38ba8 red */
--border: oklch(0.35 0.03 260);
--border-glass: oklch(0.75 0.15 280 / 0.3);
--input: oklch(0.22 0.02 260);
--ring: oklch(0.75 0.15 280);
--chart-1: oklch(0.75 0.15 280); /* Mauve */
--chart-2: oklch(0.75 0.15 220); /* Blue #89b4fa */
--chart-3: oklch(0.8 0.15 160); /* Green #a6e3a1 */
--chart-4: oklch(0.8 0.15 350); /* Pink #f5c2e7 */
--chart-5: oklch(0.85 0.12 90); /* Yellow #f9e2af */
--sidebar: oklch(0.16 0.02 260); /* #181825 mantle */
--sidebar-foreground: oklch(0.9 0.01 280);
--sidebar-primary: oklch(0.75 0.15 280);
--sidebar-primary-foreground: oklch(0.18 0.02 260);
--sidebar-accent: oklch(0.26 0.02 260);
--sidebar-accent-foreground: oklch(0.9 0.01 280);
--sidebar-border: oklch(0.35 0.03 260);
--sidebar-ring: oklch(0.75 0.15 280);
/* Action button colors - Catppuccin mauve/pink theme */
--action-view: oklch(0.75 0.15 280); /* Mauve */
--action-view-hover: oklch(0.7 0.17 280);
--action-followup: oklch(0.75 0.15 220); /* Blue */
--action-followup-hover: oklch(0.7 0.17 220);
--action-commit: oklch(0.8 0.15 160); /* Green */
--action-commit-hover: oklch(0.75 0.17 160);
--action-verify: oklch(0.8 0.15 160); /* Green */
--action-verify-hover: oklch(0.75 0.17 160);
/* Running indicator - Mauve */
--running-indicator: oklch(0.75 0.15 280);
--running-indicator-text: oklch(0.8 0.13 280);
}
/* ========================================
ONE DARK THEME
Atom's iconic One Dark theme
======================================== */
/* Theme-specific overrides */
.catppuccin .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #cba6f7 0%, #f5c2e7 50%, #cba6f7 100%);
}
.catppuccin .animated-outline-inner {
background: oklch(0.18 0.02 260) !important;
color: #cba6f7 !important;
}
.catppuccin [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.24 0.03 260) !important;
color: #f5c2e7 !important;
}
.catppuccin .slider-track {
background: oklch(0.26 0.02 260);
}
.catppuccin .slider-range {
background: linear-gradient(to right, #cba6f7, #89b4fa);
}
.catppuccin .slider-thumb {
background: oklch(0.22 0.02 260);
border-color: #cba6f7;
}
.catppuccin .xml-highlight {
color: oklch(0.9 0.01 280); /* #cdd6f4 */
}
.catppuccin .xml-tag-bracket {
color: oklch(0.65 0.2 15); /* #f38ba8 red */
}
.catppuccin .xml-tag-name {
color: oklch(0.65 0.2 15); /* Red for tags */
}
.catppuccin .xml-attribute-name {
color: oklch(0.75 0.15 280); /* #cba6f7 mauve */
}
.catppuccin .xml-attribute-equals {
color: oklch(0.75 0.02 280); /* Subtext */
}
.catppuccin .xml-attribute-value {
color: oklch(0.8 0.15 160); /* #a6e3a1 green */
}
.catppuccin .xml-comment {
color: oklch(0.5 0.04 280); /* Overlay */
font-style: italic;
}
.catppuccin .xml-cdata {
color: oklch(0.75 0.15 220); /* #89b4fa blue */
}
.catppuccin .xml-doctype {
color: oklch(0.8 0.15 350); /* #f5c2e7 pink */
}
.catppuccin .xml-text {
color: oklch(0.9 0.01 280); /* Text */
}

View File

@@ -0,0 +1,116 @@
/* Cream Theme */
.cream {
/* Cream Theme - Warm, soft, easy on the eyes */
--background: oklch(0.95 0.01 70); /* Warm cream background */
--background-50: oklch(0.95 0.01 70 / 0.5);
--background-80: oklch(0.95 0.01 70 / 0.8);
--foreground: oklch(0.25 0.02 60); /* Dark warm brown */
--foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */
--foreground-muted: oklch(0.55 0.02 60); /* Light brown */
--card: oklch(0.98 0.005 70); /* Slightly lighter cream */
--card-foreground: oklch(0.25 0.02 60);
--popover: oklch(0.97 0.008 70);
--popover-foreground: oklch(0.25 0.02 60);
--primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */
--primary-foreground: oklch(0.98 0.005 70);
--brand-400: oklch(0.55 0.12 45);
--brand-500: oklch(0.5 0.12 45); /* Terracotta */
--brand-600: oklch(0.45 0.13 45);
--secondary: oklch(0.88 0.02 70);
--secondary-foreground: oklch(0.25 0.02 60);
--muted: oklch(0.9 0.015 70);
--muted-foreground: oklch(0.45 0.02 60);
--accent: oklch(0.85 0.025 70);
--accent-foreground: oklch(0.25 0.02 60);
--destructive: oklch(0.55 0.22 25); /* Warm red */
--border: oklch(0.85 0.015 70);
--border-glass: oklch(0.5 0.12 45 / 0.2);
--input: oklch(0.98 0.005 70);
--ring: oklch(0.5 0.12 45);
--chart-1: oklch(0.5 0.12 45); /* Terracotta */
--chart-2: oklch(0.55 0.15 35); /* Burnt orange */
--chart-3: oklch(0.6 0.12 100); /* Olive */
--chart-4: oklch(0.5 0.15 20); /* Deep rust */
--chart-5: oklch(0.65 0.1 80); /* Golden */
--sidebar: oklch(0.93 0.012 70);
--sidebar-foreground: oklch(0.25 0.02 60);
--sidebar-primary: oklch(0.5 0.12 45);
--sidebar-primary-foreground: oklch(0.98 0.005 70);
--sidebar-accent: oklch(0.88 0.02 70);
--sidebar-accent-foreground: oklch(0.25 0.02 60);
--sidebar-border: oklch(0.85 0.015 70);
--sidebar-ring: oklch(0.5 0.12 45);
/* Action button colors - Warm earth tones */
--action-view: oklch(0.5 0.12 45); /* Terracotta */
--action-view-hover: oklch(0.45 0.13 45);
--action-followup: oklch(0.55 0.15 35); /* Burnt orange */
--action-followup-hover: oklch(0.5 0.16 35);
--action-commit: oklch(0.55 0.12 130); /* Sage green */
--action-commit-hover: oklch(0.5 0.13 130);
--action-verify: oklch(0.55 0.12 130); /* Sage green */
--action-verify-hover: oklch(0.5 0.13 130);
/* Running indicator - Terracotta */
--running-indicator: oklch(0.5 0.12 45);
--running-indicator-text: oklch(0.55 0.12 45);
/* Status colors - Cream theme */
--status-success: oklch(0.55 0.15 130);
--status-success-bg: oklch(0.55 0.15 130 / 0.15);
--status-warning: oklch(0.6 0.15 70);
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
--status-error: oklch(0.55 0.22 25);
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
--status-info: oklch(0.5 0.15 230);
--status-info-bg: oklch(0.5 0.15 230 / 0.15);
--status-backlog: oklch(0.6 0.02 60);
--status-in-progress: oklch(0.6 0.15 70);
--status-waiting: oklch(0.58 0.13 50);
}
/* Theme-specific overrides */
/* Cream theme scrollbar */
.cream ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.cream ::-webkit-scrollbar-thumb,
.cream .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.7 0.03 60);
border-radius: 4px;
}
.cream ::-webkit-scrollbar-thumb:hover,
.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0.04 60);
}
.cream ::-webkit-scrollbar-track,
.cream .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.9 0.015 70);
}
.cream .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.7 0.03 60);
}
.cream .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0.04 60);
}

View File

@@ -0,0 +1,166 @@
/* Dark Theme */
.dark {
/* Deep dark backgrounds - zinc-950 family */
--background: oklch(0.04 0 0); /* zinc-950 */
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
--background-80: oklch(0.04 0 0 / 0.8); /* zinc-950/80 */
/* Text colors following hierarchy */
--foreground: oklch(1 0 0); /* text-white */
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
/* Card and popover backgrounds */
--card: oklch(0.14 0 0); /* slightly lighter than background for contrast */
--card-foreground: oklch(1 0 0);
--popover: oklch(0.10 0 0); /* slightly lighter than background */
--popover-foreground: oklch(1 0 0);
/* Brand colors - purple/violet theme */
--primary: oklch(0.55 0.25 265); /* brand-500 */
--primary-foreground: oklch(1 0 0);
--brand-400: oklch(0.6 0.22 265);
--brand-500: oklch(0.55 0.25 265);
--brand-600: oklch(0.5 0.28 270); /* purple-600 for gradients */
/* Glass morphism borders and accents */
--secondary: oklch(1 0 0 / 0.05); /* bg-white/5 */
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.176 0 0); /* zinc-800 */
--muted-foreground: oklch(0.588 0 0); /* text-zinc-400 */
--accent: oklch(1 0 0 / 0.1); /* bg-white/10 for hover */
--accent-foreground: oklch(1 0 0);
/* Borders with transparency for glass effect */
--border: oklch(0.176 0 0); /* zinc-800 */
--border-glass: oklch(1 0 0 / 0.1); /* white/10 for glass morphism */
--destructive: oklch(0.6 0.25 25);
--input: oklch(0.04 0 0 / 0.8); /* Semi-transparent dark */
--ring: oklch(0.55 0.25 265);
/* Chart colors with brand theme */
--chart-1: oklch(0.55 0.25 265);
--chart-2: oklch(0.65 0.2 160);
--chart-3: oklch(0.75 0.2 70);
--chart-4: oklch(0.6 0.25 300);
--chart-5: oklch(0.6 0.25 20);
/* Sidebar with glass morphism */
--sidebar: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with backdrop blur */
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.55 0.25 265);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(1 0 0 / 0.05); /* bg-white/5 */
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(1 0 0 / 0.1); /* white/10 for glass borders */
--sidebar-ring: oklch(0.55 0.25 265);
/* Action button colors */
--action-view: oklch(0.6 0.25 265); /* Purple */
--action-view-hover: oklch(0.55 0.27 270);
--action-followup: oklch(0.6 0.2 230); /* Blue */
--action-followup-hover: oklch(0.55 0.22 230);
--action-commit: oklch(0.55 0.2 140); /* Green */
--action-commit-hover: oklch(0.5 0.22 140);
--action-verify: oklch(0.55 0.2 140); /* Green */
--action-verify-hover: oklch(0.5 0.22 140);
/* Running indicator - Purple */
--running-indicator: oklch(0.6 0.25 265);
--running-indicator-text: oklch(0.65 0.22 265);
/* Status colors - Dark mode */
--status-success: oklch(0.65 0.2 140);
--status-success-bg: oklch(0.65 0.2 140 / 0.2);
--status-warning: oklch(0.75 0.15 70);
--status-warning-bg: oklch(0.75 0.15 70 / 0.2);
--status-error: oklch(0.65 0.22 25);
--status-error-bg: oklch(0.65 0.22 25 / 0.2);
--status-info: oklch(0.65 0.2 230);
--status-info-bg: oklch(0.65 0.2 230 / 0.2);
--status-backlog: oklch(0.6 0 0);
--status-in-progress: oklch(0.75 0.15 70);
--status-waiting: oklch(0.7 0.18 50);
/* Shadow tokens - darker for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
}
/* Theme-specific overrides */
.dark .content-bg {
background: linear-gradient(135deg, oklch(0.04 0 0), oklch(0.08 0 0), oklch(0.04 0 0));
}
.dark .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #a855f7 0%, #3b82f6 50%, #a855f7 100%);
}
.dark .animated-outline-inner {
background: oklch(0.15 0 0) !important;
color: #c084fc !important;
}
.dark [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.2 0.02 270) !important;
color: #e9d5ff !important;
}
.dark .slider-track {
background: oklch(0.2 0 0);
}
.dark .slider-range {
background: linear-gradient(to right, #a855f7, #3b82f6);
}
.dark .slider-thumb {
background: oklch(0.25 0 0);
border-color: oklch(0.4 0 0);
}
.dark .xml-highlight {
color: oklch(0.9 0 0); /* Default light text */
}
.dark .xml-tag-bracket {
color: oklch(0.7 0.12 220); /* Soft blue for < > */
}
.dark .xml-tag-name {
color: oklch(0.75 0.2 25); /* Coral/salmon for tag names */
}
.dark .xml-attribute-name {
color: oklch(0.8 0.15 280); /* Light purple for attributes */
}
.dark .xml-attribute-equals {
color: oklch(0.6 0 0); /* Gray for = */
}
.dark .xml-attribute-value {
color: oklch(0.8 0.18 145); /* Bright green for strings */
}
.dark .xml-comment {
color: oklch(0.55 0.05 100); /* Muted for comments */
font-style: italic;
}
.dark .xml-cdata {
color: oklch(0.7 0.12 200); /* Teal for CDATA */
}
.dark .xml-doctype {
color: oklch(0.7 0.15 280); /* Purple for DOCTYPE */
}
.dark .xml-text {
color: oklch(0.85 0 0); /* Off-white for text */
}

View File

@@ -0,0 +1,144 @@
/* Dracula Theme */
.dracula {
--background: oklch(0.18 0.02 280); /* #282a36 */
--background-50: oklch(0.18 0.02 280 / 0.5);
--background-80: oklch(0.18 0.02 280 / 0.8);
--foreground: oklch(0.95 0.01 280); /* #f8f8f2 */
--foreground-secondary: oklch(0.7 0.05 280);
--foreground-muted: oklch(0.55 0.08 280); /* #6272a4 */
--card: oklch(0.22 0.02 280); /* #44475a */
--card-foreground: oklch(0.95 0.01 280);
--popover: oklch(0.2 0.02 280);
--popover-foreground: oklch(0.95 0.01 280);
--primary: oklch(0.7 0.2 320); /* #bd93f9 purple */
--primary-foreground: oklch(0.18 0.02 280);
--brand-400: oklch(0.75 0.2 320);
--brand-500: oklch(0.7 0.2 320); /* #bd93f9 */
--brand-600: oklch(0.65 0.22 320);
--secondary: oklch(0.28 0.03 280); /* #44475a */
--secondary-foreground: oklch(0.95 0.01 280);
--muted: oklch(0.28 0.03 280);
--muted-foreground: oklch(0.55 0.08 280); /* #6272a4 */
--accent: oklch(0.32 0.04 280);
--accent-foreground: oklch(0.95 0.01 280);
--destructive: oklch(0.65 0.25 15); /* #ff5555 */
--border: oklch(0.35 0.05 280);
--border-glass: oklch(0.7 0.2 320 / 0.3);
--input: oklch(0.22 0.02 280);
--ring: oklch(0.7 0.2 320);
--chart-1: oklch(0.7 0.2 320); /* Purple */
--chart-2: oklch(0.75 0.2 180); /* Cyan #8be9fd */
--chart-3: oklch(0.8 0.2 130); /* Green #50fa7b */
--chart-4: oklch(0.7 0.25 350); /* Pink #ff79c6 */
--chart-5: oklch(0.85 0.2 90); /* Yellow #f1fa8c */
--sidebar: oklch(0.16 0.02 280);
--sidebar-foreground: oklch(0.95 0.01 280);
--sidebar-primary: oklch(0.7 0.2 320);
--sidebar-primary-foreground: oklch(0.18 0.02 280);
--sidebar-accent: oklch(0.28 0.03 280);
--sidebar-accent-foreground: oklch(0.95 0.01 280);
--sidebar-border: oklch(0.35 0.05 280);
--sidebar-ring: oklch(0.7 0.2 320);
/* Action button colors - Dracula purple/pink theme */
--action-view: oklch(0.7 0.2 320); /* Purple */
--action-view-hover: oklch(0.65 0.22 320);
--action-followup: oklch(0.65 0.25 350); /* Pink */
--action-followup-hover: oklch(0.6 0.27 350);
--action-commit: oklch(0.75 0.2 130); /* Green */
--action-commit-hover: oklch(0.7 0.22 130);
--action-verify: oklch(0.75 0.2 130); /* Green */
--action-verify-hover: oklch(0.7 0.22 130);
/* Running indicator - Purple */
--running-indicator: oklch(0.7 0.2 320);
--running-indicator-text: oklch(0.75 0.18 320);
}
/* ========================================
NORD THEME
Inspired by the Arctic, north-bluish color palette
======================================== */
/* Theme-specific overrides */
.dracula .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #bd93f9 0%, #ff79c6 50%, #bd93f9 100%);
}
.dracula .animated-outline-inner {
background: oklch(0.18 0.02 280) !important;
color: #bd93f9 !important;
}
.dracula [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.24 0.03 280) !important;
color: #ff79c6 !important;
}
.dracula .slider-track {
background: oklch(0.28 0.03 280);
}
.dracula .slider-range {
background: linear-gradient(to right, #bd93f9, #ff79c6);
}
.dracula .slider-thumb {
background: oklch(0.22 0.02 280);
border-color: #bd93f9;
}
.dracula .xml-highlight {
color: oklch(0.95 0.01 280); /* #f8f8f2 */
}
.dracula .xml-tag-bracket {
color: oklch(0.7 0.25 350); /* Pink #ff79c6 */
}
.dracula .xml-tag-name {
color: oklch(0.7 0.25 350); /* Pink for tags */
}
.dracula .xml-attribute-name {
color: oklch(0.8 0.2 130); /* Green #50fa7b */
}
.dracula .xml-attribute-equals {
color: oklch(0.95 0.01 280); /* White */
}
.dracula .xml-attribute-value {
color: oklch(0.85 0.2 90); /* Yellow #f1fa8c */
}
.dracula .xml-comment {
color: oklch(0.55 0.08 280); /* #6272a4 */
font-style: italic;
}
.dracula .xml-cdata {
color: oklch(0.75 0.2 180); /* Cyan */
}
.dracula .xml-doctype {
color: oklch(0.7 0.2 320); /* Purple #bd93f9 */
}
.dracula .xml-text {
color: oklch(0.95 0.01 280); /* White */
}

View File

@@ -0,0 +1,110 @@
/* Gray Theme */
.gray {
/* Gray Theme - Modern, minimal gray scheme inspired by Cursor */
--background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */
--background-50: oklch(0.2 0.005 250 / 0.5);
--background-80: oklch(0.2 0.005 250 / 0.8);
--foreground: oklch(0.9 0.005 250); /* Light gray */
--foreground-secondary: oklch(0.65 0.005 250);
--foreground-muted: oklch(0.5 0.005 250);
--card: oklch(0.24 0.005 250);
--card-foreground: oklch(0.9 0.005 250);
--popover: oklch(0.22 0.005 250);
--popover-foreground: oklch(0.9 0.005 250);
--primary: oklch(0.6 0.08 250); /* Subtle blue-gray */
--primary-foreground: oklch(0.95 0.005 250);
--brand-400: oklch(0.65 0.08 250);
--brand-500: oklch(0.6 0.08 250); /* Blue-gray */
--brand-600: oklch(0.55 0.09 250);
--secondary: oklch(0.28 0.005 250);
--secondary-foreground: oklch(0.9 0.005 250);
--muted: oklch(0.3 0.005 250);
--muted-foreground: oklch(0.6 0.005 250);
--accent: oklch(0.35 0.01 250);
--accent-foreground: oklch(0.9 0.005 250);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.005 250);
--border-glass: oklch(0.6 0.08 250 / 0.2);
--input: oklch(0.24 0.005 250);
--ring: oklch(0.6 0.08 250);
--chart-1: oklch(0.6 0.08 250); /* Blue-gray */
--chart-2: oklch(0.65 0.1 210); /* Cyan */
--chart-3: oklch(0.7 0.12 160); /* Teal */
--chart-4: oklch(0.65 0.1 280); /* Purple */
--chart-5: oklch(0.7 0.08 300); /* Violet */
--sidebar: oklch(0.18 0.005 250);
--sidebar-foreground: oklch(0.9 0.005 250);
--sidebar-primary: oklch(0.6 0.08 250);
--sidebar-primary-foreground: oklch(0.95 0.005 250);
--sidebar-accent: oklch(0.28 0.005 250);
--sidebar-accent-foreground: oklch(0.9 0.005 250);
--sidebar-border: oklch(0.32 0.005 250);
--sidebar-ring: oklch(0.6 0.08 250);
/* Action button colors - Subtle modern colors */
--action-view: oklch(0.6 0.08 250); /* Blue-gray */
--action-view-hover: oklch(0.55 0.09 250);
--action-followup: oklch(0.65 0.1 210); /* Cyan */
--action-followup-hover: oklch(0.6 0.11 210);
--action-commit: oklch(0.65 0.12 150); /* Teal-green */
--action-commit-hover: oklch(0.6 0.13 150);
--action-verify: oklch(0.65 0.12 150); /* Teal-green */
--action-verify-hover: oklch(0.6 0.13 150);
/* Running indicator - Blue-gray */
--running-indicator: oklch(0.6 0.08 250);
--running-indicator-text: oklch(0.65 0.08 250);
/* Status colors - Gray theme */
--status-success: oklch(0.65 0.12 150);
--status-success-bg: oklch(0.65 0.12 150 / 0.2);
--status-warning: oklch(0.7 0.15 70);
--status-warning-bg: oklch(0.7 0.15 70 / 0.2);
--status-error: oklch(0.6 0.2 25);
--status-error-bg: oklch(0.6 0.2 25 / 0.2);
--status-info: oklch(0.65 0.1 210);
--status-info-bg: oklch(0.65 0.1 210 / 0.2);
--status-backlog: oklch(0.6 0.005 250);
--status-in-progress: oklch(0.7 0.15 70);
--status-waiting: oklch(0.68 0.1 220);
}
/* Theme-specific overrides */
/* Gray theme scrollbar */
.gray ::-webkit-scrollbar-thumb,
.gray .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.4 0.01 250);
border-radius: 4px;
}
.gray ::-webkit-scrollbar-thumb:hover,
.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0.02 250);
}
.gray ::-webkit-scrollbar-track,
.gray .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.25 0.005 250);
}
.gray .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.4 0.01 250);
}
.gray .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0.02 250);
}

View File

@@ -0,0 +1,144 @@
/* Gruvbox Theme */
.gruvbox {
--background: oklch(0.18 0.02 60); /* #282828 bg */
--background-50: oklch(0.18 0.02 60 / 0.5);
--background-80: oklch(0.18 0.02 60 / 0.8);
--foreground: oklch(0.85 0.05 85); /* #ebdbb2 fg */
--foreground-secondary: oklch(0.7 0.04 85); /* #d5c4a1 */
--foreground-muted: oklch(0.55 0.04 85); /* #928374 */
--card: oklch(0.22 0.02 60); /* #3c3836 bg1 */
--card-foreground: oklch(0.85 0.05 85);
--popover: oklch(0.2 0.02 60);
--popover-foreground: oklch(0.85 0.05 85);
--primary: oklch(0.7 0.18 55); /* #fabd2f yellow */
--primary-foreground: oklch(0.18 0.02 60);
--brand-400: oklch(0.75 0.18 55);
--brand-500: oklch(0.7 0.18 55); /* Yellow */
--brand-600: oklch(0.65 0.2 55);
--secondary: oklch(0.26 0.02 60); /* #504945 bg2 */
--secondary-foreground: oklch(0.85 0.05 85);
--muted: oklch(0.26 0.02 60);
--muted-foreground: oklch(0.55 0.04 85);
--accent: oklch(0.3 0.03 60);
--accent-foreground: oklch(0.85 0.05 85);
--destructive: oklch(0.55 0.22 25); /* #fb4934 red */
--border: oklch(0.35 0.03 60);
--border-glass: oklch(0.7 0.18 55 / 0.3);
--input: oklch(0.22 0.02 60);
--ring: oklch(0.7 0.18 55);
--chart-1: oklch(0.7 0.18 55); /* Yellow */
--chart-2: oklch(0.65 0.2 140); /* Green #b8bb26 */
--chart-3: oklch(0.7 0.15 200); /* Aqua #8ec07c */
--chart-4: oklch(0.6 0.2 30); /* Orange #fe8019 */
--chart-5: oklch(0.6 0.2 320); /* Purple #d3869b */
--sidebar: oklch(0.16 0.02 60);
--sidebar-foreground: oklch(0.85 0.05 85);
--sidebar-primary: oklch(0.7 0.18 55);
--sidebar-primary-foreground: oklch(0.18 0.02 60);
--sidebar-accent: oklch(0.26 0.02 60);
--sidebar-accent-foreground: oklch(0.85 0.05 85);
--sidebar-border: oklch(0.35 0.03 60);
--sidebar-ring: oklch(0.7 0.18 55);
/* Action button colors - Gruvbox yellow/orange theme */
--action-view: oklch(0.7 0.18 55); /* Yellow */
--action-view-hover: oklch(0.65 0.2 55);
--action-followup: oklch(0.7 0.15 200); /* Aqua */
--action-followup-hover: oklch(0.65 0.17 200);
--action-commit: oklch(0.65 0.2 140); /* Green */
--action-commit-hover: oklch(0.6 0.22 140);
--action-verify: oklch(0.65 0.2 140); /* Green */
--action-verify-hover: oklch(0.6 0.22 140);
/* Running indicator - Yellow */
--running-indicator: oklch(0.7 0.18 55);
--running-indicator-text: oklch(0.75 0.16 55);
}
/* ========================================
CATPPUCCIN MOCHA THEME
Soothing pastel theme for the high-spirited
======================================== */
/* Theme-specific overrides */
.gruvbox .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #fabd2f 0%, #fe8019 50%, #fabd2f 100%);
}
.gruvbox .animated-outline-inner {
background: oklch(0.18 0.02 60) !important;
color: #fabd2f !important;
}
.gruvbox [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.24 0.03 60) !important;
color: #fe8019 !important;
}
.gruvbox .slider-track {
background: oklch(0.26 0.02 60);
}
.gruvbox .slider-range {
background: linear-gradient(to right, #fabd2f, #fe8019);
}
.gruvbox .slider-thumb {
background: oklch(0.22 0.02 60);
border-color: #fabd2f;
}
.gruvbox .xml-highlight {
color: oklch(0.85 0.05 85); /* #ebdbb2 */
}
.gruvbox .xml-tag-bracket {
color: oklch(0.55 0.22 25); /* #fb4934 red */
}
.gruvbox .xml-tag-name {
color: oklch(0.55 0.22 25); /* Red for tags */
}
.gruvbox .xml-attribute-name {
color: oklch(0.7 0.15 200); /* #8ec07c aqua */
}
.gruvbox .xml-attribute-equals {
color: oklch(0.7 0.04 85); /* Dim text */
}
.gruvbox .xml-attribute-value {
color: oklch(0.65 0.2 140); /* #b8bb26 green */
}
.gruvbox .xml-comment {
color: oklch(0.55 0.04 85); /* #928374 gray */
font-style: italic;
}
.gruvbox .xml-cdata {
color: oklch(0.7 0.15 200); /* Aqua */
}
.gruvbox .xml-doctype {
color: oklch(0.6 0.2 320); /* #d3869b purple */
}
.gruvbox .xml-text {
color: oklch(0.85 0.05 85); /* Foreground */
}

View File

@@ -0,0 +1,103 @@
/* Light Theme Overrides */
.light .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.95 0 0);
}
.light .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.7 0 0);
}
.light .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0 0);
}
.light .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.75 0 0);
}
.light .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.65 0 0);
}
.light .bg-glass {
background: oklch(1 0 0 / 0.8);
}
.light .bg-glass-80 {
background: oklch(1 0 0 / 0.95);
}
.light .content-bg {
background: linear-gradient(135deg, oklch(0.99 0 0), oklch(0.98 0 0), oklch(0.99 0 0));
}
.light .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #7c3aed 0%, #2563eb 50%, #7c3aed 100%);
}
.light .animated-outline-inner {
background: oklch(100% 0 0) !important;
color: #7c3aed !important;
border: 1px solid oklch(92% 0 0);
}
.light [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(97% 0.02 270) !important;
color: #5b21b6 !important;
}
.light .slider-track {
background: oklch(90% 0 0);
}
.light .slider-range {
background: linear-gradient(to right, #7c3aed, #2563eb);
}
.light .slider-thumb {
background: oklch(100% 0 0);
border-color: oklch(80% 0 0);
}
.light .xml-highlight {
color: oklch(0.3 0 0); /* Default text */
}
.light .xml-tag-bracket {
color: oklch(0.45 0.15 250); /* Blue-gray for < > */
}
.light .xml-tag-name {
color: oklch(0.45 0.22 25); /* Red/maroon for tag names */
}
.light .xml-attribute-name {
color: oklch(0.45 0.18 280); /* Purple for attributes */
}
.light .xml-attribute-equals {
color: oklch(0.4 0 0); /* Dark gray for = */
}
.light .xml-attribute-value {
color: oklch(0.45 0.18 145); /* Green for string values */
}
.light .xml-comment {
color: oklch(0.55 0.05 100); /* Muted olive for comments */
font-style: italic;
}
.light .xml-cdata {
color: oklch(0.5 0.1 200); /* Teal for CDATA */
}
.light .xml-doctype {
color: oklch(0.5 0.15 280); /* Purple for DOCTYPE */
}
.light .xml-text {
color: oklch(0.25 0 0); /* Near-black for text content */
}

View File

@@ -0,0 +1,144 @@
/* Monokai Theme */
.monokai {
--background: oklch(0.17 0.01 90); /* #272822 */
--background-50: oklch(0.17 0.01 90 / 0.5);
--background-80: oklch(0.17 0.01 90 / 0.8);
--foreground: oklch(0.95 0.02 100); /* #f8f8f2 */
--foreground-secondary: oklch(0.8 0.02 100);
--foreground-muted: oklch(0.55 0.04 100); /* #75715e */
--card: oklch(0.22 0.01 90); /* #3e3d32 */
--card-foreground: oklch(0.95 0.02 100);
--popover: oklch(0.2 0.01 90);
--popover-foreground: oklch(0.95 0.02 100);
--primary: oklch(0.8 0.2 350); /* #f92672 pink */
--primary-foreground: oklch(0.17 0.01 90);
--brand-400: oklch(0.85 0.2 350);
--brand-500: oklch(0.8 0.2 350); /* #f92672 */
--brand-600: oklch(0.75 0.22 350);
--secondary: oklch(0.25 0.02 90);
--secondary-foreground: oklch(0.95 0.02 100);
--muted: oklch(0.25 0.02 90);
--muted-foreground: oklch(0.55 0.04 100);
--accent: oklch(0.3 0.02 90);
--accent-foreground: oklch(0.95 0.02 100);
--destructive: oklch(0.65 0.25 15); /* red */
--border: oklch(0.35 0.03 90);
--border-glass: oklch(0.8 0.2 350 / 0.3);
--input: oklch(0.22 0.01 90);
--ring: oklch(0.8 0.2 350);
--chart-1: oklch(0.8 0.2 350); /* Pink #f92672 */
--chart-2: oklch(0.85 0.2 90); /* Yellow #e6db74 */
--chart-3: oklch(0.8 0.2 140); /* Green #a6e22e */
--chart-4: oklch(0.75 0.2 200); /* Cyan #66d9ef */
--chart-5: oklch(0.75 0.2 30); /* Orange #fd971f */
--sidebar: oklch(0.15 0.01 90);
--sidebar-foreground: oklch(0.95 0.02 100);
--sidebar-primary: oklch(0.8 0.2 350);
--sidebar-primary-foreground: oklch(0.17 0.01 90);
--sidebar-accent: oklch(0.25 0.02 90);
--sidebar-accent-foreground: oklch(0.95 0.02 100);
--sidebar-border: oklch(0.35 0.03 90);
--sidebar-ring: oklch(0.8 0.2 350);
/* Action button colors - Monokai pink/yellow theme */
--action-view: oklch(0.8 0.2 350); /* Pink */
--action-view-hover: oklch(0.75 0.22 350);
--action-followup: oklch(0.75 0.2 200); /* Cyan */
--action-followup-hover: oklch(0.7 0.22 200);
--action-commit: oklch(0.8 0.2 140); /* Green */
--action-commit-hover: oklch(0.75 0.22 140);
--action-verify: oklch(0.8 0.2 140); /* Green */
--action-verify-hover: oklch(0.75 0.22 140);
/* Running indicator - Pink */
--running-indicator: oklch(0.8 0.2 350);
--running-indicator-text: oklch(0.85 0.18 350);
}
/* ========================================
TOKYO NIGHT THEME
A clean dark theme celebrating Tokyo at night
======================================== */
/* Theme-specific overrides */
.monokai .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #f92672 0%, #e6db74 50%, #f92672 100%);
}
.monokai .animated-outline-inner {
background: oklch(0.17 0.01 90) !important;
color: #f92672 !important;
}
.monokai [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.22 0.02 90) !important;
color: #e6db74 !important;
}
.monokai .slider-track {
background: oklch(0.25 0.02 90);
}
.monokai .slider-range {
background: linear-gradient(to right, #f92672, #fd971f);
}
.monokai .slider-thumb {
background: oklch(0.22 0.01 90);
border-color: #f92672;
}
.monokai .xml-highlight {
color: oklch(0.95 0.02 100); /* #f8f8f2 */
}
.monokai .xml-tag-bracket {
color: oklch(0.95 0.02 100); /* White */
}
.monokai .xml-tag-name {
color: oklch(0.8 0.2 350); /* #f92672 pink */
}
.monokai .xml-attribute-name {
color: oklch(0.8 0.2 140); /* #a6e22e green */
}
.monokai .xml-attribute-equals {
color: oklch(0.95 0.02 100); /* White */
}
.monokai .xml-attribute-value {
color: oklch(0.85 0.2 90); /* #e6db74 yellow */
}
.monokai .xml-comment {
color: oklch(0.55 0.04 100); /* #75715e */
font-style: italic;
}
.monokai .xml-cdata {
color: oklch(0.75 0.2 200); /* Cyan #66d9ef */
}
.monokai .xml-doctype {
color: oklch(0.75 0.2 200); /* Cyan */
}
.monokai .xml-text {
color: oklch(0.95 0.02 100); /* White */
}

View File

@@ -0,0 +1,144 @@
/* Nord Theme */
.nord {
--background: oklch(0.23 0.02 240); /* #2e3440 */
--background-50: oklch(0.23 0.02 240 / 0.5);
--background-80: oklch(0.23 0.02 240 / 0.8);
--foreground: oklch(0.9 0.01 230); /* #eceff4 */
--foreground-secondary: oklch(0.75 0.02 230); /* #d8dee9 */
--foreground-muted: oklch(0.6 0.03 230); /* #4c566a */
--card: oklch(0.27 0.02 240); /* #3b4252 */
--card-foreground: oklch(0.9 0.01 230);
--popover: oklch(0.25 0.02 240);
--popover-foreground: oklch(0.9 0.01 230);
--primary: oklch(0.7 0.12 220); /* #88c0d0 frost */
--primary-foreground: oklch(0.23 0.02 240);
--brand-400: oklch(0.75 0.12 220);
--brand-500: oklch(0.7 0.12 220); /* #88c0d0 */
--brand-600: oklch(0.65 0.14 220); /* #81a1c1 */
--secondary: oklch(0.31 0.02 240); /* #434c5e */
--secondary-foreground: oklch(0.9 0.01 230);
--muted: oklch(0.31 0.02 240);
--muted-foreground: oklch(0.55 0.03 230);
--accent: oklch(0.35 0.03 240); /* #4c566a */
--accent-foreground: oklch(0.9 0.01 230);
--destructive: oklch(0.65 0.2 15); /* #bf616a */
--border: oklch(0.35 0.03 240);
--border-glass: oklch(0.7 0.12 220 / 0.3);
--input: oklch(0.27 0.02 240);
--ring: oklch(0.7 0.12 220);
--chart-1: oklch(0.7 0.12 220); /* Frost blue */
--chart-2: oklch(0.65 0.14 220); /* #81a1c1 */
--chart-3: oklch(0.7 0.15 140); /* #a3be8c green */
--chart-4: oklch(0.7 0.2 320); /* #b48ead purple */
--chart-5: oklch(0.75 0.15 70); /* #ebcb8b yellow */
--sidebar: oklch(0.21 0.02 240);
--sidebar-foreground: oklch(0.9 0.01 230);
--sidebar-primary: oklch(0.7 0.12 220);
--sidebar-primary-foreground: oklch(0.23 0.02 240);
--sidebar-accent: oklch(0.31 0.02 240);
--sidebar-accent-foreground: oklch(0.9 0.01 230);
--sidebar-border: oklch(0.35 0.03 240);
--sidebar-ring: oklch(0.7 0.12 220);
/* Action button colors - Nord frost blue theme */
--action-view: oklch(0.7 0.12 220); /* Frost blue */
--action-view-hover: oklch(0.65 0.14 220);
--action-followup: oklch(0.65 0.14 220); /* Darker frost */
--action-followup-hover: oklch(0.6 0.16 220);
--action-commit: oklch(0.7 0.15 140); /* Green */
--action-commit-hover: oklch(0.65 0.17 140);
--action-verify: oklch(0.7 0.15 140); /* Green */
--action-verify-hover: oklch(0.65 0.17 140);
/* Running indicator - Frost blue */
--running-indicator: oklch(0.7 0.12 220);
--running-indicator-text: oklch(0.75 0.1 220);
}
/* ========================================
MONOKAI THEME
The classic Monokai color scheme
======================================== */
/* Theme-specific overrides */
.nord .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #88c0d0 0%, #81a1c1 50%, #88c0d0 100%);
}
.nord .animated-outline-inner {
background: oklch(0.23 0.02 240) !important;
color: #88c0d0 !important;
}
.nord [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.28 0.03 240) !important;
color: #8fbcbb !important;
}
.nord .slider-track {
background: oklch(0.31 0.02 240);
}
.nord .slider-range {
background: linear-gradient(to right, #88c0d0, #81a1c1);
}
.nord .slider-thumb {
background: oklch(0.27 0.02 240);
border-color: #88c0d0;
}
.nord .xml-highlight {
color: oklch(0.9 0.01 230); /* #eceff4 */
}
.nord .xml-tag-bracket {
color: oklch(0.65 0.14 220); /* #81a1c1 */
}
.nord .xml-tag-name {
color: oklch(0.65 0.14 220); /* Frost blue for tags */
}
.nord .xml-attribute-name {
color: oklch(0.7 0.12 220); /* #88c0d0 */
}
.nord .xml-attribute-equals {
color: oklch(0.75 0.02 230); /* Dim white */
}
.nord .xml-attribute-value {
color: oklch(0.7 0.15 140); /* #a3be8c green */
}
.nord .xml-comment {
color: oklch(0.5 0.04 230); /* Dim text */
font-style: italic;
}
.nord .xml-cdata {
color: oklch(0.7 0.12 220); /* Frost blue */
}
.nord .xml-doctype {
color: oklch(0.7 0.2 320); /* #b48ead purple */
}
.nord .xml-text {
color: oklch(0.9 0.01 230); /* Snow white */
}

View File

@@ -0,0 +1,144 @@
/* Onedark Theme */
.onedark {
--background: oklch(0.19 0.01 250); /* #282c34 */
--background-50: oklch(0.19 0.01 250 / 0.5);
--background-80: oklch(0.19 0.01 250 / 0.8);
--foreground: oklch(0.85 0.02 240); /* #abb2bf */
--foreground-secondary: oklch(0.7 0.02 240);
--foreground-muted: oklch(0.5 0.03 240); /* #5c6370 */
--card: oklch(0.23 0.01 250); /* #21252b */
--card-foreground: oklch(0.85 0.02 240);
--popover: oklch(0.21 0.01 250);
--popover-foreground: oklch(0.85 0.02 240);
--primary: oklch(0.7 0.18 230); /* #61afef blue */
--primary-foreground: oklch(0.19 0.01 250);
--brand-400: oklch(0.75 0.18 230);
--brand-500: oklch(0.7 0.18 230); /* Blue */
--brand-600: oklch(0.65 0.2 230);
--secondary: oklch(0.25 0.01 250);
--secondary-foreground: oklch(0.85 0.02 240);
--muted: oklch(0.25 0.01 250);
--muted-foreground: oklch(0.5 0.03 240);
--accent: oklch(0.28 0.02 250);
--accent-foreground: oklch(0.85 0.02 240);
--destructive: oklch(0.6 0.2 20); /* #e06c75 red */
--border: oklch(0.35 0.02 250);
--border-glass: oklch(0.7 0.18 230 / 0.3);
--input: oklch(0.23 0.01 250);
--ring: oklch(0.7 0.18 230);
--chart-1: oklch(0.7 0.18 230); /* Blue */
--chart-2: oklch(0.75 0.15 320); /* Magenta #c678dd */
--chart-3: oklch(0.75 0.18 150); /* Green #98c379 */
--chart-4: oklch(0.8 0.15 80); /* Yellow #e5c07b */
--chart-5: oklch(0.7 0.15 180); /* Cyan #56b6c2 */
--sidebar: oklch(0.17 0.01 250);
--sidebar-foreground: oklch(0.85 0.02 240);
--sidebar-primary: oklch(0.7 0.18 230);
--sidebar-primary-foreground: oklch(0.19 0.01 250);
--sidebar-accent: oklch(0.25 0.01 250);
--sidebar-accent-foreground: oklch(0.85 0.02 240);
--sidebar-border: oklch(0.35 0.02 250);
--sidebar-ring: oklch(0.7 0.18 230);
/* Action button colors - One Dark blue/magenta theme */
--action-view: oklch(0.7 0.18 230); /* Blue */
--action-view-hover: oklch(0.65 0.2 230);
--action-followup: oklch(0.75 0.15 320); /* Magenta */
--action-followup-hover: oklch(0.7 0.17 320);
--action-commit: oklch(0.75 0.18 150); /* Green */
--action-commit-hover: oklch(0.7 0.2 150);
--action-verify: oklch(0.75 0.18 150); /* Green */
--action-verify-hover: oklch(0.7 0.2 150);
/* Running indicator - Blue */
--running-indicator: oklch(0.7 0.18 230);
--running-indicator-text: oklch(0.75 0.16 230);
}
/* ========================================
SYNTHWAVE '84 THEME
Neon dreams of the 80s
======================================== */
/* Theme-specific overrides */
.onedark .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #61afef 0%, #c678dd 50%, #61afef 100%);
}
.onedark .animated-outline-inner {
background: oklch(0.19 0.01 250) !important;
color: #61afef !important;
}
.onedark [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.25 0.02 250) !important;
color: #c678dd !important;
}
.onedark .slider-track {
background: oklch(0.25 0.01 250);
}
.onedark .slider-range {
background: linear-gradient(to right, #61afef, #c678dd);
}
.onedark .slider-thumb {
background: oklch(0.23 0.01 250);
border-color: #61afef;
}
.onedark .xml-highlight {
color: oklch(0.85 0.02 240); /* #abb2bf */
}
.onedark .xml-tag-bracket {
color: oklch(0.6 0.2 20); /* #e06c75 red */
}
.onedark .xml-tag-name {
color: oklch(0.6 0.2 20); /* Red for tags */
}
.onedark .xml-attribute-name {
color: oklch(0.8 0.15 80); /* #e5c07b yellow */
}
.onedark .xml-attribute-equals {
color: oklch(0.7 0.02 240); /* Dim text */
}
.onedark .xml-attribute-value {
color: oklch(0.75 0.18 150); /* #98c379 green */
}
.onedark .xml-comment {
color: oklch(0.5 0.03 240); /* #5c6370 */
font-style: italic;
}
.onedark .xml-cdata {
color: oklch(0.7 0.15 180); /* #56b6c2 cyan */
}
.onedark .xml-doctype {
color: oklch(0.75 0.15 320); /* #c678dd magenta */
}
.onedark .xml-text {
color: oklch(0.85 0.02 240); /* Text */
}

View File

@@ -0,0 +1,70 @@
/* Red Theme */
.red {
--background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */
--background-50: oklch(0.12 0.03 15 / 0.5);
--background-80: oklch(0.12 0.03 15 / 0.8);
--foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */
--foreground-secondary: oklch(0.7 0.02 15);
--foreground-muted: oklch(0.5 0.03 15);
--card: oklch(0.18 0.04 15); /* Slightly lighter dark red */
--card-foreground: oklch(0.95 0.01 15);
--popover: oklch(0.15 0.035 15);
--popover-foreground: oklch(0.95 0.01 15);
--primary: oklch(0.55 0.25 25); /* Vibrant crimson red */
--primary-foreground: oklch(0.98 0 0);
--brand-400: oklch(0.6 0.23 25);
--brand-500: oklch(0.55 0.25 25); /* Crimson */
--brand-600: oklch(0.5 0.27 25);
--secondary: oklch(0.22 0.05 15);
--secondary-foreground: oklch(0.95 0.01 15);
--muted: oklch(0.22 0.05 15);
--muted-foreground: oklch(0.5 0.03 15);
--accent: oklch(0.28 0.06 15);
--accent-foreground: oklch(0.95 0.01 15);
--destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */
--border: oklch(0.35 0.08 15);
--border-glass: oklch(0.55 0.25 25 / 0.3);
--input: oklch(0.18 0.04 15);
--ring: oklch(0.55 0.25 25);
--chart-1: oklch(0.55 0.25 25); /* Crimson */
--chart-2: oklch(0.7 0.2 50); /* Orange */
--chart-3: oklch(0.8 0.18 80); /* Gold */
--chart-4: oklch(0.6 0.22 0); /* Pure red */
--chart-5: oklch(0.65 0.2 350); /* Pink-red */
--sidebar: oklch(0.1 0.025 15);
--sidebar-foreground: oklch(0.95 0.01 15);
--sidebar-primary: oklch(0.55 0.25 25);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.22 0.05 15);
--sidebar-accent-foreground: oklch(0.95 0.01 15);
--sidebar-border: oklch(0.35 0.08 15);
--sidebar-ring: oklch(0.55 0.25 25);
/* Action button colors - Red theme */
--action-view: oklch(0.55 0.25 25); /* Crimson */
--action-view-hover: oklch(0.5 0.27 25);
--action-followup: oklch(0.7 0.2 50); /* Orange */
--action-followup-hover: oklch(0.65 0.22 50);
--action-commit: oklch(0.6 0.2 140); /* Green for positive actions */
--action-commit-hover: oklch(0.55 0.22 140);
--action-verify: oklch(0.6 0.2 140); /* Green */
--action-verify-hover: oklch(0.55 0.22 140);
/* Running indicator - Crimson */
--running-indicator: oklch(0.55 0.25 25);
--running-indicator-text: oklch(0.6 0.23 25);
}

View File

@@ -0,0 +1,227 @@
/* Retro Theme */
.retro {
/* Retro / Cyberpunk Theme */
--background: oklch(0 0 0); /* Pure Black */
--background-50: oklch(0 0 0 / 0.5);
--background-80: oklch(0 0 0 / 0.8);
/* Neon Green Text */
--foreground: oklch(0.85 0.25 145); /* Neon Green */
--foreground-secondary: oklch(0.7 0.2 145);
--foreground-muted: oklch(0.5 0.15 145);
/* Hard Edges */
--radius: 0px;
/* UI Elements */
--card: oklch(0 0 0); /* Black card */
--card-foreground: oklch(0.85 0.25 145);
--popover: oklch(0.05 0.05 145);
--popover-foreground: oklch(0.85 0.25 145);
--primary: oklch(0.85 0.25 145); /* Neon Green */
--primary-foreground: oklch(0 0 0); /* Black text on green */
--brand-400: oklch(0.85 0.25 145);
--brand-500: oklch(0.85 0.25 145);
--brand-600: oklch(0.75 0.25 145);
--secondary: oklch(0.1 0.1 145); /* Dark Green bg */
--secondary-foreground: oklch(0.85 0.25 145);
--muted: oklch(0.1 0.05 145);
--muted-foreground: oklch(0.5 0.15 145);
--accent: oklch(0.2 0.2 145); /* Brighter green accent */
--accent-foreground: oklch(0.85 0.25 145);
--destructive: oklch(0.6 0.25 25); /* Keep red for destructive */
--border: oklch(0.3 0.15 145); /* Visible Green Border */
--border-glass: oklch(0.85 0.25 145 / 0.3);
--input: oklch(0.1 0.1 145);
--ring: oklch(0.85 0.25 145);
/* Charts - various neons */
--chart-1: oklch(0.85 0.25 145); /* Green */
--chart-2: oklch(0.8 0.25 300); /* Purple Neon */
--chart-3: oklch(0.8 0.25 200); /* Cyan Neon */
--chart-4: oklch(0.8 0.25 60); /* Yellow Neon */
--chart-5: oklch(0.8 0.25 20); /* Red Neon */
/* Sidebar */
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(0.85 0.25 145);
--sidebar-primary: oklch(0.85 0.25 145);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.1 0.1 145);
--sidebar-accent-foreground: oklch(0.85 0.25 145);
--sidebar-border: oklch(0.3 0.15 145);
--sidebar-ring: oklch(0.85 0.25 145);
/* Fonts */
--font-sans: var(--font-geist-mono); /* Force Mono everywhere */
/* Action button colors - All green neon for retro theme */
--action-view: oklch(0.85 0.25 145); /* Neon Green */
--action-view-hover: oklch(0.9 0.25 145);
--action-followup: oklch(0.85 0.25 145); /* Neon Green */
--action-followup-hover: oklch(0.9 0.25 145);
--action-commit: oklch(0.85 0.25 145); /* Neon Green */
--action-commit-hover: oklch(0.9 0.25 145);
--action-verify: oklch(0.85 0.25 145); /* Neon Green */
--action-verify-hover: oklch(0.9 0.25 145);
/* Running indicator - Neon Green for retro */
--running-indicator: oklch(0.85 0.25 145);
--running-indicator-text: oklch(0.85 0.25 145);
}
/* ========================================
DRACULA THEME
Inspired by the popular Dracula VS Code theme
======================================== */
/* Theme-specific overrides */
.retro .scrollbar-visible::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 0;
}
.retro .scrollbar-visible::-webkit-scrollbar-track {
background: var(--background);
border-radius: 0;
}
.retro .scrollbar-styled::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 0;
}
.retro .scrollbar-styled::-webkit-scrollbar-track {
background: var(--background);
border-radius: 0;
}
.retro .glass,
.retro .glass-subtle,
.retro .glass-strong,
.retro .bg-glass,
.retro .bg-glass-80 {
backdrop-filter: none;
background: var(--background);
border: 1px solid var(--border);
}
.retro .gradient-brand {
background: var(--primary);
color: var(--primary-foreground);
}
.retro .content-bg {
background:
linear-gradient(rgba(0, 255, 65, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 65, 0.03) 1px, transparent 1px),
var(--background);
background-size: 20px 20px;
}
.retro .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #00ff41 0%, #00ffff 25%, #ff00ff 50%, #00ffff 75%, #00ff41 100%);
animation: spin 2s linear infinite, retro-glow 1s ease-in-out infinite alternate;
}
.retro [data-slot="button"][class*="animated-outline"] {
border-radius: 0 !important;
}
.retro .animated-outline-inner {
background: oklch(0 0 0) !important;
color: #00ff41 !important;
border-radius: 0 !important;
text-shadow: 0 0 5px #00ff41;
font-family: var(--font-geist-mono), monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.retro [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.1 0.1 145) !important;
color: #00ff41 !important;
box-shadow:
0 0 10px #00ff41,
0 0 20px #00ff41,
inset 0 0 10px rgba(0, 255, 65, 0.1);
text-shadow: 0 0 10px #00ff41, 0 0 20px #00ff41;
}
.retro .slider-track {
background: oklch(0.15 0.05 145);
border: 1px solid #00ff41;
border-radius: 0 !important;
}
.retro .slider-range {
background: #00ff41;
box-shadow: 0 0 10px #00ff41, 0 0 5px #00ff41;
border-radius: 0 !important;
}
.retro .slider-thumb {
background: oklch(0 0 0);
border: 2px solid #00ff41;
border-radius: 0 !important;
box-shadow: 0 0 8px #00ff41;
}
.retro .slider-thumb:hover {
background: oklch(0.1 0.1 145);
box-shadow: 0 0 12px #00ff41, 0 0 20px #00ff41;
}
.retro .xml-highlight {
color: oklch(0.85 0.25 145); /* Neon green default */
}
.retro .xml-tag-bracket {
color: oklch(0.8 0.25 200); /* Cyan for brackets */
}
.retro .xml-tag-name {
color: oklch(0.85 0.25 145); /* Bright green for tags */
text-shadow: 0 0 5px oklch(0.85 0.25 145 / 0.5);
}
.retro .xml-attribute-name {
color: oklch(0.8 0.25 300); /* Purple neon for attrs */
}
.retro .xml-attribute-equals {
color: oklch(0.6 0.15 145); /* Dim green for = */
}
.retro .xml-attribute-value {
color: oklch(0.8 0.25 60); /* Yellow neon for strings */
}
.retro .xml-comment {
color: oklch(0.5 0.15 145); /* Dim green for comments */
font-style: italic;
}
.retro .xml-cdata {
color: oklch(0.75 0.2 200); /* Cyan for CDATA */
}
.retro .xml-doctype {
color: oklch(0.75 0.2 300); /* Purple for DOCTYPE */
}
.retro .xml-text {
color: oklch(0.7 0.2 145); /* Green text */
}

View File

@@ -0,0 +1,144 @@
/* Solarized Theme */
.solarized {
--background: oklch(0.2 0.02 230); /* #002b36 base03 */
--background-50: oklch(0.2 0.02 230 / 0.5);
--background-80: oklch(0.2 0.02 230 / 0.8);
--foreground: oklch(0.75 0.02 90); /* #839496 base0 */
--foreground-secondary: oklch(0.6 0.03 200); /* #657b83 base00 */
--foreground-muted: oklch(0.5 0.04 200); /* #586e75 base01 */
--card: oklch(0.23 0.02 230); /* #073642 base02 */
--card-foreground: oklch(0.75 0.02 90);
--popover: oklch(0.22 0.02 230);
--popover-foreground: oklch(0.75 0.02 90);
--primary: oklch(0.65 0.15 220); /* #268bd2 blue */
--primary-foreground: oklch(0.2 0.02 230);
--brand-400: oklch(0.7 0.15 220);
--brand-500: oklch(0.65 0.15 220); /* #268bd2 */
--brand-600: oklch(0.6 0.17 220);
--secondary: oklch(0.25 0.02 230);
--secondary-foreground: oklch(0.75 0.02 90);
--muted: oklch(0.25 0.02 230);
--muted-foreground: oklch(0.5 0.04 200);
--accent: oklch(0.28 0.03 230);
--accent-foreground: oklch(0.75 0.02 90);
--destructive: oklch(0.55 0.2 25); /* #dc322f red */
--border: oklch(0.35 0.03 230);
--border-glass: oklch(0.65 0.15 220 / 0.3);
--input: oklch(0.23 0.02 230);
--ring: oklch(0.65 0.15 220);
--chart-1: oklch(0.65 0.15 220); /* Blue */
--chart-2: oklch(0.6 0.18 180); /* Cyan #2aa198 */
--chart-3: oklch(0.65 0.2 140); /* Green #859900 */
--chart-4: oklch(0.7 0.18 55); /* Yellow #b58900 */
--chart-5: oklch(0.6 0.2 30); /* Orange #cb4b16 */
--sidebar: oklch(0.18 0.02 230);
--sidebar-foreground: oklch(0.75 0.02 90);
--sidebar-primary: oklch(0.65 0.15 220);
--sidebar-primary-foreground: oklch(0.2 0.02 230);
--sidebar-accent: oklch(0.25 0.02 230);
--sidebar-accent-foreground: oklch(0.75 0.02 90);
--sidebar-border: oklch(0.35 0.03 230);
--sidebar-ring: oklch(0.65 0.15 220);
/* Action button colors - Solarized blue/cyan theme */
--action-view: oklch(0.65 0.15 220); /* Blue */
--action-view-hover: oklch(0.6 0.17 220);
--action-followup: oklch(0.6 0.18 180); /* Cyan */
--action-followup-hover: oklch(0.55 0.2 180);
--action-commit: oklch(0.65 0.2 140); /* Green */
--action-commit-hover: oklch(0.6 0.22 140);
--action-verify: oklch(0.65 0.2 140); /* Green */
--action-verify-hover: oklch(0.6 0.22 140);
/* Running indicator - Blue */
--running-indicator: oklch(0.65 0.15 220);
--running-indicator-text: oklch(0.7 0.13 220);
}
/* ========================================
GRUVBOX THEME
Retro groove color scheme
======================================== */
/* Theme-specific overrides */
.solarized .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #268bd2 0%, #2aa198 50%, #268bd2 100%);
}
.solarized .animated-outline-inner {
background: oklch(0.2 0.02 230) !important;
color: #268bd2 !important;
}
.solarized [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.25 0.03 230) !important;
color: #2aa198 !important;
}
.solarized .slider-track {
background: oklch(0.25 0.02 230);
}
.solarized .slider-range {
background: linear-gradient(to right, #268bd2, #2aa198);
}
.solarized .slider-thumb {
background: oklch(0.23 0.02 230);
border-color: #268bd2;
}
.solarized .xml-highlight {
color: oklch(0.75 0.02 90); /* #839496 */
}
.solarized .xml-tag-bracket {
color: oklch(0.65 0.15 220); /* #268bd2 blue */
}
.solarized .xml-tag-name {
color: oklch(0.65 0.15 220); /* Blue for tags */
}
.solarized .xml-attribute-name {
color: oklch(0.6 0.18 180); /* #2aa198 cyan */
}
.solarized .xml-attribute-equals {
color: oklch(0.75 0.02 90); /* Base text */
}
.solarized .xml-attribute-value {
color: oklch(0.65 0.2 140); /* #859900 green */
}
.solarized .xml-comment {
color: oklch(0.5 0.04 200); /* #586e75 */
font-style: italic;
}
.solarized .xml-cdata {
color: oklch(0.6 0.18 180); /* Cyan */
}
.solarized .xml-doctype {
color: oklch(0.6 0.2 290); /* #6c71c4 violet */
}
.solarized .xml-text {
color: oklch(0.75 0.02 90); /* Base text */
}

View File

@@ -0,0 +1,111 @@
/* Sunset Theme */
.sunset {
/* Sunset Theme - Mellow oranges and soft purples */
--background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */
--background-50: oklch(0.15 0.02 280 / 0.5);
--background-80: oklch(0.15 0.02 280 / 0.8);
--foreground: oklch(0.95 0.01 80); /* Warm white */
--foreground-secondary: oklch(0.75 0.02 60);
--foreground-muted: oklch(0.6 0.02 60);
--card: oklch(0.2 0.025 280);
--card-foreground: oklch(0.95 0.01 80);
--popover: oklch(0.18 0.02 280);
--popover-foreground: oklch(0.95 0.01 80);
--primary: oklch(0.68 0.18 45); /* Mellow sunset orange */
--primary-foreground: oklch(0.15 0.02 280);
--brand-400: oklch(0.72 0.17 45);
--brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */
--brand-600: oklch(0.64 0.19 42);
--secondary: oklch(0.25 0.03 280);
--secondary-foreground: oklch(0.95 0.01 80);
--muted: oklch(0.27 0.03 280);
--muted-foreground: oklch(0.6 0.02 60);
--accent: oklch(0.35 0.04 310);
--accent-foreground: oklch(0.95 0.01 80);
--destructive: oklch(0.6 0.2 25); /* Muted red */
--border: oklch(0.32 0.04 280);
--border-glass: oklch(0.68 0.18 45 / 0.3);
--input: oklch(0.2 0.025 280);
--ring: oklch(0.68 0.18 45);
--chart-1: oklch(0.68 0.18 45); /* Mellow orange */
--chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */
--chart-3: oklch(0.78 0.18 70); /* Soft golden */
--chart-4: oklch(0.66 0.19 42); /* Subtle coral */
--chart-5: oklch(0.72 0.14 310); /* Pastel purple */
--sidebar: oklch(0.13 0.015 280);
--sidebar-foreground: oklch(0.95 0.01 80);
--sidebar-primary: oklch(0.68 0.18 45);
--sidebar-primary-foreground: oklch(0.15 0.02 280);
--sidebar-accent: oklch(0.25 0.03 280);
--sidebar-accent-foreground: oklch(0.95 0.01 80);
--sidebar-border: oklch(0.32 0.04 280);
--sidebar-ring: oklch(0.68 0.18 45);
/* Action button colors - Mellow sunset palette */
--action-view: oklch(0.68 0.18 45); /* Mellow orange */
--action-view-hover: oklch(0.64 0.19 42);
--action-followup: oklch(0.75 0.16 340); /* Soft pink */
--action-followup-hover: oklch(0.7 0.17 340);
--action-commit: oklch(0.65 0.16 140); /* Soft green */
--action-commit-hover: oklch(0.6 0.17 140);
--action-verify: oklch(0.65 0.16 140); /* Soft green */
--action-verify-hover: oklch(0.6 0.17 140);
/* Running indicator - Mellow orange */
--running-indicator: oklch(0.68 0.18 45);
--running-indicator-text: oklch(0.72 0.17 45);
/* Status colors - Sunset theme */
--status-success: oklch(0.65 0.16 140);
--status-success-bg: oklch(0.65 0.16 140 / 0.2);
--status-warning: oklch(0.78 0.18 70);
--status-warning-bg: oklch(0.78 0.18 70 / 0.2);
--status-error: oklch(0.65 0.2 25);
--status-error-bg: oklch(0.65 0.2 25 / 0.2);
--status-info: oklch(0.75 0.16 340);
--status-info-bg: oklch(0.75 0.16 340 / 0.2);
--status-backlog: oklch(0.65 0.02 280);
--status-in-progress: oklch(0.78 0.18 70);
--status-waiting: oklch(0.72 0.17 60);
}
/* Theme-specific overrides */
/* Sunset theme scrollbar */
.sunset ::-webkit-scrollbar-thumb,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb {
background: oklch(0.5 0.14 45);
border-radius: 4px;
}
.sunset ::-webkit-scrollbar-thumb:hover,
.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: oklch(0.58 0.16 45);
}
.sunset ::-webkit-scrollbar-track,
.sunset .scrollbar-visible::-webkit-scrollbar-track {
background: oklch(0.18 0.03 280);
}
.sunset .scrollbar-styled::-webkit-scrollbar-thumb {
background: oklch(0.5 0.14 45);
}
.sunset .scrollbar-styled::-webkit-scrollbar-thumb:hover {
background: oklch(0.58 0.16 45);
}

View File

@@ -0,0 +1,149 @@
/* Synthwave Theme */
.synthwave {
--background: oklch(0.15 0.05 290); /* #262335 */
--background-50: oklch(0.15 0.05 290 / 0.5);
--background-80: oklch(0.15 0.05 290 / 0.8);
--foreground: oklch(0.95 0.02 320); /* #ffffff with warm tint */
--foreground-secondary: oklch(0.75 0.05 320);
--foreground-muted: oklch(0.55 0.08 290);
--card: oklch(0.2 0.06 290); /* #34294f */
--card-foreground: oklch(0.95 0.02 320);
--popover: oklch(0.18 0.05 290);
--popover-foreground: oklch(0.95 0.02 320);
--primary: oklch(0.7 0.28 350); /* #f97e72 hot pink */
--primary-foreground: oklch(0.15 0.05 290);
--brand-400: oklch(0.75 0.28 350);
--brand-500: oklch(0.7 0.28 350); /* Hot pink */
--brand-600: oklch(0.65 0.3 350);
--secondary: oklch(0.25 0.07 290);
--secondary-foreground: oklch(0.95 0.02 320);
--muted: oklch(0.25 0.07 290);
--muted-foreground: oklch(0.55 0.08 290);
--accent: oklch(0.3 0.08 290);
--accent-foreground: oklch(0.95 0.02 320);
--destructive: oklch(0.6 0.25 15);
--border: oklch(0.4 0.1 290);
--border-glass: oklch(0.7 0.28 350 / 0.3);
--input: oklch(0.2 0.06 290);
--ring: oklch(0.7 0.28 350);
--chart-1: oklch(0.7 0.28 350); /* Hot pink */
--chart-2: oklch(0.8 0.25 200); /* Cyan #72f1b8 */
--chart-3: oklch(0.85 0.2 60); /* Yellow #fede5d */
--chart-4: oklch(0.7 0.25 280); /* Purple #ff7edb */
--chart-5: oklch(0.7 0.2 30); /* Orange #f97e72 */
--sidebar: oklch(0.13 0.05 290);
--sidebar-foreground: oklch(0.95 0.02 320);
--sidebar-primary: oklch(0.7 0.28 350);
--sidebar-primary-foreground: oklch(0.15 0.05 290);
--sidebar-accent: oklch(0.25 0.07 290);
--sidebar-accent-foreground: oklch(0.95 0.02 320);
--sidebar-border: oklch(0.4 0.1 290);
--sidebar-ring: oklch(0.7 0.28 350);
/* Action button colors - Synthwave hot pink/cyan theme */
--action-view: oklch(0.7 0.28 350); /* Hot pink */
--action-view-hover: oklch(0.65 0.3 350);
--action-followup: oklch(0.8 0.25 200); /* Cyan */
--action-followup-hover: oklch(0.75 0.27 200);
--action-commit: oklch(0.85 0.2 60); /* Yellow */
--action-commit-hover: oklch(0.8 0.22 60);
--action-verify: oklch(0.85 0.2 60); /* Yellow */
--action-verify-hover: oklch(0.8 0.22 60);
/* Running indicator - Hot pink */
--running-indicator: oklch(0.7 0.28 350);
--running-indicator-text: oklch(0.75 0.26 350);
}
/* Red Theme - Bold crimson/red aesthetic */
/* Theme-specific overrides */
.synthwave .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #f97e72 0%, #72f1b8 25%, #ff7edb 50%, #72f1b8 75%, #f97e72 100%);
animation: spin 2s linear infinite, synthwave-glow 1.5s ease-in-out infinite alternate;
}
.synthwave .animated-outline-inner {
background: oklch(0.15 0.05 290) !important;
color: #f97e72 !important;
text-shadow: 0 0 8px #f97e72;
}
.synthwave [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.22 0.07 290) !important;
color: #72f1b8 !important;
text-shadow: 0 0 12px #72f1b8;
box-shadow: 0 0 15px rgba(114, 241, 184, 0.3);
}
.synthwave .slider-track {
background: oklch(0.25 0.07 290);
}
.synthwave .slider-range {
background: linear-gradient(to right, #f97e72, #ff7edb);
box-shadow: 0 0 10px #f97e72, 0 0 5px #ff7edb;
}
.synthwave .slider-thumb {
background: oklch(0.2 0.06 290);
border-color: #f97e72;
box-shadow: 0 0 8px #f97e72;
}
.synthwave .xml-highlight {
color: oklch(0.95 0.02 320); /* Warm white */
}
.synthwave .xml-tag-bracket {
color: oklch(0.7 0.28 350); /* #f97e72 hot pink */
}
.synthwave .xml-tag-name {
color: oklch(0.7 0.28 350); /* Hot pink */
text-shadow: 0 0 8px oklch(0.7 0.28 350 / 0.5);
}
.synthwave .xml-attribute-name {
color: oklch(0.7 0.25 280); /* #ff7edb purple */
}
.synthwave .xml-attribute-equals {
color: oklch(0.8 0.02 320); /* White-ish */
}
.synthwave .xml-attribute-value {
color: oklch(0.85 0.2 60); /* #fede5d yellow */
text-shadow: 0 0 5px oklch(0.85 0.2 60 / 0.3);
}
.synthwave .xml-comment {
color: oklch(0.55 0.08 290); /* Dim purple */
font-style: italic;
}
.synthwave .xml-cdata {
color: oklch(0.8 0.25 200); /* #72f1b8 cyan */
}
.synthwave .xml-doctype {
color: oklch(0.8 0.25 200); /* Cyan */
}
.synthwave .xml-text {
color: oklch(0.95 0.02 320); /* White */
}

View File

@@ -0,0 +1,144 @@
/* Tokyonight Theme */
.tokyonight {
--background: oklch(0.16 0.03 260); /* #1a1b26 */
--background-50: oklch(0.16 0.03 260 / 0.5);
--background-80: oklch(0.16 0.03 260 / 0.8);
--foreground: oklch(0.85 0.02 250); /* #a9b1d6 */
--foreground-secondary: oklch(0.7 0.03 250);
--foreground-muted: oklch(0.5 0.04 250); /* #565f89 */
--card: oklch(0.2 0.03 260); /* #24283b */
--card-foreground: oklch(0.85 0.02 250);
--popover: oklch(0.18 0.03 260);
--popover-foreground: oklch(0.85 0.02 250);
--primary: oklch(0.7 0.18 280); /* #7aa2f7 blue */
--primary-foreground: oklch(0.16 0.03 260);
--brand-400: oklch(0.75 0.18 280);
--brand-500: oklch(0.7 0.18 280); /* #7aa2f7 */
--brand-600: oklch(0.65 0.2 280); /* #7dcfff */
--secondary: oklch(0.24 0.03 260); /* #292e42 */
--secondary-foreground: oklch(0.85 0.02 250);
--muted: oklch(0.24 0.03 260);
--muted-foreground: oklch(0.5 0.04 250);
--accent: oklch(0.28 0.04 260);
--accent-foreground: oklch(0.85 0.02 250);
--destructive: oklch(0.65 0.2 15); /* #f7768e */
--border: oklch(0.32 0.04 260);
--border-glass: oklch(0.7 0.18 280 / 0.3);
--input: oklch(0.2 0.03 260);
--ring: oklch(0.7 0.18 280);
--chart-1: oklch(0.7 0.18 280); /* Blue #7aa2f7 */
--chart-2: oklch(0.75 0.18 200); /* Cyan #7dcfff */
--chart-3: oklch(0.75 0.18 140); /* Green #9ece6a */
--chart-4: oklch(0.7 0.2 320); /* Magenta #bb9af7 */
--chart-5: oklch(0.8 0.18 70); /* Yellow #e0af68 */
--sidebar: oklch(0.14 0.03 260);
--sidebar-foreground: oklch(0.85 0.02 250);
--sidebar-primary: oklch(0.7 0.18 280);
--sidebar-primary-foreground: oklch(0.16 0.03 260);
--sidebar-accent: oklch(0.24 0.03 260);
--sidebar-accent-foreground: oklch(0.85 0.02 250);
--sidebar-border: oklch(0.32 0.04 260);
--sidebar-ring: oklch(0.7 0.18 280);
/* Action button colors - Tokyo Night blue/magenta theme */
--action-view: oklch(0.7 0.18 280); /* Blue */
--action-view-hover: oklch(0.65 0.2 280);
--action-followup: oklch(0.75 0.18 200); /* Cyan */
--action-followup-hover: oklch(0.7 0.2 200);
--action-commit: oklch(0.75 0.18 140); /* Green */
--action-commit-hover: oklch(0.7 0.2 140);
--action-verify: oklch(0.75 0.18 140); /* Green */
--action-verify-hover: oklch(0.7 0.2 140);
/* Running indicator - Blue */
--running-indicator: oklch(0.7 0.18 280);
--running-indicator-text: oklch(0.75 0.16 280);
}
/* ========================================
SOLARIZED DARK THEME
The classic color scheme by Ethan Schoonover
======================================== */
/* Theme-specific overrides */
.tokyonight .animated-outline-gradient {
background: conic-gradient(from 90deg at 50% 50%, #7aa2f7 0%, #bb9af7 50%, #7aa2f7 100%);
}
.tokyonight .animated-outline-inner {
background: oklch(0.16 0.03 260) !important;
color: #7aa2f7 !important;
}
.tokyonight [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
background: oklch(0.22 0.04 260) !important;
color: #bb9af7 !important;
}
.tokyonight .slider-track {
background: oklch(0.24 0.03 260);
}
.tokyonight .slider-range {
background: linear-gradient(to right, #7aa2f7, #bb9af7);
}
.tokyonight .slider-thumb {
background: oklch(0.2 0.03 260);
border-color: #7aa2f7;
}
.tokyonight .xml-highlight {
color: oklch(0.85 0.02 250); /* #a9b1d6 */
}
.tokyonight .xml-tag-bracket {
color: oklch(0.65 0.2 15); /* #f7768e red */
}
.tokyonight .xml-tag-name {
color: oklch(0.65 0.2 15); /* Red for tags */
}
.tokyonight .xml-attribute-name {
color: oklch(0.7 0.2 320); /* #bb9af7 purple */
}
.tokyonight .xml-attribute-equals {
color: oklch(0.75 0.02 250); /* Dim text */
}
.tokyonight .xml-attribute-value {
color: oklch(0.75 0.18 140); /* #9ece6a green */
}
.tokyonight .xml-comment {
color: oklch(0.5 0.04 250); /* #565f89 */
font-style: italic;
}
.tokyonight .xml-cdata {
color: oklch(0.75 0.18 200); /* #7dcfff cyan */
}
.tokyonight .xml-doctype {
color: oklch(0.7 0.18 280); /* #7aa2f7 blue */
}
.tokyonight .xml-text {
color: oklch(0.85 0.02 250); /* Text color */
}

View File

@@ -667,6 +667,13 @@ export interface WorktreeAPI {
hasWorktree: boolean; // Does this branch have an active worktree?
hasChanges?: boolean;
changedFilesCount?: number;
pr?: {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
};
}>;
removedWorktrees?: Array<{
path: string;
@@ -737,6 +744,7 @@ export interface WorktreeAPI {
createPR: (
worktreePath: string,
options?: {
projectPath?: string;
commitMessage?: string;
prTitle?: string;
prBody?: string;
@@ -751,7 +759,9 @@ export interface WorktreeAPI {
commitHash?: string;
pushed: boolean;
prUrl?: string;
prNumber?: number;
prCreated: boolean;
prAlreadyExisted?: boolean;
prError?: string;
browserUrl?: string;
ghCliAvailable?: boolean;
@@ -894,6 +904,44 @@ export interface WorktreeAPI {
};
error?: string;
}>;
// Get PR info and comments for a branch
getPRInfo: (
worktreePath: string,
branchName: string
) => Promise<{
success: boolean;
result?: {
hasPR: boolean;
ghCliAvailable: boolean;
prInfo?: {
number: number;
title: string;
url: string;
state: string;
author: string;
body: string;
comments: Array<{
id: number;
author: string;
body: string;
createdAt: string;
isReviewComment: boolean;
}>;
reviewComments: Array<{
id: number;
author: string;
body: string;
path?: string;
line?: number;
createdAt: string;
isReviewComment: boolean;
}>;
};
error?: string;
};
error?: string;
}>;
}
export interface GitAPI {