From 4ab54270db598b355146c8ffc220f2cb0a1e572e Mon Sep 17 00:00:00 2001 From: anonymous Date: Sat, 10 Jan 2026 16:52:29 +0100 Subject: [PATCH 01/33] fix: enable sidebar expand and project switching on mobile - Sidebar now uses overlay pattern on mobile (fixed position when open) - Added backdrop overlay that dismisses sidebar on tap - Made collapse toggle button visible on all screen sizes - Made project options menu visible on all screen sizes Previously the sidebar was forced to collapsed width (w-16) on mobile even when sidebarOpen was true, and the toggle/options buttons were hidden with `hidden lg:flex`. --- apps/ui/src/components/layout/sidebar.tsx | 13 ++++++------- .../sidebar/components/collapse-toggle-button.tsx | 4 +--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index a8c70cb6..be547e04 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -258,26 +258,25 @@ export function Sidebar() { return ( <> - {/* Mobile overlay backdrop */} + {/* Mobile backdrop overlay */} {sidebarOpen && ( ) : currentProject ? ( @@ -137,7 +137,7 @@ export function SidebarNavigation({ {item.shortcut && sidebarOpen && !item.count && ( Date: Sun, 11 Jan 2026 20:15:23 -0800 Subject: [PATCH 03/33] Fix model selector on mobile --- IMPLEMENTATION_PLAN.md | 203 +++++++++++++ .../model-defaults/phase-model-selector.tsx | 285 ++++++++++++++++++ apps/ui/src/hooks/use-media-query.ts | 50 +++ 3 files changed, 538 insertions(+) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 apps/ui/src/hooks/use-media-query.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..3ca605f4 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -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 + + +// Secondary popovers (thinking level, reasoning effort, etc.) + +``` + +#### 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 diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index db5a4d2f..0fd11065 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -1,6 +1,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; +import { useIsMobile } from '@/hooks/use-media-query'; import type { ModelAlias, CursorModelId, @@ -167,6 +168,9 @@ export function PhaseModelSelector({ dynamicOpencodeModels, } = useAppStore(); + // Detect mobile devices to use inline expansion instead of nested popovers + const isMobile = useIsMobile(); + // Extract model and thinking/reasoning levels from value const selectedModel = value.model; const selectedThinkingLevel = value.thinkingLevel || 'none'; @@ -585,6 +589,107 @@ export function PhaseModelSelector({ } // Model supports reasoning - show popover with reasoning effort options + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentReasoning !== 'none' + ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline reasoning effort options on mobile */} + {isExpanded && ( +
+
+ Reasoning Effort +
+ {REASONING_EFFORT_LEVELS.map((effort) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover return ( + setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline thinking level options on mobile */} + {isExpanded && ( +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+ )} + + ); + } + + // Desktop: Use nested popover return ( + setExpandedGroup(isExpanded ? null : group.baseId)} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {group.label} + + + {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} + +
+
+ +
+ {groupIsSelected && !isExpanded && } + +
+
+ + {/* Inline variant options on mobile */} + {isExpanded && ( +
+
+ {variantTypeLabel} +
+ {group.variants.map((variant) => ( + + ))} +
+ )} + + ); + } + + // Desktop: Use nested popover return ( { + 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)'); +} From e56db2362c5da7b9cf01701c0eecb1e09df837ff Mon Sep 17 00:00:00 2001 From: anonymous Date: Sat, 10 Jan 2026 21:10:33 -0800 Subject: [PATCH 04/33] feat: Add AI-generated commit messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate Claude Haiku to automatically generate commit messages when committing worktree changes. Shows a sparkle animation while generating and auto-populates the commit message field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/routes/worktree/index.ts | 7 + .../routes/generate-commit-message.ts | 178 ++++++++++++++++++ .../dialogs/commit-worktree-dialog.tsx | 67 ++++++- apps/ui/src/lib/electron.ts | 9 + apps/ui/src/lib/http-api-client.ts | 2 + apps/ui/src/types/electron.d.ts | 7 + libs/git-utils/src/diff.ts | 2 +- 7 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/routes/worktree/routes/generate-commit-message.ts diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a00e0bfe..537a9acd 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js'; import { createCreatePRHandler } from './routes/create-pr.js'; import { createPRInfoHandler } from './routes/pr-info.js'; import { createCommitHandler } from './routes/commit.js'; +import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js'; import { createPushHandler } from './routes/push.js'; import { createPullHandler } from './routes/pull.js'; import { createCheckoutBranchHandler } from './routes/checkout-branch.js'; @@ -64,6 +65,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router { requireGitRepoOnly, createCommitHandler() ); + router.post( + '/generate-commit-message', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGenerateCommitMessageHandler() + ); router.post( '/push', validatePathParams('worktreePath'), diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts new file mode 100644 index 00000000..69e44058 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -0,0 +1,178 @@ +/** + * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff + * + * Uses Claude Haiku to generate a concise, conventional commit message from git changes. + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('GenerateCommitMessage'); +const execAsync = promisify(exec); + +interface GenerateCommitMessageRequestBody { + worktreePath: string; +} + +interface GenerateCommitMessageSuccessResponse { + success: true; + message: string; +} + +interface GenerateCommitMessageErrorResponse { + success: false; + error: string; +} + +const SYSTEM_PROMPT = `You are a git commit message generator. Your task is to create a clear, concise commit message based on the git diff provided. + +Rules: +- Output ONLY the commit message, nothing else +- First line should be a short summary (50 chars or less) in imperative mood +- Start with a conventional commit type if appropriate (feat:, fix:, refactor:, docs:, etc.) +- Keep it concise and descriptive +- Focus on WHAT changed and WHY (if clear from the diff), not HOW +- No quotes, backticks, or extra formatting +- If there are multiple changes, provide a brief summary on the first line + +Examples: +- feat: Add dark mode toggle to settings +- fix: Resolve login validation edge case +- refactor: Extract user authentication logic +- docs: Update installation instructions`; + +async function extractTextFromStream( + stream: AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + }> +): Promise { + let responseText = ''; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + responseText = msg.result || responseText; + } + } + + return responseText; +} + +export function createGenerateCommitMessageHandler(): ( + req: Request, + res: Response +) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as GenerateCommitMessageRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating commit message for worktree: ${worktreePath}`); + + // Get git diff of staged and unstaged changes + let diff = ''; + try { + // First try to get staged changes + const { stdout: stagedDiff } = await execAsync('git diff --cached', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + + // If no staged changes, get unstaged changes + if (!stagedDiff.trim()) { + const { stdout: unstagedDiff } = await execAsync('git diff', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + diff = unstagedDiff; + } else { + diff = stagedDiff; + } + } catch (error) { + logger.error('Failed to get git diff:', error); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to get git changes', + }; + res.status(500).json(response); + return; + } + + if (!diff.trim()) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'No changes to commit', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long (keep first 10000 characters to avoid token limits) + const truncatedDiff = + diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff; + + const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + + const stream = query({ + prompt: userPrompt, + options: { + model: CLAUDE_MODEL_MAP.haiku, + systemPrompt: SYSTEM_PROMPT, + maxTurns: 1, + allowedTools: [], + permissionMode: 'default', + }, + }); + + const message = await extractTextFromStream(stream); + + if (!message || message.trim().length === 0) { + logger.warn('Received empty response from Claude'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to generate commit message - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`); + + const response: GenerateCommitMessageSuccessResponse = { + success: true, + message: message.trim(), + }; + res.json(response); + } catch (error) { + logError(error, 'Generate commit message failed'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index 8e905c5e..492f671f 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -10,7 +10,7 @@ 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'; @@ -37,6 +37,7 @@ export function CommitWorktreeDialog({ }: CommitWorktreeDialogProps) { const [message, setMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); const handleCommit = async () => { @@ -82,6 +83,45 @@ export function CommitWorktreeDialog({ } }; + // Generate AI commit message when dialog opens + useEffect(() => { + if (open && worktree) { + // Reset state + setMessage(''); + setError(null); + setIsGenerating(true); + + const generateMessage = async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.generateCommitMessage) { + setError('AI commit message generation not available'); + setIsGenerating(false); + return; + } + + const result = await api.worktree.generateCommitMessage(worktree.path); + + 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) { + // Don't show error toast for generation failures + console.warn('Error generating commit message:', err); + setMessage(''); + } finally { + setIsGenerating(false); + } + }; + + generateMessage(); + } + }, [open, worktree]); + if (!worktree) return null; return ( @@ -106,10 +146,20 @@ export function CommitWorktreeDialog({
- +