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 (
<TooltipProvider>
<div className="flex items-center gap-2">
<div className="flex items-center gap-5">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -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 (
<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">
<BoardSearchBar
searchQuery={searchQuery}
@@ -130,12 +134,7 @@ export function BoardHeader({
creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath}
/>
{isMounted && (
<ViewToggle
viewMode={viewMode}
onViewModeChange={onViewModeChange}
/>
)}
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
@@ -143,12 +142,28 @@ export function BoardHeader({
completedCount={completedCount}
/>
</div>
<div className="flex gap-2 items-center">
<div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated */}
{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 */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<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 */}
{isMounted && (
{isMounted && !isMobile && (
<Popover>
<PopoverTrigger asChild>
<button
@@ -223,7 +238,7 @@ export function BoardHeader({
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
@@ -253,25 +268,27 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
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>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
{!isMobile && (
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
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>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Plan Settings Dialog */}
<PlanSettingsDialog

View File

@@ -23,6 +23,7 @@ interface ColumnDef {
/**
* 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[] = [
{
@@ -30,42 +31,7 @@ export const LIST_COLUMNS: ColumnDef[] = [
label: 'Title',
sortable: true,
width: 'flex-1',
minWidth: 'min-w-[200px]',
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]',
minWidth: 'min-w-0',
align: 'left',
},
];
@@ -92,13 +58,7 @@ export interface ListHeaderProps {
/**
* SortIcon displays the current sort state for a column
*/
function SortIcon({
column,
sortConfig,
}: {
column: SortColumn;
sortConfig: SortConfig;
}) {
function SortIcon({ column, sortConfig }: { column: SortColumn; sortConfig: SortConfig }) {
if (sortConfig.column !== column) {
// Not sorted by this column - show neutral indicator
return (
@@ -173,11 +133,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
/**
* StaticColumnHeader renders a non-sortable header cell
*/
const StaticColumnHeader = memo(function StaticColumnHeader({
column,
}: {
column: ColumnDef;
}) {
const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column: ColumnDef }) {
return (
<div
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 type { Feature } from '@/store/app-store';
import type { PipelineConfig } from '@automaker/types';
import { StatusBadge } from './status-badge';
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
@@ -123,8 +122,10 @@ const IndicatorBadges = memo(function IndicatorBadges({
isCurrentAutoTask?: boolean;
}) {
const hasError = feature.error && !isCurrentAutoTask;
const isBlocked = blockingDependencies.length > 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 && (
<div
role="cell"
className="flex items-center justify-center w-10 px-2 py-3 shrink-0"
>
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
<input
type="checkbox"
checked={isSelected}
@@ -376,7 +370,7 @@ export const ListRow = memo(function ListRow({
</div>
)}
{/* Title column */}
{/* Title column - full width with margin for actions */}
<div
role="cell"
className={cn(
@@ -414,94 +408,16 @@ export const ListRow = memo(function ListRow({
</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 */}
<div
role="cell"
className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0"
>
<RowActions
feature={feature}
handlers={handlers}
isCurrentAutoTask={isCurrentAutoTask}
/>
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
</div>
</div>
);
// Wrap with animated border for currently running auto task
if (isCurrentAutoTask) {
return (
<div className="animated-border-wrapper-row">
{rowContent}
</div>
);
return <div className="animated-border-wrapper-row">{rowContent}</div>;
}
return rowContent;

View File

@@ -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 (
<div
className={cn(
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200',
'focus-within:opacity-100',
open && 'opacity-100',
className
)}
className={cn('flex items-center gap-1', className)}
onClick={(e) => 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({
<Button
variant="ghost"
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}`}
>
<MoreHorizontal className="w-4 h-4" />
@@ -310,6 +312,14 @@ export const RowActions = memo(function RowActions({
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 && (
<>
<DropdownMenuSeparator />
@@ -327,17 +337,9 @@ export const RowActions = memo(function RowActions({
{/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<MenuItem
icon={Edit}
label="Edit"
onClick={withClose(handlers.onEdit)}
/>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{feature.planSpec?.content && handlers.onViewPlan && (
<MenuItem
icon={Eye}
label="View Plan"
onClick={withClose(handlers.onViewPlan)}
/>
<MenuItem icon={Eye} label="View Plan" onClick={withClose(handlers.onViewPlan)} />
)}
{handlers.onImplement && (
<MenuItem
@@ -347,6 +349,13 @@ export const RowActions = memo(function RowActions({
variant="primary"
/>
)}
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
@@ -391,11 +400,14 @@ export const RowActions = memo(function RowActions({
/>
) : null}
<DropdownMenuSeparator />
<MenuItem
icon={Edit}
label="Edit"
onClick={withClose(handlers.onEdit)}
/>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
@@ -416,11 +428,7 @@ export const RowActions = memo(function RowActions({
/>
)}
{handlers.onFollowUp && (
<MenuItem
icon={Wand2}
label="Refine"
onClick={withClose(handlers.onFollowUp)}
/>
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
)}
{feature.prUrl && (
<MenuItem
@@ -438,11 +446,14 @@ export const RowActions = memo(function RowActions({
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Edit}
label="Edit"
onClick={withClose(handlers.onEdit)}
/>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
@@ -486,11 +497,14 @@ export const RowActions = memo(function RowActions({
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Edit}
label="Edit"
onClick={withClose(handlers.onEdit)}
/>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
@@ -501,30 +515,32 @@ export const RowActions = memo(function RowActions({
)}
{/* Pipeline status actions (generic fallback) */}
{!isCurrentAutoTask &&
feature.status.startsWith('pipeline_') && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{!isCurrentAutoTask && feature.status.startsWith('pipeline_') && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={Edit}
label="Edit"
onClick={withClose(handlers.onEdit)}
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
<DropdownMenuSeparator />
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
</>
)}
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -304,22 +304,22 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<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"
>
<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">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</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 && (
<button
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'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -332,7 +332,7 @@ export function AgentOutputModal({
)}
<button
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'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -344,7 +344,7 @@ export function AgentOutputModal({
</button>
<button
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'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -356,7 +356,7 @@ export function AgentOutputModal({
</button>
<button
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'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -384,7 +384,7 @@ export function AgentOutputModal({
/>
{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 ? (
<GitDiffPanel
projectPath={projectPath}
@@ -401,7 +401,7 @@ export function AgentOutputModal({
)}
</div>
) : 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>
</div>
) : (
@@ -409,7 +409,7 @@ export function AgentOutputModal({
<div
ref={scrollRef}
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 ? (
<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 { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -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({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? 'default' : 'outline'}
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-l-none',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
'h-7 w-7 p-0',
!standalone && 'rounded-l-none',
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>
</DropdownMenuTrigger>
<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 { 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<NodeJS.Timeout | null>(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 (
<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 (
<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" />

View File

@@ -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<TaskNodeData>) => {
const data = node.data as TaskNodeData | undefined;