From 9fe5b485f8765b1c720637c3ffc159f466ccc666 Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Thu, 1 Jan 2026 13:33:40 +0100 Subject: [PATCH 01/12] feat: support ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for custom endpoints - apps/server/src/providers/claude-provider.ts Add ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN to the environment variable allowlist, enabling use of LLM gateways (LiteLLM, Helicone) and Anthropic- compatible providers (GLM 4.7, Minimax M2.1, etc.). Closes #338 --- apps/server/src/providers/claude-provider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 33494535..ee3204f0 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -19,6 +19,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', From d2f64f10fff11f796a64c15295f8f00e23b68ebe Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Thu, 1 Jan 2026 13:43:12 +0100 Subject: [PATCH 02/12] test: add tests for ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN passthrough - apps/server/tests/unit/providers/claude-provider.test.ts Verify custom endpoint environment variables are passed to the SDK. --- .../unit/providers/claude-provider.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 3dbd9982..e9afa1c0 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -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', () => { @@ -286,6 +288,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(); From 1a1517dffb19297ab5d6577eb9a7bdf07c4057a1 Mon Sep 17 00:00:00 2001 From: Soham Dasgupta Date: Wed, 14 Jan 2026 00:49:28 +0530 Subject: [PATCH 03/12] fix: ensure npm cache directory has correct permissions Fix EACCES permission error when running npx commands (e.g., MCP servers) inside the Docker container. Error that was occurring: npm error code EACCES npm error syscall mkdir npm error path /home/automaker/.npm/_cacache/index-v5/1f/fc npm error errno EACCES npm error Your cache folder contains root-owned files, due to a bug in npm error previous versions of npm which has since been addressed. The fix ensures the /home/automaker/.npm directory exists and has correct ownership before switching to the automaker user in the entrypoint script. --- docker-entrypoint.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 153f5122..2af347c7 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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 From 8419b12f3fabc47ac6e2676857fa3fe01ff76118 Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 14 Jan 2026 15:33:51 +0100 Subject: [PATCH 04/12] feat(ui): Add project theme selection to context menu with clean code refactoring Implement per-project theme override capability in the Discord-like layout: - Add theme submenu to project context menu with live preview - Reuse existing theme constants and useThemePreview hook from sidebar - Extract reusable ThemeButton and ThemeColumn components (DRY principle) - Replace magic z-index values with named constants Co-Authored-By: Claude Opus 4.5 --- .../components/project-context-menu.tsx | 222 +++++++++++++++++- 1 file changed, 216 insertions(+), 6 deletions(-) diff --git a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx index 32a6315d..2d450ed8 100644 --- a/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/project-context-menu.tsx @@ -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 from sidebar constants +interface ThemeOption { + value: string; + 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 ( + + ); +}); + +// Reusable theme column component +interface ThemeColumnProps { + title: string; + icon: LucideIcon; + themes: ThemeOption[]; + selectedTheme: string | null; + onPreviewEnter: (value: string) => void; + onPreviewLeave: (e: React.PointerEvent) => void; + onSelect: (value: string) => void; +} + +const ThemeColumn = memo(function ThemeColumn({ + title, + icon: Icon, + themes, + selectedTheme, + onPreviewEnter, + onPreviewLeave, + onSelect, +}: ThemeColumnProps) { + return ( +
+
+ + {title} +
+
+ {themes.map((option) => ( + onPreviewEnter(option.value)} + onPointerLeave={onPreviewLeave} + onClick={() => onSelect(option.value)} + /> + ))} +
+
+ ); +}); interface ProjectContextMenuProps { project: Project; @@ -19,18 +115,30 @@ export function ProjectContextMenu({ onEdit, }: ProjectContextMenuProps) { const menuRef = useRef(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(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: string) => { + setPreviewTheme(null); + if (value !== '') { + setTheme(value as ThemeMode); + } else { + setTheme(globalTheme); + } + setProjectTheme(project.id, value === '' ? null : (value as ThemeMode)); + setShowThemeSubmenu(false); + }; + const handleConfirmRemove = () => { moveProjectToTrash(project.id); onClose(); @@ -62,7 +181,7 @@ export function ProjectContextMenu({
@@ -88,6 +208,96 @@ export function ProjectContextMenu({ Edit Name & Icon + {/* Theme Submenu Trigger */} +
+ + + {/* Theme Submenu */} + {showThemeSubmenu && ( +
{ + setShowThemeSubmenu(false); + setPreviewTheme(null); + }} + data-testid="project-theme-submenu" + > +
+ {/* Use Global Option */} + + +
+ + {/* Two Column Layout - Using reusable ThemeColumn component */} +
+ + +
+
+
+ )} +
+ {/* Theme Submenu Trigger */} -
+
setShowThemeSubmenu(true)} + onMouseLeave={() => { + setShowThemeSubmenu(false); + setPreviewTheme(null); + }} + >
-
+
- setBaseBranch(e.target.value)} - className="font-mono text-sm" + onChange={setBaseBranch} + branches={branches} + placeholder="Select base branch..." + disabled={isLoadingBranches} + data-testid="base-branch-autocomplete" />
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 2ce7f6a7..f08e7620 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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) => diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 23814c18..94a033f5 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -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; From 0898578c11ce1d37b51fd4cc4dd5373e269410ad Mon Sep 17 00:00:00 2001 From: Shirone Date: Wed, 14 Jan 2026 18:36:14 +0100 Subject: [PATCH 12/12] fix: Include remote branches in PR base selection even when local branch exists The branch listing logic now correctly shows remote branches (e.g., "origin/main") even if a local branch with the same base name exists, since users need remote branches as PR base targets. Also extracted duplicate state reset logic in create-pr-dialog into a reusable function. --- .../routes/worktree/routes/list-branches.ts | 12 ++--- .../board-view/dialogs/create-pr-dialog.tsx | 49 +++++++------------ 2 files changed, 25 insertions(+), 36 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 84beee40..c6db10fc 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -92,12 +92,12 @@ export function createListBranchesHandler() { // Skip HEAD pointers like "origin/HEAD" if (cleanName.includes('/HEAD')) return; - // Extract the branch name without the remote prefix for deduplication - // e.g., "origin/main" -> "main" - const branchNameWithoutRemote = cleanName.replace(/^[^/]+\//, ''); - - // Only add remote branches that don't exist locally (to avoid duplicates) - if (!localBranchNames.has(branchNameWithoutRemote)) { + // 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, diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index 59906dee..125e8416 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -59,6 +59,21 @@ export function CreatePRDialog({ // 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; @@ -87,39 +102,13 @@ export function CreatePRDialog({ // 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; - // Reset branches and fetch fresh ones - setBranches([]); + // Fetch fresh branches when dialog opens fetchBranches(); - } else { - // Reset everything when dialog closes - setTitle(''); - setBody(''); - setCommitMessage(''); - setBaseBranch(defaultBaseBranch); - setIsDraft(false); - setError(null); - setPrUrl(null); - setBrowserUrl(null); - setShowBrowserFallback(false); - operationCompletedRef.current = false; - setBranches([]); } - }, [open, worktree?.path, defaultBaseBranch, fetchBranches]); + }, [open, worktree?.path, resetState, fetchBranches]); const handleCreate = async () => { if (!worktree) return;