From 8840b22633c58162675306b755d0190a7995cbb0 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Mon, 19 Jan 2026 15:22:36 +0800 Subject: [PATCH 1/7] Add settings dialog with theme, font size, AI provider toggles, and terminal settings --- packages/ui/src/App.tsx | 42 +++ packages/ui/src/assets/claude-icon.svg | 1 + packages/ui/src/assets/gemini-icon.svg | 1 + packages/ui/src/assets/openai-icon.svg | 1 + packages/ui/src/components/SettingsDialog.tsx | 299 ++++++++++++++++++ packages/ui/src/components/Sidebar.tsx | 54 +++- .../ui/src/components/icons/ProviderIcons.tsx | 74 +++++ .../ui/src/components/layout/InputBar.tsx | 8 +- .../ui/src/components/layout/useLayoutData.ts | 51 ++- packages/ui/src/stores/settingsStore.ts | 87 +++++ 10 files changed, 595 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/assets/claude-icon.svg create mode 100644 packages/ui/src/assets/gemini-icon.svg create mode 100644 packages/ui/src/assets/openai-icon.svg create mode 100644 packages/ui/src/components/SettingsDialog.tsx create mode 100644 packages/ui/src/components/icons/ProviderIcons.tsx create mode 100644 packages/ui/src/stores/settingsStore.ts 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..641d8153 --- /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) || 14 })} + 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} /> )} - +
+ + +
@@ -186,7 +186,7 @@ export function SettingsDialog() { > @@ -213,7 +213,7 @@ export function SettingsDialog() { > diff --git a/packages/ui/src/components/layout/useRightPanelData.ts b/packages/ui/src/components/layout/useRightPanelData.ts index 4c4e4a5f..3cf1fd70 100644 --- a/packages/ui/src/components/layout/useRightPanelData.ts +++ b/packages/ui/src/components/layout/useRightPanelData.ts @@ -576,7 +576,7 @@ export function useRightPanelData(sessionId: string | undefined): RightPanelData try { const newCIStatus = await fetchCIStatus(); - if (!controller.signal.aborted) { + if (!controller.signal.aborted && newCIStatus !== null) { setCIStatus(newCIStatus); } } catch (error) { diff --git a/packages/ui/src/features/ci-status/components/CIStatusDetails.tsx b/packages/ui/src/features/ci-status/components/CIStatusDetails.tsx index bdbfe9f2..5a29a038 100644 --- a/packages/ui/src/features/ci-status/components/CIStatusDetails.tsx +++ b/packages/ui/src/features/ci-status/components/CIStatusDetails.tsx @@ -119,9 +119,9 @@ export const CIStatusDetails: React.FC = ({ - {check.name} + {check.workflow ? `${check.workflow} / ${check.name}` : check.name}
diff --git a/packages/ui/src/features/ci-status/types.ts b/packages/ui/src/features/ci-status/types.ts index 0d2e593d..eac9d459 100644 --- a/packages/ui/src/features/ci-status/types.ts +++ b/packages/ui/src/features/ci-status/types.ts @@ -16,6 +16,7 @@ export type CheckStatus = 'queued' | 'in_progress' | 'completed'; export interface CICheck { id: number; name: string; + workflow: string | null; status: CheckStatus; conclusion: CheckConclusion; startedAt: string | null; diff --git a/packages/ui/src/stores/settingsStore.ts b/packages/ui/src/stores/settingsStore.ts index 046e74bf..a0e56765 100644 --- a/packages/ui/src/stores/settingsStore.ts +++ b/packages/ui/src/stores/settingsStore.ts @@ -21,7 +21,7 @@ export interface AppSettings { const DEFAULT_SETTINGS: AppSettings = { theme: 'system', - fontSize: 14, + fontSize: 15, enabledProviders: { claude: true, codex: true, From 3b88c0d4a32a5cfe8b49e50ede179e962760f399 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Mon, 19 Jan 2026 19:02:40 +0800 Subject: [PATCH 3/7] Add CI status grouping and sorting with collapsible success section --- .../ipc/__tests__/gitHandlers.test.ts | 4 +- .../ci-status/components/CIStatusDetails.tsx | 131 ++++++++++++++---- 2 files changed, 107 insertions(+), 28 deletions(-) diff --git a/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts b/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts index 455245cd..5bfc482a 100644 --- a/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts +++ b/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts @@ -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/ui/src/features/ci-status/components/CIStatusDetails.tsx b/packages/ui/src/features/ci-status/components/CIStatusDetails.tsx index 5a29a038..df1ccb7a 100644 --- a/packages/ui/src/features/ci-status/components/CIStatusDetails.tsx +++ b/packages/ui/src/features/ci-status/components/CIStatusDetails.tsx @@ -1,5 +1,5 @@ -import React, { useCallback } from 'react'; -import { Check, X, Loader2, Clock, Circle, ExternalLink } from 'lucide-react'; +import React, { useCallback, useState } from 'react'; +import { Check, X, Loader2, Clock, Circle, ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'; import type { CICheck, CheckStatus, CheckConclusion } from '../types'; interface CIStatusDetailsProps { @@ -56,11 +56,36 @@ function getStatusLabel(status: CheckStatus, conclusion: CheckConclusion): strin return conclusion || 'completed'; } -export const CIStatusDetails: React.FC = ({ - checks, - onCheckClick, -}) => { - const handleClick = useCallback( +function getCheckCategory(status: CheckStatus, conclusion: CheckConclusion): 'failed' | 'pending' | 'success' { + if (status === 'completed') { + if (conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'cancelled') { + return 'failed'; + } + return 'success'; + } + return 'pending'; +} + +function sortChecks(checks: CICheck[]): CICheck[] { + return [...checks].sort((a, b) => { + const categoryA = getCheckCategory(a.status, a.conclusion); + const categoryB = getCheckCategory(b.status, b.conclusion); + + const categoryOrder = { failed: 0, pending: 1, success: 2 }; + return categoryOrder[categoryA] - categoryOrder[categoryB]; + }); +} + +interface CheckGroupProps { + title: string; + checks: CICheck[]; + onCheckClick?: (check: CICheck) => void; + collapsed: boolean; + onToggle: () => void; +} + +const CheckGroup: React.FC = ({ title, checks, onCheckClick, collapsed, onToggle }) => { + const handleCheckClick = useCallback( (check: CICheck) => { if (onCheckClick && check.detailsUrl) { onCheckClick(check); @@ -69,26 +94,25 @@ export const CIStatusDetails: React.FC = ({ [onCheckClick] ); - if (checks.length === 0) { - return ( -
- No checks -
- ); - } + if (checks.length === 0) return null; + + const ChevronIcon = collapsed ? ChevronRight : ChevronDown; return ( -
- {checks.map((check) => { +
+ + {!collapsed && checks.map((check) => { const { icon: Icon, color, animate } = getCheckIcon( check.status, check.conclusion @@ -101,7 +125,7 @@ export const CIStatusDetails: React.FC = ({
); }; + +export const CIStatusDetails: React.FC = ({ + checks, + onCheckClick, +}) => { + const [successCollapsed, setSuccessCollapsed] = useState(true); + + if (checks.length === 0) { + return ( +
+ No checks +
+ ); + } + + const sortedChecks = sortChecks(checks); + const failedChecks = sortedChecks.filter(c => getCheckCategory(c.status, c.conclusion) === 'failed'); + const pendingChecks = sortedChecks.filter(c => getCheckCategory(c.status, c.conclusion) === 'pending'); + const successChecks = sortedChecks.filter(c => getCheckCategory(c.status, c.conclusion) === 'success'); + + return ( +
+ {}} + /> + {}} + /> + setSuccessCollapsed(!successCollapsed)} + /> +
+ ); +}; From be162f82d137fdb7a4007a442a7d00fd604b3d75 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Mon, 19 Jan 2026 19:13:27 +0800 Subject: [PATCH 4/7] Update CI status tests for grouped and collapsible display --- .../components/CIStatusDetails.test.tsx | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/ci-status/components/CIStatusDetails.test.tsx b/packages/ui/src/features/ci-status/components/CIStatusDetails.test.tsx index eac9870e..65ebee2f 100644 --- a/packages/ui/src/features/ci-status/components/CIStatusDetails.test.tsx +++ b/packages/ui/src/features/ci-status/components/CIStatusDetails.test.tsx @@ -7,6 +7,7 @@ describe('CIStatusDetails', () => { const createMockCheck = (overrides: Partial = {}): CICheck => ({ id: 1, name: 'test-check', + workflow: null, status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:25:00Z', @@ -28,6 +29,10 @@ describe('CIStatusDetails', () => { ]; render(); + // Success checks are collapsed by default, need to expand them + const successHeader = screen.getByText(/Success \(3\)/); + fireEvent.click(successHeader); + expect(screen.getByText('build')).toBeInTheDocument(); expect(screen.getByText('test')).toBeInTheDocument(); expect(screen.getByText('lint')).toBeInTheDocument(); @@ -37,6 +42,10 @@ describe('CIStatusDetails', () => { const checks = [createMockCheck({ conclusion: 'success' })]; render(); + // Expand success section + const successHeader = screen.getByText(/Success \(1\)/); + fireEvent.click(successHeader); + expect(screen.getByText('success')).toBeInTheDocument(); }); @@ -44,6 +53,7 @@ describe('CIStatusDetails', () => { const checks = [createMockCheck({ conclusion: 'failure' })]; render(); + // Failure checks are always expanded expect(screen.getByText('failure')).toBeInTheDocument(); }); @@ -57,6 +67,7 @@ describe('CIStatusDetails', () => { ]; render(); + // In-progress checks are in pending section, always expanded expect(screen.getByText('running')).toBeInTheDocument(); }); @@ -70,6 +81,7 @@ describe('CIStatusDetails', () => { ]; render(); + // Queued checks are in pending section, always expanded expect(screen.getByText('pending')).toBeInTheDocument(); }); @@ -77,6 +89,10 @@ describe('CIStatusDetails', () => { const checks = [createMockCheck({ conclusion: 'skipped' })]; render(); + // Expand success section + const successHeader = screen.getByText(/Success \(1\)/); + fireEvent.click(successHeader); + expect(screen.getByText('skipped')).toBeInTheDocument(); }); @@ -84,6 +100,7 @@ describe('CIStatusDetails', () => { const checks = [createMockCheck({ conclusion: 'cancelled' })]; render(); + // Cancelled checks are in failed section, always expanded expect(screen.getByText('cancelled')).toBeInTheDocument(); }); @@ -92,7 +109,14 @@ describe('CIStatusDetails', () => { const check = createMockCheck({ detailsUrl: 'https://github.com/test' }); render(); - fireEvent.click(screen.getByRole('button')); + // Expand success section to access the check button + const successHeader = screen.getByText(/Success \(1\)/); + fireEvent.click(successHeader); + + // Now click the check button (skip the group header button) + const buttons = screen.getAllByRole('button'); + const checkButton = buttons[1]; // First is group header, second is check + fireEvent.click(checkButton); expect(onCheckClick).toHaveBeenCalledWith(check); }); @@ -101,7 +125,14 @@ describe('CIStatusDetails', () => { const check = createMockCheck({ detailsUrl: null }); render(); - fireEvent.click(screen.getByRole('button')); + // Expand success section + const successHeader = screen.getByText(/Success \(1\)/); + fireEvent.click(successHeader); + + // Click the check button (not the group header) + const buttons = screen.getAllByRole('button'); + const checkButton = buttons[1]; + fireEvent.click(checkButton); expect(onCheckClick).not.toHaveBeenCalled(); }); @@ -109,14 +140,26 @@ describe('CIStatusDetails', () => { const check = createMockCheck({ detailsUrl: null }); render(); - expect(screen.getByRole('button')).toBeDisabled(); + // Expand success section + const successHeader = screen.getByText(/Success \(1\)/); + fireEvent.click(successHeader); + + const buttons = screen.getAllByRole('button'); + const checkButton = buttons[1]; + expect(checkButton).toBeDisabled(); }); it('enables button when check has URL', () => { const check = createMockCheck({ detailsUrl: 'https://github.com/test' }); render(); - expect(screen.getByRole('button')).not.toBeDisabled(); + // Expand success section + const successHeader = screen.getByText(/Success \(1\)/); + fireEvent.click(successHeader); + + const buttons = screen.getAllByRole('button'); + const checkButton = buttons[1]; + expect(checkButton).not.toBeDisabled(); }); it('renders multiple checks in order', () => { @@ -127,14 +170,23 @@ describe('CIStatusDetails', () => { ]; render(); + // Expand success section + const successHeader = screen.getByText(/Success \(3\)/); + fireEvent.click(successHeader); + + // Now we have 1 group header + 3 check buttons = 4 buttons total const buttons = screen.getAllByRole('button'); - expect(buttons).toHaveLength(3); + expect(buttons).toHaveLength(4); }); it('renders check with neutral conclusion', () => { const checks = [createMockCheck({ conclusion: 'neutral' })]; render(); + // Expand success section + const successHeader = screen.getByText(/Success \(1\)/); + fireEvent.click(successHeader); + expect(screen.getByText('neutral')).toBeInTheDocument(); }); @@ -142,6 +194,7 @@ describe('CIStatusDetails', () => { const checks = [createMockCheck({ conclusion: 'timed_out' })]; render(); + // Timed out checks are in failed section, always expanded expect(screen.getByText('timed_out')).toBeInTheDocument(); }); }); From 7cfae76a8e2619b5833200f2b4568f32527f9397 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Mon, 19 Jan 2026 19:22:48 +0800 Subject: [PATCH 5/7] Replace Unicode chevron with standard ChevronDown/ChevronRight icons in CI status badge --- .../components/CIStatusBadge.test.tsx | 32 ++++++++++++------- .../ci-status/components/CIStatusBadge.tsx | 13 ++++---- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/features/ci-status/components/CIStatusBadge.test.tsx b/packages/ui/src/features/ci-status/components/CIStatusBadge.test.tsx index d4782585..5687a1f4 100644 --- a/packages/ui/src/features/ci-status/components/CIStatusBadge.test.tsx +++ b/packages/ui/src/features/ci-status/components/CIStatusBadge.test.tsx @@ -92,33 +92,41 @@ describe('CIStatusBadge', () => { it('shows expand indicator when onClick is provided', () => { const onClick = vi.fn(); const status = createMockStatus(); - render(); + const { container } = render(); - expect(screen.getByText('▼')).toBeInTheDocument(); + // Check for ChevronRight icon (collapsed state) + const chevron = container.querySelector('svg.lucide-chevron-right'); + expect(chevron).toBeInTheDocument(); }); it('does not show expand indicator when onClick is not provided', () => { const status = createMockStatus(); - render(); + const { container } = render(); - expect(screen.queryByText('▼')).not.toBeInTheDocument(); + // No chevron icons should be present + const chevronRight = container.querySelector('svg.lucide-chevron-right'); + const chevronDown = container.querySelector('svg.lucide-chevron-down'); + expect(chevronRight).not.toBeInTheDocument(); + expect(chevronDown).not.toBeInTheDocument(); }); - it('rotates expand indicator when expanded', () => { + it('shows ChevronDown when expanded', () => { const onClick = vi.fn(); const status = createMockStatus(); - render(); + const { container } = render(); - const indicator = screen.getByText('▼'); - expect(indicator).toHaveClass('rotate-180'); + // Check for ChevronDown icon (expanded state) + const chevron = container.querySelector('svg.lucide-chevron-down'); + expect(chevron).toBeInTheDocument(); }); - it('does not rotate expand indicator when not expanded', () => { + it('shows ChevronRight when not expanded', () => { const onClick = vi.fn(); const status = createMockStatus(); - render(); + const { container } = render(); - const indicator = screen.getByText('▼'); - expect(indicator).not.toHaveClass('rotate-180'); + // Check for ChevronRight icon (collapsed state) + const chevron = container.querySelector('svg.lucide-chevron-right'); + expect(chevron).toBeInTheDocument(); }); }); diff --git a/packages/ui/src/features/ci-status/components/CIStatusBadge.tsx b/packages/ui/src/features/ci-status/components/CIStatusBadge.tsx index fcdf9783..9dee6ae4 100644 --- a/packages/ui/src/features/ci-status/components/CIStatusBadge.tsx +++ b/packages/ui/src/features/ci-status/components/CIStatusBadge.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Check, X, Loader2, Clock, Circle } from 'lucide-react'; +import { Check, X, Loader2, Clock, Circle, ChevronDown, ChevronRight } from 'lucide-react'; import type { CIStatus, CIRollupState } from '../types'; interface CIStatusBadgeProps { @@ -84,12 +84,11 @@ export const CIStatusBadge: React.FC = ({ )} {onClick && ( - - ▼ - + expanded ? ( + + ) : ( + + ) )} ); From 1195afdbddc24f34e41c549430aa85442dcde860 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Mon, 19 Jan 2026 20:00:01 +0800 Subject: [PATCH 6/7] Add draft PR status display and mark-as-ready functionality - Add draft PR detection with gray icon styling in right panel and sidebar - Consolidate merged/isDraft booleans into single state field (draft/open/merged) - Add mark-as-ready button when draft PR has passing CI and is synced - Update all UI components to use new state field - Update tests to reflect new PR state structure --- .../ipc/__tests__/gitHandlers.test.ts | 16 +-- .../desktop/src/infrastructure/ipc/git.ts | 113 +++++++++++++++++- .../components/layout/RightPanel/index.tsx | 60 ++++++++-- .../ui/src/components/layout/StageBadge.tsx | 32 +++-- .../__tests__/RightPanelBranchSync.test.tsx | 34 +++--- .../layout/useRightPanelData.test.tsx | 8 +- .../components/layout/useRightPanelData.ts | 10 +- .../ui/src/hooks/useWorkspaceStageSync.ts | 10 +- packages/ui/src/types/electron.d.ts | 3 +- packages/ui/src/types/workspace.ts | 7 +- 10 files changed, 232 insertions(+), 61 deletions(-) diff --git a/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts b/packages/desktop/src/infrastructure/ipc/__tests__/gitHandlers.test.ts index 5bfc482a..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 diff --git a/packages/desktop/src/infrastructure/ipc/git.ts b/packages/desktop/src/infrastructure/ipc/git.ts index c578ef41..90ca83c5 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 @@ -1259,6 +1269,97 @@ 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 { + const session = sessionManager.getSession(sessionId); + if (!session?.worktreePath) { + return { success: false, error: 'Session worktree not found' }; + } + + // Get owner/repo from remotes + const remoteRes = await gitExecutor.run({ + sessionId, + cwd: session.worktreePath, + argv: ['git', 'remote', '-v'], + op: 'read', + recordTimeline: false, + throwOnError: false, + timeoutMs: 5_000, + meta: { source: 'ipc.git', operation: 'mark-pr-ready-remote' }, + }); + + if (remoteRes.exitCode !== 0) { + return { success: false, error: 'Failed to get git remotes' }; + } + + const ownerRepo = parseOwnerRepoFromRemoteOutput(remoteRes.stdout || ''); + if (!ownerRepo) { + return { success: false, error: 'Could not parse owner/repo from remotes' }; + } + + // 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()) { + return { success: false, error: 'Failed to get current branch' }; + } + + const branch = branchRes.stdout.trim(); + + // Determine if this is a fork PR + const remotesOutput = remoteRes.stdout || ''; + const upstreamMatch = remotesOutput.match(/^upstream\s+(\S+)/m); + const originMatch = remotesOutput.match(/^origin\s+(\S+)/m); + + let branchArg = branch; + if (upstreamMatch && originMatch) { + const upstreamUrl = upstreamMatch[1]; + const originUrl = originMatch[1]; + if (isForkOfUpstream(originUrl, upstreamUrl)) { + const originOwner = parseGitRemoteToOwnerRepoParts(originUrl)?.owner; + if (originOwner) { + branchArg = `${originOwner}:${branch}`; + } + } + } + + // 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' }, + }); + + if (readyRes.exitCode !== 0) { + return { + success: false, + error: readyRes.stderr || 'Failed to mark PR as ready' + }; + } + + return { success: true }; + } 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/ui/src/components/layout/RightPanel/index.tsx b/packages/ui/src/components/layout/RightPanel/index.tsx index 221b8fda..73227bfb 100644 --- a/packages/ui/src/components/layout/RightPanel/index.tsx +++ b/packages/ui/src/components/layout/RightPanel/index.tsx @@ -96,6 +96,19 @@ export const RightPanel: React.FC = React.memo( } }, [session?.id]); + const handleMarkPRReady = useCallback(async () => { + if (!session?.id) return; + try { + const result = await window.electronAPI?.sessions?.markPRReady?.(session.id); + if (result?.success) { + // Refresh to get updated PR status + refresh(); + } + } catch { + // ignore + } + }, [session?.id, refresh]); + const isWorkingTreeSelected = selection?.kind === 'working'; const selectedCommitHash = selection?.kind === 'commit' ? selection.hash : null; @@ -292,11 +305,26 @@ export const RightPanel: React.FC = React.memo( data-testid="right-panel-open-remote-pr" >
- + PR #{remotePullRequest.number} - {remotePullRequest.merged && ( + {remotePullRequest.state === 'draft' && ( + + draft + + )} + {remotePullRequest.state === 'merged' && ( = React.memo( {/* Action Buttons */}
+ {/* Mark as Ready (only show if draft, CI passed, working tree clean, and PR synced) */} + {remotePullRequest && remotePullRequest.state === 'draft' && !hasUncommittedChanges && prSyncStatus?.localAhead === 0 && ciStatus?.rollupState === 'success' && ( + + )} + {/* Push & Create/Sync PR */} {onPushPR && (