mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Fix model selector on mobile
This commit is contained in:
203
IMPLEMENTATION_PLAN.md
Normal file
203
IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import type {
|
import type {
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
CursorModelId,
|
CursorModelId,
|
||||||
@@ -167,6 +168,9 @@ export function PhaseModelSelector({
|
|||||||
dynamicOpencodeModels,
|
dynamicOpencodeModels,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
|
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Extract model and thinking/reasoning levels from value
|
// Extract model and thinking/reasoning levels from value
|
||||||
const selectedModel = value.model;
|
const selectedModel = value.model;
|
||||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||||
@@ -585,6 +589,107 @@ export function PhaseModelSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Model supports reasoning - show popover with reasoning effort options
|
// Model supports reasoning - show popover with reasoning effort options
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={model.id}>
|
||||||
|
<CommandItem
|
||||||
|
value={model.label}
|
||||||
|
onSelect={() => setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<OpenAIIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||||
|
{model.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{isSelected && currentReasoning !== 'none'
|
||||||
|
? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
|
||||||
|
: model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||||
|
isFavorite
|
||||||
|
? 'text-yellow-500 opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavoriteModel(model.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||||
|
</Button>
|
||||||
|
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline reasoning effort options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Reasoning Effort
|
||||||
|
</div>
|
||||||
|
{REASONING_EFFORT_LEVELS.map((effort) => (
|
||||||
|
<button
|
||||||
|
key={effort}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
model: model.id as CodexModelId,
|
||||||
|
reasoningEffort: effort,
|
||||||
|
});
|
||||||
|
setExpandedCodexModel(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{REASONING_EFFORT_LABELS[effort]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{effort === 'none' && 'No reasoning capability'}
|
||||||
|
{effort === 'minimal' && 'Minimal reasoning'}
|
||||||
|
{effort === 'low' && 'Light reasoning'}
|
||||||
|
{effort === 'medium' && 'Moderate reasoning'}
|
||||||
|
{effort === 'high' && 'Deep reasoning'}
|
||||||
|
{effort === 'xhigh' && 'Maximum reasoning'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && currentReasoning === effort && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
@@ -829,6 +934,106 @@ export function PhaseModelSelector({
|
|||||||
const isExpanded = expandedClaudeModel === model.id;
|
const isExpanded = expandedClaudeModel === model.id;
|
||||||
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
||||||
|
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={model.id}>
|
||||||
|
<CommandItem
|
||||||
|
value={model.label}
|
||||||
|
onSelect={() => setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<AnthropicIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||||
|
{model.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{isSelected && currentThinking !== 'none'
|
||||||
|
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||||
|
: model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||||
|
isFavorite
|
||||||
|
? 'text-yellow-500 opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavoriteModel(model.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||||
|
</Button>
|
||||||
|
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline thinking level options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Thinking Level
|
||||||
|
</div>
|
||||||
|
{THINKING_LEVELS.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
model: model.id as ModelAlias,
|
||||||
|
thinkingLevel: level,
|
||||||
|
});
|
||||||
|
setExpandedClaudeModel(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{level === 'none' && 'No extended thinking'}
|
||||||
|
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||||
|
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||||
|
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||||
|
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && currentThinking === level && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
@@ -963,6 +1168,86 @@ export function PhaseModelSelector({
|
|||||||
? 'Reasoning Mode'
|
? 'Reasoning Mode'
|
||||||
: 'Capacity Options';
|
: 'Capacity Options';
|
||||||
|
|
||||||
|
// On mobile, render inline expansion instead of nested popover
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div key={group.baseId}>
|
||||||
|
<CommandItem
|
||||||
|
value={group.label}
|
||||||
|
onSelect={() => setExpandedGroup(isExpanded ? null : group.baseId)}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<CursorIcon
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', groupIsSelected && 'text-primary')}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
{groupIsSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Inline variant options on mobile */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||||
|
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{variantTypeLabel}
|
||||||
|
</div>
|
||||||
|
{group.variants.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ model: variant.id });
|
||||||
|
setExpandedGroup(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
selectedModel === variant.id && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium text-xs">{variant.label}</span>
|
||||||
|
{variant.description && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{variant.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{variant.badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{variant.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedModel === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: Use nested popover
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={group.baseId}
|
key={group.baseId}
|
||||||
|
|||||||
50
apps/ui/src/hooks/use-media-query.ts
Normal file
50
apps/ui/src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if a media query matches
|
||||||
|
* @param query - The media query string (e.g., '(max-width: 768px)')
|
||||||
|
* @returns boolean indicating if the media query matches
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia(query);
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setMatches(e.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
setMatches(mediaQuery.matches);
|
||||||
|
|
||||||
|
// Listen for changes
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the device is mobile (screen width <= 768px)
|
||||||
|
* @returns boolean indicating if the device is mobile
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 768px)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the device is tablet or smaller (screen width <= 1024px)
|
||||||
|
* @returns boolean indicating if the device is tablet or smaller
|
||||||
|
*/
|
||||||
|
export function useIsTablet(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 1024px)');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user