Merge branch 'v0.11.0rc' into fix/openrouter-models-kanban

This commit is contained in:
Soham Dasgupta
2026-01-15 00:01:00 +05:30
14 changed files with 617 additions and 68 deletions

View File

@@ -11,6 +11,7 @@ import { useSetupStore } from '@/store/setup-store';
const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
TRUST_PROMPT: 'TRUST_PROMPT',
UNKNOWN: 'UNKNOWN',
} as const;
@@ -55,8 +56,12 @@ export function ClaudeUsagePopover() {
}
const data = await api.claude.getUsage();
if ('error' in data) {
// Detect trust prompt error
const isTrustPrompt =
data.error === 'Trust prompt pending' ||
(data.message && data.message.includes('folder permission'));
setError({
code: ERROR_CODES.AUTH_ERROR,
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
@@ -257,6 +262,11 @@ export function ClaudeUsagePopover() {
<p className="text-xs text-muted-foreground">
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : error.code === ERROR_CODES.TRUST_PROMPT ? (
<>
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in your
terminal and approve access to continue
</>
) : (
<>
Make sure Claude CLI is installed and authenticated via{' '}

View File

@@ -1,9 +1,105 @@
import { useEffect, useRef, useState } from 'react';
import { Edit2, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState, memo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { type ThemeMode, useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
import { useThemePreview } from '@/components/layout/sidebar/hooks';
// Constants for z-index values
const Z_INDEX = {
CONTEXT_MENU: 100,
THEME_SUBMENU: 101,
} as const;
// Theme option type - using ThemeMode for type safety
interface ThemeOption {
value: ThemeMode;
label: string;
icon: LucideIcon;
color: string;
}
// Reusable theme button component to avoid duplication (DRY principle)
interface ThemeButtonProps {
option: ThemeOption;
isSelected: boolean;
onPointerEnter: () => void;
onPointerLeave: (e: React.PointerEvent) => void;
onClick: () => void;
}
const ThemeButton = memo(function ThemeButton({
option,
isSelected,
onPointerEnter,
onPointerLeave,
onClick,
}: ThemeButtonProps) {
const Icon = option.icon;
return (
<button
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onClick={onClick}
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md',
'text-xs text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
isSelected && 'bg-accent'
)}
data-testid={`project-theme-${option.value}`}
>
<Icon className="w-3.5 h-3.5" style={{ color: option.color }} />
<span>{option.label}</span>
</button>
);
});
// Reusable theme column component
interface ThemeColumnProps {
title: string;
icon: LucideIcon;
themes: ThemeOption[];
selectedTheme: ThemeMode | null;
onPreviewEnter: (value: ThemeMode) => void;
onPreviewLeave: (e: React.PointerEvent) => void;
onSelect: (value: ThemeMode) => void;
}
const ThemeColumn = memo(function ThemeColumn({
title,
icon: Icon,
themes,
selectedTheme,
onPreviewEnter,
onPreviewLeave,
onSelect,
}: ThemeColumnProps) {
return (
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<Icon className="w-3 h-3" />
{title}
</div>
<div className="space-y-0.5">
{themes.map((option) => (
<ThemeButton
key={option.value}
option={option}
isSelected={selectedTheme === option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
onClick={() => onSelect(option.value)}
/>
))}
</div>
</div>
);
});
interface ProjectContextMenuProps {
project: Project;
@@ -19,18 +115,30 @@ export function ProjectContextMenu({
onEdit,
}: ProjectContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const { moveProjectToTrash } = useAppStore();
const {
moveProjectToTrash,
theme: globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setPreviewTheme(null);
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setPreviewTheme(null);
onClose();
}
};
@@ -42,7 +150,7 @@ export function ProjectContextMenu({
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
}, [onClose, setPreviewTheme]);
const handleEdit = () => {
onEdit(project);
@@ -52,6 +160,17 @@ export function ProjectContextMenu({
setShowRemoveDialog(true);
};
const handleThemeSelect = (value: ThemeMode | '') => {
setPreviewTheme(null);
if (value !== '') {
setTheme(value);
} else {
setTheme(globalTheme);
}
setProjectTheme(project.id, value === '' ? null : value);
setShowThemeSubmenu(false);
};
const handleConfirmRemove = () => {
moveProjectToTrash(project.id);
onClose();
@@ -62,7 +181,7 @@ export function ProjectContextMenu({
<div
ref={menuRef}
className={cn(
'fixed z-[100] min-w-48 rounded-lg',
'fixed min-w-48 rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
@@ -70,6 +189,7 @@ export function ProjectContextMenu({
style={{
top: position.y,
left: position.x,
zIndex: Z_INDEX.CONTEXT_MENU,
}}
data-testid="project-context-menu"
>
@@ -88,6 +208,98 @@ export function ProjectContextMenu({
<span>Edit Name & Icon</span>
</button>
{/* Theme Submenu Trigger */}
<div
className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
>
<button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="theme-project-button"
>
<Palette className="w-4 h-4" />
<span className="flex-1">Project Theme</span>
{project.theme && (
<span className="text-[10px] text-muted-foreground capitalize">
{project.theme}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{/* Theme Submenu */}
{showThemeSubmenu && (
<div
ref={themeSubmenuRef}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
data-testid="project-theme-submenu"
>
<div className="p-2">
{/* Use Global Option */}
<button
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect('')}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent',
!project.theme && 'bg-accent'
)}
data-testid="project-theme-global"
>
<Monitor className="w-4 h-4" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</button>
<div className="h-px bg-border my-2" />
{/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2">
<ThemeColumn
title="Dark"
icon={Moon}
themes={PROJECT_DARK_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
<ThemeColumn
title="Light"
icon={Sun}
themes={PROJECT_LIGHT_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
</div>
</div>
</div>
)}
</div>
<button
onClick={handleRemove}
className={cn(

View File

@@ -14,6 +14,7 @@ const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
NOT_AVAILABLE: 'NOT_AVAILABLE',
TRUST_PROMPT: 'TRUST_PROMPT',
UNKNOWN: 'UNKNOWN',
} as const;
@@ -108,8 +109,12 @@ export function UsagePopover() {
}
const data = await api.claude.getUsage();
if ('error' in data) {
// Detect trust prompt error
const isTrustPrompt =
data.error === 'Trust prompt pending' ||
(data.message && data.message.includes('folder permission'));
setClaudeError({
code: ERROR_CODES.AUTH_ERROR,
code: isTrustPrompt ? ERROR_CODES.TRUST_PROMPT : ERROR_CODES.AUTH_ERROR,
message: data.message || data.error,
});
return;
@@ -404,6 +409,11 @@ export function UsagePopover() {
<p className="text-xs text-muted-foreground">
{claudeError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
'Ensure the Electron bridge is running or restart the app'
) : claudeError.code === ERROR_CODES.TRUST_PROMPT ? (
<>
Run <code className="font-mono bg-muted px-1 rounded">claude</code> in
your terminal and approve access to continue
</>
) : (
<>
Make sure Claude CLI is installed and authenticated via{' '}

View File

@@ -701,6 +701,7 @@ export function BoardView() {
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch
priority: 1, // High priority for PR feedback
planningMode: 'skip' as const,
requirePlanApproval: false,
@@ -743,6 +744,7 @@ export function BoardView() {
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch
priority: 1, // High priority for conflict resolution
planningMode: 'skip' as const,
requirePlanApproval: false,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -52,40 +53,62 @@ export function CreatePRDialog({
const [prUrl, setPrUrl] = useState<string | null>(null);
const [browserUrl, setBrowserUrl] = useState<string | null>(null);
const [showBrowserFallback, setShowBrowserFallback] = useState(false);
// Branch fetching state
const [branches, setBranches] = useState<string[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch(defaultBaseBranch);
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
setBranches([]);
}, [defaultBaseBranch]);
// Fetch branches for autocomplete
const fetchBranches = useCallback(async () => {
if (!worktree?.path) return;
setIsLoadingBranches(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.listBranches) {
return;
}
// Fetch both local and remote branches for PR base branch selection
const result = await api.worktree.listBranches(worktree.path, true);
if (result.success && result.result) {
// Extract branch names, filtering out the current worktree branch
const branchNames = result.result.branches
.map((b) => b.name)
.filter((name) => name !== worktree.branch);
setBranches(branchNames);
}
} catch {
// Silently fail - branches will default to main only
} finally {
setIsLoadingBranches(false);
}
}, [worktree?.path, worktree?.branch]);
// Reset state when dialog opens or worktree changes
useEffect(() => {
// Reset all state on both open and close
resetState();
if (open) {
// Reset form fields
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch(defaultBaseBranch);
setIsDraft(false);
setError(null);
// Also reset result states when opening for a new worktree
// This prevents showing stale PR URLs from previous worktrees
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
// Reset operation tracking
operationCompletedRef.current = false;
} else {
// Reset everything when dialog closes
setTitle('');
setBody('');
setCommitMessage('');
setBaseBranch(defaultBaseBranch);
setIsDraft(false);
setError(null);
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
operationCompletedRef.current = false;
// Fetch fresh branches when dialog opens
fetchBranches();
}
}, [open, worktree?.path, defaultBaseBranch]);
}, [open, worktree?.path, resetState, fetchBranches]);
const handleCreate = async () => {
if (!worktree) return;
@@ -346,15 +369,16 @@ export function CreatePRDialog({
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<Input
id="base-branch"
placeholder="main"
<BranchAutocomplete
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
className="font-mono text-sm"
onChange={setBaseBranch}
branches={branches}
placeholder="Select base branch..."
disabled={isLoadingBranches}
data-testid="base-branch-autocomplete"
/>
</div>
<div className="flex items-end">

View File

@@ -1746,8 +1746,8 @@ export class HttpApiClient implements ElectronAPI {
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
listBranches: (worktreePath: string) =>
this.post('/api/worktree/list-branches', { worktreePath }),
listBranches: (worktreePath: string, includeRemote?: boolean) =>
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
openInEditor: (worktreePath: string, editorCommand?: string) =>

View File

@@ -858,8 +858,11 @@ export interface WorktreeAPI {
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
}>;
// List all local branches
listBranches: (worktreePath: string) => Promise<{
// List branches (local and optionally remote)
listBranches: (
worktreePath: string,
includeRemote?: boolean
) => Promise<{
success: boolean;
result?: {
currentBranch: string;