feat: Implement responsive mobile header layout with menu consolidation

This commit is contained in:
anonymous
2026-01-11 21:47:02 -08:00
committed by Shirone
parent 007830ec74
commit e2394244f6
13 changed files with 592 additions and 257 deletions

View File

@@ -19,7 +19,7 @@ export function BoardControls({
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex items-center gap-2"> <div className="flex items-center gap-5">
{/* Board Background Button */} {/* Board Background Button */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -8,6 +8,7 @@ import { Bot, Wand2, Settings2, GitBranch } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover'; import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { useIsMobile } from '@/hooks/use-media-query';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog'; import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog'; import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-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 { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls'; import { BoardControls } from './board-controls';
import { ViewToggle, type ViewMode } from './components'; import { ViewToggle, type ViewMode } from './components';
import { HeaderMobileMenu } from './header-mobile-menu';
export type { ViewMode }; export type { ViewMode };
@@ -120,8 +122,10 @@ export function BoardHeader({
// Show if Codex is authenticated (CLI or API key) // Show if Codex is authenticated (CLI or API key)
const showCodexUsage = !!codexAuthStatus?.authenticated; const showCodexUsage = !!codexAuthStatus?.authenticated;
const isMobile = useIsMobile();
return ( return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<BoardSearchBar <BoardSearchBar
searchQuery={searchQuery} searchQuery={searchQuery}
@@ -130,12 +134,7 @@ export function BoardHeader({
creatingSpecProjectPath={creatingSpecProjectPath} creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath} currentProjectPath={projectPath}
/> />
{isMounted && ( {isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
<ViewToggle
viewMode={viewMode}
onViewModeChange={onViewModeChange}
/>
)}
<BoardControls <BoardControls
isMounted={isMounted} isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground} onShowBoardBackground={onShowBoardBackground}
@@ -143,12 +142,28 @@ export function BoardHeader({
completedCount={completedCount} completedCount={completedCount}
/> />
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated */} {/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />} {isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Mobile view: show hamburger menu with all controls */}
{isMounted && isMobile && (
<HeaderMobileMenu
isWorktreePanelVisible={isWorktreePanelVisible}
onWorktreePanelToggle={handleWorktreePanelToggle}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAgentsCount}
onConcurrencyChange={onConcurrencyChange}
isAutoModeRunning={isAutoModeRunning}
onAutoModeToggle={onAutoModeToggle}
onOpenAutoModeSettings={() => setShowAutoModeSettings(true)}
onOpenPlanDialog={onOpenPlanDialog}
/>
)}
{/* Desktop view: show full controls */}
{/* Worktrees Toggle - only show after mount to prevent hydration issues */} {/* Worktrees Toggle - only show after mount to prevent hydration issues */}
{isMounted && ( {isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="worktrees-toggle-container"> <div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" /> <GitBranch className="w-4 h-4 text-muted-foreground" />
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer"> <Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
@@ -180,7 +195,7 @@ export function BoardHeader({
/> />
{/* Concurrency Control - only show after mount to prevent hydration issues */} {/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && ( {isMounted && !isMobile && (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
@@ -223,7 +238,7 @@ export function BoardHeader({
)} )}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && ( {isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container"> <div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer"> <Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode Auto Mode
@@ -253,25 +268,27 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode} onSkipVerificationChange={setSkipVerificationInAutoMode}
/> />
{/* Plan Button with Settings */} {/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
<div className={controlContainerClass} data-testid="plan-button-container"> {!isMobile && (
<button <div className={controlContainerClass} data-testid="plan-button-container">
onClick={onOpenPlanDialog} <button
className="flex items-center gap-1.5 hover:text-foreground transition-colors" onClick={onOpenPlanDialog}
data-testid="plan-backlog-button" className="flex items-center gap-1.5 hover:text-foreground transition-colors"
> data-testid="plan-backlog-button"
<Wand2 className="w-4 h-4 text-muted-foreground" /> >
<span className="text-sm font-medium">Plan</span> <Wand2 className="w-4 h-4 text-muted-foreground" />
</button> <span className="text-sm font-medium">Plan</span>
<button </button>
onClick={() => setShowPlanSettings(true)} <button
className="p-1 rounded hover:bg-accent/50 transition-colors" onClick={() => setShowPlanSettings(true)}
title="Plan Settings" className="p-1 rounded hover:bg-accent/50 transition-colors"
data-testid="plan-settings-button" title="Plan Settings"
> data-testid="plan-settings-button"
<Settings2 className="w-4 h-4 text-muted-foreground" /> >
</button> <Settings2 className="w-4 h-4 text-muted-foreground" />
</div> </button>
</div>
)}
{/* Plan Settings Dialog */} {/* Plan Settings Dialog */}
<PlanSettingsDialog <PlanSettingsDialog

View File

@@ -23,6 +23,7 @@ interface ColumnDef {
/** /**
* Default column definitions for the list view * Default column definitions for the list view
* Only showing title column with full width for a cleaner, more spacious layout
*/ */
export const LIST_COLUMNS: ColumnDef[] = [ export const LIST_COLUMNS: ColumnDef[] = [
{ {
@@ -30,42 +31,7 @@ export const LIST_COLUMNS: ColumnDef[] = [
label: 'Title', label: 'Title',
sortable: true, sortable: true,
width: 'flex-1', width: 'flex-1',
minWidth: 'min-w-[200px]', minWidth: 'min-w-0',
align: 'left',
},
{
id: 'status',
label: 'Status',
sortable: true,
width: 'w-[140px]',
align: 'left',
},
{
id: 'category',
label: 'Category',
sortable: true,
width: 'w-[120px]',
align: 'left',
},
{
id: 'priority',
label: 'Priority',
sortable: true,
width: 'w-[100px]',
align: 'center',
},
{
id: 'createdAt',
label: 'Created',
sortable: true,
width: 'w-[110px]',
align: 'left',
},
{
id: 'updatedAt',
label: 'Updated',
sortable: true,
width: 'w-[110px]',
align: 'left', align: 'left',
}, },
]; ];
@@ -92,13 +58,7 @@ export interface ListHeaderProps {
/** /**
* SortIcon displays the current sort state for a column * SortIcon displays the current sort state for a column
*/ */
function SortIcon({ function SortIcon({ column, sortConfig }: { column: SortColumn; sortConfig: SortConfig }) {
column,
sortConfig,
}: {
column: SortColumn;
sortConfig: SortConfig;
}) {
if (sortConfig.column !== column) { if (sortConfig.column !== column) {
// Not sorted by this column - show neutral indicator // Not sorted by this column - show neutral indicator
return ( return (
@@ -173,11 +133,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
/** /**
* StaticColumnHeader renders a non-sortable header cell * StaticColumnHeader renders a non-sortable header cell
*/ */
const StaticColumnHeader = memo(function StaticColumnHeader({ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column: ColumnDef }) {
column,
}: {
column: ColumnDef;
}) {
return ( return (
<div <div
role="columnheader" role="columnheader"

View File

@@ -5,9 +5,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react'; import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
import type { Feature } from '@/store/app-store'; import type { Feature } from '@/store/app-store';
import type { PipelineConfig } from '@automaker/types'; import type { PipelineConfig } from '@automaker/types';
import { StatusBadge } from './status-badge';
import { RowActions, type RowActionHandlers } from './row-actions'; import { RowActions, type RowActionHandlers } from './row-actions';
import { LIST_COLUMNS, getColumnWidth, getColumnAlign } from './list-header'; import { getColumnWidth, getColumnAlign } from './list-header';
/** /**
* Format a date string for display in the table * Format a date string for display in the table
@@ -123,8 +122,10 @@ const IndicatorBadges = memo(function IndicatorBadges({
isCurrentAutoTask?: boolean; isCurrentAutoTask?: boolean;
}) { }) {
const hasError = feature.error && !isCurrentAutoTask; const hasError = feature.error && !isCurrentAutoTask;
const isBlocked = blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog'; const isBlocked =
const showManualVerification = feature.skipTests && !feature.error && feature.status === 'backlog'; blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog';
const hasPlan = feature.planSpec?.content; const hasPlan = feature.planSpec?.content;
// Check if just finished (within 2 minutes) // Check if just finished (within 2 minutes)
@@ -237,11 +238,7 @@ const IndicatorBadges = memo(function IndicatorBadges({
/** /**
* PriorityBadge displays the priority indicator in the table * PriorityBadge displays the priority indicator in the table
*/ */
const PriorityBadge = memo(function PriorityBadge({ const PriorityBadge = memo(function PriorityBadge({ priority }: { priority: number | undefined }) {
priority,
}: {
priority: number | undefined;
}) {
const display = getPriorityDisplay(priority); const display = getPriorityDisplay(priority);
if (!display) { if (!display) {
@@ -358,10 +355,7 @@ export const ListRow = memo(function ListRow({
> >
{/* Checkbox column */} {/* Checkbox column */}
{showCheckbox && ( {showCheckbox && (
<div <div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
role="cell"
className="flex items-center justify-center w-10 px-2 py-3 shrink-0"
>
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
@@ -376,7 +370,7 @@ export const ListRow = memo(function ListRow({
</div> </div>
)} )}
{/* Title column */} {/* Title column - full width with margin for actions */}
<div <div
role="cell" role="cell"
className={cn( className={cn(
@@ -414,94 +408,16 @@ export const ListRow = memo(function ListRow({
</div> </div>
</div> </div>
{/* Status column */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3',
getColumnWidth('status'),
getColumnAlign('status')
)}
>
<StatusBadge
status={feature.status}
pipelineConfig={pipelineConfig}
size="sm"
/>
</div>
{/* Category column */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 text-sm text-muted-foreground truncate',
getColumnWidth('category'),
getColumnAlign('category')
)}
title={feature.category}
>
{feature.category || '-'}
</div>
{/* Priority column */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3',
getColumnWidth('priority'),
getColumnAlign('priority')
)}
>
<PriorityBadge priority={feature.priority} />
</div>
{/* Created At column */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 text-sm text-muted-foreground',
getColumnWidth('createdAt'),
getColumnAlign('createdAt')
)}
title={feature.createdAt ? new Date(feature.createdAt).toLocaleString() : undefined}
>
{formatRelativeDate(feature.createdAt)}
</div>
{/* Updated At column */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 text-sm text-muted-foreground',
getColumnWidth('updatedAt'),
getColumnAlign('updatedAt')
)}
title={feature.updatedAt ? new Date(feature.updatedAt).toLocaleString() : undefined}
>
{formatRelativeDate(feature.updatedAt)}
</div>
{/* Actions column */} {/* Actions column */}
<div <div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
role="cell" <RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0"
>
<RowActions
feature={feature}
handlers={handlers}
isCurrentAutoTask={isCurrentAutoTask}
/>
</div> </div>
</div> </div>
); );
// Wrap with animated border for currently running auto task // Wrap with animated border for currently running auto task
if (isCurrentAutoTask) { if (isCurrentAutoTask) {
return ( return <div className="animated-border-wrapper-row">{rowContent}</div>;
<div className="animated-border-wrapper-row">
{rowContent}
</div>
);
} }
return rowContent; return rowContent;

View File

@@ -13,6 +13,7 @@ import {
Wand2, Wand2,
Archive, Archive,
GitBranch, GitBranch,
GitFork,
ExternalLink, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -80,8 +81,10 @@ const MenuItem = memo(function MenuItem({
default: '', default: '',
destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10', destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10',
primary: 'text-primary focus:text-primary focus:bg-primary/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', success:
warning: 'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10', '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 ( return (
@@ -193,11 +196,12 @@ function getPrimaryAction(
* - Keyboard accessible (focus, Enter/Space to open) * - Keyboard accessible (focus, Enter/Space to open)
* *
* Actions by status: * Actions by status:
* - Backlog: Edit, Delete, Make (implement), View Plan * - Backlog: Edit, Delete, Make (implement), View Plan, Spawn Sub-Task
* - In Progress: View Logs, Resume, Approve Plan, Manual Verify * - In Progress: View Logs, Resume, Approve Plan, Manual Verify, Edit, Spawn Sub-Task, Delete
* - Waiting Approval: Refine, Verify, View Logs * - Waiting Approval: Refine, Verify, View Logs, View PR, Edit, Spawn Sub-Task, Delete
* - Verified: View Logs, Complete * - Verified: View Logs, View PR, View Branch, Complete, Edit, Spawn Sub-Task, Delete
* - Running (auto task): View Logs, Force Stop, Approve Plan * - Running (auto task): View Logs, Approve Plan, Edit, Spawn Sub-Task, Force Stop
* - Pipeline statuses: View Logs, Edit, Spawn Sub-Task, Delete
* *
* @example * @example
* ```tsx * ```tsx
@@ -246,12 +250,7 @@ export const RowActions = memo(function RowActions({
return ( return (
<div <div
className={cn( className={cn('flex items-center gap-1', className)}
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200',
'focus-within:opacity-100',
open && 'opacity-100',
className
)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
data-testid={`row-actions-${feature.id}`} data-testid={`row-actions-${feature.id}`}
> >
@@ -262,10 +261,13 @@ export const RowActions = memo(function RowActions({
size="icon-sm" size="icon-sm"
className={cn( className={cn(
'h-7 w-7', '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 === 'primary' && 'hover:bg-primary/10 hover:text-primary',
primaryAction.variant === 'success' && 'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]', primaryAction.variant === 'success' &&
primaryAction.variant === 'warning' && 'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]' '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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -284,7 +286,7 @@ export const RowActions = memo(function RowActions({
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="h-7 w-7" className="h-7 w-7 text-muted-foreground hover:text-foreground"
data-testid={`row-actions-trigger-${feature.id}`} data-testid={`row-actions-trigger-${feature.id}`}
> >
<MoreHorizontal className="w-4 h-4" /> <MoreHorizontal className="w-4 h-4" />
@@ -310,6 +312,14 @@ export const RowActions = memo(function RowActions({
variant="warning" variant="warning"
/> />
)} )}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onForceStop && ( {handlers.onForceStop && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -327,17 +337,9 @@ export const RowActions = memo(function RowActions({
{/* Backlog actions */} {/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && ( {!isCurrentAutoTask && feature.status === 'backlog' && (
<> <>
<MenuItem <MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
icon={Edit}
label="Edit"
onClick={withClose(handlers.onEdit)}
/>
{feature.planSpec?.content && handlers.onViewPlan && ( {feature.planSpec?.content && handlers.onViewPlan && (
<MenuItem <MenuItem icon={Eye} label="View Plan" onClick={withClose(handlers.onViewPlan)} />
icon={Eye}
label="View Plan"
onClick={withClose(handlers.onViewPlan)}
/>
)} )}
{handlers.onImplement && ( {handlers.onImplement && (
<MenuItem <MenuItem
@@ -347,6 +349,13 @@ export const RowActions = memo(function RowActions({
variant="primary" variant="primary"
/> />
)} )}
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
@@ -391,11 +400,14 @@ export const RowActions = memo(function RowActions({
/> />
) : null} ) : null}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<MenuItem <MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
icon={Edit} {handlers.onSpawnTask && (
label="Edit" <MenuItem
onClick={withClose(handlers.onEdit)} icon={GitFork}
/> label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
label="Delete" label="Delete"
@@ -416,11 +428,7 @@ export const RowActions = memo(function RowActions({
/> />
)} )}
{handlers.onFollowUp && ( {handlers.onFollowUp && (
<MenuItem <MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
icon={Wand2}
label="Refine"
onClick={withClose(handlers.onFollowUp)}
/>
)} )}
{feature.prUrl && ( {feature.prUrl && (
<MenuItem <MenuItem
@@ -438,11 +446,14 @@ export const RowActions = memo(function RowActions({
/> />
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<MenuItem <MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
icon={Edit} {handlers.onSpawnTask && (
label="Edit" <MenuItem
onClick={withClose(handlers.onEdit)} icon={GitFork}
/> label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
label="Delete" label="Delete"
@@ -486,11 +497,14 @@ export const RowActions = memo(function RowActions({
/> />
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<MenuItem <MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
icon={Edit} {handlers.onSpawnTask && (
label="Edit" <MenuItem
onClick={withClose(handlers.onEdit)} icon={GitFork}
/> label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem <MenuItem
icon={Trash2} icon={Trash2}
label="Delete" label="Delete"
@@ -501,30 +515,32 @@ export const RowActions = memo(function RowActions({
)} )}
{/* Pipeline status actions (generic fallback) */} {/* Pipeline status actions (generic fallback) */}
{!isCurrentAutoTask && {!isCurrentAutoTask && feature.status.startsWith('pipeline_') && (
feature.status.startsWith('pipeline_') && ( <>
<> {handlers.onViewOutput && (
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
<MenuItem <MenuItem
icon={Edit} icon={FileText}
label="Edit" label="View Logs"
onClick={withClose(handlers.onEdit)} onClick={withClose(handlers.onViewOutput)}
/> />
<DropdownMenuSeparator /> )}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem <MenuItem
icon={Trash2} icon={GitFork}
label="Delete" label="Spawn Sub-Task"
onClick={withClose(handlers.onDelete)} onClick={withClose(handlers.onSpawnTask)}
variant="destructive"
/> />
</> )}
)} <DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@@ -304,22 +304,22 @@ export function AgentOutputModal({
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent <DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col" className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
data-testid="agent-output-modal" data-testid="agent-output-modal"
> >
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && ( {featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" /> <Loader2 className="w-5 h-5 text-primary animate-spin" />
)} )}
Agent Output Agent Output
</DialogTitle> </DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1"> <div className="flex items-center gap-1 bg-muted rounded-lg p-1 overflow-x-auto">
{summary && ( {summary && (
<button <button
onClick={() => setViewMode('summary')} onClick={() => setViewMode('summary')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'summary' effectiveViewMode === 'summary'
? 'bg-primary/20 text-primary shadow-sm' ? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -332,7 +332,7 @@ export function AgentOutputModal({
)} )}
<button <button
onClick={() => setViewMode('parsed')} onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'parsed' effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm' ? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -344,7 +344,7 @@ export function AgentOutputModal({
</button> </button>
<button <button
onClick={() => setViewMode('changes')} onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'changes' effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm' ? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -356,7 +356,7 @@ export function AgentOutputModal({
</button> </button>
<button <button
onClick={() => setViewMode('raw')} onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'raw' effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm' ? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -384,7 +384,7 @@ export function AgentOutputModal({
/> />
{effectiveViewMode === 'changes' ? ( {effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible"> <div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? ( {projectPath ? (
<GitDiffPanel <GitDiffPanel
projectPath={projectPath} projectPath={projectPath}
@@ -401,7 +401,7 @@ export function AgentOutputModal({
)} )}
</div> </div>
) : effectiveViewMode === 'summary' && summary ? ( ) : effectiveViewMode === 'summary' && summary ? (
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible"> <div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
<Markdown>{summary}</Markdown> <Markdown>{summary}</Markdown>
</div> </div>
) : ( ) : (
@@ -409,7 +409,7 @@ export function AgentOutputModal({
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible" className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
> >
{isLoading && !output ? ( {isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">

View File

@@ -0,0 +1,157 @@
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
interface HeaderMobileMenuProps {
// Worktree panel visibility
isWorktreePanelVisible: boolean;
onWorktreePanelToggle: (visible: boolean) => void;
// Concurrency control
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
// Auto mode
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onOpenAutoModeSettings: () => void;
// Plan button
onOpenPlanDialog: () => void;
}
export function HeaderMobileMenu({
isWorktreePanelVisible,
onWorktreePanelToggle,
maxConcurrency,
runningAgentsCount,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onOpenAutoModeSettings,
onOpenPlanDialog,
}: HeaderMobileMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
data-testid="header-mobile-menu-trigger"
>
<Menu className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Controls
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Auto Mode Toggle */}
<div
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
data-testid="mobile-auto-mode-toggle-container"
>
<div className="flex items-center gap-2">
<Zap
className={cn(
'w-4 h-4',
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
)}
/>
<span className="text-sm font-medium">Auto Mode</span>
</div>
<div className="flex items-center gap-2">
<Switch
id="mobile-auto-mode-toggle"
checked={isAutoModeRunning}
onCheckedChange={onAutoModeToggle}
onClick={(e) => e.stopPropagation()}
data-testid="mobile-auto-mode-toggle"
/>
<button
onClick={(e) => {
e.stopPropagation();
onOpenAutoModeSettings();
}}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="mobile-auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</div>
<DropdownMenuSeparator />
{/* Worktrees Toggle */}
<div
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
data-testid="mobile-worktrees-toggle-container"
>
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Worktrees</span>
</div>
<Switch
id="mobile-worktrees-toggle"
checked={isWorktreePanelVisible}
onCheckedChange={onWorktreePanelToggle}
onClick={(e) => e.stopPropagation()}
data-testid="mobile-worktrees-toggle"
/>
</div>
<DropdownMenuSeparator />
{/* Concurrency Control */}
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
<div className="flex items-center gap-2 mb-2">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Max Agents</span>
<span
className="text-sm text-muted-foreground ml-auto"
data-testid="mobile-concurrency-value"
>
{runningAgentsCount}/{maxConcurrency}
</span>
</div>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-full"
data-testid="mobile-concurrency-slider"
/>
</div>
<DropdownMenuSeparator />
{/* Plan Button */}
<DropdownMenuItem
onClick={onOpenPlanDialog}
className="flex items-center gap-2"
data-testid="mobile-plan-button"
>
<Wand2 className="w-4 h-4" />
<span>Plan</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,3 +1,4 @@
export { BranchSwitchDropdown } from './branch-switch-dropdown'; export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown'; export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
export { WorktreeTab } from './worktree-tab'; export { WorktreeTab } from './worktree-tab';

View File

@@ -44,6 +44,8 @@ interface WorktreeActionsDropdownProps {
isDevServerRunning: boolean; isDevServerRunning: boolean;
devServerInfo?: DevServerInfo; devServerInfo?: DevServerInfo;
gitRepoStatus: GitRepoStatus; gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void;
@@ -71,6 +73,7 @@ export function WorktreeActionsDropdown({
isDevServerRunning, isDevServerRunning,
devServerInfo, devServerInfo,
gitRepoStatus, gitRepoStatus,
standalone = false,
onOpenChange, onOpenChange,
onPull, onPull,
onPush, onPush,
@@ -115,15 +118,17 @@ export function WorktreeActionsDropdown({
<DropdownMenu onOpenChange={onOpenChange}> <DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant={isSelected ? 'default' : 'outline'} variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
size="sm" size="sm"
className={cn( className={cn(
'h-7 w-7 p-0 rounded-l-none', 'h-7 w-7 p-0',
isSelected && 'bg-primary text-primary-foreground', !standalone && 'rounded-l-none',
!isSelected && 'bg-secondary/50 hover:bg-secondary' standalone && 'h-8 w-8 shrink-0',
!standalone && isSelected && 'bg-primary text-primary-foreground',
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
)} )}
> >
<MoreHorizontal className="w-3 h-3" /> <MoreHorizontal className="w-3.5 h-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56"> <DropdownMenuContent align="start" className="w-56">

View File

@@ -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<string, number>;
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3 gap-2 font-mono text-xs bg-secondary/50 hover:bg-secondary flex-1 min-w-0"
disabled={isActivating}
>
<GitBranch className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayBranch}</span>
{isActivating ? (
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
) : (
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 max-h-80 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Branches & Worktrees
</DropdownMenuLabel>
<DropdownMenuSeparator />
{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 (
<DropdownMenuItem
key={worktree.path}
onClick={() => onSelectWorktree(worktree)}
className={cn('flex items-center gap-2 cursor-pointer', isSelected && 'bg-accent')}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isSelected ? (
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{worktree.branch}
</span>
{worktree.isMain && (
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
main
</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
)}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -4,6 +4,7 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils'; import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import type { WorktreePanelProps, WorktreeInfo } from './types'; import type { WorktreePanelProps, WorktreeInfo } from './types';
import { import {
useWorktrees, useWorktrees,
@@ -12,7 +13,7 @@ import {
useWorktreeActions, useWorktreeActions,
useRunningFeatures, useRunningFeatures,
} from './hooks'; } from './hooks';
import { WorktreeTab } from './components'; import { WorktreeTab, WorktreeMobileDropdown, WorktreeActionsDropdown } from './components';
export function WorktreePanel({ export function WorktreePanel({
projectPath, projectPath,
@@ -103,6 +104,8 @@ export function WorktreePanel({
checkInitScript(); checkInitScript();
}, [projectPath]); }, [projectPath]);
const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk // 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 // Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -167,6 +170,85 @@ export function WorktreePanel({
const mainWorktree = worktrees.find((w) => w.isMain); const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((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 (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<WorktreeMobileDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
onSelectWorktree={handleSelectWorktree}
/>
{/* Actions menu for the selected worktree */}
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
standalone={true}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
/>
)}
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</>
)}
</div>
);
}
// Desktop view: full tabs layout
return ( return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm"> <div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" /> <GitBranch className="w-4 h-4 text-muted-foreground" />

View File

@@ -8,6 +8,7 @@ import {
useNodesState, useNodesState,
useEdgesState, useEdgesState,
ReactFlowProvider, ReactFlowProvider,
useReactFlow,
SelectionMode, SelectionMode,
ConnectionMode, ConnectionMode,
Node, 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 // MiniMap node color based on status
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => { const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
const data = node.data as TaskNodeData | undefined; const data = node.data as TaskNodeData | undefined;

View File

@@ -386,6 +386,18 @@ export interface GlobalSettings {
/** How to pass notification data to the command */ /** How to pass notification data to the command */
notificationCommandMode: 'args' | 'stdin' | 'env'; 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) // AI Model Selection (per-phase configuration)
/** Phase-specific AI model configuration */ /** Phase-specific AI model configuration */
phaseModels: PhaseModelConfig; phaseModels: PhaseModelConfig;
@@ -707,6 +719,11 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
muteDoneSound: false, muteDoneSound: false,
notificationCommand: '', notificationCommand: '',
notificationCommandMode: 'args', notificationCommandMode: 'args',
ntfyEnabled: false,
ntfyServerUrl: 'https://ntfy.sh',
ntfyTopic: '',
ntfyAuthToken: undefined,
ntfyPriority: 3,
phaseModels: DEFAULT_PHASE_MODELS, phaseModels: DEFAULT_PHASE_MODELS,
enhancementModel: 'sonnet', enhancementModel: 'sonnet',
validationModel: 'opus', validationModel: 'opus',