mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Merge branch 'v0.11.0rc' into feat/dev-server-log-panel
Resolved conflict in worktree-panel.tsx by combining imports: - DevServerLogsPanel from this branch - WorktreeMobileDropdown, WorktreeActionsDropdown, BranchSwitchDropdown from v0.11.0rc Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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,12 +8,17 @@ 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';
|
||||
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 };
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectPath: string;
|
||||
@@ -33,6 +38,9 @@ interface BoardHeaderProps {
|
||||
onShowBoardBackground: () => void;
|
||||
onShowCompletedModal: () => void;
|
||||
completedCount: number;
|
||||
// View toggle props
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
}
|
||||
|
||||
// Shared styles for header control containers
|
||||
@@ -55,11 +63,12 @@ export function BoardHeader({
|
||||
onShowBoardBackground,
|
||||
onShowCompletedModal,
|
||||
completedCount,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: BoardHeaderProps) {
|
||||
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
|
||||
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
|
||||
const [showPlanSettings, setShowPlanSettings] = useState(false);
|
||||
const apiKeys = useAppStore((state) => state.apiKeys);
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
|
||||
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
|
||||
@@ -98,22 +107,17 @@ export function BoardHeader({
|
||||
[projectPath, setWorktreePanelVisible]
|
||||
);
|
||||
|
||||
// Claude usage tracking visibility logic
|
||||
// Hide when using API key (only show for Claude Code CLI users)
|
||||
// Also hide on Windows for now (CLI usage command not supported)
|
||||
const isWindows =
|
||||
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
|
||||
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
|
||||
const isClaudeCliVerified =
|
||||
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
|
||||
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
|
||||
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
|
||||
const showClaudeUsage = isClaudeCliVerified;
|
||||
|
||||
// Codex usage tracking visibility logic
|
||||
// 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}
|
||||
@@ -122,6 +126,7 @@ export function BoardHeader({
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
currentProjectPath={projectPath}
|
||||
/>
|
||||
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
|
||||
<BoardControls
|
||||
isMounted={isMounted}
|
||||
onShowBoardBackground={onShowBoardBackground}
|
||||
@@ -129,12 +134,30 @@ export function BoardHeader({
|
||||
completedCount={completedCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated */}
|
||||
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isMobile && (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}
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
@@ -166,7 +189,7 @@ export function BoardHeader({
|
||||
/>
|
||||
|
||||
{/* Concurrency Control - only show after mount to prevent hydration issues */}
|
||||
{isMounted && (
|
||||
{isMounted && !isMobile && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -209,7 +232,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
|
||||
@@ -239,25 +262,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 */}
|
||||
{isMounted && !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
|
||||
|
||||
@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
|
||||
export { KanbanColumn } from './kanban-column';
|
||||
export { SelectionActionBar } from './selection-action-bar';
|
||||
export { EmptyStateCard } from './empty-state-card';
|
||||
export { ViewToggle, type ViewMode } from './view-toggle';
|
||||
|
||||
// List view components
|
||||
export {
|
||||
ListHeader,
|
||||
LIST_COLUMNS,
|
||||
getColumnById,
|
||||
getColumnWidth,
|
||||
getColumnAlign,
|
||||
ListRow,
|
||||
getFeatureSortValue,
|
||||
sortFeatures,
|
||||
ListView,
|
||||
getFlatFeatures,
|
||||
getTotalFeatureCount,
|
||||
RowActions,
|
||||
createRowActionHandlers,
|
||||
StatusBadge,
|
||||
getStatusLabel,
|
||||
getStatusOrder,
|
||||
} from './list-view';
|
||||
export type {
|
||||
ListHeaderProps,
|
||||
ListRowProps,
|
||||
ListViewProps,
|
||||
ListViewActionHandlers,
|
||||
RowActionsProps,
|
||||
RowActionHandlers,
|
||||
StatusBadgeProps,
|
||||
} from './list-view';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
ListHeader,
|
||||
LIST_COLUMNS,
|
||||
getColumnById,
|
||||
getColumnWidth,
|
||||
getColumnAlign,
|
||||
} from './list-header';
|
||||
export type { ListHeaderProps } from './list-header';
|
||||
|
||||
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
|
||||
export type { ListRowProps } from './list-row';
|
||||
|
||||
export { ListView, getFlatFeatures, getTotalFeatureCount } from './list-view';
|
||||
export type { ListViewProps, ListViewActionHandlers } from './list-view';
|
||||
|
||||
export { RowActions, createRowActionHandlers } from './row-actions';
|
||||
export type { RowActionsProps, RowActionHandlers } from './row-actions';
|
||||
|
||||
export { StatusBadge, getStatusLabel, getStatusOrder } from './status-badge';
|
||||
export type { StatusBadgeProps } from './status-badge';
|
||||
@@ -0,0 +1,284 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SortColumn, SortConfig, SortDirection } from '../../hooks/use-list-view-state';
|
||||
|
||||
/**
|
||||
* Column definition for the list header
|
||||
*/
|
||||
interface ColumnDef {
|
||||
id: SortColumn;
|
||||
label: string;
|
||||
/** Whether this column is sortable */
|
||||
sortable?: boolean;
|
||||
/** Minimum width for the column */
|
||||
minWidth?: string;
|
||||
/** Width class for the column */
|
||||
width?: string;
|
||||
/** Alignment of the column content */
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** Additional className for the column */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = [
|
||||
{
|
||||
id: 'title',
|
||||
label: 'Title',
|
||||
sortable: true,
|
||||
width: 'flex-1',
|
||||
minWidth: 'min-w-0',
|
||||
align: 'left',
|
||||
},
|
||||
];
|
||||
|
||||
export interface ListHeaderProps {
|
||||
/** Current sort configuration */
|
||||
sortConfig: SortConfig;
|
||||
/** Callback when a sortable column is clicked */
|
||||
onSortChange: (column: SortColumn) => void;
|
||||
/** Whether to show a checkbox column for selection */
|
||||
showCheckbox?: boolean;
|
||||
/** Whether all items are selected (for checkbox state) */
|
||||
allSelected?: boolean;
|
||||
/** Whether some but not all items are selected */
|
||||
someSelected?: boolean;
|
||||
/** Callback when the select all checkbox is clicked */
|
||||
onSelectAll?: () => void;
|
||||
/** Custom column definitions (defaults to LIST_COLUMNS) */
|
||||
columns?: ColumnDef[];
|
||||
/** Additional className for the header */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SortIcon displays the current sort state for a column
|
||||
*/
|
||||
function SortIcon({ column, sortConfig }: { column: SortColumn; sortConfig: SortConfig }) {
|
||||
if (sortConfig.column !== column) {
|
||||
// Not sorted by this column - show neutral indicator
|
||||
return (
|
||||
<ChevronsUpDown className="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
|
||||
);
|
||||
}
|
||||
|
||||
// Currently sorted by this column
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return <ChevronUp className="w-3.5 h-3.5 text-foreground" />;
|
||||
}
|
||||
|
||||
return <ChevronDown className="w-3.5 h-3.5 text-foreground" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* SortableColumnHeader renders a clickable header cell that triggers sorting
|
||||
*/
|
||||
const SortableColumnHeader = memo(function SortableColumnHeader({
|
||||
column,
|
||||
sortConfig,
|
||||
onSortChange,
|
||||
}: {
|
||||
column: ColumnDef;
|
||||
sortConfig: SortConfig;
|
||||
onSortChange: (column: SortColumn) => void;
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSortChange(column.id);
|
||||
}, [column.id, onSortChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSortChange(column.id);
|
||||
}
|
||||
},
|
||||
[column.id, onSortChange]
|
||||
);
|
||||
|
||||
const isSorted = sortConfig.column === column.id;
|
||||
const sortDirection: SortDirection | undefined = isSorted ? sortConfig.direction : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="columnheader"
|
||||
aria-sort={isSorted ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||
'cursor-pointer select-none transition-colors duration-200',
|
||||
'hover:text-foreground hover:bg-accent/50 rounded-md',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
column.width,
|
||||
column.minWidth,
|
||||
column.align === 'center' && 'justify-center',
|
||||
column.align === 'right' && 'justify-end',
|
||||
isSorted && 'text-foreground',
|
||||
column.className
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
<SortIcon column={column.id} sortConfig={sortConfig} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* StaticColumnHeader renders a non-sortable header cell
|
||||
*/
|
||||
const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column: ColumnDef }) {
|
||||
return (
|
||||
<div
|
||||
role="columnheader"
|
||||
className={cn(
|
||||
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
|
||||
column.width,
|
||||
column.minWidth,
|
||||
column.align === 'center' && 'justify-center',
|
||||
column.align === 'right' && 'justify-end',
|
||||
column.className
|
||||
)}
|
||||
data-testid={`list-header-${column.id}`}
|
||||
>
|
||||
<span>{column.label}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ListHeader displays the header row for the list view table with sortable columns.
|
||||
*
|
||||
* Features:
|
||||
* - Clickable column headers for sorting
|
||||
* - Visual sort direction indicators (chevron up/down)
|
||||
* - Keyboard accessible (Tab + Enter/Space to sort)
|
||||
* - ARIA attributes for screen readers
|
||||
* - Optional checkbox column for bulk selection
|
||||
* - Customizable column definitions
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { sortConfig, setSortColumn } = useListViewState();
|
||||
*
|
||||
* <ListHeader
|
||||
* sortConfig={sortConfig}
|
||||
* onSortChange={setSortColumn}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // With selection support
|
||||
* <ListHeader
|
||||
* sortConfig={sortConfig}
|
||||
* onSortChange={setSortColumn}
|
||||
* showCheckbox
|
||||
* allSelected={allSelected}
|
||||
* someSelected={someSelected}
|
||||
* onSelectAll={handleSelectAll}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ListHeader = memo(function ListHeader({
|
||||
sortConfig,
|
||||
onSortChange,
|
||||
showCheckbox = false,
|
||||
allSelected = false,
|
||||
someSelected = false,
|
||||
onSelectAll,
|
||||
columns = LIST_COLUMNS,
|
||||
className,
|
||||
}: ListHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
className={cn(
|
||||
'flex items-center w-full border-b border-border bg-muted/30',
|
||||
'sticky top-0 z-10 backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
data-testid="list-header"
|
||||
>
|
||||
{/* Checkbox column for selection */}
|
||||
{showCheckbox && (
|
||||
<div
|
||||
role="columnheader"
|
||||
className="flex items-center justify-center w-10 px-2 py-2 shrink-0"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
el.indeterminate = someSelected && !allSelected;
|
||||
}
|
||||
}}
|
||||
onChange={onSelectAll}
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-border text-primary cursor-pointer',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
|
||||
)}
|
||||
aria-label={allSelected ? 'Deselect all' : 'Select all'}
|
||||
data-testid="list-header-select-all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column headers */}
|
||||
{columns.map((column) =>
|
||||
column.sortable !== false ? (
|
||||
<SortableColumnHeader
|
||||
key={column.id}
|
||||
column={column}
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
) : (
|
||||
<StaticColumnHeader key={column.id} column={column} />
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Actions column (placeholder for row action buttons) */}
|
||||
<div
|
||||
role="columnheader"
|
||||
className="w-[80px] px-3 py-2 text-xs font-medium text-muted-foreground shrink-0"
|
||||
aria-label="Actions"
|
||||
data-testid="list-header-actions"
|
||||
>
|
||||
<span className="sr-only">Actions</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to get a column definition by ID
|
||||
*/
|
||||
export function getColumnById(columnId: SortColumn): ColumnDef | undefined {
|
||||
return LIST_COLUMNS.find((col) => col.id === columnId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get column width class for consistent styling in rows
|
||||
*/
|
||||
export function getColumnWidth(columnId: SortColumn): string {
|
||||
const column = getColumnById(columnId);
|
||||
return cn(column?.width, column?.minWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get column alignment class
|
||||
*/
|
||||
export function getColumnAlign(columnId: SortColumn): string {
|
||||
const column = getColumnById(columnId);
|
||||
if (column?.align === 'center') return 'justify-center text-center';
|
||||
if (column?.align === 'right') return 'justify-end text-right';
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
|
||||
// The `[key: string]: unknown` in BaseFeature causes property access type errors
|
||||
// @ts-nocheck
|
||||
import { memo, useCallback, useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import { RowActions, type RowActionHandlers } from './row-actions';
|
||||
import { getColumnWidth, getColumnAlign } from './list-header';
|
||||
|
||||
export interface ListRowProps {
|
||||
/** The feature to display */
|
||||
feature: Feature;
|
||||
/** Action handlers for the row */
|
||||
handlers: RowActionHandlers;
|
||||
/** Whether this feature is the current auto task (agent is running) */
|
||||
isCurrentAutoTask?: boolean;
|
||||
/** Whether the row is selected */
|
||||
isSelected?: boolean;
|
||||
/** Whether to show the checkbox for selection */
|
||||
showCheckbox?: boolean;
|
||||
/** Callback when the row selection is toggled */
|
||||
onToggleSelect?: () => void;
|
||||
/** Callback when the row is clicked */
|
||||
onClick?: () => void;
|
||||
/** Blocking dependency feature IDs */
|
||||
blockingDependencies?: string[];
|
||||
/** Additional className for custom styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IndicatorBadges shows small indicator icons for special states (error, blocked, manual verification, just finished)
|
||||
*/
|
||||
const IndicatorBadges = memo(function IndicatorBadges({
|
||||
feature,
|
||||
blockingDependencies = [],
|
||||
isCurrentAutoTask,
|
||||
}: {
|
||||
feature: Feature;
|
||||
blockingDependencies?: string[];
|
||||
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 hasPlan = feature.planSpec?.content;
|
||||
|
||||
// Check if just finished (within 2 minutes) - uses timer to auto-expire
|
||||
const [isJustFinished, setIsJustFinished] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
|
||||
setIsJustFinished(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const twoMinutes = 2 * 60 * 1000;
|
||||
const elapsed = Date.now() - finishedTime;
|
||||
|
||||
if (elapsed >= twoMinutes) {
|
||||
setIsJustFinished(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set as just finished
|
||||
setIsJustFinished(true);
|
||||
|
||||
// Set a timeout to clear the "just finished" state when 2 minutes have passed
|
||||
const remainingTime = twoMinutes - elapsed;
|
||||
const timeoutId = setTimeout(() => {
|
||||
setIsJustFinished(false);
|
||||
}, remainingTime);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [feature.justFinishedAt, feature.status, feature.error]);
|
||||
|
||||
const badges: Array<{
|
||||
key: string;
|
||||
icon: typeof AlertCircle;
|
||||
tooltip: string;
|
||||
colorClass: string;
|
||||
bgClass: string;
|
||||
borderClass: string;
|
||||
animate?: boolean;
|
||||
}> = [];
|
||||
|
||||
if (hasError) {
|
||||
badges.push({
|
||||
key: 'error',
|
||||
icon: AlertCircle,
|
||||
tooltip: feature.error || 'Error',
|
||||
colorClass: 'text-[var(--status-error)]',
|
||||
bgClass: 'bg-[var(--status-error)]/15',
|
||||
borderClass: 'border-[var(--status-error)]/30',
|
||||
});
|
||||
}
|
||||
|
||||
if (isBlocked) {
|
||||
badges.push({
|
||||
key: 'blocked',
|
||||
icon: Lock,
|
||||
tooltip: `Blocked by ${blockingDependencies.length} incomplete ${blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}`,
|
||||
colorClass: 'text-orange-500',
|
||||
bgClass: 'bg-orange-500/15',
|
||||
borderClass: 'border-orange-500/30',
|
||||
});
|
||||
}
|
||||
|
||||
if (showManualVerification) {
|
||||
badges.push({
|
||||
key: 'manual',
|
||||
icon: Hand,
|
||||
tooltip: 'Manual verification required',
|
||||
colorClass: 'text-[var(--status-warning)]',
|
||||
bgClass: 'bg-[var(--status-warning)]/15',
|
||||
borderClass: 'border-[var(--status-warning)]/30',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPlan) {
|
||||
badges.push({
|
||||
key: 'plan',
|
||||
icon: FileText,
|
||||
tooltip: 'Has implementation plan',
|
||||
colorClass: 'text-[var(--status-info)]',
|
||||
bgClass: 'bg-[var(--status-info)]/15',
|
||||
borderClass: 'border-[var(--status-info)]/30',
|
||||
});
|
||||
}
|
||||
|
||||
if (isJustFinished) {
|
||||
badges.push({
|
||||
key: 'just-finished',
|
||||
icon: Sparkles,
|
||||
tooltip: 'Agent just finished working on this feature',
|
||||
colorClass: 'text-[var(--status-success)]',
|
||||
bgClass: 'bg-[var(--status-success)]/15',
|
||||
borderClass: 'border-[var(--status-success)]/30',
|
||||
animate: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (badges.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{badges.map((badge) => (
|
||||
<Tooltip key={badge.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-5 h-5 rounded border',
|
||||
badge.colorClass,
|
||||
badge.bgClass,
|
||||
badge.borderClass,
|
||||
badge.animate && 'animate-pulse'
|
||||
)}
|
||||
data-testid={`list-row-badge-${badge.key}`}
|
||||
>
|
||||
<badge.icon className="w-3 h-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs max-w-[250px]">
|
||||
<p>{badge.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ListRow displays a single feature row in the list view table.
|
||||
*
|
||||
* Features:
|
||||
* - Displays feature data in columns matching ListHeader
|
||||
* - Hover state with highlight and action buttons
|
||||
* - Click handler for opening feature details
|
||||
* - Animated border for currently running auto task
|
||||
* - Status badge with appropriate colors
|
||||
* - Priority indicator
|
||||
* - Indicator badges for errors, blocked state, manual verification, etc.
|
||||
* - Selection checkbox for bulk operations
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ListRow
|
||||
* feature={feature}
|
||||
* handlers={{
|
||||
* onEdit: () => handleEdit(feature.id),
|
||||
* onDelete: () => handleDelete(feature.id),
|
||||
* // ... other handlers
|
||||
* }}
|
||||
* onClick={() => handleViewDetails(feature)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ListRow = memo(function ListRow({
|
||||
feature,
|
||||
handlers,
|
||||
isCurrentAutoTask = false,
|
||||
isSelected = false,
|
||||
showCheckbox = false,
|
||||
onToggleSelect,
|
||||
onClick,
|
||||
blockingDependencies = [],
|
||||
className,
|
||||
}: ListRowProps) {
|
||||
const handleRowClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't trigger row click if clicking on checkbox or actions
|
||||
if ((e.target as HTMLElement).closest('[data-testid^="row-actions"]')) {
|
||||
return;
|
||||
}
|
||||
if ((e.target as HTMLElement).closest('input[type="checkbox"]')) {
|
||||
return;
|
||||
}
|
||||
onClick?.();
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
const handleCheckboxChange = useCallback(() => {
|
||||
onToggleSelect?.();
|
||||
}, [onToggleSelect]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
const hasError = feature.error && !isCurrentAutoTask;
|
||||
|
||||
const rowContent = (
|
||||
<div
|
||||
role="row"
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={onClick ? handleKeyDown : undefined}
|
||||
className={cn(
|
||||
'group flex items-center w-full border-b border-border/50',
|
||||
'transition-colors duration-200',
|
||||
onClick && 'cursor-pointer',
|
||||
'hover:bg-accent/50',
|
||||
isSelected && 'bg-accent/70',
|
||||
hasError && 'bg-[var(--status-error)]/5 hover:bg-[var(--status-error)]/10',
|
||||
className
|
||||
)}
|
||||
data-testid={`list-row-${feature.id}`}
|
||||
>
|
||||
{/* Checkbox column */}
|
||||
{showCheckbox && (
|
||||
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={handleCheckboxChange}
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-border text-primary cursor-pointer',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
|
||||
)}
|
||||
aria-label={`Select ${feature.title || feature.description}`}
|
||||
data-testid={`list-row-checkbox-${feature.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title column - full width with margin for actions */}
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center px-3 py-3 gap-2',
|
||||
getColumnWidth('title'),
|
||||
getColumnAlign('title')
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium truncate',
|
||||
feature.titleGenerating && 'animate-pulse text-muted-foreground'
|
||||
)}
|
||||
title={feature.title || feature.description}
|
||||
>
|
||||
{feature.title || feature.description}
|
||||
</span>
|
||||
<IndicatorBadges
|
||||
feature={feature}
|
||||
blockingDependencies={blockingDependencies}
|
||||
isCurrentAutoTask={isCurrentAutoTask}
|
||||
/>
|
||||
</div>
|
||||
{/* Show description as subtitle if title exists and is different */}
|
||||
{feature.title && feature.title !== feature.description && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground truncate mt-0.5"
|
||||
title={feature.description}
|
||||
>
|
||||
{feature.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Wrap with animated border for currently running auto task
|
||||
if (isCurrentAutoTask) {
|
||||
return <div className="animated-border-wrapper-row">{rowContent}</div>;
|
||||
}
|
||||
|
||||
return rowContent;
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to get feature sort value for a column
|
||||
*/
|
||||
export function getFeatureSortValue(
|
||||
feature: Feature,
|
||||
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt'
|
||||
): string | number | Date {
|
||||
switch (column) {
|
||||
case 'title':
|
||||
return (feature.title || feature.description).toLowerCase();
|
||||
case 'status':
|
||||
return feature.status;
|
||||
case 'category':
|
||||
return (feature.category || '').toLowerCase();
|
||||
case 'priority':
|
||||
return feature.priority || 999; // No priority sorts last
|
||||
case 'createdAt':
|
||||
return feature.createdAt ? new Date(feature.createdAt) : new Date(0);
|
||||
case 'updatedAt':
|
||||
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to sort features by a column
|
||||
*/
|
||||
export function sortFeatures(
|
||||
features: Feature[],
|
||||
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt',
|
||||
direction: 'asc' | 'desc'
|
||||
): Feature[] {
|
||||
return [...features].sort((a, b) => {
|
||||
const aValue = getFeatureSortValue(a, column);
|
||||
const bValue = getFeatureSortValue(b, column);
|
||||
|
||||
let comparison = 0;
|
||||
|
||||
if (aValue instanceof Date && bValue instanceof Date) {
|
||||
comparison = aValue.getTime() - bValue.getTime();
|
||||
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
comparison = aValue - bValue;
|
||||
} else {
|
||||
comparison = String(aValue).localeCompare(String(bValue));
|
||||
}
|
||||
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { memo, useMemo, useCallback, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
|
||||
import { ListHeader } from './list-header';
|
||||
import { ListRow, sortFeatures } from './list-row';
|
||||
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
|
||||
import { getStatusLabel, getStatusOrder } from './status-badge';
|
||||
import { getColumnsWithPipeline } from '../../constants';
|
||||
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
|
||||
|
||||
/** Empty set constant to avoid creating new instances on each render */
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
/**
|
||||
* Status group configuration for the list view
|
||||
*/
|
||||
interface StatusGroup {
|
||||
id: FeatureStatusWithPipeline;
|
||||
title: string;
|
||||
colorClass: string;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for action handlers passed from the parent board view
|
||||
*/
|
||||
export interface ListViewActionHandlers {
|
||||
onEdit: (feature: Feature) => void;
|
||||
onDelete: (featureId: string) => void;
|
||||
onViewOutput?: (feature: Feature) => void;
|
||||
onVerify?: (feature: Feature) => void;
|
||||
onResume?: (feature: Feature) => void;
|
||||
onForceStop?: (feature: Feature) => void;
|
||||
onManualVerify?: (feature: Feature) => void;
|
||||
onFollowUp?: (feature: Feature) => void;
|
||||
onImplement?: (feature: Feature) => void;
|
||||
onComplete?: (feature: Feature) => void;
|
||||
onViewPlan?: (feature: Feature) => void;
|
||||
onApprovePlan?: (feature: Feature) => void;
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export interface ListViewProps {
|
||||
/** Map of column/status ID to features in that column */
|
||||
columnFeaturesMap: Record<string, Feature[]>;
|
||||
/** All features (for dependency checking) */
|
||||
allFeatures: Feature[];
|
||||
/** Current sort configuration */
|
||||
sortConfig: SortConfig;
|
||||
/** Callback when sort column is changed */
|
||||
onSortChange: (column: SortColumn) => void;
|
||||
/** Action handlers for rows */
|
||||
actionHandlers: ListViewActionHandlers;
|
||||
/** Set of feature IDs that are currently running */
|
||||
runningAutoTasks: string[];
|
||||
/** Pipeline configuration for custom statuses */
|
||||
pipelineConfig?: PipelineConfig | null;
|
||||
/** Callback to add a new feature */
|
||||
onAddFeature?: () => void;
|
||||
/** Whether selection mode is enabled */
|
||||
isSelectionMode?: boolean;
|
||||
/** Set of selected feature IDs */
|
||||
selectedFeatureIds?: Set<string>;
|
||||
/** Callback when a feature's selection is toggled */
|
||||
onToggleFeatureSelection?: (featureId: string) => void;
|
||||
/** Callback when the row is clicked */
|
||||
onRowClick?: (feature: Feature) => void;
|
||||
/** Additional className for custom styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusGroupHeader displays the header for a status group with collapse toggle
|
||||
*/
|
||||
const StatusGroupHeader = memo(function StatusGroupHeader({
|
||||
group,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
group: StatusGroup;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'flex items-center gap-2 w-full px-3 py-2 text-left',
|
||||
'bg-muted/50 hover:bg-muted/70 transition-colors duration-200',
|
||||
'border-b border-border/50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
data-testid={`list-group-header-${group.id}`}
|
||||
>
|
||||
{/* Collapse indicator */}
|
||||
<span className="text-muted-foreground">
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</span>
|
||||
|
||||
{/* Status color indicator */}
|
||||
<span
|
||||
className={cn('w-2.5 h-2.5 rounded-full shrink-0', group.colorClass)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Group title */}
|
||||
<span className="font-medium text-sm">{group.title}</span>
|
||||
|
||||
{/* Feature count */}
|
||||
<span className="text-xs text-muted-foreground">({group.features.length})</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* EmptyState displays a message when there are no features
|
||||
*/
|
||||
const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-16 px-4',
|
||||
'text-center text-muted-foreground'
|
||||
)}
|
||||
data-testid="list-view-empty"
|
||||
>
|
||||
<p className="text-sm mb-4">No features to display</p>
|
||||
{onAddFeature && (
|
||||
<Button variant="outline" size="sm" onClick={onAddFeature}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ListView displays features in a table format grouped by status.
|
||||
*
|
||||
* Features:
|
||||
* - Groups features by status (backlog, in_progress, waiting_approval, verified, pipeline steps)
|
||||
* - Collapsible status groups
|
||||
* - Sortable columns (title, status, category, priority, dates)
|
||||
* - Inline row actions with hover state
|
||||
* - Selection support for bulk operations
|
||||
* - Animated border for currently running features
|
||||
* - Keyboard accessible
|
||||
*
|
||||
* The component receives features grouped by status via columnFeaturesMap
|
||||
* and applies the current sort configuration within each group.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { sortConfig, setSortColumn } = useListViewState();
|
||||
* const { columnFeaturesMap } = useBoardColumnFeatures({ features, ... });
|
||||
*
|
||||
* <ListView
|
||||
* columnFeaturesMap={columnFeaturesMap}
|
||||
* allFeatures={features}
|
||||
* sortConfig={sortConfig}
|
||||
* onSortChange={setSortColumn}
|
||||
* actionHandlers={{
|
||||
* onEdit: handleEdit,
|
||||
* onDelete: handleDelete,
|
||||
* // ...
|
||||
* }}
|
||||
* runningAutoTasks={runningAutoTasks}
|
||||
* pipelineConfig={pipelineConfig}
|
||||
* onAddFeature={handleAddFeature}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ListView = memo(function ListView({
|
||||
columnFeaturesMap,
|
||||
allFeatures,
|
||||
sortConfig,
|
||||
onSortChange,
|
||||
actionHandlers,
|
||||
runningAutoTasks,
|
||||
pipelineConfig = null,
|
||||
onAddFeature,
|
||||
isSelectionMode = false,
|
||||
selectedFeatureIds = EMPTY_SET,
|
||||
onToggleFeatureSelection,
|
||||
onRowClick,
|
||||
className,
|
||||
}: ListViewProps) {
|
||||
// Track collapsed state for each status group
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// Generate status groups from columnFeaturesMap
|
||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||
const columns = getColumnsWithPipeline(pipelineConfig);
|
||||
const groups: StatusGroup[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
const features = columnFeaturesMap[column.id] || [];
|
||||
if (features.length > 0) {
|
||||
// Sort features within the group according to current sort config
|
||||
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
|
||||
|
||||
groups.push({
|
||||
id: column.id as FeatureStatusWithPipeline,
|
||||
title: column.title,
|
||||
colorClass: column.colorClass,
|
||||
features: sortedFeatures,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups by status order
|
||||
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
|
||||
}, [columnFeaturesMap, pipelineConfig, sortConfig]);
|
||||
|
||||
// Calculate total feature count
|
||||
const totalFeatures = useMemo(
|
||||
() => statusGroups.reduce((sum, group) => sum + group.features.length, 0),
|
||||
[statusGroups]
|
||||
);
|
||||
|
||||
// Toggle group collapse state
|
||||
const toggleGroup = useCallback((groupId: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Create row action handlers for a feature
|
||||
const createHandlers = useCallback(
|
||||
(feature: Feature): RowActionHandlers => {
|
||||
return createRowActionHandlers(feature.id, {
|
||||
editFeature: (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onEdit(f);
|
||||
},
|
||||
deleteFeature: (id) => actionHandlers.onDelete(id),
|
||||
viewOutput: actionHandlers.onViewOutput
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onViewOutput?.(f);
|
||||
}
|
||||
: undefined,
|
||||
verifyFeature: actionHandlers.onVerify
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onVerify?.(f);
|
||||
}
|
||||
: undefined,
|
||||
resumeFeature: actionHandlers.onResume
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onResume?.(f);
|
||||
}
|
||||
: undefined,
|
||||
forceStop: actionHandlers.onForceStop
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onForceStop?.(f);
|
||||
}
|
||||
: undefined,
|
||||
manualVerify: actionHandlers.onManualVerify
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onManualVerify?.(f);
|
||||
}
|
||||
: undefined,
|
||||
followUp: actionHandlers.onFollowUp
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onFollowUp?.(f);
|
||||
}
|
||||
: undefined,
|
||||
implement: actionHandlers.onImplement
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onImplement?.(f);
|
||||
}
|
||||
: undefined,
|
||||
complete: actionHandlers.onComplete
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onComplete?.(f);
|
||||
}
|
||||
: undefined,
|
||||
viewPlan: actionHandlers.onViewPlan
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onViewPlan?.(f);
|
||||
}
|
||||
: undefined,
|
||||
approvePlan: actionHandlers.onApprovePlan
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onApprovePlan?.(f);
|
||||
}
|
||||
: undefined,
|
||||
spawnTask: actionHandlers.onSpawnTask
|
||||
? (id) => {
|
||||
const f = allFeatures.find((f) => f.id === id);
|
||||
if (f) actionHandlers.onSpawnTask?.(f);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[actionHandlers, allFeatures]
|
||||
);
|
||||
|
||||
// Get blocking dependencies for a feature
|
||||
const getBlockingDeps = useCallback(
|
||||
(feature: Feature): string[] => {
|
||||
return getBlockingDependencies(feature, allFeatures);
|
||||
},
|
||||
[allFeatures]
|
||||
);
|
||||
|
||||
// Calculate selection state for header checkbox
|
||||
const selectionState = useMemo(() => {
|
||||
if (!isSelectionMode || totalFeatures === 0) {
|
||||
return { allSelected: false, someSelected: false };
|
||||
}
|
||||
const selectedCount = selectedFeatureIds.size;
|
||||
return {
|
||||
allSelected: selectedCount === totalFeatures && selectedCount > 0,
|
||||
someSelected: selectedCount > 0 && selectedCount < totalFeatures,
|
||||
};
|
||||
}, [isSelectionMode, totalFeatures, selectedFeatureIds]);
|
||||
|
||||
// Handle select all toggle
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (!onToggleFeatureSelection) return;
|
||||
|
||||
// If all selected, deselect all; otherwise select all
|
||||
if (selectionState.allSelected) {
|
||||
// Clear all selections
|
||||
selectedFeatureIds.forEach((id) => onToggleFeatureSelection(id));
|
||||
} else {
|
||||
// Select all features that aren't already selected
|
||||
for (const group of statusGroups) {
|
||||
for (const feature of group.features) {
|
||||
if (!selectedFeatureIds.has(feature.id)) {
|
||||
onToggleFeatureSelection(feature.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onToggleFeatureSelection, selectionState.allSelected, selectedFeatureIds, statusGroups]);
|
||||
|
||||
// Show empty state if no features
|
||||
if (totalFeatures === 0) {
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
|
||||
<EmptyState onAddFeature={onAddFeature} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col h-full bg-background', className)}
|
||||
role="table"
|
||||
aria-label="Features list"
|
||||
data-testid="list-view"
|
||||
>
|
||||
{/* Table header */}
|
||||
<ListHeader
|
||||
sortConfig={sortConfig}
|
||||
onSortChange={onSortChange}
|
||||
showCheckbox={isSelectionMode}
|
||||
allSelected={selectionState.allSelected}
|
||||
someSelected={selectionState.someSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
|
||||
{/* Table body with status groups */}
|
||||
<div className="flex-1 overflow-y-auto" role="rowgroup">
|
||||
{statusGroups.map((group) => {
|
||||
const isExpanded = !collapsedGroups.has(group.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className="border-b border-border/30"
|
||||
data-testid={`list-group-${group.id}`}
|
||||
>
|
||||
{/* Group header */}
|
||||
<StatusGroupHeader
|
||||
group={group}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
/>
|
||||
|
||||
{/* Group rows */}
|
||||
{isExpanded && (
|
||||
<div role="rowgroup">
|
||||
{group.features.map((feature) => (
|
||||
<ListRow
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
handlers={createHandlers(feature)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
pipelineConfig={pipelineConfig}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
showCheckbox={isSelectionMode}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
onClick={() => onRowClick?.(feature)}
|
||||
blockingDependencies={getBlockingDeps(feature)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer with Add Feature button */}
|
||||
{onAddFeature && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddFeature}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="list-view-add-feature"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to get all features from the columnFeaturesMap as a flat array
|
||||
*/
|
||||
export function getFlatFeatures(columnFeaturesMap: Record<string, Feature[]>): Feature[] {
|
||||
return Object.values(columnFeaturesMap).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to count total features across all groups
|
||||
*/
|
||||
export function getTotalFeatureCount(columnFeaturesMap: Record<string, Feature[]>): number {
|
||||
return Object.values(columnFeaturesMap).reduce((sum, features) => sum + features.length, 0);
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
Trash2,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
StopCircle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Eye,
|
||||
Wand2,
|
||||
Archive,
|
||||
GitBranch,
|
||||
GitFork,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
|
||||
/**
|
||||
* Action handler types for row actions
|
||||
*/
|
||||
export interface RowActionHandlers {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onVerify?: () => void;
|
||||
onResume?: () => void;
|
||||
onForceStop?: () => void;
|
||||
onManualVerify?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onImplement?: () => void;
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
}
|
||||
|
||||
export interface RowActionsProps {
|
||||
/** The feature for this row */
|
||||
feature: Feature;
|
||||
/** Action handlers */
|
||||
handlers: RowActionHandlers;
|
||||
/** Whether this feature is the current auto task (agent is running) */
|
||||
isCurrentAutoTask?: boolean;
|
||||
/** Whether the dropdown menu is open */
|
||||
isOpen?: boolean;
|
||||
/** Callback when the dropdown open state changes */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Additional className for custom styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MenuItem is a helper component for dropdown menu items with consistent styling
|
||||
*/
|
||||
const MenuItem = memo(function MenuItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
disabled = false,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const variantClasses = {
|
||||
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',
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn('gap-2', variantClasses[variant])}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the primary action for quick access button based on feature status
|
||||
*/
|
||||
function getPrimaryAction(
|
||||
feature: Feature,
|
||||
handlers: RowActionHandlers,
|
||||
isCurrentAutoTask: boolean
|
||||
): {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
|
||||
} | null {
|
||||
// Running task - force stop is primary
|
||||
if (isCurrentAutoTask) {
|
||||
if (handlers.onForceStop) {
|
||||
return {
|
||||
icon: StopCircle,
|
||||
label: 'Stop',
|
||||
onClick: handlers.onForceStop,
|
||||
variant: 'destructive',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Backlog - implement is primary
|
||||
if (feature.status === 'backlog' && handlers.onImplement) {
|
||||
return {
|
||||
icon: PlayCircle,
|
||||
label: 'Make',
|
||||
onClick: handlers.onImplement,
|
||||
variant: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
// In progress with plan approval pending
|
||||
if (
|
||||
feature.status === 'in_progress' &&
|
||||
feature.planSpec?.status === 'generated' &&
|
||||
handlers.onApprovePlan
|
||||
) {
|
||||
return {
|
||||
icon: FileText,
|
||||
label: 'Approve',
|
||||
onClick: handlers.onApprovePlan,
|
||||
variant: 'warning',
|
||||
};
|
||||
}
|
||||
|
||||
// In progress - resume is primary
|
||||
if (feature.status === 'in_progress' && handlers.onResume) {
|
||||
return {
|
||||
icon: RotateCcw,
|
||||
label: 'Resume',
|
||||
onClick: handlers.onResume,
|
||||
variant: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
// Waiting approval - verify is primary
|
||||
if (feature.status === 'waiting_approval' && handlers.onManualVerify) {
|
||||
return {
|
||||
icon: CheckCircle2,
|
||||
label: 'Verify',
|
||||
onClick: handlers.onManualVerify,
|
||||
variant: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
// Verified - complete is primary
|
||||
if (feature.status === 'verified' && handlers.onComplete) {
|
||||
return {
|
||||
icon: Archive,
|
||||
label: 'Complete',
|
||||
onClick: handlers.onComplete,
|
||||
variant: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secondary actions for inline display based on feature status
|
||||
*/
|
||||
function getSecondaryActions(
|
||||
feature: Feature,
|
||||
handlers: RowActionHandlers
|
||||
): Array<{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}> {
|
||||
const actions = [];
|
||||
|
||||
// Refine action for waiting_approval status
|
||||
if (feature.status === 'waiting_approval' && handlers.onFollowUp) {
|
||||
actions.push({
|
||||
icon: Wand2,
|
||||
label: 'Refine',
|
||||
onClick: handlers.onFollowUp,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* RowActions provides an inline action menu for list view rows.
|
||||
*
|
||||
* Features:
|
||||
* - Quick access button for primary action (Make, Resume, Verify, etc.)
|
||||
* - Dropdown menu with all available actions
|
||||
* - Context-aware actions based on feature status
|
||||
* - Support for running task actions (view logs, force stop)
|
||||
* - Keyboard accessible (focus, Enter/Space to open)
|
||||
*
|
||||
* Actions by status:
|
||||
* - 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 (inline secondary), 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
|
||||
* <RowActions
|
||||
* feature={feature}
|
||||
* handlers={{
|
||||
* onEdit: () => handleEdit(feature.id),
|
||||
* onDelete: () => handleDelete(feature.id),
|
||||
* onImplement: () => handleImplement(feature.id),
|
||||
* // ... other handlers
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const RowActions = memo(function RowActions({
|
||||
feature,
|
||||
handlers,
|
||||
isCurrentAutoTask = false,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
className,
|
||||
}: RowActionsProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
// Use controlled or uncontrolled state
|
||||
const open = isOpen ?? internalOpen;
|
||||
const setOpen = (value: boolean) => {
|
||||
if (onOpenChange) {
|
||||
onOpenChange(value);
|
||||
} else {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
|
||||
const secondaryActions = getSecondaryActions(feature, handlers);
|
||||
|
||||
// Helper to close menu after action
|
||||
const withClose = useCallback(
|
||||
(handler: () => void) => () => {
|
||||
setOpen(false);
|
||||
handler();
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center gap-1', className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`row-actions-${feature.id}`}
|
||||
>
|
||||
{/* Primary action quick button */}
|
||||
{primaryAction && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
'h-7 w-7',
|
||||
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)]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
primaryAction.onClick();
|
||||
}}
|
||||
title={primaryAction.label}
|
||||
data-testid={`row-action-primary-${feature.id}`}
|
||||
>
|
||||
<primaryAction.icon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Secondary action buttons */}
|
||||
{secondaryActions.map((action, index) => (
|
||||
<Button
|
||||
key={`secondary-action-${index}`}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn('h-7 w-7', 'text-muted-foreground', 'hover:bg-muted hover:text-foreground')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
}}
|
||||
title={action.label}
|
||||
data-testid={`row-action-secondary-${feature.id}-${action.label.toLowerCase()}`}
|
||||
>
|
||||
<action.icon className="w-4 h-4" />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
data-testid={`row-actions-trigger-${feature.id}`}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
<span className="sr-only">Open actions menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{/* Running task actions */}
|
||||
{isCurrentAutoTask && (
|
||||
<>
|
||||
{handlers.onViewOutput && (
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label="View Logs"
|
||||
onClick={withClose(handlers.onViewOutput)}
|
||||
/>
|
||||
)}
|
||||
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label="Approve Plan"
|
||||
onClick={withClose(handlers.onApprovePlan)}
|
||||
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 />
|
||||
<MenuItem
|
||||
icon={StopCircle}
|
||||
label="Force Stop"
|
||||
onClick={withClose(handlers.onForceStop)}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Backlog actions */}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
<>
|
||||
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||
{feature.planSpec?.content && handlers.onViewPlan && (
|
||||
<MenuItem icon={Eye} label="View Plan" onClick={withClose(handlers.onViewPlan)} />
|
||||
)}
|
||||
{handlers.onImplement && (
|
||||
<MenuItem
|
||||
icon={PlayCircle}
|
||||
label="Make"
|
||||
onClick={withClose(handlers.onImplement)}
|
||||
variant="primary"
|
||||
/>
|
||||
)}
|
||||
{handlers.onSpawnTask && (
|
||||
<MenuItem
|
||||
icon={GitFork}
|
||||
label="Spawn Sub-Task"
|
||||
onClick={withClose(handlers.onSpawnTask)}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<MenuItem
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
onClick={withClose(handlers.onDelete)}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* In Progress actions */}
|
||||
{!isCurrentAutoTask && feature.status === 'in_progress' && (
|
||||
<>
|
||||
{handlers.onViewOutput && (
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label="View Logs"
|
||||
onClick={withClose(handlers.onViewOutput)}
|
||||
/>
|
||||
)}
|
||||
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label="Approve Plan"
|
||||
onClick={withClose(handlers.onApprovePlan)}
|
||||
variant="warning"
|
||||
/>
|
||||
)}
|
||||
{feature.skipTests && handlers.onManualVerify ? (
|
||||
<MenuItem
|
||||
icon={CheckCircle2}
|
||||
label="Verify"
|
||||
onClick={withClose(handlers.onManualVerify)}
|
||||
variant="success"
|
||||
/>
|
||||
) : handlers.onResume ? (
|
||||
<MenuItem
|
||||
icon={RotateCcw}
|
||||
label="Resume"
|
||||
onClick={withClose(handlers.onResume)}
|
||||
variant="success"
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<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"
|
||||
onClick={withClose(handlers.onDelete)}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Waiting Approval actions */}
|
||||
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
|
||||
<>
|
||||
{handlers.onViewOutput && (
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label="View Logs"
|
||||
onClick={withClose(handlers.onViewOutput)}
|
||||
/>
|
||||
)}
|
||||
{handlers.onFollowUp && (
|
||||
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
|
||||
)}
|
||||
{feature.prUrl && (
|
||||
<MenuItem
|
||||
icon={ExternalLink}
|
||||
label="View PR"
|
||||
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
|
||||
/>
|
||||
)}
|
||||
{handlers.onManualVerify && (
|
||||
<MenuItem
|
||||
icon={CheckCircle2}
|
||||
label={feature.prUrl ? 'Verify' : 'Mark as Verified'}
|
||||
onClick={withClose(handlers.onManualVerify)}
|
||||
variant="success"
|
||||
/>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<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"
|
||||
onClick={withClose(handlers.onDelete)}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Verified actions */}
|
||||
{!isCurrentAutoTask && feature.status === 'verified' && (
|
||||
<>
|
||||
{handlers.onViewOutput && (
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label="View Logs"
|
||||
onClick={withClose(handlers.onViewOutput)}
|
||||
/>
|
||||
)}
|
||||
{feature.prUrl && (
|
||||
<MenuItem
|
||||
icon={ExternalLink}
|
||||
label="View PR"
|
||||
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
|
||||
/>
|
||||
)}
|
||||
{feature.worktree && (
|
||||
<MenuItem
|
||||
icon={GitBranch}
|
||||
label="View Branch"
|
||||
onClick={withClose(() => {})}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
{handlers.onComplete && (
|
||||
<MenuItem
|
||||
icon={Archive}
|
||||
label="Complete"
|
||||
onClick={withClose(handlers.onComplete)}
|
||||
variant="primary"
|
||||
/>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<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"
|
||||
onClick={withClose(handlers.onDelete)}
|
||||
variant="destructive"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pipeline status actions (generic fallback) */}
|
||||
{!isCurrentAutoTask && feature.status.startsWith('pipeline_') && (
|
||||
<>
|
||||
{handlers.onViewOutput && (
|
||||
<MenuItem
|
||||
icon={FileText}
|
||||
label="View Logs"
|
||||
onClick={withClose(handlers.onViewOutput)}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||
{handlers.onSpawnTask && (
|
||||
<MenuItem
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create action handlers from common patterns
|
||||
*/
|
||||
export function createRowActionHandlers(
|
||||
featureId: string,
|
||||
actions: {
|
||||
editFeature?: (id: string) => void;
|
||||
deleteFeature?: (id: string) => void;
|
||||
viewOutput?: (id: string) => void;
|
||||
verifyFeature?: (id: string) => void;
|
||||
resumeFeature?: (id: string) => void;
|
||||
forceStop?: (id: string) => void;
|
||||
manualVerify?: (id: string) => void;
|
||||
followUp?: (id: string) => void;
|
||||
implement?: (id: string) => void;
|
||||
complete?: (id: string) => void;
|
||||
viewPlan?: (id: string) => void;
|
||||
approvePlan?: (id: string) => void;
|
||||
spawnTask?: (id: string) => void;
|
||||
}
|
||||
): RowActionHandlers {
|
||||
return {
|
||||
onEdit: () => actions.editFeature?.(featureId),
|
||||
onDelete: () => actions.deleteFeature?.(featureId),
|
||||
onViewOutput: actions.viewOutput ? () => actions.viewOutput!(featureId) : undefined,
|
||||
onVerify: actions.verifyFeature ? () => actions.verifyFeature!(featureId) : undefined,
|
||||
onResume: actions.resumeFeature ? () => actions.resumeFeature!(featureId) : undefined,
|
||||
onForceStop: actions.forceStop ? () => actions.forceStop!(featureId) : undefined,
|
||||
onManualVerify: actions.manualVerify ? () => actions.manualVerify!(featureId) : undefined,
|
||||
onFollowUp: actions.followUp ? () => actions.followUp!(featureId) : undefined,
|
||||
onImplement: actions.implement ? () => actions.implement!(featureId) : undefined,
|
||||
onComplete: actions.complete ? () => actions.complete!(featureId) : undefined,
|
||||
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
||||
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
||||
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { COLUMNS, isPipelineStatus } from '../../constants';
|
||||
import type { FeatureStatusWithPipeline, PipelineConfig } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Status display configuration
|
||||
*/
|
||||
interface StatusDisplay {
|
||||
label: string;
|
||||
colorClass: string;
|
||||
bgClass: string;
|
||||
borderClass: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base status display configurations using CSS variables
|
||||
*/
|
||||
const BASE_STATUS_DISPLAY: Record<string, StatusDisplay> = {
|
||||
backlog: {
|
||||
label: 'Backlog',
|
||||
colorClass: 'text-[var(--status-backlog)]',
|
||||
bgClass: 'bg-[var(--status-backlog)]/15',
|
||||
borderClass: 'border-[var(--status-backlog)]/30',
|
||||
},
|
||||
in_progress: {
|
||||
label: 'In Progress',
|
||||
colorClass: 'text-[var(--status-in-progress)]',
|
||||
bgClass: 'bg-[var(--status-in-progress)]/15',
|
||||
borderClass: 'border-[var(--status-in-progress)]/30',
|
||||
},
|
||||
waiting_approval: {
|
||||
label: 'Waiting Approval',
|
||||
colorClass: 'text-[var(--status-waiting)]',
|
||||
bgClass: 'bg-[var(--status-waiting)]/15',
|
||||
borderClass: 'border-[var(--status-waiting)]/30',
|
||||
},
|
||||
verified: {
|
||||
label: 'Verified',
|
||||
colorClass: 'text-[var(--status-success)]',
|
||||
bgClass: 'bg-[var(--status-success)]/15',
|
||||
borderClass: 'border-[var(--status-success)]/30',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get display configuration for a pipeline status
|
||||
*/
|
||||
function getPipelineStatusDisplay(
|
||||
status: string,
|
||||
pipelineConfig: PipelineConfig | null
|
||||
): StatusDisplay | null {
|
||||
if (!isPipelineStatus(status) || !pipelineConfig?.steps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stepId = status.replace('pipeline_', '');
|
||||
const step = pipelineConfig.steps.find((s) => s.id === stepId);
|
||||
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the color variable from the colorClass (e.g., "bg-[var(--status-in-progress)]")
|
||||
// and use it for the badge styling
|
||||
const colorVar = step.colorClass?.match(/var\(([^)]+)\)/)?.[1] || '--status-in-progress';
|
||||
|
||||
return {
|
||||
label: step.name || 'Pipeline Step',
|
||||
colorClass: `text-[var(${colorVar})]`,
|
||||
bgClass: `bg-[var(${colorVar})]/15`,
|
||||
borderClass: `border-[var(${colorVar})]/30`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display configuration for a status
|
||||
*/
|
||||
function getStatusDisplay(
|
||||
status: FeatureStatusWithPipeline,
|
||||
pipelineConfig: PipelineConfig | null
|
||||
): StatusDisplay {
|
||||
// Check for pipeline status first
|
||||
if (isPipelineStatus(status)) {
|
||||
const pipelineDisplay = getPipelineStatusDisplay(status, pipelineConfig);
|
||||
if (pipelineDisplay) {
|
||||
return pipelineDisplay;
|
||||
}
|
||||
// Fallback for unknown pipeline status
|
||||
return {
|
||||
label: status.replace('pipeline_', '').replace(/_/g, ' '),
|
||||
colorClass: 'text-[var(--status-in-progress)]',
|
||||
bgClass: 'bg-[var(--status-in-progress)]/15',
|
||||
borderClass: 'border-[var(--status-in-progress)]/30',
|
||||
};
|
||||
}
|
||||
|
||||
// Check base status
|
||||
const baseDisplay = BASE_STATUS_DISPLAY[status];
|
||||
if (baseDisplay) {
|
||||
return baseDisplay;
|
||||
}
|
||||
|
||||
// Try to find from COLUMNS constant
|
||||
const column = COLUMNS.find((c) => c.id === status);
|
||||
if (column) {
|
||||
return {
|
||||
label: column.title,
|
||||
colorClass: 'text-muted-foreground',
|
||||
bgClass: 'bg-muted/50',
|
||||
borderClass: 'border-border/50',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown status
|
||||
return {
|
||||
label: status.replace(/_/g, ' '),
|
||||
colorClass: 'text-muted-foreground',
|
||||
bgClass: 'bg-muted/50',
|
||||
borderClass: 'border-border/50',
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
/** The status to display */
|
||||
status: FeatureStatusWithPipeline;
|
||||
/** Optional pipeline configuration for custom pipeline steps */
|
||||
pipelineConfig?: PipelineConfig | null;
|
||||
/** Size variant for the badge */
|
||||
size?: 'sm' | 'default' | 'lg';
|
||||
/** Additional className for custom styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusBadge displays a feature status as a colored chip/badge for use in the list view table.
|
||||
*
|
||||
* Features:
|
||||
* - Displays status with appropriate color based on status type
|
||||
* - Supports base statuses (backlog, in_progress, waiting_approval, verified)
|
||||
* - Supports pipeline statuses with custom colors from pipeline configuration
|
||||
* - Size variants (sm, default, lg)
|
||||
* - Uses CSS variables for consistent theming
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage
|
||||
* <StatusBadge status="backlog" />
|
||||
*
|
||||
* // With pipeline configuration
|
||||
* <StatusBadge status="pipeline_review" pipelineConfig={pipelineConfig} />
|
||||
*
|
||||
* // Small size
|
||||
* <StatusBadge status="verified" size="sm" />
|
||||
* ```
|
||||
*/
|
||||
export const StatusBadge = memo(function StatusBadge({
|
||||
status,
|
||||
pipelineConfig = null,
|
||||
size = 'default',
|
||||
className,
|
||||
}: StatusBadgeProps) {
|
||||
const display = useMemo(() => getStatusDisplay(status, pipelineConfig), [status, pipelineConfig]);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||
default: 'px-2 py-0.5 text-xs',
|
||||
lg: 'px-2.5 py-1 text-sm',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border font-medium whitespace-nowrap',
|
||||
'transition-colors duration-200',
|
||||
sizeClasses[size],
|
||||
display.colorClass,
|
||||
display.bgClass,
|
||||
display.borderClass,
|
||||
className
|
||||
)}
|
||||
data-testid={`status-badge-${status}`}
|
||||
>
|
||||
{display.label}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to get the status label without rendering the badge
|
||||
* Useful for sorting or filtering operations
|
||||
*/
|
||||
export function getStatusLabel(
|
||||
status: FeatureStatusWithPipeline,
|
||||
pipelineConfig: PipelineConfig | null = null
|
||||
): string {
|
||||
return getStatusDisplay(status, pipelineConfig).label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the status order for sorting
|
||||
* Returns a numeric value representing the status position in the workflow
|
||||
*/
|
||||
export function getStatusOrder(status: FeatureStatusWithPipeline): number {
|
||||
const baseOrder: Record<string, number> = {
|
||||
backlog: 0,
|
||||
in_progress: 1,
|
||||
waiting_approval: 2,
|
||||
verified: 3,
|
||||
};
|
||||
|
||||
if (isPipelineStatus(status)) {
|
||||
// Pipeline statuses come after in_progress but before waiting_approval
|
||||
return 1.5;
|
||||
}
|
||||
|
||||
return baseOrder[status] ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type ViewMode = 'kanban' | 'list';
|
||||
|
||||
interface ViewToggleProps {
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A segmented control component for switching between kanban (grid) and list views.
|
||||
* Uses icons to represent each view mode with clear visual feedback.
|
||||
*/
|
||||
export function ViewToggle({ viewMode, onViewModeChange, className }: ViewToggleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center rounded-md bg-muted p-[3px] border border-border',
|
||||
className
|
||||
)}
|
||||
role="tablist"
|
||||
aria-label="View mode"
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'kanban'}
|
||||
aria-label="Kanban view"
|
||||
onClick={() => onViewModeChange('kanban')}
|
||||
className={cn(
|
||||
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
viewMode === 'kanban'
|
||||
? 'bg-primary text-primary-foreground shadow-md'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="view-toggle-kanban"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
<span className="sr-only sm:not-sr-only">Kanban</span>
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'list'}
|
||||
aria-label="List view"
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={cn(
|
||||
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
viewMode === 'list'
|
||||
? 'bg-primary text-primary-foreground shadow-md'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
)}
|
||||
data-testid="view-toggle-list"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
<span className="sr-only sm:not-sr-only">List</span>
|
||||
</button>
|
||||
</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">
|
||||
|
||||
@@ -419,7 +419,7 @@ export function BacklogPlanDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">{renderContent()}</div>
|
||||
<div className="py-4 overflow-y-auto">{renderContent()}</div>
|
||||
|
||||
<DialogFooter>
|
||||
{mode === 'input' && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitCommit, Loader2 } from 'lucide-react';
|
||||
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -37,7 +38,9 @@ export function CommitWorktreeDialog({
|
||||
}: CommitWorktreeDialogProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!worktree || !message.trim()) return;
|
||||
@@ -77,11 +80,68 @@ export function CommitWorktreeDialog({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
|
||||
// Prevent commit while loading or while AI is generating a message
|
||||
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
|
||||
handleCommit();
|
||||
}
|
||||
};
|
||||
|
||||
// Generate AI commit message when dialog opens (if enabled)
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
// Reset state
|
||||
setMessage('');
|
||||
setError(null);
|
||||
|
||||
// Only generate AI commit message if enabled
|
||||
if (!enableAiCommitMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
let cancelled = false;
|
||||
|
||||
const generateMessage = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.generateCommitMessage) {
|
||||
if (!cancelled) {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.generateCommitMessage(worktree.path);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.success && result.message) {
|
||||
setMessage(result.message);
|
||||
} else {
|
||||
// Don't show error toast, just log it and leave message empty
|
||||
console.warn('Failed to generate commit message:', result.error);
|
||||
setMessage('');
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
// Don't show error toast for generation failures
|
||||
console.warn('Error generating commit message:', err);
|
||||
setMessage('');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generateMessage();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [open, worktree, enableAiCommitMessages]);
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
return (
|
||||
@@ -106,10 +166,20 @@ export function CommitWorktreeDialog({
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="commit-message">Commit Message</Label>
|
||||
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
||||
Commit Message
|
||||
{isGenerating && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3 animate-pulse" />
|
||||
Generating...
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="commit-message"
|
||||
placeholder="Describe your changes..."
|
||||
placeholder={
|
||||
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
|
||||
}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
@@ -118,6 +188,7 @@ export function CommitWorktreeDialog({
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[100px] font-mono text-sm"
|
||||
autoFocus
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
@@ -128,10 +199,14 @@ export function CommitWorktreeDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading || isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
|
||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
|
||||
@@ -117,7 +117,7 @@ export function CreatePRDialog({
|
||||
description: `PR already exists for ${result.result.branch}`,
|
||||
action: {
|
||||
label: 'View PR',
|
||||
onClick: () => window.open(result.result!.prUrl!, '_blank'),
|
||||
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -125,7 +125,7 @@ export function CreatePRDialog({
|
||||
description: `PR created from ${result.result.branch}`,
|
||||
action: {
|
||||
label: 'View PR',
|
||||
onClick: () => window.open(result.result!.prUrl!, '_blank'),
|
||||
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -251,7 +251,10 @@ export function CreatePRDialog({
|
||||
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
|
||||
<Button
|
||||
onClick={() => window.open(prUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Pull Request
|
||||
</Button>
|
||||
@@ -277,7 +280,7 @@ export function CreatePRDialog({
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (browserUrl) {
|
||||
window.open(browserUrl, '_blank');
|
||||
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
className="gap-2 w-full"
|
||||
|
||||
174
apps/ui/src/components/views/board-view/header-mobile-menu.tsx
Normal file
174
apps/ui/src/components/views/board-view/header-mobile-menu.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
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';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
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;
|
||||
// Usage bar visibility
|
||||
showClaudeUsage: boolean;
|
||||
showCodexUsage: boolean;
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
isWorktreePanelVisible,
|
||||
onWorktreePanelToggle,
|
||||
maxConcurrency,
|
||||
runningAgentsCount,
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onOpenAutoModeSettings,
|
||||
onOpenPlanDialog,
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
}: 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">
|
||||
{/* Usage Bar - show if either provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage) && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Usage
|
||||
</DropdownMenuLabel>
|
||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background';
|
||||
export { useBoardPersistence } from './use-board-persistence';
|
||||
export { useFollowUpState } from './use-follow-up-state';
|
||||
export { useSelectionMode } from './use-selection-mode';
|
||||
export { useListViewState } from './use-list-view-state';
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { getJSON, setJSON } from '@/lib/storage';
|
||||
import type { ViewMode } from '../components/view-toggle';
|
||||
|
||||
// Re-export ViewMode for convenience
|
||||
export type { ViewMode };
|
||||
|
||||
/** Columns that can be sorted in the list view */
|
||||
export type SortColumn = 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt';
|
||||
|
||||
/** Sort direction */
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
/** Sort configuration */
|
||||
export interface SortConfig {
|
||||
column: SortColumn;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/** Persisted state for the list view */
|
||||
interface ListViewPersistedState {
|
||||
viewMode: ViewMode;
|
||||
sortConfig: SortConfig;
|
||||
}
|
||||
|
||||
/** Storage key for list view preferences */
|
||||
const STORAGE_KEY = 'automaker:list-view-state';
|
||||
|
||||
/** Default sort configuration */
|
||||
const DEFAULT_SORT_CONFIG: SortConfig = {
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
};
|
||||
|
||||
/** Default persisted state */
|
||||
const DEFAULT_STATE: ListViewPersistedState = {
|
||||
viewMode: 'kanban',
|
||||
sortConfig: DEFAULT_SORT_CONFIG,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and returns a valid ViewMode, defaulting to 'kanban' if invalid
|
||||
*/
|
||||
function validateViewMode(value: unknown): ViewMode {
|
||||
if (value === 'kanban' || value === 'list') {
|
||||
return value;
|
||||
}
|
||||
return 'kanban';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and returns a valid SortColumn, defaulting to 'createdAt' if invalid
|
||||
*/
|
||||
function validateSortColumn(value: unknown): SortColumn {
|
||||
const validColumns: SortColumn[] = [
|
||||
'title',
|
||||
'status',
|
||||
'category',
|
||||
'priority',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
if (typeof value === 'string' && validColumns.includes(value as SortColumn)) {
|
||||
return value as SortColumn;
|
||||
}
|
||||
return 'createdAt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and returns a valid SortDirection, defaulting to 'desc' if invalid
|
||||
*/
|
||||
function validateSortDirection(value: unknown): SortDirection {
|
||||
if (value === 'asc' || value === 'desc') {
|
||||
return value;
|
||||
}
|
||||
return 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted state from localStorage with validation
|
||||
*/
|
||||
function loadPersistedState(): ListViewPersistedState {
|
||||
const stored = getJSON<Partial<ListViewPersistedState>>(STORAGE_KEY);
|
||||
|
||||
if (!stored) {
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
|
||||
return {
|
||||
viewMode: validateViewMode(stored.viewMode),
|
||||
sortConfig: {
|
||||
column: validateSortColumn(stored.sortConfig?.column),
|
||||
direction: validateSortDirection(stored.sortConfig?.direction),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state to localStorage
|
||||
*/
|
||||
function savePersistedState(state: ListViewPersistedState): void {
|
||||
setJSON(STORAGE_KEY, state);
|
||||
}
|
||||
|
||||
export interface UseListViewStateReturn {
|
||||
/** Current view mode (kanban or list) */
|
||||
viewMode: ViewMode;
|
||||
/** Set the view mode */
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
/** Toggle between kanban and list views */
|
||||
toggleViewMode: () => void;
|
||||
/** Whether the current view is list mode */
|
||||
isListView: boolean;
|
||||
/** Whether the current view is kanban mode */
|
||||
isKanbanView: boolean;
|
||||
/** Current sort configuration */
|
||||
sortConfig: SortConfig;
|
||||
/** Set the sort column (toggles direction if same column) */
|
||||
setSortColumn: (column: SortColumn) => void;
|
||||
/** Set the full sort configuration */
|
||||
setSortConfig: (config: SortConfig) => void;
|
||||
/** Reset sort to default */
|
||||
resetSort: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing list view state including view mode, sorting, and localStorage persistence.
|
||||
*
|
||||
* Features:
|
||||
* - View mode toggle between kanban and list views
|
||||
* - Sort configuration with column and direction
|
||||
* - Automatic persistence to localStorage
|
||||
* - Validated state restoration on mount
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { viewMode, setViewMode, sortConfig, setSortColumn } = useListViewState();
|
||||
*
|
||||
* // Toggle view mode
|
||||
* <ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
*
|
||||
* // Sort by column (clicking same column toggles direction)
|
||||
* <TableHeader onClick={() => setSortColumn('title')}>Title</TableHeader>
|
||||
* ```
|
||||
*/
|
||||
export function useListViewState(): UseListViewStateReturn {
|
||||
// Initialize state from localStorage
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>(() => loadPersistedState().viewMode);
|
||||
const [sortConfig, setSortConfigState] = useState<SortConfig>(
|
||||
() => loadPersistedState().sortConfig
|
||||
);
|
||||
|
||||
// Derived state
|
||||
const isListView = viewMode === 'list';
|
||||
const isKanbanView = viewMode === 'kanban';
|
||||
|
||||
// Persist state changes to localStorage
|
||||
useEffect(() => {
|
||||
savePersistedState({ viewMode, sortConfig });
|
||||
}, [viewMode, sortConfig]);
|
||||
|
||||
// Set view mode
|
||||
const setViewMode = useCallback((mode: ViewMode) => {
|
||||
setViewModeState(mode);
|
||||
}, []);
|
||||
|
||||
// Toggle between kanban and list views
|
||||
const toggleViewMode = useCallback(() => {
|
||||
setViewModeState((prev) => (prev === 'kanban' ? 'list' : 'kanban'));
|
||||
}, []);
|
||||
|
||||
// Set sort column - toggles direction if same column is clicked
|
||||
const setSortColumn = useCallback((column: SortColumn) => {
|
||||
setSortConfigState((prev) => {
|
||||
if (prev.column === column) {
|
||||
// Toggle direction if same column
|
||||
return {
|
||||
column,
|
||||
direction: prev.direction === 'asc' ? 'desc' : 'asc',
|
||||
};
|
||||
}
|
||||
// New column - default to descending for dates, ascending for others
|
||||
const defaultDirection: SortDirection =
|
||||
column === 'createdAt' || column === 'updatedAt' ? 'desc' : 'asc';
|
||||
return { column, direction: defaultDirection };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Set full sort configuration
|
||||
const setSortConfig = useCallback((config: SortConfig) => {
|
||||
setSortConfigState(config);
|
||||
}, []);
|
||||
|
||||
// Reset sort to default
|
||||
const resetSort = useCallback(() => {
|
||||
setSortConfigState(DEFAULT_SORT_CONFIG);
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
viewMode,
|
||||
setViewMode,
|
||||
toggleViewMode,
|
||||
isListView,
|
||||
isKanbanView,
|
||||
sortConfig,
|
||||
setSortColumn,
|
||||
setSortConfig,
|
||||
resetSort,
|
||||
}),
|
||||
[
|
||||
viewMode,
|
||||
setViewMode,
|
||||
toggleViewMode,
|
||||
isListView,
|
||||
isKanbanView,
|
||||
sortConfig,
|
||||
setSortColumn,
|
||||
setSortConfig,
|
||||
resetSort,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-reac
|
||||
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||
import type { PipelineConfig } from '@automaker/types';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
interface KanbanBoardProps {
|
||||
sensors: any;
|
||||
collisionDetectionStrategy: (args: any) => any;
|
||||
@@ -57,6 +57,8 @@ interface KanbanBoardProps {
|
||||
isDragging?: boolean;
|
||||
/** Whether the board is in read-only mode */
|
||||
isReadOnly?: boolean;
|
||||
/** Additional className for custom styling (e.g., transition classes) */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
@@ -95,6 +97,7 @@ export function KanbanBoard({
|
||||
onAiSuggest,
|
||||
isDragging = false,
|
||||
isReadOnly = false,
|
||||
className,
|
||||
}: KanbanBoardProps) {
|
||||
// Generate columns including pipeline steps
|
||||
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
|
||||
@@ -108,7 +111,14 @@ export function KanbanBoard({
|
||||
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
|
||||
'transition-opacity duration-200',
|
||||
className
|
||||
)}
|
||||
style={backgroundImageStyle}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetectionStrategy}
|
||||
|
||||
229
apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
Normal file
229
apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface MobileUsageBarProps {
|
||||
showClaudeUsage: boolean;
|
||||
showCodexUsage: boolean;
|
||||
}
|
||||
|
||||
// Helper to get progress bar color based on percentage
|
||||
function getProgressBarColor(percentage: number): string {
|
||||
if (percentage >= 80) return 'bg-red-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
// Individual usage bar component
|
||||
function UsageBar({
|
||||
label,
|
||||
percentage,
|
||||
isStale,
|
||||
}: {
|
||||
label: string;
|
||||
percentage: number;
|
||||
isStale: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-1.5 first:mt-0">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
percentage >= 80
|
||||
? 'text-red-500'
|
||||
: percentage >= 50
|
||||
? 'text-yellow-500'
|
||||
: 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
|
||||
isStale && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Container for a provider's usage info
|
||||
function UsageItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
children,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefresh();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Refresh usage"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pl-6 space-y-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
|
||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isClaudeStale =
|
||||
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
|
||||
const fetchClaudeUsage = useCallback(async () => {
|
||||
setIsClaudeLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.claude) return;
|
||||
const data = await api.claude.getUsage();
|
||||
if (!('error' in data)) {
|
||||
setClaudeUsage(data);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - usage display is optional
|
||||
} finally {
|
||||
setIsClaudeLoading(false);
|
||||
}
|
||||
}, [setClaudeUsage]);
|
||||
|
||||
const fetchCodexUsage = useCallback(async () => {
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) return;
|
||||
const data = await api.codex.getUsage();
|
||||
if (!('error' in data)) {
|
||||
setCodexUsage(data);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - usage display is optional
|
||||
} finally {
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}, [setCodexUsage]);
|
||||
|
||||
const getCodexWindowLabel = (durationMins: number) => {
|
||||
if (durationMins < 60) return `${durationMins}m Window`;
|
||||
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
|
||||
return `${Math.round(durationMins / 1440)}d Window`;
|
||||
};
|
||||
|
||||
// Auto-fetch on mount if data is stale
|
||||
useEffect(() => {
|
||||
if (showClaudeUsage && isClaudeStale) {
|
||||
fetchClaudeUsage();
|
||||
}
|
||||
}, [showClaudeUsage, isClaudeStale, fetchClaudeUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCodexUsage && isCodexStale) {
|
||||
fetchCodexUsage();
|
||||
}
|
||||
}, [showCodexUsage, isCodexStale, fetchCodexUsage]);
|
||||
|
||||
// Don't render if there's nothing to show
|
||||
if (!showClaudeUsage && !showCodexUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 py-1" data-testid="mobile-usage-bar">
|
||||
{showClaudeUsage && (
|
||||
<UsageItem
|
||||
icon={AnthropicIcon}
|
||||
label="Claude"
|
||||
isLoading={isClaudeLoading}
|
||||
onRefresh={fetchClaudeUsage}
|
||||
>
|
||||
{claudeUsage ? (
|
||||
<>
|
||||
<UsageBar
|
||||
label="Session"
|
||||
percentage={claudeUsage.sessionPercentage}
|
||||
isStale={isClaudeStale}
|
||||
/>
|
||||
<UsageBar
|
||||
label="Weekly"
|
||||
percentage={claudeUsage.weeklyPercentage}
|
||||
isStale={isClaudeStale}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
|
||||
)}
|
||||
</UsageItem>
|
||||
)}
|
||||
|
||||
{showCodexUsage && (
|
||||
<UsageItem
|
||||
icon={OpenAIIcon}
|
||||
label="Codex"
|
||||
isLoading={isCodexLoading}
|
||||
onRefresh={fetchCodexUsage}
|
||||
>
|
||||
{codexUsage?.rateLimits ? (
|
||||
<>
|
||||
{codexUsage.rateLimits.primary && (
|
||||
<UsageBar
|
||||
label={getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)}
|
||||
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||
isStale={isCodexStale}
|
||||
/>
|
||||
)}
|
||||
{codexUsage.rateLimits.secondary && (
|
||||
<UsageBar
|
||||
label={getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)}
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
isStale={isCodexStale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
|
||||
)}
|
||||
</UsageItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function PrioritySelector({
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(1)}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||
selectedPriority === 1
|
||||
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
|
||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||
@@ -30,7 +30,7 @@ export function PrioritySelector({
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(2)}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||
selectedPriority === 2
|
||||
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
|
||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||
@@ -43,7 +43,7 @@ export function PrioritySelector({
|
||||
type="button"
|
||||
onClick={() => onPrioritySelect(3)}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
|
||||
selectedPriority === 3
|
||||
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
|
||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||
|
||||
@@ -20,6 +20,8 @@ interface BranchSwitchDropdownProps {
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
/** When true, renders as a standalone button (not attached to another element) */
|
||||
standalone?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
@@ -33,6 +35,7 @@ export function BranchSwitchDropdown({
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
standalone = false,
|
||||
onOpenChange,
|
||||
onFilterChange,
|
||||
onSwitchBranch,
|
||||
@@ -42,16 +45,18 @@ export function BranchSwitchDropdown({
|
||||
<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-none border-r-0',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||
'h-7 w-7 p-0',
|
||||
!standalone && 'rounded-none border-r-0',
|
||||
standalone && 'h-8 w-8 shrink-0',
|
||||
!standalone && isSelected && 'bg-primary text-primary-foreground',
|
||||
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
|
||||
)}
|
||||
title="Switch branch"
|
||||
>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
|
||||
export { WorktreeTab } from './worktree-tab';
|
||||
|
||||
@@ -45,6 +45,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;
|
||||
@@ -73,6 +75,7 @@ export function WorktreeActionsDropdown({
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
gitRepoStatus,
|
||||
standalone = false,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
@@ -118,15 +121,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">
|
||||
@@ -146,8 +151,12 @@ export function WorktreeActionsDropdown({
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Dev Server Running (:{devServerInfo?.port})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onOpenDevServerUrl(worktree)} className="text-xs">
|
||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||
@@ -327,7 +336,7 @@ export function WorktreeActionsDropdown({
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(worktree.pr!.url, '_blank');
|
||||
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -300,20 +300,29 @@ export function WorktreeTab({
|
||||
)}
|
||||
|
||||
{isDevServerRunning && (
|
||||
<Button
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 rounded-none border-r-0',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary',
|
||||
'text-green-500'
|
||||
)}
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
title={`Open dev server (port ${devServerInfo?.port})`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 rounded-none border-r-0',
|
||||
isSelected && 'bg-primary text-primary-foreground',
|
||||
!isSelected && 'bg-secondary/50 hover:bg-secondary',
|
||||
'text-green-500'
|
||||
)}
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||
>
|
||||
<Globe className="w-3 h-3" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open dev server (:{devServerInfo?.port})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<WorktreeActionsDropdown
|
||||
|
||||
@@ -118,8 +118,37 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
const handleOpenDevServerUrl = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
|
||||
if (serverInfo) {
|
||||
window.open(serverInfo.url, '_blank');
|
||||
if (!serverInfo) {
|
||||
logger.warn('No dev server info found for worktree:', getWorktreeKey(worktree));
|
||||
toast.error('Dev server not found', {
|
||||
description: 'The dev server may have stopped. Try starting it again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Rewrite URL hostname to match the current browser's hostname.
|
||||
// This ensures dev server URLs work when accessing Automaker from
|
||||
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
|
||||
const devServerUrl = new URL(serverInfo.url);
|
||||
|
||||
// Security: Only allow http/https protocols to prevent potential attacks
|
||||
// via data:, javascript:, file:, or other dangerous URL schemes
|
||||
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
||||
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
|
||||
toast.error('Invalid dev server URL', {
|
||||
description: 'The server returned an unsupported URL protocol.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
devServerUrl.hostname = window.location.hostname;
|
||||
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse dev server URL:', error);
|
||||
toast.error('Failed to open dev server', {
|
||||
description: 'The server URL could not be processed. Please try again.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[runningDevServers, getWorktreeKey]
|
||||
|
||||
@@ -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,13 @@ import {
|
||||
useWorktreeActions,
|
||||
useRunningFeatures,
|
||||
} from './hooks';
|
||||
import { WorktreeTab, DevServerLogsPanel } from './components';
|
||||
import {
|
||||
WorktreeTab,
|
||||
DevServerLogsPanel,
|
||||
WorktreeMobileDropdown,
|
||||
WorktreeActionsDropdown,
|
||||
BranchSwitchDropdown,
|
||||
} from './components';
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -107,6 +114,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);
|
||||
@@ -183,6 +192,105 @@ 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}
|
||||
/>
|
||||
|
||||
{/* Branch switch dropdown for the selected worktree */}
|
||||
{selectedWorktree && (
|
||||
<BranchSwitchDropdown
|
||||
worktree={selectedWorktree}
|
||||
isSelected={true}
|
||||
standalone={true}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onOpenChange={handleBranchDropdownOpenChange(selectedWorktree)}
|
||||
onFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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" />
|
||||
|
||||
Reference in New Issue
Block a user