diff --git a/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts b/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts index 455245cd..90ecb12d 100644 --- a/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts +++ b/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts @@ -119,7 +119,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { // 5. gh pr view .mockResolvedValueOnce({ exitCode: 0, - stdout: JSON.stringify({ number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', state: 'OPEN' }), + stdout: JSON.stringify({ number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', state: 'OPEN', isDraft: false }), stderr: '', } as MockRunResult); @@ -127,7 +127,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { expect(result).toEqual({ success: true, - data: { number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', merged: false }, + data: { number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', state: 'open' }, }); // Verify gh pr view was called with --repo and branch @@ -166,7 +166,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { // 5. gh pr view .mockResolvedValueOnce({ exitCode: 0, - stdout: JSON.stringify({ number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'MERGED' }), + stdout: JSON.stringify({ number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'MERGED', isDraft: false }), stderr: '', } as MockRunResult); @@ -174,7 +174,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { expect(result).toEqual({ success: true, - data: { number: 42, url: 'https://github.com/owner/repo/pull/42', merged: true }, + data: { number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'merged' }, }); // Verify --repo contains owner/repo from HTTPS URL @@ -316,7 +316,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { // 5. gh pr view .mockResolvedValueOnce({ exitCode: 0, - stdout: JSON.stringify({ number: 50, url: 'https://github.com/owner/repo/pull/50', state: 'MERGED' }), + stdout: JSON.stringify({ number: 50, url: 'https://github.com/owner/repo/pull/50', state: 'MERGED', isDraft: false }), stderr: '', } as MockRunResult); @@ -324,7 +324,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { expect(result).toEqual({ success: true, - data: { number: 50, url: 'https://github.com/owner/repo/pull/50', merged: true }, + data: { number: 50, url: 'https://github.com/owner/repo/pull/50', state: 'merged' }, }); }); @@ -410,7 +410,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { // 5. gh pr view .mockResolvedValueOnce({ exitCode: 0, - stdout: JSON.stringify({ number: 10, url: 'https://github.com/owner/repo/pull/10', state: 'OPEN' }), + stdout: JSON.stringify({ number: 10, url: 'https://github.com/owner/repo/pull/10', state: 'OPEN', isDraft: false }), stderr: '', } as MockRunResult); @@ -418,7 +418,7 @@ describe('Git IPC Handlers - Remote Pull Request', () => { expect(result).toEqual({ success: true, - data: { number: 10, url: 'https://github.com/owner/repo/pull/10', merged: false }, + data: { number: 10, url: 'https://github.com/owner/repo/pull/10', state: 'open' }, }); // Verify owner/repo was parsed correctly @@ -1383,8 +1383,8 @@ describe('Git IPC Handlers - CI Status', () => { expect(result.data).toEqual({ rollupState: 'success', checks: [ - { id: 0, name: 'build', status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:10:00Z', detailsUrl: 'https://github.com/test/link1' }, - { id: 1, name: 'test', status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:12:00Z', detailsUrl: 'https://github.com/test/link2' }, + { id: 0, name: 'build', workflow: null, status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:10:00Z', detailsUrl: 'https://github.com/test/link1' }, + { id: 1, name: 'test', workflow: null, status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:12:00Z', detailsUrl: 'https://github.com/test/link2' }, ], totalCount: 2, successCount: 2, diff --git a/packages/desktop/src/infrastructure/ipc/git.ts b/packages/desktop/src/infrastructure/ipc/git.ts index a25cd4af..bcdf1970 100644 --- a/packages/desktop/src/infrastructure/ipc/git.ts +++ b/packages/desktop/src/infrastructure/ipc/git.ts @@ -3,7 +3,7 @@ import type { AppServices } from './types'; import { promises as fs } from 'fs'; import { join } from 'path'; -type RemotePullRequest = { number: number; url: string; merged: boolean }; +type RemotePullRequest = { number: number; url: string; state: 'draft' | 'open' | 'merged' }; /** * Parse a git remote URL and return the GitHub web URL for the repository. @@ -419,7 +419,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo const res = await gitExecutor.run({ sessionId, cwd: session.worktreePath, - argv: ['gh', 'pr', 'view', ...repoArgs, '--json', 'number,url,state'], + argv: ['gh', 'pr', 'view', ...repoArgs, '--json', 'number,url,state,isDraft'], op: 'read', recordTimeline: false, throwOnError: false, @@ -433,13 +433,23 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo if (!raw) continue; // Try next remote try { - const parsed = JSON.parse(raw) as { number?: unknown; url?: unknown; state?: unknown } | null; + const parsed = JSON.parse(raw) as { number?: unknown; url?: unknown; state?: unknown; isDraft?: unknown } | null; const number = parsed && typeof parsed.number === 'number' ? parsed.number : null; const url = parsed && typeof parsed.url === 'string' ? parsed.url : ''; - const state = parsed && typeof parsed.state === 'string' ? parsed.state : ''; - const merged = state === 'MERGED'; + const ghState = parsed && typeof parsed.state === 'string' ? parsed.state.toUpperCase() : ''; + const isDraft = parsed && typeof parsed.isDraft === 'boolean' ? parsed.isDraft : false; + + let prState: 'draft' | 'open' | 'merged'; + if (ghState === 'MERGED') { + prState = 'merged'; + } else if (isDraft) { + prState = 'draft'; + } else { + prState = 'open'; + } + if (!number || !url) continue; // Try next remote - const out: RemotePullRequest = { number, url, merged }; + const out: RemotePullRequest = { number, url, state: prState }; return { success: true, data: out }; // Found PR, return immediately } catch { continue; // Try next remote @@ -1155,7 +1165,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } // Use gh pr checks to get CI status - // gh pr checks --json returns: name, state (SUCCESS/FAILURE/PENDING/etc), startedAt, completedAt, link + // gh pr checks --json returns: name, workflow, state (SUCCESS/FAILURE/PENDING/etc), startedAt, completedAt, link const checksRes = await gitExecutor.run({ sessionId, cwd, @@ -1163,7 +1173,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo 'gh', 'pr', 'checks', '--repo', ownerRepo, branchRef, - '--json', 'name,state,startedAt,completedAt,link', + '--json', 'name,state,startedAt,completedAt,link,workflow', ], op: 'read', recordTimeline: false, @@ -1185,6 +1195,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo try { const checksData = JSON.parse(raw) as Array<{ name?: string; + workflow?: string; state?: string; // SUCCESS, FAILURE, PENDING, IN_PROGRESS, SKIPPED, etc startedAt?: string; completedAt?: string; @@ -1202,6 +1213,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo return { id: idx, name: c.name || 'Unknown', + workflow: c.workflow || null, status, conclusion, startedAt: c.startedAt || null, @@ -1257,6 +1269,120 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo return { success: false, error: error instanceof Error ? error.message : 'Failed to get CI status' }; } }); + + ipcMain.handle('sessions:mark-pr-ready', async (_event, sessionId: string) => { + try { + console.log('[git.ts] Mark PR ready called for session:', sessionId); + const session = sessionManager.getSession(sessionId); + if (!session?.worktreePath) { + console.error('[git.ts] Session worktree not found'); + return { success: false, error: 'Session worktree not found' }; + } + + // Get current branch + const branchRes = await gitExecutor.run({ + sessionId, + cwd: session.worktreePath, + argv: ['git', 'branch', '--show-current'], + op: 'read', + recordTimeline: false, + throwOnError: false, + timeoutMs: 5_000, + meta: { source: 'ipc.git', operation: 'mark-pr-ready-branch' }, + }); + + if (branchRes.exitCode !== 0 || !branchRes.stdout?.trim()) { + console.error('[git.ts] Failed to get current branch'); + return { success: false, error: 'Failed to get current branch' }; + } + + const branch = branchRes.stdout.trim(); + console.log('[git.ts] Current branch:', branch); + + // Get origin owner for fork workflow + let originOwner: string | null = null; + const originRes = await gitExecutor.run({ + sessionId, + cwd: session.worktreePath, + argv: ['git', 'remote', 'get-url', 'origin'], + op: 'read', + recordTimeline: false, + throwOnError: false, + timeoutMs: 3_000, + meta: { source: 'ipc.git', operation: 'mark-pr-ready-origin' }, + }); + if (originRes.exitCode === 0 && originRes.stdout?.trim()) { + const originUrl = originRes.stdout.trim(); + const originMatch = originUrl.match(/github\.com[:/]([^/]+)\//); + if (originMatch) { + originOwner = originMatch[1]; + } + } + console.log('[git.ts] Origin owner:', originOwner); + + // Try to mark PR ready in multiple remotes (same logic as get-remote-pull-request) + const remoteNames = ['upstream', 'origin']; + + for (const remoteName of remoteNames) { + const remoteRes = await gitExecutor.run({ + sessionId, + cwd: session.worktreePath, + argv: ['git', 'remote', 'get-url', remoteName], + op: 'read', + recordTimeline: false, + throwOnError: false, + timeoutMs: 3_000, + meta: { source: 'ipc.git', operation: 'mark-pr-ready-remote' }, + }); + + if (remoteRes.exitCode !== 0 || !remoteRes.stdout?.trim()) continue; + + const url = remoteRes.stdout.trim(); + // Parse: git@github.com:owner/repo.git or https://github.com/owner/repo.git + const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/); + if (!match) continue; + + const ownerRepo = match[1]; + + // For upstream repo in fork workflow, use "owner:branch" format + // For origin repo, use just "branch" + const branchArg = remoteName === 'upstream' && originOwner ? `${originOwner}:${branch}` : branch; + + console.log(`[git.ts] Trying ${remoteName} with repo=${ownerRepo}, branch=${branchArg}`); + + // Mark PR as ready for review using gh pr ready + const readyRes = await gitExecutor.run({ + sessionId, + cwd: session.worktreePath, + argv: ['gh', 'pr', 'ready', '--repo', ownerRepo, branchArg], + op: 'write', + recordTimeline: true, + throwOnError: false, + timeoutMs: 30_000, + meta: { source: 'ipc.git', operation: 'mark-pr-ready' }, + }); + + console.log('[git.ts] gh pr ready result:', { exitCode: readyRes.exitCode, stdout: readyRes.stdout, stderr: readyRes.stderr }); + + if (readyRes.exitCode === 0) { + console.log('[git.ts] Successfully marked PR as ready'); + return { success: true }; + } + + // If failed, try next remote + console.log(`[git.ts] Failed on ${remoteName}, trying next remote...`); + } + + // Failed on all remotes + console.error('[git.ts] Failed to mark PR as ready on all remotes'); + return { success: false, error: 'Failed to mark PR as ready' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to mark PR as ready' + }; + } + }); } // Helper: parse gh pr checks 'state' field to status and conclusion diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 86adb2b5..88fb2df4 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -93,6 +93,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // CI status getCIStatus: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-ci-status', sessionId), + markPRReady: (sessionId: string): Promise => + ipcRenderer.invoke('sessions:mark-pr-ready', sessionId), // Terminal helpers ensureTerminalPanel: (sessionId: string): Promise => ipcRenderer.invoke('sessions:terminal-ensure-panel', sessionId), diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 435a1b4f..6a49459f 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -3,12 +3,52 @@ import { MainLayout } from './components/layout'; import { useIPCEvents } from './hooks/useIPCEvents'; import { useWorkspaceStageSync } from './hooks/useWorkspaceStageSync'; import { ErrorDialog } from './components/ErrorDialog'; +import { SettingsDialog } from './components/SettingsDialog'; import { useErrorStore } from './stores/errorStore'; +import { useSettingsStore } from './stores/settingsStore'; +import { useThemeStore } from './stores/themeStore'; +import { useEffect } from 'react'; + +function getResolvedTheme(themeSetting: 'light' | 'dark' | 'system'): 'light' | 'dark' { + if (themeSetting !== 'system') return themeSetting; + + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'dark'; +} export default function App() { useIPCEvents(); useWorkspaceStageSync(); const { currentError, clearError } = useErrorStore(); + const { settings } = useSettingsStore(); + const { setTheme } = useThemeStore(); + + // Initialize theme and font size on mount + useEffect(() => { + // Apply theme + const resolvedTheme = getResolvedTheme(settings.theme); + setTheme(resolvedTheme); + + // Listen for system theme changes + if (settings.theme === 'system' && window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + setTheme(e.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } + }, [settings.theme, setTheme]); + + // Apply font size + useEffect(() => { + // Apply to body and root elements to ensure it takes effect + document.body.style.fontSize = `${settings.fontSize}px`; + document.documentElement.style.fontSize = `${settings.fontSize}px`; + }, [settings.fontSize]); return (
)} + +
); } diff --git a/packages/ui/src/assets/claude-icon.svg b/packages/ui/src/assets/claude-icon.svg new file mode 100644 index 00000000..62dc0db1 --- /dev/null +++ b/packages/ui/src/assets/claude-icon.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/packages/ui/src/assets/gemini-icon.svg b/packages/ui/src/assets/gemini-icon.svg new file mode 100644 index 00000000..f1cf3575 --- /dev/null +++ b/packages/ui/src/assets/gemini-icon.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/packages/ui/src/assets/openai-icon.svg b/packages/ui/src/assets/openai-icon.svg new file mode 100644 index 00000000..50d94d6c --- /dev/null +++ b/packages/ui/src/assets/openai-icon.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/packages/ui/src/components/SettingsDialog.tsx b/packages/ui/src/components/SettingsDialog.tsx new file mode 100644 index 00000000..5220b423 --- /dev/null +++ b/packages/ui/src/components/SettingsDialog.tsx @@ -0,0 +1,299 @@ +import { Settings, X } from 'lucide-react'; +import { useSettingsStore } from '../stores/settingsStore'; +import { useThemeStore } from '../stores/themeStore'; +import { useEffect } from 'react'; +import { ClaudeIcon, CodexIcon, GeminiIcon } from './icons/ProviderIcons'; + +function getResolvedTheme(themeSetting: 'light' | 'dark' | 'system'): 'light' | 'dark' { + if (themeSetting !== 'system') return themeSetting; + + // Detect system theme + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'dark'; +} + +export function SettingsDialog() { + const { isOpen, closeSettings, settings, updateSettings, resetSettings } = useSettingsStore(); + const { setTheme } = useThemeStore(); + + // Sync theme when settings change or system preference changes + useEffect(() => { + const resolvedTheme = getResolvedTheme(settings.theme); + setTheme(resolvedTheme); + + // Listen for system theme changes when in system mode + if (settings.theme === 'system' && window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + setTheme(e.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } + }, [settings.theme, setTheme]); + + // Apply font size + useEffect(() => { + if (typeof document === 'undefined') return; + + // Apply to body and root elements to ensure it takes effect + document.body.style.fontSize = `${settings.fontSize}px`; + document.documentElement.style.fontSize = `${settings.fontSize}px`; + }, [settings.fontSize]); + + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) closeSettings(); + }} + > +
+ {/* Header */} +
+
+ +
Settings
+
+ +
+ + {/* Content */} +
+ {/* Theme & Appearance */} +
+

+ Theme & Appearance +

+
+
+ + +
+ +
+ + updateSettings({ fontSize: parseInt(e.target.value) || 15 })} + min="10" + max="24" + className="px-3 py-1.5 rounded border text-sm w-20 st-focus-ring" + style={{ + backgroundColor: 'var(--st-editor)', + borderColor: 'var(--st-border)', + color: 'var(--st-text)', + }} + /> +
+
+
+ + {/* AI Tool Settings */} +
+

+ AI Providers +

+
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+
+ + {/* Terminal Settings */} +
+

+ Terminal Settings +

+
+
+ + updateSettings({ terminalFontSize: parseInt(e.target.value) || 13 })} + min="8" + max="24" + className="px-3 py-1.5 rounded border text-sm w-20 st-focus-ring" + style={{ + backgroundColor: 'var(--st-editor)', + borderColor: 'var(--st-border)', + color: 'var(--st-text)', + }} + /> +
+ +
+ + updateSettings({ terminalScrollback: parseInt(e.target.value) || 1000 })} + min="100" + max="10000" + step="100" + className="px-3 py-1.5 rounded border text-sm w-24 st-focus-ring" + style={{ + backgroundColor: 'var(--st-editor)', + borderColor: 'var(--st-border)', + color: 'var(--st-text)', + }} + /> +
+
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index dbcdc541..1edb0299 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ChevronDown, FolderPlus, Plus, Trash2, Loader2, Sun, Moon } from 'lucide-react'; +import { ChevronDown, FolderPlus, Plus, Trash2, Loader2, Sun, Moon, Settings } from 'lucide-react'; import { API } from '../utils/api'; import { useErrorStore } from '../stores/errorStore'; import { useSessionStore } from '../stores/sessionStore'; import { formatDistanceToNow } from '../utils/timestampUtils'; import { StageBadge } from './layout/StageBadge'; import { useThemeStore } from '../stores/themeStore'; +import { useSettingsStore } from '../stores/settingsStore'; import { useUpdateStatus } from '../hooks/useUpdateStatus'; import { SidebarUpdateButton } from './SidebarUpdateButton'; @@ -45,6 +46,7 @@ const applyBaseCommitSuffix = (name: string, baseCommit?: string): string => { export function Sidebar() { const { showError } = useErrorStore(); const { sessions, activeSessionId, setActiveSession } = useSessionStore(); + const { openSettings } = useSettingsStore(); const [projects, setProjects] = useState([]); const [activeProjectId, setActiveProjectId] = useState(null); const [collapsedProjects, setCollapsedProjects] = useState>(() => new Set()); @@ -56,8 +58,16 @@ export function Sidebar() { const [draftWorktreeName, setDraftWorktreeName] = useState(''); const refreshTimersRef = useRef>({}); const hasInitializedRenameInputRef = useRef(false); - const { theme, toggleTheme } = useThemeStore(); + const { theme } = useThemeStore(); + const { updateSettings } = useSettingsStore(); const [appVersion, setAppVersion] = useState(''); + + // Handle theme toggle - toggle between dark and light + const handleToggleTheme = useCallback(() => { + // Toggle between dark and light, ignoring system setting + const nextTheme = theme === 'dark' ? 'light' : 'dark'; + updateSettings({ theme: nextTheme }); + }, [theme, updateSettings]); const { updateAvailable, updateVersion, @@ -777,20 +787,32 @@ export function Sidebar() { onInstall={installUpdate} /> )} - +
+ + +
+ )} + {/* Push & Create/Sync PR */} {onPushPR && ( + {!collapsed && checks.map((check) => { const { icon: Icon, color, animate } = getCheckIcon( check.status, check.conclusion @@ -101,7 +125,7 @@ export const CIStatusDetails: React.FC = ({