mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
fix: adjust pr commnets
This commit is contained in:
@@ -1,203 +0,0 @@
|
|||||||
# Implementation Plan: Fix Mobile Model Selection
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
Users cannot change the model when creating a new task on mobile devices. The model selector uses fixed-width Radix UI Popovers with nested secondary popovers that extend to the right, causing the interface to go off-screen on mobile devices (typically 375-428px width).
|
|
||||||
|
|
||||||
## Root Cause Analysis
|
|
||||||
|
|
||||||
### Current Implementation Issues:
|
|
||||||
1. **Fixed Widths**: Main popover is 320px, secondary popovers are 220px
|
|
||||||
2. **Horizontal Nesting**: Secondary popovers (thinking levels, reasoning effort, cursor variants) position to the right of the main popover
|
|
||||||
3. **No Collision Handling**: Radix Popover doesn't have sufficient collision padding configured
|
|
||||||
4. **No Mobile-Specific UI**: Same component used for all screen sizes
|
|
||||||
|
|
||||||
### Affected Files:
|
|
||||||
- `/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx` - Core implementation
|
|
||||||
- `/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx` - Wrapper for agent view
|
|
||||||
- `/apps/ui/src/components/views/agent-view/input-area/input-controls.tsx` - Usage location
|
|
||||||
|
|
||||||
## Proposed Solution: Responsive Popover with Mobile Optimization
|
|
||||||
|
|
||||||
### Approach: Add Responsive Width & Collision Handling
|
|
||||||
|
|
||||||
**Rationale**: Minimal changes, maximum compatibility, leverages existing Radix UI features
|
|
||||||
|
|
||||||
### Implementation Steps:
|
|
||||||
|
|
||||||
#### 1. Create a Custom Hook for Mobile Detection
|
|
||||||
**File**: `/apps/ui/src/hooks/use-mobile.ts` (new file)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function useMobile(breakpoint: number = 768) {
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
|
||||||
|
|
||||||
const handleChange = () => {
|
|
||||||
setIsMobile(mediaQuery.matches);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set initial value
|
|
||||||
handleChange();
|
|
||||||
|
|
||||||
// Listen for changes
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
||||||
}, [breakpoint]);
|
|
||||||
|
|
||||||
return isMobile;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why**: Follows existing pattern from `use-sidebar-auto-collapse.ts`, reusable across components
|
|
||||||
|
|
||||||
#### 2. Update Phase Model Selector with Responsive Behavior
|
|
||||||
**File**: `/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx`
|
|
||||||
|
|
||||||
**Changes**:
|
|
||||||
- Import and use `useMobile()` hook
|
|
||||||
- Apply responsive widths:
|
|
||||||
- Mobile: `w-[calc(100vw-32px)] max-w-[340px]` (full width with padding)
|
|
||||||
- Desktop: `w-80` (320px - current)
|
|
||||||
- Add collision handling to Radix Popover:
|
|
||||||
- `collisionPadding={16}` - Prevent edge overflow
|
|
||||||
- `avoidCollisions={true}` - Enable collision detection
|
|
||||||
- `sideOffset={4}` - Add spacing from trigger
|
|
||||||
- Secondary popovers:
|
|
||||||
- Mobile: Position `side="bottom"` instead of `side="right"`
|
|
||||||
- Desktop: Keep `side="right"` (current behavior)
|
|
||||||
- Mobile width: `w-[calc(100vw-32px)] max-w-[340px]`
|
|
||||||
- Desktop width: `w-[220px]` (current)
|
|
||||||
|
|
||||||
**Specific Code Changes**:
|
|
||||||
```typescript
|
|
||||||
// Add at top of component
|
|
||||||
const isMobile = useMobile(768);
|
|
||||||
|
|
||||||
// Main popover content
|
|
||||||
<PopoverContent
|
|
||||||
className={cn(
|
|
||||||
isMobile ? "w-[calc(100vw-32px)] max-w-[340px]" : "w-80",
|
|
||||||
"p-0"
|
|
||||||
)}
|
|
||||||
collisionPadding={16}
|
|
||||||
avoidCollisions={true}
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
|
|
||||||
// Secondary popovers (thinking level, reasoning effort, etc.)
|
|
||||||
<PopoverContent
|
|
||||||
side={isMobile ? "bottom" : "right"}
|
|
||||||
className={cn(
|
|
||||||
isMobile ? "w-[calc(100vw-32px)] max-w-[340px]" : "w-[220px]",
|
|
||||||
"p-2"
|
|
||||||
)}
|
|
||||||
collisionPadding={16}
|
|
||||||
avoidCollisions={true}
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Test Responsive Behavior
|
|
||||||
|
|
||||||
**Test Cases**:
|
|
||||||
- [ ] Mobile (< 768px): Popovers fit within screen, secondary popovers open below
|
|
||||||
- [ ] Tablet (768-1024px): Popovers use optimal width
|
|
||||||
- [ ] Desktop (> 1024px): Current behavior preserved
|
|
||||||
- [ ] Edge cases: Very narrow screens (320px), screen rotation
|
|
||||||
- [ ] Functionality: All model selections work correctly on all screen sizes
|
|
||||||
|
|
||||||
## Alternative Approaches Considered
|
|
||||||
|
|
||||||
### Alternative 1: Use Sheet Component for Mobile
|
|
||||||
**Pros**: Better mobile UX, full-screen takeover common pattern
|
|
||||||
**Cons**: Requires duplicating component logic, more complex state management, different UX between mobile/desktop
|
|
||||||
|
|
||||||
**Verdict**: Rejected - Too much complexity for the benefit
|
|
||||||
|
|
||||||
### Alternative 2: Simplify Mobile UI (Remove Nested Popovers)
|
|
||||||
**Pros**: Simpler mobile interface
|
|
||||||
**Cons**: Removes functionality (thinking levels, reasoning effort) on mobile, poor UX
|
|
||||||
|
|
||||||
**Verdict**: Rejected - Removes essential features
|
|
||||||
|
|
||||||
### Alternative 3: Horizontal Scrolling Container
|
|
||||||
**Pros**: Preserves exact desktop layout
|
|
||||||
**Cons**: Poor mobile UX, non-standard pattern, accessibility issues
|
|
||||||
|
|
||||||
**Verdict**: Rejected - Bad mobile UX
|
|
||||||
|
|
||||||
## Technical Considerations
|
|
||||||
|
|
||||||
### Breakpoint Selection
|
|
||||||
- **768px chosen**: Standard tablet breakpoint
|
|
||||||
- Matches pattern in existing codebase (`use-sidebar-auto-collapse.ts` uses 1024px)
|
|
||||||
- Covers iPhone SE (375px) through iPhone 14 Pro Max (428px)
|
|
||||||
|
|
||||||
### Collision Handling
|
|
||||||
- `collisionPadding={16}`: 16px buffer from edges (standard spacing)
|
|
||||||
- `avoidCollisions={true}`: Radix will automatically reposition if needed
|
|
||||||
- `sideOffset={4}`: Small gap between trigger and popover
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- `useMobile` hook uses `window.matchMedia` (performant, native API)
|
|
||||||
- Re-renders only on breakpoint changes (not every resize)
|
|
||||||
- No additional dependencies
|
|
||||||
|
|
||||||
### Compatibility
|
|
||||||
- Works with existing compact/full modes
|
|
||||||
- Preserves all functionality
|
|
||||||
- No breaking changes to props/API
|
|
||||||
- Compatible with existing styles
|
|
||||||
|
|
||||||
## Implementation Checklist
|
|
||||||
|
|
||||||
- [ ] Create `/apps/ui/src/hooks/use-mobile.ts`
|
|
||||||
- [ ] Update `phase-model-selector.tsx` with responsive behavior
|
|
||||||
- [ ] Test on mobile devices/emulators (Chrome DevTools)
|
|
||||||
- [ ] Test on tablet breakpoint
|
|
||||||
- [ ] Test on desktop (ensure no regression)
|
|
||||||
- [ ] Verify all model variants are selectable
|
|
||||||
- [ ] Check nested popovers (thinking level, reasoning effort, cursor)
|
|
||||||
- [ ] Verify compact mode still works in agent view
|
|
||||||
- [ ] Test keyboard navigation
|
|
||||||
- [ ] Test with touch interactions
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
If issues arise:
|
|
||||||
1. Revert `phase-model-selector.tsx` changes
|
|
||||||
2. Remove `use-mobile.ts` hook
|
|
||||||
3. Original functionality immediately restored
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
✅ Users can select any model on mobile devices (< 768px width)
|
|
||||||
✅ All nested popover options are accessible on mobile
|
|
||||||
✅ Desktop behavior unchanged (no regressions)
|
|
||||||
✅ UI fits within viewport on all screen sizes (320px+)
|
|
||||||
✅ No horizontal scrolling required
|
|
||||||
✅ Touch interactions work correctly
|
|
||||||
|
|
||||||
## Estimated Effort
|
|
||||||
- Implementation: 30-45 minutes
|
|
||||||
- Testing: 15-20 minutes
|
|
||||||
- **Total**: ~1 hour
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
None - uses existing Radix UI Popover features
|
|
||||||
|
|
||||||
## Risks & Mitigation
|
|
||||||
| Risk | Likelihood | Impact | Mitigation |
|
|
||||||
|------|-----------|--------|------------|
|
|
||||||
| Breaks desktop layout | Low | Medium | Thorough testing, conditional logic |
|
|
||||||
| Poor mobile UX | Low | Medium | Follow mobile-first best practices |
|
|
||||||
| Touch interaction issues | Low | Low | Use Radix UI touch handlers |
|
|
||||||
| Breakpoint conflicts | Low | Low | Use standard 768px breakpoint |
|
|
||||||
|
|
||||||
## Notes for Developer
|
|
||||||
- The `compact` prop in `agent-model-selector.tsx` is preserved and still works
|
|
||||||
- All existing functionality (thinking levels, reasoning effort, cursor variants) remains
|
|
||||||
- Only visual layout changes on mobile - no logic changes
|
|
||||||
- Consider adding this pattern to other popovers if successful
|
|
||||||
@@ -185,7 +185,6 @@ export class ClaudeUsageService {
|
|||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
});
|
});
|
||||||
} catch (spawnError) {
|
} catch (spawnError) {
|
||||||
// ... (error handling omitted for brevity in replace block, keep existing)
|
|
||||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||||
|
|
||||||
|
|||||||
@@ -72,17 +72,17 @@ export function UsagePopover() {
|
|||||||
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
const [codexError, setCodexError] = useState<UsageError | null>(null);
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
|
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
// Determine which tab to show by default
|
// Determine which tab to show by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeCliVerified) {
|
if (isClaudeAuthenticated) {
|
||||||
setActiveTab('claude');
|
setActiveTab('claude');
|
||||||
} else if (isCodexAuthenticated) {
|
} else if (isCodexAuthenticated) {
|
||||||
setActiveTab('codex');
|
setActiveTab('codex');
|
||||||
}
|
}
|
||||||
}, [isClaudeCliVerified, isCodexAuthenticated]);
|
}, [isClaudeAuthenticated, isCodexAuthenticated]);
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale = useMemo(() => {
|
const isClaudeStale = useMemo(() => {
|
||||||
@@ -173,10 +173,10 @@ export function UsagePopover() {
|
|||||||
|
|
||||||
// Auto-fetch on mount if data is stale
|
// Auto-fetch on mount if data is stale
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeStale && isClaudeCliVerified) {
|
if (isClaudeStale && isClaudeAuthenticated) {
|
||||||
fetchClaudeUsage(true);
|
fetchClaudeUsage(true);
|
||||||
}
|
}
|
||||||
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
|
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCodexStale && isCodexAuthenticated) {
|
if (isCodexStale && isCodexAuthenticated) {
|
||||||
@@ -189,7 +189,7 @@ export function UsagePopover() {
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
// Fetch based on active tab
|
// Fetch based on active tab
|
||||||
if (activeTab === 'claude' && isClaudeCliVerified) {
|
if (activeTab === 'claude' && isClaudeAuthenticated) {
|
||||||
if (!claudeUsage || isClaudeStale) {
|
if (!claudeUsage || isClaudeStale) {
|
||||||
fetchClaudeUsage();
|
fetchClaudeUsage();
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@ export function UsagePopover() {
|
|||||||
activeTab,
|
activeTab,
|
||||||
claudeUsage,
|
claudeUsage,
|
||||||
isClaudeStale,
|
isClaudeStale,
|
||||||
isClaudeCliVerified,
|
isClaudeAuthenticated,
|
||||||
codexUsage,
|
codexUsage,
|
||||||
isCodexStale,
|
isCodexStale,
|
||||||
isCodexAuthenticated,
|
isCodexAuthenticated,
|
||||||
@@ -348,7 +348,7 @@ export function UsagePopover() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Determine which tabs to show
|
// Determine which tabs to show
|
||||||
const showClaudeTab = isClaudeCliVerified;
|
const showClaudeTab = isClaudeAuthenticated;
|
||||||
const showCodexTab = isCodexAuthenticated;
|
const showCodexTab = isCodexAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export function BoardHeader({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||||
{!isMobile && (
|
{isMounted && !isMobile && (
|
||||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||||
<button
|
<button
|
||||||
onClick={onOpenPlanDialog}
|
onClick={onOpenPlanDialog}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export { ListHeader, LIST_COLUMNS, getColumnById, getColumnWidth, getColumnAlign } from './list-header';
|
export {
|
||||||
|
ListHeader,
|
||||||
|
LIST_COLUMNS,
|
||||||
|
getColumnById,
|
||||||
|
getColumnWidth,
|
||||||
|
getColumnAlign,
|
||||||
|
} from './list-header';
|
||||||
export type { ListHeaderProps } from './list-header';
|
export type { ListHeaderProps } from './list-header';
|
||||||
|
|
||||||
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
|
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
|
||||||
|
// The `[key: string]: unknown` in BaseFeature causes property access type errors
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { memo, useCallback, useMemo } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { memo, useMemo, useCallback, useState } from 'react';
|
import { memo, useMemo, useCallback, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -10,7 +9,7 @@ import { ListHeader } from './list-header';
|
|||||||
import { ListRow, sortFeatures } from './list-row';
|
import { ListRow, sortFeatures } from './list-row';
|
||||||
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
|
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
|
||||||
import { getStatusLabel, getStatusOrder } from './status-badge';
|
import { getStatusLabel, getStatusOrder } from './status-badge';
|
||||||
import { COLUMNS, getColumnsWithPipeline } from '../../constants';
|
import { getColumnsWithPipeline } from '../../constants';
|
||||||
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
|
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,11 +97,7 @@ const StatusGroupHeader = memo(function StatusGroupHeader({
|
|||||||
>
|
>
|
||||||
{/* Collapse indicator */}
|
{/* Collapse indicator */}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{isExpanded ? (
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Status color indicator */}
|
{/* Status color indicator */}
|
||||||
@@ -123,11 +118,7 @@ const StatusGroupHeader = memo(function StatusGroupHeader({
|
|||||||
/**
|
/**
|
||||||
* EmptyState displays a message when there are no features
|
* EmptyState displays a message when there are no features
|
||||||
*/
|
*/
|
||||||
const EmptyState = memo(function EmptyState({
|
const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) {
|
||||||
onAddFeature,
|
|
||||||
}: {
|
|
||||||
onAddFeature?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -210,11 +201,7 @@ export const ListView = memo(function ListView({
|
|||||||
const features = columnFeaturesMap[column.id] || [];
|
const features = columnFeaturesMap[column.id] || [];
|
||||||
if (features.length > 0) {
|
if (features.length > 0) {
|
||||||
// Sort features within the group according to current sort config
|
// Sort features within the group according to current sort config
|
||||||
const sortedFeatures = sortFeatures(
|
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
|
||||||
features,
|
|
||||||
sortConfig.column,
|
|
||||||
sortConfig.direction
|
|
||||||
);
|
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
id: column.id as FeatureStatusWithPipeline,
|
id: column.id as FeatureStatusWithPipeline,
|
||||||
@@ -366,20 +353,12 @@ export const ListView = memo(function ListView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [onToggleFeatureSelection, selectionState.allSelected, selectedFeatureIds, statusGroups]);
|
||||||
onToggleFeatureSelection,
|
|
||||||
selectionState.allSelected,
|
|
||||||
selectedFeatureIds,
|
|
||||||
statusGroups,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Show empty state if no features
|
// Show empty state if no features
|
||||||
if (totalFeatures === 0) {
|
if (totalFeatures === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
|
||||||
className={cn('flex flex-col h-full bg-background', className)}
|
|
||||||
data-testid="list-view"
|
|
||||||
>
|
|
||||||
<EmptyState onAddFeature={onAddFeature} />
|
<EmptyState onAddFeature={onAddFeature} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -466,20 +445,13 @@ export const ListView = memo(function ListView({
|
|||||||
/**
|
/**
|
||||||
* Helper to get all features from the columnFeaturesMap as a flat array
|
* Helper to get all features from the columnFeaturesMap as a flat array
|
||||||
*/
|
*/
|
||||||
export function getFlatFeatures(
|
export function getFlatFeatures(columnFeaturesMap: Record<string, Feature[]>): Feature[] {
|
||||||
columnFeaturesMap: Record<string, Feature[]>
|
|
||||||
): Feature[] {
|
|
||||||
return Object.values(columnFeaturesMap).flat();
|
return Object.values(columnFeaturesMap).flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to count total features across all groups
|
* Helper to count total features across all groups
|
||||||
*/
|
*/
|
||||||
export function getTotalFeatureCount(
|
export function getTotalFeatureCount(columnFeaturesMap: Record<string, Feature[]>): number {
|
||||||
columnFeaturesMap: Record<string, Feature[]>
|
return Object.values(columnFeaturesMap).reduce((sum, features) => sum + features.length, 0);
|
||||||
): number {
|
|
||||||
return Object.values(columnFeaturesMap).reduce(
|
|
||||||
(sum, features) => sum + features.length,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import { memo, useCallback, useState } from 'react';
|
import { memo, useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
@@ -253,7 +252,13 @@ export const RowActions = memo(function RowActions({
|
|||||||
|
|
||||||
// Use controlled or uncontrolled state
|
// Use controlled or uncontrolled state
|
||||||
const open = isOpen ?? internalOpen;
|
const open = isOpen ?? internalOpen;
|
||||||
const setOpen = onOpenChange ?? setInternalOpen;
|
const setOpen = (value: boolean) => {
|
||||||
|
if (onOpenChange) {
|
||||||
|
onOpenChange(value);
|
||||||
|
} else {
|
||||||
|
setInternalOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(newOpen: boolean) => {
|
(newOpen: boolean) => {
|
||||||
|
|||||||
@@ -160,10 +160,7 @@ export const StatusBadge = memo(function StatusBadge({
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
className,
|
className,
|
||||||
}: StatusBadgeProps) {
|
}: StatusBadgeProps) {
|
||||||
const display = useMemo(
|
const display = useMemo(() => getStatusDisplay(status, pipelineConfig), [status, pipelineConfig]);
|
||||||
() => getStatusDisplay(status, pipelineConfig),
|
|
||||||
[status, pipelineConfig]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||||
|
|||||||
@@ -98,17 +98,22 @@ export function CommitWorktreeDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const generateMessage = async () => {
|
const generateMessage = async () => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.generateCommitMessage) {
|
if (!api?.worktree?.generateCommitMessage) {
|
||||||
setIsGenerating(false);
|
if (!cancelled) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.worktree.generateCommitMessage(worktree.path);
|
const result = await api.worktree.generateCommitMessage(worktree.path);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
if (result.success && result.message) {
|
if (result.success && result.message) {
|
||||||
setMessage(result.message);
|
setMessage(result.message);
|
||||||
} else {
|
} else {
|
||||||
@@ -117,15 +122,22 @@ export function CommitWorktreeDialog({
|
|||||||
setMessage('');
|
setMessage('');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
// Don't show error toast for generation failures
|
// Don't show error toast for generation failures
|
||||||
console.warn('Error generating commit message:', err);
|
console.warn('Error generating commit message:', err);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
if (!cancelled) {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
generateMessage();
|
generateMessage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [open, worktree, enableAiCommitMessages]);
|
}, [open, worktree, enableAiCommitMessages]);
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ function validateViewMode(value: unknown): ViewMode {
|
|||||||
* Validates and returns a valid SortColumn, defaulting to 'createdAt' if invalid
|
* Validates and returns a valid SortColumn, defaulting to 'createdAt' if invalid
|
||||||
*/
|
*/
|
||||||
function validateSortColumn(value: unknown): SortColumn {
|
function validateSortColumn(value: unknown): SortColumn {
|
||||||
const validColumns: SortColumn[] = ['title', 'status', 'category', 'priority', 'createdAt', 'updatedAt'];
|
const validColumns: SortColumn[] = [
|
||||||
|
'title',
|
||||||
|
'status',
|
||||||
|
'category',
|
||||||
|
'priority',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
];
|
||||||
if (typeof value === 'string' && validColumns.includes(value as SortColumn)) {
|
if (typeof value === 'string' && validColumns.includes(value as SortColumn)) {
|
||||||
return value as SortColumn;
|
return value as SortColumn;
|
||||||
}
|
}
|
||||||
@@ -139,7 +146,9 @@ export interface UseListViewStateReturn {
|
|||||||
export function useListViewState(): UseListViewStateReturn {
|
export function useListViewState(): UseListViewStateReturn {
|
||||||
// Initialize state from localStorage
|
// Initialize state from localStorage
|
||||||
const [viewMode, setViewModeState] = useState<ViewMode>(() => loadPersistedState().viewMode);
|
const [viewMode, setViewModeState] = useState<ViewMode>(() => loadPersistedState().viewMode);
|
||||||
const [sortConfig, setSortConfigState] = useState<SortConfig>(() => loadPersistedState().sortConfig);
|
const [sortConfig, setSortConfigState] = useState<SortConfig>(
|
||||||
|
() => loadPersistedState().sortConfig
|
||||||
|
);
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const isListView = viewMode === 'list';
|
const isListView = viewMode === 'list';
|
||||||
@@ -199,6 +208,16 @@ export function useListViewState(): UseListViewStateReturn {
|
|||||||
setSortConfig,
|
setSortConfig,
|
||||||
resetSort,
|
resetSort,
|
||||||
}),
|
}),
|
||||||
[viewMode, setViewMode, toggleViewMode, isListView, isKanbanView, sortConfig, setSortColumn, setSortConfig, resetSort]
|
[
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
toggleViewMode,
|
||||||
|
isListView,
|
||||||
|
isKanbanView,
|
||||||
|
sortConfig,
|
||||||
|
setSortColumn,
|
||||||
|
setSortConfig,
|
||||||
|
resetSort,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
|||||||
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
import { getColumnsWithPipeline, type ColumnId } from './constants';
|
||||||
import type { PipelineConfig } from '@automaker/types';
|
import type { PipelineConfig } from '@automaker/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ViewMode } from './hooks/use-list-view-state';
|
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
sensors: any;
|
||||||
collisionDetectionStrategy: (args: any) => any;
|
collisionDetectionStrategy: (args: any) => any;
|
||||||
@@ -59,8 +57,6 @@ interface KanbanBoardProps {
|
|||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
/** Whether the board is in read-only mode */
|
/** Whether the board is in read-only mode */
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
// View mode for transition animation
|
|
||||||
viewMode?: ViewMode;
|
|
||||||
/** Additional className for custom styling (e.g., transition classes) */
|
/** Additional className for custom styling (e.g., transition classes) */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -101,7 +97,6 @@ export function KanbanBoard({
|
|||||||
onAiSuggest,
|
onAiSuggest,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
viewMode,
|
|
||||||
className,
|
className,
|
||||||
}: KanbanBoardProps) {
|
}: KanbanBoardProps) {
|
||||||
// Generate columns including pipeline steps
|
// Generate columns including pipeline steps
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
import { useEffect, useCallback, useState } from 'react';
|
||||||
import { RefreshCw, AlertTriangle } from 'lucide-react';
|
import { RefreshCw, AlertTriangle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
@@ -104,6 +104,8 @@ function UsageItem({
|
|||||||
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
|
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
|
||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = 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)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale =
|
const isClaudeStale =
|
||||||
@@ -111,6 +113,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||||
|
|
||||||
const fetchClaudeUsage = useCallback(async () => {
|
const fetchClaudeUsage = useCallback(async () => {
|
||||||
|
setIsClaudeLoading(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.claude) return;
|
if (!api.claude) return;
|
||||||
@@ -120,10 +123,13 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail - usage display is optional
|
// Silently fail - usage display is optional
|
||||||
|
} finally {
|
||||||
|
setIsClaudeLoading(false);
|
||||||
}
|
}
|
||||||
}, [setClaudeUsage]);
|
}, [setClaudeUsage]);
|
||||||
|
|
||||||
const fetchCodexUsage = useCallback(async () => {
|
const fetchCodexUsage = useCallback(async () => {
|
||||||
|
setIsCodexLoading(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.codex) return;
|
if (!api.codex) return;
|
||||||
@@ -133,6 +139,8 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail - usage display is optional
|
// Silently fail - usage display is optional
|
||||||
|
} finally {
|
||||||
|
setIsCodexLoading(false);
|
||||||
}
|
}
|
||||||
}, [setCodexUsage]);
|
}, [setCodexUsage]);
|
||||||
|
|
||||||
@@ -166,7 +174,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
<UsageItem
|
<UsageItem
|
||||||
icon={AnthropicIcon}
|
icon={AnthropicIcon}
|
||||||
label="Claude"
|
label="Claude"
|
||||||
isLoading={false}
|
isLoading={isClaudeLoading}
|
||||||
onRefresh={fetchClaudeUsage}
|
onRefresh={fetchClaudeUsage}
|
||||||
>
|
>
|
||||||
{claudeUsage ? (
|
{claudeUsage ? (
|
||||||
@@ -189,7 +197,12 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCodexUsage && (
|
{showCodexUsage && (
|
||||||
<UsageItem icon={OpenAIIcon} label="Codex" isLoading={false} onRefresh={fetchCodexUsage}>
|
<UsageItem
|
||||||
|
icon={OpenAIIcon}
|
||||||
|
label="Codex"
|
||||||
|
isLoading={isCodexLoading}
|
||||||
|
onRefresh={fetchCodexUsage}
|
||||||
|
>
|
||||||
{codexUsage?.rateLimits ? (
|
{codexUsage?.rateLimits ? (
|
||||||
<>
|
<>
|
||||||
{codexUsage.rateLimits.primary && (
|
{codexUsage.rateLimits.primary && (
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ export function WorktreePanel({
|
|||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServer}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
|
onRunInitScript={handleRunInitScript}
|
||||||
|
hasInitScript={hasInitScript}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
|
|||||||
|
|
||||||
export interface Feature extends Omit<
|
export interface Feature extends Omit<
|
||||||
BaseFeature,
|
BaseFeature,
|
||||||
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
|
'steps' | 'imagePaths' | 'textFilePaths' | 'status' | 'planSpec'
|
||||||
> {
|
> {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -354,6 +354,7 @@ export interface Feature extends Omit<
|
|||||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||||
prUrl?: string; // UI-specific: Pull request URL
|
prUrl?: string; // UI-specific: Pull request URL
|
||||||
|
planSpec?: PlanSpec; // Explicit planSpec type to override BaseFeature's index signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsed task from spec (for spec and full planning modes)
|
// Parsed task from spec (for spec and full planning modes)
|
||||||
|
|||||||
Reference in New Issue
Block a user