diff --git a/apps/ui/src/components/views/board-view/board-controls.tsx b/apps/ui/src/components/views/board-view/board-controls.tsx index c4d7f3af..fd36031b 100644 --- a/apps/ui/src/components/views/board-view/board-controls.tsx +++ b/apps/ui/src/components/views/board-view/board-controls.tsx @@ -19,7 +19,7 @@ export function BoardControls({ return ( -
+
{/* Board Background Button */} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index c9ffd23c..9ad795d3 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -8,6 +8,7 @@ import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react'; import { UsagePopover } from '@/components/usage-popover'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; +import { useIsMobile } from '@/hooks/use-media-query'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog'; import { PlanSettingsDialog } from './dialogs/plan-settings-dialog'; @@ -15,6 +16,7 @@ import { getHttpApiClient } from '@/lib/http-api-client'; import { BoardSearchBar } from './board-search-bar'; import { BoardControls } from './board-controls'; import { ViewToggle, type ViewMode } from './components'; +import { HeaderMobileMenu } from './header-mobile-menu'; export type { ViewMode }; @@ -120,8 +122,10 @@ export function BoardHeader({ // Show if Codex is authenticated (CLI or API key) const showCodexUsage = !!codexAuthStatus?.authenticated; + const isMobile = useIsMobile(); + return ( -
+
- {isMounted && ( - - )} + {isMounted && }
-
+
{/* Usage Popover - show if either provider is authenticated */} {isMounted && (showClaudeUsage || showCodexUsage) && } + {/* Mobile view: show hamburger menu with all controls */} + {isMounted && isMobile && ( + setShowAutoModeSettings(true)} + onOpenPlanDialog={onOpenPlanDialog} + /> + )} + + {/* Desktop view: show full controls */} {/* Worktrees Toggle - only show after mount to prevent hydration issues */} - {isMounted && ( + {isMounted && !isMobile && (
+ {/* Plan Button with Settings - only show on desktop, mobile has it in the menu */} + {!isMobile && ( +
+ + +
+ )} {/* Plan Settings Dialog */} 0 && !feature.error && feature.status === 'backlog'; - const showManualVerification = feature.skipTests && !feature.error && feature.status === 'backlog'; + const isBlocked = + blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog'; + const showManualVerification = + feature.skipTests && !feature.error && feature.status === 'backlog'; const hasPlan = feature.planSpec?.content; // Check if just finished (within 2 minutes) @@ -237,11 +238,7 @@ const IndicatorBadges = memo(function IndicatorBadges({ /** * PriorityBadge displays the priority indicator in the table */ -const PriorityBadge = memo(function PriorityBadge({ - priority, -}: { - priority: number | undefined; -}) { +const PriorityBadge = memo(function PriorityBadge({ priority }: { priority: number | undefined }) { const display = getPriorityDisplay(priority); if (!display) { @@ -358,10 +355,7 @@ export const ListRow = memo(function ListRow({ > {/* Checkbox column */} {showCheckbox && ( -
+
)} - {/* Title column */} + {/* Title column - full width with margin for actions */}
- {/* Status column */} -
- -
- - {/* Category column */} -
- {feature.category || '-'} -
- - {/* Priority column */} -
- -
- - {/* Created At column */} -
- {formatRelativeDate(feature.createdAt)} -
- - {/* Updated At column */} -
- {formatRelativeDate(feature.updatedAt)} -
- {/* Actions column */} -
- +
+
); // Wrap with animated border for currently running auto task if (isCurrentAutoTask) { - return ( -
- {rowContent} -
- ); + return
{rowContent}
; } return rowContent; diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index 6882c437..edb667ce 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -13,6 +13,7 @@ import { Wand2, Archive, GitBranch, + GitFork, ExternalLink, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -80,8 +81,10 @@ const MenuItem = memo(function MenuItem({ default: '', destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10', primary: 'text-primary focus:text-primary focus:bg-primary/10', - success: 'text-[var(--status-success)] focus:text-[var(--status-success)] focus:bg-[var(--status-success)]/10', - warning: 'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10', + success: + 'text-[var(--status-success)] focus:text-[var(--status-success)] focus:bg-[var(--status-success)]/10', + warning: + 'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10', }; return ( @@ -193,11 +196,12 @@ function getPrimaryAction( * - Keyboard accessible (focus, Enter/Space to open) * * Actions by status: - * - Backlog: Edit, Delete, Make (implement), View Plan - * - In Progress: View Logs, Resume, Approve Plan, Manual Verify - * - Waiting Approval: Refine, Verify, View Logs - * - Verified: View Logs, Complete - * - Running (auto task): View Logs, Force Stop, Approve Plan + * - Backlog: Edit, Delete, Make (implement), View Plan, Spawn Sub-Task + * - In Progress: View Logs, Resume, Approve Plan, Manual Verify, Edit, Spawn Sub-Task, Delete + * - Waiting Approval: Refine, Verify, View Logs, View PR, Edit, Spawn Sub-Task, Delete + * - Verified: View Logs, View PR, View Branch, Complete, Edit, Spawn Sub-Task, Delete + * - Running (auto task): View Logs, Approve Plan, Edit, Spawn Sub-Task, Force Stop + * - Pipeline statuses: View Logs, Edit, Spawn Sub-Task, Delete * * @example * ```tsx @@ -246,12 +250,7 @@ export const RowActions = memo(function RowActions({ return (
e.stopPropagation()} data-testid={`row-actions-${feature.id}`} > @@ -262,10 +261,13 @@ export const RowActions = memo(function RowActions({ size="icon-sm" className={cn( 'h-7 w-7', - primaryAction.variant === 'destructive' && 'hover:bg-destructive/10 hover:text-destructive', + primaryAction.variant === 'destructive' && + 'hover:bg-destructive/10 hover:text-destructive', primaryAction.variant === 'primary' && 'hover:bg-primary/10 hover:text-primary', - primaryAction.variant === 'success' && 'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]', - primaryAction.variant === 'warning' && 'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]' + primaryAction.variant === 'success' && + 'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]', + primaryAction.variant === 'warning' && + 'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]' )} onClick={(e) => { e.stopPropagation(); @@ -284,7 +286,7 @@ export const RowActions = memo(function RowActions({
diff --git a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx index 5124f7af..7b900409 100644 --- a/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx @@ -304,22 +304,22 @@ export function AgentOutputModal({ return ( -
+
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && ( )} Agent Output -
+
{summary && ( + + + + Controls + + + + {/* Auto Mode Toggle */} +
onAutoModeToggle(!isAutoModeRunning)} + data-testid="mobile-auto-mode-toggle-container" + > +
+ + Auto Mode +
+
+ e.stopPropagation()} + data-testid="mobile-auto-mode-toggle" + /> + +
+
+ + + + {/* Worktrees Toggle */} +
onWorktreePanelToggle(!isWorktreePanelVisible)} + data-testid="mobile-worktrees-toggle-container" + > +
+ + Worktrees +
+ e.stopPropagation()} + data-testid="mobile-worktrees-toggle" + /> +
+ + + + {/* Concurrency Control */} +
+
+ + Max Agents + + {runningAgentsCount}/{maxConcurrency} + +
+ onConcurrencyChange(value[0])} + min={1} + max={10} + step={1} + className="w-full" + data-testid="mobile-concurrency-slider" + /> +
+ + + + {/* Plan Button */} + + + Plan + +
+ + ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts b/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts index b436fa26..e67e4d04 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts @@ -1,3 +1,4 @@ export { BranchSwitchDropdown } from './branch-switch-dropdown'; export { WorktreeActionsDropdown } from './worktree-actions-dropdown'; +export { WorktreeMobileDropdown } from './worktree-mobile-dropdown'; export { WorktreeTab } from './worktree-tab'; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index c7d8f26b..7148983f 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -44,6 +44,8 @@ interface WorktreeActionsDropdownProps { isDevServerRunning: boolean; devServerInfo?: DevServerInfo; gitRepoStatus: GitRepoStatus; + /** When true, renders as a standalone button (not attached to another element) */ + standalone?: boolean; onOpenChange: (open: boolean) => void; onPull: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void; @@ -71,6 +73,7 @@ export function WorktreeActionsDropdown({ isDevServerRunning, devServerInfo, gitRepoStatus, + standalone = false, onOpenChange, onPull, onPush, @@ -115,15 +118,17 @@ export function WorktreeActionsDropdown({ diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx new file mode 100644 index 00000000..52a07c96 --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-mobile-dropdown.tsx @@ -0,0 +1,112 @@ +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { WorktreeInfo } from '../types'; + +interface WorktreeMobileDropdownProps { + worktrees: WorktreeInfo[]; + isWorktreeSelected: (worktree: WorktreeInfo) => boolean; + hasRunningFeatures: (worktree: WorktreeInfo) => boolean; + isActivating: boolean; + branchCardCounts?: Record; + onSelectWorktree: (worktree: WorktreeInfo) => void; +} + +export function WorktreeMobileDropdown({ + worktrees, + isWorktreeSelected, + hasRunningFeatures, + isActivating, + branchCardCounts, + onSelectWorktree, +}: WorktreeMobileDropdownProps) { + // Find the currently selected worktree to display in the trigger + const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)); + const displayBranch = selectedWorktree?.branch || 'Select branch'; + + return ( + + + + + + + Branches & Worktrees + + + {worktrees.map((worktree) => { + const isSelected = isWorktreeSelected(worktree); + const isRunning = hasRunningFeatures(worktree); + const cardCount = branchCardCounts?.[worktree.branch]; + const hasChanges = worktree.hasChanges; + const changedFilesCount = worktree.changedFilesCount; + + return ( + onSelectWorktree(worktree)} + className={cn('flex items-center gap-2 cursor-pointer', isSelected && 'bg-accent')} + > +
+ {isSelected ? ( + + ) : ( +
+ )} + {isRunning && } + + {worktree.branch} + + {worktree.isMain && ( + + main + + )} +
+
+ {cardCount !== undefined && cardCount > 0 && ( + + {cardCount} + + )} + {hasChanges && ( + + + {changedFilesCount ?? '!'} + + )} +
+ + ); + })} + + + ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index a9f2431e..6b63682c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -4,6 +4,7 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react'; import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { getHttpApiClient } from '@/lib/http-api-client'; +import { useIsMobile } from '@/hooks/use-media-query'; import type { WorktreePanelProps, WorktreeInfo } from './types'; import { useWorktrees, @@ -12,7 +13,7 @@ import { useWorktreeActions, useRunningFeatures, } from './hooks'; -import { WorktreeTab } from './components'; +import { WorktreeTab, WorktreeMobileDropdown, WorktreeActionsDropdown } from './components'; export function WorktreePanel({ projectPath, @@ -103,6 +104,8 @@ export function WorktreePanel({ checkInitScript(); }, [projectPath]); + const isMobile = useIsMobile(); + // Periodic interval check (5 seconds) to detect branch changes on disk // Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders const intervalRef = useRef(null); @@ -167,6 +170,85 @@ export function WorktreePanel({ const mainWorktree = worktrees.find((w) => w.isMain); const nonMainWorktrees = worktrees.filter((w) => !w.isMain); + // Mobile view: single dropdown for all worktrees + if (isMobile) { + // Find the currently selected worktree for the actions menu + const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)) || mainWorktree; + + return ( +
+ + + {/* Actions menu for the selected worktree */} + {selectedWorktree && ( + + )} + + {useWorktreesEnabled && ( + <> + + + + + )} +
+ ); + } + + // Desktop view: full tabs layout return (
diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index 642daaf6..4c0c279b 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -8,6 +8,7 @@ import { useNodesState, useEdgesState, ReactFlowProvider, + useReactFlow, SelectionMode, ConnectionMode, Node, @@ -244,6 +245,61 @@ function GraphCanvasInner({ [] ); + // Get fitView from React Flow for orientation change handling + const { fitView } = useReactFlow(); + + // Handle orientation changes on mobile devices + // When rotating from landscape to portrait, the view may incorrectly zoom in + // This effect listens for orientation changes and calls fitView to correct the viewport + useEffect(() => { + if (typeof window === 'undefined') return; + + // Track the previous orientation to detect changes + let previousWidth = window.innerWidth; + let previousHeight = window.innerHeight; + + const handleOrientationChange = () => { + // Small delay to allow the browser to complete the orientation change + setTimeout(() => { + fitView({ padding: 0.2, duration: 300 }); + }, 100); + }; + + const handleResize = () => { + const currentWidth = window.innerWidth; + const currentHeight = window.innerHeight; + + // Detect orientation change by checking if width and height swapped significantly + // This happens when device rotates between portrait and landscape + const widthDiff = Math.abs(currentWidth - previousHeight); + const heightDiff = Math.abs(currentHeight - previousWidth); + + // If the dimensions are close to being swapped (within 100px tolerance) + // it's likely an orientation change + const isOrientationChange = widthDiff < 100 && heightDiff < 100; + + if (isOrientationChange) { + // Delay fitView to allow browser to complete the layout + setTimeout(() => { + fitView({ padding: 0.2, duration: 300 }); + }, 150); + } + + previousWidth = currentWidth; + previousHeight = currentHeight; + }; + + // Listen for orientation change event (mobile specific) + window.addEventListener('orientationchange', handleOrientationChange); + // Also listen for resize as a fallback (some browsers don't fire orientationchange) + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('orientationchange', handleOrientationChange); + window.removeEventListener('resize', handleResize); + }; + }, [fitView]); + // MiniMap node color based on status const minimapNodeColor = useCallback((node: Node) => { const data = node.data as TaskNodeData | undefined; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index a542d79a..b253129f 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -386,6 +386,18 @@ export interface GlobalSettings { /** How to pass notification data to the command */ notificationCommandMode: 'args' | 'stdin' | 'env'; + // ntfy.sh Notifications + /** Enable ntfy.sh notifications for task completion */ + ntfyEnabled: boolean; + /** ntfy.sh server URL (default: https://ntfy.sh) */ + ntfyServerUrl: string; + /** ntfy.sh topic to publish notifications to */ + ntfyTopic: string; + /** Optional authentication token for private ntfy servers */ + ntfyAuthToken?: string; + /** Priority for ntfy notifications (1-5, default: 3) */ + ntfyPriority: 1 | 2 | 3 | 4 | 5; + // AI Model Selection (per-phase configuration) /** Phase-specific AI model configuration */ phaseModels: PhaseModelConfig; @@ -707,6 +719,11 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { muteDoneSound: false, notificationCommand: '', notificationCommandMode: 'args', + ntfyEnabled: false, + ntfyServerUrl: 'https://ntfy.sh', + ntfyTopic: '', + ntfyAuthToken: undefined, + ntfyPriority: 3, phaseModels: DEFAULT_PHASE_MODELS, enhancementModel: 'sonnet', validationModel: 'opus',