diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f763c08d..63b5c6da 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService)); app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes(events)); +app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); app.use('/api/git', createGitRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a00e0bfe..b0196e7f 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'; @@ -39,8 +40,12 @@ import { createDeleteInitScriptHandler, createRunInitScriptHandler, } from './routes/init-script.js'; +import type { SettingsService } from '../../services/settings-service.js'; -export function createWorktreeRoutes(events: EventEmitter): Router { +export function createWorktreeRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { const router = Router(); router.post('/info', validatePathParams('projectPath'), createInfoHandler()); @@ -64,6 +69,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router { requireGitRepoOnly, createCommitHandler() ); + router.post( + '/generate-commit-message', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGenerateCommitMessageHandler(settingsService) + ); 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..a450659f --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -0,0 +1,275 @@ +/** + * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff + * + * Uses the configured model (via phaseModels.commitMessageModel) to generate a concise, + * conventional commit message from git changes. Defaults to Claude Haiku for speed. + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { mergeCommitMessagePrompts } from '@automaker/prompts'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('GenerateCommitMessage'); +const execAsync = promisify(exec); + +/** Timeout for AI provider calls in milliseconds (30 seconds) */ +const AI_TIMEOUT_MS = 30_000; + +/** + * Wraps an async generator with a timeout. + * If the generator takes longer than the timeout, it throws an error. + */ +async function* withTimeout( + generator: AsyncIterable, + timeoutMs: number +): AsyncGenerator { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + const iterator = generator[Symbol.asyncIterator](); + let done = false; + + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]); + if (result.done) { + done = true; + } else { + yield result.value; + } + } +} + +/** + * Get the effective system prompt for commit message generation. + * Uses custom prompt from settings if enabled, otherwise falls back to default. + */ +async function getSystemPrompt(settingsService?: SettingsService): Promise { + const settings = await settingsService?.getGlobalSettings(); + const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage); + return prompts.systemPrompt; +} + +interface GenerateCommitMessageRequestBody { + worktreePath: string; +} + +interface GenerateCommitMessageSuccessResponse { + success: true; + message: string; +} + +interface GenerateCommitMessageErrorResponse { + success: false; + error: string; +} + +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( + settingsService?: SettingsService +): (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; + } + + // Validate that the directory exists + if (!existsSync(worktreePath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath does not exist', + }; + res.status(400).json(response); + return; + } + + // Validate that it's a git repository (check for .git folder or file for worktrees) + const gitPath = join(worktreePath, '.git'); + if (!existsSync(gitPath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is not a git repository', + }; + 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\`\`\``; + + // Get model from phase settings + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel; + const { model } = resolvePhaseModel(phaseModelEntry); + + logger.info(`Using model for commit message: ${model}`); + + // Get the effective system prompt (custom or default) + const systemPrompt = await getSystemPrompt(settingsService); + + let message: string; + + // Route to appropriate provider based on model type + if (isCursorModel(model)) { + // Use Cursor provider for Cursor models + logger.info(`Using Cursor provider for model: ${model}`); + + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`; + + let responseText = ''; + const cursorStream = provider.executeQuery({ + prompt: cursorPrompt, + model: bareModel, + cwd: worktreePath, + maxTurns: 1, + allowedTools: [], + readOnly: true, + }); + + // Wrap with timeout to prevent indefinite hangs + for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + } + + message = responseText.trim(); + } else { + // Use Claude SDK for Claude models + const stream = query({ + prompt: userPrompt, + options: { + model, + systemPrompt, + maxTurns: 1, + allowedTools: [], + permissionMode: 'default', + }, + }); + + // Wrap with timeout to prevent indefinite hangs + message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS)); + } + + if (!message || message.trim().length === 0) { + logger.warn('Received empty response from model'); + 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/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 64ace35d..c9000582 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -161,11 +161,15 @@ export class ClaudeUsageService { const workingDirectory = this.isWindows ? process.env.USERPROFILE || os.homedir() || 'C:\\' - : process.env.HOME || os.homedir() || '/tmp'; + : os.tmpdir(); // Use platform-appropriate shell and command const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; - const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; + // Use --add-dir to whitelist the current directory and bypass the trust prompt + // We don't pass /usage here, we'll type it into the REPL + const args = this.isWindows + ? ['/c', 'claude', '--add-dir', workingDirectory] + : ['-c', `claude --add-dir "${workingDirectory}"`]; let ptyProcess: any = null; @@ -181,8 +185,6 @@ export class ClaudeUsageService { } as Record, }); } catch (spawnError) { - // pty.spawn() can throw synchronously if the native module fails to load - // or if PTY is not available in the current environment (e.g., containers without /dev/pts) const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); @@ -205,16 +207,52 @@ export class ClaudeUsageService { if (output.includes('Current session')) { resolve(output); } else { - reject(new Error('Command timed out')); + reject( + new Error( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ) + ); } } - }, this.timeout); + }, 45000); // 45 second timeout + + let hasSentCommand = false; + let hasApprovedTrust = false; ptyProcess.onData((data: string) => { output += data; - // Check if we've seen the usage data (look for "Current session") - if (!hasSeenUsageData && output.includes('Current session')) { + // Strip ANSI codes for easier matching + // eslint-disable-next-line no-control-regex + const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + + // Check for specific authentication/permission errors + if ( + cleanOutput.includes('OAuth token does not meet scope requirement') || + cleanOutput.includes('permission_error') || + cleanOutput.includes('token_expired') || + cleanOutput.includes('authentication_error') + ) { + if (!settled) { + settled = true; + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } + reject( + new Error( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ) + ); + } + return; + } + + // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) + if ( + !hasSeenUsageData && + (cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left'))) + ) { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { @@ -228,16 +266,54 @@ export class ClaudeUsageService { } }, 2000); } - }, 2000); + }, 3000); + } + + // Handle Trust Dialog: "Do you want to work in this folder?" + // Since we are running in os.tmpdir(), it is safe to approve. + if (!hasApprovedTrust && cleanOutput.includes('Do you want to work in this folder?')) { + hasApprovedTrust = true; + // Wait a tiny bit to ensure prompt is ready, then send Enter + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1000); + } + + // Detect REPL prompt and send /usage command + if ( + !hasSentCommand && + (cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts')) + ) { + hasSentCommand = true; + // Wait for REPL to fully settle + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + // Send command with carriage return + ptyProcess.write('/usage\r'); + + // Send another enter after 1 second to confirm selection if autocomplete menu appeared + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1200); + } + }, 1500); } // Fallback: if we see "Esc to cancel" but haven't seen usage data yet - if (!hasSeenUsageData && output.includes('Esc to cancel')) { + if ( + !hasSeenUsageData && + cleanOutput.includes('Esc to cancel') && + !cleanOutput.includes('Do you want to work in this folder?') + ) { setTimeout(() => { if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } - }, 3000); + }, 5000); } }); @@ -246,8 +322,11 @@ export class ClaudeUsageService { if (settled) return; settled = true; - // Check for authentication errors in output - if (output.includes('token_expired') || output.includes('authentication_error')) { + if ( + output.includes('token_expired') || + output.includes('authentication_error') || + output.includes('permission_error') + ) { reject(new Error("Authentication required - please run 'claude login'")); return; } diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index d16802f6..4b3f3c94 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -551,7 +551,7 @@ Resets in 2h expect(result.sessionPercentage).toBe(35); expect(pty.spawn).toHaveBeenCalledWith( 'cmd.exe', - ['/c', 'claude', '/usage'], + ['/c', 'claude', '--add-dir', 'C:\\Users\\testuser'], expect.any(Object) ); }); @@ -582,8 +582,8 @@ Resets in 2h // Simulate seeing usage data dataCallback!(mockOutput); - // Advance time to trigger escape key sending - vi.advanceTimersByTime(2100); + // Advance time to trigger escape key sending (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); expect(mockPty.write).toHaveBeenCalledWith('\x1b'); @@ -614,9 +614,10 @@ Resets in 2h const promise = windowsService.fetchUsageData(); dataCallback!('authentication_error'); - exitCallback!({ exitCode: 1 }); - await expect(promise).rejects.toThrow('Authentication required'); + await expect(promise).rejects.toThrow( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ); }); it('should handle timeout with no data on Windows', async () => { @@ -628,14 +629,18 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); const promise = windowsService.fetchUsageData(); - vi.advanceTimersByTime(31000); + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); - await expect(promise).rejects.toThrow('Command timed out'); + await expect(promise).rejects.toThrow( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ); expect(mockPty.kill).toHaveBeenCalled(); vi.useRealTimers(); @@ -654,6 +659,7 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); @@ -662,8 +668,8 @@ Resets in 2h // Simulate receiving usage data dataCallback!('Current session\n65% left\nResets in 2h'); - // Advance time past timeout (30 seconds) - vi.advanceTimersByTime(31000); + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); // Should resolve with data instead of rejecting const result = await promise; @@ -686,6 +692,7 @@ Resets in 2h onExit: vi.fn(), write: vi.fn(), kill: vi.fn(), + killed: false, }; vi.mocked(pty.spawn).mockReturnValue(mockPty as any); @@ -694,8 +701,8 @@ Resets in 2h // Simulate seeing usage data dataCallback!('Current session\n65% left'); - // Advance 2s to trigger ESC - vi.advanceTimersByTime(2100); + // Advance 3s to trigger ESC (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); expect(mockPty.write).toHaveBeenCalledWith('\x1b'); // Advance another 2s to trigger SIGTERM fallback diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 31a71e85..c27cd5e7 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -5,6 +5,7 @@ import { router } from './utils/router'; import { SplashScreen } from './components/splash-screen'; import { useSettingsSync } from './hooks/use-settings-sync'; import { useCursorStatusInit } from './hooks/use-cursor-status-init'; +import { useProviderAuthInit } from './hooks/use-provider-auth-init'; import './styles/global.css'; import './styles/theme-imports'; @@ -24,8 +25,11 @@ export default function App() { useEffect(() => { if (import.meta.env.DEV) { const clearPerfEntries = () => { - performance.clearMarks(); - performance.clearMeasures(); + // Check if window.performance is available before calling its methods + if (window.performance) { + window.performance.clearMarks(); + window.performance.clearMeasures(); + } }; const interval = setInterval(clearPerfEntries, 5000); return () => clearInterval(interval); @@ -45,6 +49,9 @@ export default function App() { // Initialize Cursor CLI status at startup useCursorStatusInit(); + // Initialize Provider auth status at startup (for Claude/Codex usage display) + useProviderAuthInit(); + const handleSplashComplete = useCallback(() => { sessionStorage.setItem('automaker-splash-shown', 'true'); setShowSplash(false); diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 613b113f..92e804ce 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -253,26 +253,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 && ( e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} > ); diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index e772d48b..1194cb9c 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -72,18 +72,17 @@ export function UsagePopover() { const [codexError, setCodexError] = useState(null); // Check authentication status - const isClaudeCliVerified = - claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated'; + const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; // Determine which tab to show by default useEffect(() => { - if (isClaudeCliVerified) { + if (isClaudeAuthenticated) { setActiveTab('claude'); } else if (isCodexAuthenticated) { setActiveTab('codex'); } - }, [isClaudeCliVerified, isCodexAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -174,10 +173,10 @@ export function UsagePopover() { // Auto-fetch on mount if data is stale useEffect(() => { - if (isClaudeStale && isClaudeCliVerified) { + if (isClaudeStale && isClaudeAuthenticated) { fetchClaudeUsage(true); } - }, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]); + }, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]); useEffect(() => { if (isCodexStale && isCodexAuthenticated) { @@ -190,7 +189,7 @@ export function UsagePopover() { if (!open) return; // Fetch based on active tab - if (activeTab === 'claude' && isClaudeCliVerified) { + if (activeTab === 'claude' && isClaudeAuthenticated) { if (!claudeUsage || isClaudeStale) { fetchClaudeUsage(); } @@ -214,7 +213,7 @@ export function UsagePopover() { activeTab, claudeUsage, isClaudeStale, - isClaudeCliVerified, + isClaudeAuthenticated, codexUsage, isCodexStale, isCodexAuthenticated, @@ -349,7 +348,7 @@ export function UsagePopover() { ); // Determine which tabs to show - const showClaudeTab = isClaudeCliVerified; + const showClaudeTab = isClaudeAuthenticated; const showCodexTab = isCodexAuthenticated; return ( diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index be56f70d..5d877471 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -16,11 +16,32 @@ import { import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components'; import { AgentInputArea } from './agent-view/input-area'; +/** Tailwind lg breakpoint in pixels */ +const LG_BREAKPOINT = 1024; + export function AgentView() { const { currentProject } = useAppStore(); const [input, setInput] = useState(''); const [currentTool, setCurrentTool] = useState(null); + // Initialize session manager state - starts as true to match SSR + // Then updates on mount based on actual screen size to prevent hydration mismatch const [showSessionManager, setShowSessionManager] = useState(true); + + // Update session manager visibility based on screen size after mount and on resize + useEffect(() => { + const updateVisibility = () => { + const isDesktop = window.innerWidth >= LG_BREAKPOINT; + setShowSessionManager(isDesktop); + }; + + // Set initial value + updateVisibility(); + + // Listen for resize events + window.addEventListener('resize', updateVisibility); + return () => window.removeEventListener('resize', updateVisibility); + }, []); + const [modelSelection, setModelSelection] = useState({ model: 'sonnet' }); // Input ref for auto-focus @@ -119,6 +140,13 @@ export function AgentView() { } }, [currentSessionId]); + // Auto-close session manager on mobile when a session is selected + useEffect(() => { + if (currentSessionId && typeof window !== 'undefined' && window.innerWidth < 1024) { + setShowSessionManager(false); + } + }, [currentSessionId]); + // Show welcome message if no messages yet const displayMessages = messages.length === 0 @@ -139,9 +167,18 @@ export function AgentView() { return (
+ {/* Mobile backdrop overlay for Session Manager */} + {showSessionManager && currentProject && ( +
setShowSessionManager(false)} + data-testid="session-manager-backdrop" + /> + )} + {/* Session Manager Sidebar */} {showSessionManager && currentProject && ( -
+
-
+ {/* Textarea - full width on mobile */} +