mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch 'v0.11.0rc' into fix/openrouter-models-kanban
This commit is contained in:
108
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
108
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for Automaker
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request.
|
||||
|
||||
- type: dropdown
|
||||
id: feature-area
|
||||
attributes:
|
||||
label: Feature Area
|
||||
description: Which area of Automaker does this feature relate to?
|
||||
options:
|
||||
- UI/UX (User Interface)
|
||||
- Agent/AI
|
||||
- Kanban Board
|
||||
- Git/Worktree Management
|
||||
- Project Management
|
||||
- Settings/Configuration
|
||||
- Documentation
|
||||
- Performance
|
||||
- Other
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this feature to your workflow?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my workflow
|
||||
- Critical for my use case
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your feature request related to a problem? Please describe the problem you're trying to solve.
|
||||
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see implemented.
|
||||
placeholder: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you've considered.
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: use-cases
|
||||
attributes:
|
||||
label: Use Cases
|
||||
description: Describe specific scenarios where this feature would be useful.
|
||||
placeholder: |
|
||||
1. When working on...
|
||||
2. As a user who needs to...
|
||||
3. In situations where...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: mockups
|
||||
attributes:
|
||||
label: Mockups/Screenshots
|
||||
description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request.
|
||||
placeholder: Drag and drop images here or paste image URLs
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, references, or examples about the feature request here.
|
||||
placeholder: Any additional information that might be helpful...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched existing issues to ensure this feature hasn't been requested already
|
||||
required: true
|
||||
- label: I have provided a clear description of the problem and proposed solution
|
||||
required: true
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
|
||||
@@ -34,6 +34,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
||||
error: 'Authentication required',
|
||||
message: "Please run 'claude login' to authenticate",
|
||||
});
|
||||
} else if (message.includes('TRUST_PROMPT_PENDING')) {
|
||||
// Trust prompt appeared but couldn't be auto-approved
|
||||
res.status(200).json({
|
||||
error: 'Trust prompt pending',
|
||||
message:
|
||||
'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.',
|
||||
});
|
||||
} else if (message.includes('timed out')) {
|
||||
res.status(200).json({
|
||||
error: 'Command timed out',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* POST /list-branches endpoint - List all local branches
|
||||
* POST /list-branches endpoint - List all local branches and optionally remote branches
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
@@ -21,8 +21,9 @@ interface BranchInfo {
|
||||
export function createListBranchesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
const { worktreePath, includeRemote = false } = req.body as {
|
||||
worktreePath: string;
|
||||
includeRemote?: boolean;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -60,6 +61,55 @@ export function createListBranchesHandler() {
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch remote branches if requested
|
||||
if (includeRemote) {
|
||||
try {
|
||||
// Fetch latest remote refs (silently, don't fail if offline)
|
||||
try {
|
||||
await execAsync('git fetch --all --quiet', {
|
||||
cwd: worktreePath,
|
||||
timeout: 10000, // 10 second timeout
|
||||
});
|
||||
} catch {
|
||||
// Ignore fetch errors - we'll use cached remote refs
|
||||
}
|
||||
|
||||
// List remote branches
|
||||
const { stdout: remoteBranchesOutput } = await execAsync(
|
||||
'git branch -r --format="%(refname:short)"',
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
|
||||
const localBranchNames = new Set(branches.map((b) => b.name));
|
||||
|
||||
remoteBranchesOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((b) => b.trim())
|
||||
.forEach((name) => {
|
||||
// Remove any surrounding quotes
|
||||
const cleanName = name.trim().replace(/^['"]|['"]$/g, '');
|
||||
// Skip HEAD pointers like "origin/HEAD"
|
||||
if (cleanName.includes('/HEAD')) return;
|
||||
|
||||
// Only add remote branches if a branch with the exact same name isn't already
|
||||
// in the list. This avoids duplicates if a local branch is named like a remote one.
|
||||
// Note: We intentionally include remote branches even when a local branch with the
|
||||
// same base name exists (e.g., show "origin/main" even if local "main" exists),
|
||||
// since users need to select remote branches as PR base targets.
|
||||
if (!localBranchNames.has(cleanName)) {
|
||||
branches.push({
|
||||
name: cleanName, // Keep full name like "origin/main"
|
||||
isCurrent: false,
|
||||
isRemote: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors fetching remote branches - return local branches only
|
||||
}
|
||||
}
|
||||
|
||||
// Get ahead/behind count for current branch
|
||||
let aheadCount = 0;
|
||||
let behindCount = 0;
|
||||
|
||||
@@ -49,13 +49,11 @@ export class ClaudeUsageService {
|
||||
|
||||
/**
|
||||
* Execute the claude /usage command and return the output
|
||||
* Uses platform-specific PTY implementation
|
||||
* Uses node-pty on all platforms for consistency
|
||||
*/
|
||||
private executeClaudeUsageCommand(): Promise<string> {
|
||||
if (this.isWindows || this.isLinux) {
|
||||
return this.executeClaudeUsageCommandPty();
|
||||
}
|
||||
return this.executeClaudeUsageCommandMac();
|
||||
// Use node-pty on all platforms - it's more reliable than expect on macOS
|
||||
return this.executeClaudeUsageCommandPty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,24 +65,36 @@ export class ClaudeUsageService {
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
// Use a simple working directory (home or tmp)
|
||||
const workingDirectory = process.env.HOME || '/tmp';
|
||||
// Use current working directory - likely already trusted by Claude CLI
|
||||
const workingDirectory = process.cwd();
|
||||
|
||||
// Use 'expect' with an inline script to run claude /usage with a PTY
|
||||
// Wait for "Current session" header, then wait for full output before exiting
|
||||
// Running from cwd which should already be trusted
|
||||
const expectScript = `
|
||||
set timeout 20
|
||||
set timeout 30
|
||||
spawn claude /usage
|
||||
|
||||
# Wait for usage data or handle trust prompt if needed
|
||||
expect {
|
||||
"Current session" {
|
||||
sleep 2
|
||||
send "\\x1b"
|
||||
-re "Ready to code|permission to work|Do you want to work" {
|
||||
# Trust prompt appeared - send Enter to approve
|
||||
sleep 1
|
||||
send "\\r"
|
||||
exp_continue
|
||||
}
|
||||
"Esc to cancel" {
|
||||
"Current session" {
|
||||
# Usage data appeared - wait for full output, then exit
|
||||
sleep 3
|
||||
send "\\x1b"
|
||||
}
|
||||
timeout {}
|
||||
"% left" {
|
||||
# Usage percentage appeared
|
||||
sleep 3
|
||||
send "\\x1b"
|
||||
}
|
||||
timeout {
|
||||
send "\\x1b"
|
||||
}
|
||||
eof {}
|
||||
}
|
||||
expect eof
|
||||
@@ -158,10 +168,10 @@ export class ClaudeUsageService {
|
||||
let output = '';
|
||||
let settled = false;
|
||||
let hasSeenUsageData = false;
|
||||
let hasSeenTrustPrompt = false;
|
||||
|
||||
const workingDirectory = this.isWindows
|
||||
? process.env.USERPROFILE || os.homedir() || 'C:\\'
|
||||
: os.tmpdir();
|
||||
// Use current working directory (project dir) - most likely already trusted by Claude CLI
|
||||
const workingDirectory = process.cwd();
|
||||
|
||||
// Use platform-appropriate shell and command
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
@@ -206,6 +216,13 @@ export class ClaudeUsageService {
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
} else if (hasSeenTrustPrompt) {
|
||||
// Trust prompt was shown but we couldn't auto-approve it
|
||||
reject(
|
||||
new Error(
|
||||
'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
@@ -269,10 +286,18 @@ export class ClaudeUsageService {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Handle Trust Dialog: "Do you want to work in this folder?"
|
||||
// Since we are running in os.tmpdir(), it is safe to approve.
|
||||
if (!hasApprovedTrust && cleanOutput.includes('Do you want to work in this folder?')) {
|
||||
// Handle Trust Dialog - multiple variants:
|
||||
// - "Do you want to work in this folder?"
|
||||
// - "Ready to code here?" / "I'll need permission to work with your files"
|
||||
// Since we are running in cwd (project dir), it is safe to approve.
|
||||
if (
|
||||
!hasApprovedTrust &&
|
||||
(cleanOutput.includes('Do you want to work in this folder?') ||
|
||||
cleanOutput.includes('Ready to code here') ||
|
||||
cleanOutput.includes('permission to work with your files'))
|
||||
) {
|
||||
hasApprovedTrust = true;
|
||||
hasSeenTrustPrompt = true;
|
||||
// Wait a tiny bit to ensure prompt is ready, then send Enter
|
||||
setTimeout(() => {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
|
||||
@@ -12,6 +12,8 @@ describe('claude-provider.ts', () => {
|
||||
vi.clearAllMocks();
|
||||
provider = new ClaudeProvider();
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
});
|
||||
|
||||
describe('getName', () => {
|
||||
@@ -267,6 +269,93 @@ describe('claude-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable passthrough', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
});
|
||||
|
||||
it('should pass ANTHROPIC_BASE_URL to SDK env', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1';
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: 'https://custom.example.com/v1',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => {
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token';
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: 'custom-auth-token',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass both custom endpoint vars together', async () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token';
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: 'https://gateway.example.com',
|
||||
ANTHROPIC_AUTH_TOKEN: 'gateway-token',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return 4 Claude models', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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{' '}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
7
apps/ui/src/types/electron.d.ts
vendored
7
apps/ui/src/types/electron.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -47,6 +47,13 @@ fi
|
||||
chown -R automaker:automaker /home/automaker/.cache/opencode
|
||||
chmod -R 700 /home/automaker/.cache/opencode
|
||||
|
||||
# Ensure npm cache directory exists with correct permissions
|
||||
# This is needed for using npx to run MCP servers
|
||||
if [ ! -d "/home/automaker/.npm" ]; then
|
||||
mkdir -p /home/automaker/.npm
|
||||
fi
|
||||
chown -R automaker:automaker /home/automaker/.npm
|
||||
|
||||
# If CURSOR_AUTH_TOKEN is set, write it to the cursor auth file
|
||||
# On Linux, cursor-agent uses ~/.config/cursor/auth.json for file-based credential storage
|
||||
# The env var CURSOR_AUTH_TOKEN is also checked directly by cursor-agent
|
||||
|
||||
Reference in New Issue
Block a user