mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: Implement responsive mobile header layout with menu consolidation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
157
apps/ui/src/components/views/board-view/header-mobile-menu.tsx
Normal file
157
apps/ui/src/components/views/board-view/header-mobile-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user