From 272129e9dbf28e079d4eec08a28cf6a81ae2fb1f Mon Sep 17 00:00:00 2001 From: eclipxe Date: Tue, 20 Jan 2026 14:34:15 -0800 Subject: [PATCH 01/12] Feat: Add z.ai usage tracking --- apps/server/src/index.ts | 4 + apps/server/src/routes/zai/index.ts | 179 +++++++++ apps/server/src/services/settings-service.ts | 1 + apps/server/src/services/zai-usage-service.ts | 375 ++++++++++++++++++ apps/ui/src/components/ui/provider-icon.tsx | 8 +- apps/ui/src/components/usage-popover.tsx | 254 ++++++++++-- .../views/board-view/board-header.tsx | 11 +- .../views/board-view/header-mobile-menu.tsx | 12 +- .../views/board-view/mobile-usage-bar.tsx | 119 +++++- .../api-keys/hooks/use-api-key-management.ts | 100 ++++- apps/ui/src/config/api-providers.ts | 38 +- apps/ui/src/hooks/queries/index.ts | 2 +- apps/ui/src/hooks/queries/use-usage.ts | 37 +- apps/ui/src/hooks/use-provider-auth-init.ts | 50 ++- apps/ui/src/lib/electron.ts | 56 ++- apps/ui/src/lib/http-api-client.ts | 61 +++ apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 14 + apps/ui/src/store/setup-store.ts | 26 ++ apps/ui/src/store/types/settings-types.ts | 1 + apps/ui/src/store/types/state-types.ts | 9 +- apps/ui/src/store/types/usage-types.ts | 24 ++ libs/types/src/settings.ts | 3 + 23 files changed, 1331 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/routes/zai/index.ts create mode 100644 apps/server/src/services/zai-usage-service.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2255fdc13..5ba3dff3b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -66,6 +66,8 @@ import { createCodexRoutes } from './routes/codex/index.js'; import { CodexUsageService } from './services/codex-usage-service.js'; import { CodexAppServerService } from './services/codex-app-server-service.js'; import { CodexModelCacheService } from './services/codex-model-cache-service.js'; +import { createZaiRoutes } from './routes/zai/index.js'; +import { ZaiUsageService } from './services/zai-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -263,6 +265,7 @@ const claudeUsageService = new ClaudeUsageService(); const codexAppServerService = new CodexAppServerService(); const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); const codexUsageService = new CodexUsageService(codexAppServerService); +const zaiUsageService = new ZaiUsageService(); const mcpTestService = new MCPTestService(settingsService); const ideationService = new IdeationService(events, settingsService, featureLoader); @@ -371,6 +374,7 @@ app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); +app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); diff --git a/apps/server/src/routes/zai/index.ts b/apps/server/src/routes/zai/index.ts new file mode 100644 index 000000000..baf84e192 --- /dev/null +++ b/apps/server/src/routes/zai/index.ts @@ -0,0 +1,179 @@ +import { Router, Request, Response } from 'express'; +import { ZaiUsageService } from '../../services/zai-usage-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Zai'); + +export function createZaiRoutes( + usageService: ZaiUsageService, + settingsService: SettingsService +): Router { + const router = Router(); + + // Initialize z.ai API token from credentials on startup + (async () => { + try { + const credentials = await settingsService.getCredentials(); + if (credentials.apiKeys?.zai) { + usageService.setApiToken(credentials.apiKeys.zai); + logger.info('[init] Loaded z.ai API key from credentials'); + } + } catch (error) { + logger.error('[init] Failed to load z.ai API key from credentials:', error); + } + })(); + + // Get current usage (fetches from z.ai API) + router.get('/usage', async (_req: Request, res: Response) => { + try { + // Check if z.ai API is configured + const isAvailable = usageService.isAvailable(); + if (!isAvailable) { + // Use a 200 + error payload so the UI doesn't interpret it as session auth error + res.status(200).json({ + error: 'z.ai API not configured', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + return; + } + + const usage = await usageService.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('not configured') || message.includes('API token')) { + res.status(200).json({ + error: 'API token required', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + } else if (message.includes('failed') || message.includes('request')) { + res.status(200).json({ + error: 'API request failed', + message: message, + }); + } else { + logger.error('Error fetching z.ai usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + // Configure API token (for settings page) + router.post('/configure', async (req: Request, res: Response) => { + try { + const { apiToken, apiHost } = req.body; + + if (apiToken !== undefined) { + // Set in-memory token + usageService.setApiToken(apiToken || ''); + + // Persist to credentials (deep merge happens in updateCredentials) + try { + await settingsService.updateCredentials({ + apiKeys: { zai: apiToken || '' }, + } as Parameters[0]); + logger.info('[configure] Saved z.ai API key to credentials'); + } catch (persistError) { + logger.error('[configure] Failed to persist z.ai API key:', persistError); + } + } + + if (apiHost) { + usageService.setApiHost(apiHost); + } + + res.json({ + success: true, + message: 'z.ai configuration updated', + isAvailable: usageService.isAvailable(), + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error configuring z.ai:', error); + res.status(500).json({ error: message }); + } + }); + + // Verify API key without storing it (for testing in settings) + router.post('/verify', async (req: Request, res: Response) => { + try { + const { apiKey } = req.body; + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + res.json({ + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }); + return; + } + + // Test the key by making a request to z.ai API + const quotaUrl = + process.env.Z_AI_QUOTA_URL || + `${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`; + + logger.info(`[verify] Testing API key against: ${quotaUrl}`); + + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: 'application/json', + }, + }); + + if (response.ok) { + res.json({ + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }); + } else if (response.status === 401 || response.status === 403) { + res.json({ + success: false, + authenticated: false, + error: 'Invalid API key. Please check your key and try again.', + }); + } else { + res.json({ + success: false, + authenticated: false, + error: `API request failed: ${response.status} ${response.statusText}`, + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + res.json({ + success: false, + authenticated: false, + error: `Network error: ${message}`, + }); + } + }); + + // Check if z.ai is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const isAvailable = usageService.isAvailable(); + const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY); + const hasApiKey = usageService.getApiToken() !== null; + + res.json({ + success: true, + available: isAvailable, + hasApiKey, + hasEnvApiKey, + message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured', + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 6ffdd4882..80e8987fc 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -1018,6 +1018,7 @@ export class SettingsService { anthropic: apiKeys.anthropic || '', google: apiKeys.google || '', openai: apiKeys.openai || '', + zai: '', }, }); migratedCredentials = true; diff --git a/apps/server/src/services/zai-usage-service.ts b/apps/server/src/services/zai-usage-service.ts new file mode 100644 index 000000000..c19cf6387 --- /dev/null +++ b/apps/server/src/services/zai-usage-service.ts @@ -0,0 +1,375 @@ +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ZaiUsage'); + +/** + * z.ai quota limit entry from the API + */ +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; // epoch milliseconds +} + +/** + * z.ai usage details by model (for MCP tracking) + */ +export interface ZaiUsageDetail { + modelId: string; + used: number; + limit: number; +} + +/** + * z.ai plan types + */ +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +/** + * z.ai usage data structure + */ +export interface ZaiUsageData { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + usageDetails?: ZaiUsageDetail[]; + lastUpdated: string; +} + +/** + * z.ai API limit entry - supports multiple field naming conventions + */ +interface ZaiApiLimit { + // Type field (z.ai uses 'type', others might use 'limitType') + type?: string; + limitType?: string; + // Limit value (z.ai uses 'usage' for total limit, others might use 'limit') + usage?: number; + limit?: number; + // Used value (z.ai uses 'currentValue', others might use 'used') + currentValue?: number; + used?: number; + // Remaining + remaining?: number; + // Percentage (z.ai uses 'percentage', others might use 'usedPercent') + percentage?: number; + usedPercent?: number; + // Reset time + nextResetTime?: number; + // Additional z.ai fields + unit?: number; + number?: number; + usageDetails?: Array<{ modelCode: string; usage: number }>; +} + +/** + * z.ai API response structure + * Flexible to handle various possible response formats + */ +interface ZaiApiResponse { + code?: number; + success?: boolean; + data?: { + limits?: ZaiApiLimit[]; + // Alternative: limits might be an object instead of array + tokensLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + timeLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + // Quota-style fields + quota?: number; + quotaUsed?: number; + quotaRemaining?: number; + planName?: string; + plan?: string; + plan_type?: string; + packageName?: string; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + }; + // Root-level alternatives + limits?: ZaiApiLimit[]; + quota?: number; + quotaUsed?: number; + message?: string; +} + +/** + * z.ai Usage Service + * + * Fetches usage quota data from the z.ai API. + * Uses API token authentication stored via environment variable or settings. + */ +export class ZaiUsageService { + private apiToken: string | null = null; + private apiHost: string = 'https://api.z.ai'; + + /** + * Set the API token for authentication + */ + setApiToken(token: string): void { + this.apiToken = token; + logger.info('[setApiToken] API token configured'); + } + + /** + * Get the current API token + */ + getApiToken(): string | null { + // Priority: 1. Instance token, 2. Environment variable + return this.apiToken || process.env.Z_AI_API_KEY || null; + } + + /** + * Set the API host (for BigModel CN region support) + */ + setApiHost(host: string): void { + this.apiHost = host.startsWith('http') ? host : `https://${host}`; + logger.info(`[setApiHost] API host set to: ${this.apiHost}`); + } + + /** + * Get the API host + */ + getApiHost(): string { + // Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default + return process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : this.apiHost; + } + + /** + * Check if z.ai API is available (has token configured) + */ + isAvailable(): boolean { + const token = this.getApiToken(); + return Boolean(token && token.length > 0); + } + + /** + * Fetch usage data from z.ai API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const token = this.getApiToken(); + if (!token) { + logger.error('[fetchUsageData] No API token configured'); + throw new Error('z.ai API token not configured. Set Z_AI_API_KEY environment variable.'); + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`; + + logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`); + throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as unknown as ZaiApiResponse; + logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2)); + + return this.parseApiResponse(data); + } catch (error) { + if (error instanceof Error && error.message.includes('z.ai API')) { + throw error; + } + logger.error('[fetchUsageData] Failed to fetch:', error); + throw new Error( + `Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Parse the z.ai API response into our data structure + * Handles multiple possible response formats from z.ai API + */ + private parseApiResponse(response: ZaiApiResponse): ZaiUsageData { + const result: ZaiUsageData = { + quotaLimits: { + planType: 'unknown', + }, + lastUpdated: new Date().toISOString(), + }; + + logger.info('[parseApiResponse] Raw response:', JSON.stringify(response, null, 2)); + + // Try to find data - could be in response.data or at root level + let data = response.data; + + // Check for root-level limits array + if (!data && response.limits) { + logger.info('[parseApiResponse] Found limits at root level'); + data = { limits: response.limits }; + } + + // Check for root-level quota fields + if (!data && (response.quota !== undefined || response.quotaUsed !== undefined)) { + logger.info('[parseApiResponse] Found quota fields at root level'); + data = { quota: response.quota, quotaUsed: response.quotaUsed }; + } + + if (!data) { + logger.warn('[parseApiResponse] No data found in response'); + return result; + } + + logger.info('[parseApiResponse] Data keys:', Object.keys(data)); + + // Parse plan type from various possible field names + const planName = data.planName || data.plan || data.plan_type || data.packageName; + + if (planName) { + const normalizedPlan = String(planName).toLowerCase(); + if (['free', 'basic', 'standard', 'professional', 'enterprise'].includes(normalizedPlan)) { + result.quotaLimits!.planType = normalizedPlan as ZaiPlanType; + } + logger.info(`[parseApiResponse] Plan type: ${result.quotaLimits!.planType}`); + } + + // Parse quota limits from array format + if (data.limits && Array.isArray(data.limits)) { + logger.info('[parseApiResponse] Parsing limits array with', data.limits.length, 'entries'); + for (const limit of data.limits) { + logger.info('[parseApiResponse] Processing limit:', JSON.stringify(limit)); + + // Handle different field naming conventions from z.ai API: + // - 'usage' is the total limit, 'currentValue' is the used amount + // - OR 'limit' is the total limit, 'used' is the used amount + const limitVal = limit.usage ?? limit.limit ?? 0; + const usedVal = limit.currentValue ?? limit.used ?? 0; + + // Get percentage from 'percentage' or 'usedPercent' field, or calculate it + const apiPercent = limit.percentage ?? limit.usedPercent; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + const usedPercent = + apiPercent !== undefined && apiPercent > 0 ? apiPercent : calculatedPercent; + + // Get limit type from 'type' or 'limitType' field + const rawLimitType = limit.type ?? limit.limitType ?? ''; + + const quotaLimit: ZaiQuotaLimit = { + limitType: rawLimitType || 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: limit.remaining ?? limitVal - usedVal, + usedPercent, + nextResetTime: limit.nextResetTime ?? 0, + }; + + // Match various possible limitType values + const limitType = String(rawLimitType).toUpperCase(); + if (limitType.includes('TOKEN') || limitType === 'TOKENS_LIMIT') { + result.quotaLimits!.tokens = quotaLimit; + logger.info( + `[parseApiResponse] Tokens: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else if (limitType.includes('TIME') || limitType === 'TIME_LIMIT') { + result.quotaLimits!.mcp = quotaLimit; + logger.info( + `[parseApiResponse] MCP: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else { + // If limitType is unknown, use as tokens by default (first one) + if (!result.quotaLimits!.tokens) { + quotaLimit.limitType = 'TOKENS_LIMIT'; + result.quotaLimits!.tokens = quotaLimit; + logger.info(`[parseApiResponse] Unknown limit type '${rawLimitType}', using as tokens`); + } + } + } + } + + // Parse alternative object-style limits + if (data.tokensLimit) { + const t = data.tokensLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed tokensLimit object'); + } + + if (data.timeLimit) { + const t = data.timeLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.mcp = { + limitType: 'TIME_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed timeLimit object'); + } + + // Parse simple quota/quotaUsed format as tokens + if (data.quota !== undefined && data.quotaUsed !== undefined && !result.quotaLimits!.tokens) { + const limitVal = Number(data.quota) || 0; + const usedVal = Number(data.quotaUsed) || 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: + data.quotaRemaining !== undefined ? Number(data.quotaRemaining) : limitVal - usedVal, + usedPercent: limitVal > 0 ? (usedVal / limitVal) * 100 : 0, + nextResetTime: 0, + }; + logger.info('[parseApiResponse] Parsed simple quota format'); + } + + // Parse usage details (MCP tracking) + if (data.usageDetails && Array.isArray(data.usageDetails)) { + result.usageDetails = data.usageDetails.map((detail) => ({ + modelId: detail.modelId, + used: detail.used, + limit: detail.limit, + })); + logger.info(`[parseApiResponse] Usage details for ${result.usageDetails.length} models`); + } + + logger.info('[parseApiResponse] Final result:', JSON.stringify(result, null, 2)); + return result; + } +} diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 415872cea..637fd812e 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -105,8 +105,9 @@ const PROVIDER_ICON_DEFINITIONS: Record }, glm: { viewBox: '0 0 24 24', - // Official Z.ai logo from lobehub/lobe-icons (GLM provider) + // Official Z.ai/GLM logo from lobehub/lobe-icons (GLM/Zhipu provider) path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z', + fill: '#3B82F6', // z.ai brand blue }, bigpickle: { viewBox: '0 0 24 24', @@ -391,12 +392,15 @@ export function GlmIcon({ className, title, ...props }: { className?: string; ti {title && {title}} ); } +// Z.ai icon is the same as GLM (Zhipu AI) +export const ZaiIcon = GlmIcon; + export function BigPickleIcon({ className, title, diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 5d8acb0ba..31bb6d5af 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -27,9 +27,9 @@ type UsageError = { const CLAUDE_SESSION_WINDOW_HOURS = 5; -// Helper to format reset time for Codex -function formatCodexResetTime(unixTimestamp: number): string { - const date = new Date(unixTimestamp * 1000); +// Helper to format reset time for Codex/z.ai (unix timestamp in seconds or milliseconds) +function formatResetTime(unixTimestamp: number, isMilliseconds = false): string { + const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000); const now = new Date(); const diff = date.getTime() - now.getTime(); @@ -45,6 +45,11 @@ function formatCodexResetTime(unixTimestamp: number): string { return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; } +// Legacy alias for Codex +function formatCodexResetTime(unixTimestamp: number): string { + return formatResetTime(unixTimestamp, false); +} + // Helper to format window duration for Codex function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } { if (durationMins < 60) { @@ -58,16 +63,32 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s return { title: `${days}d Window`, subtitle: 'Rate limit' }; } +// Helper to format large numbers with K/M suffixes +function formatNumber(num: number): string { + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(1)}B`; + } + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; + const isZaiAuthenticated = zaiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -87,6 +108,14 @@ export function UsagePopover() { refetch: refetchCodex, } = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated); + const { + data: zaiUsage, + isLoading: zaiLoading, + error: zaiQueryError, + dataUpdatedAt: zaiUsageLastUpdated, + refetch: refetchZai, + } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); + // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -116,14 +145,28 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [codexQueryError]); + const zaiError = useMemo((): UsageError | null => { + if (!zaiQueryError) return null; + const message = zaiQueryError instanceof Error ? zaiQueryError.message : String(zaiQueryError); + if (message.includes('not configured') || message.includes('API token')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [zaiQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { setActiveTab('claude'); } else if (isCodexAuthenticated) { setActiveTab('codex'); + } else if (isZaiAuthenticated) { + setActiveTab('zai'); } - }, [isClaudeAuthenticated, isCodexAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -134,9 +177,14 @@ export function UsagePopover() { return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; }, [codexUsageLastUpdated]); + const isZaiStale = useMemo(() => { + return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + }, [zaiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); + const fetchZaiUsage = () => refetchZai(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -251,26 +299,33 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - }; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + title: `Usage (${codexWindowLabel})`, + } : activeTab === 'zai' ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {zaiError ? ( +
+ +
+

+ {zaiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'z.ai not configured' + : zaiError.message} +

+

+ {zaiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : zaiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Set Z_AI_API_KEY{' '} + environment variable to enable z.ai usage tracking + + ) : ( + <>Check your z.ai API key configuration + )} +

+
+
+ ) : !zaiUsage ? ( +
+ +

Loading usage data...

+
+ ) : zaiUsage.quotaLimits && + (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + + {zaiUsage.quotaLimits.mcp && ( + + )} + + {zaiUsage.quotaLimits.planType && zaiUsage.quotaLimits.planType !== 'unknown' && ( +
+

+ Plan:{' '} + + {zaiUsage.quotaLimits.planType.charAt(0).toUpperCase() + + zaiUsage.quotaLimits.planType.slice(1)} + +

+
+ )} + + ) : ( +
+ +

No usage data available

+
+ )} +
+ + {/* Footer */} +
+ + z.ai + + Updates every minute +
+ diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 0db3dd48a..05303b85d 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -81,6 +81,7 @@ export function BoardHeader({ (state) => state.setAddFeatureUseSelectedWorktreeBranch ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -112,6 +113,9 @@ export function BoardHeader({ // Show if Codex is authenticated (CLI or API key) const showCodexUsage = !!codexAuthStatus?.authenticated; + // z.ai usage tracking visibility logic + const showZaiUsage = !!zaiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -158,8 +162,10 @@ export function BoardHeader({ Refresh board state from server )} - {/* Usage Popover - show if either provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && } + {/* Usage Popover - show if any provider is authenticated, only on desktop */} + {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -178,6 +184,7 @@ export function BoardHeader({ onOpenPlanDialog={onOpenPlanDialog} showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} + showZaiUsage={showZaiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index f3c2c19d1..184e436a3 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -30,6 +30,7 @@ interface HeaderMobileMenuProps { // Usage bar visibility showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } export function HeaderMobileMenu({ @@ -47,18 +48,23 @@ export function HeaderMobileMenu({ onOpenPlanDialog, showClaudeUsage, showCodexUsage, + showZaiUsage = false, }: HeaderMobileMenuProps) { return ( <> - {/* Usage Bar - show if either provider is authenticated */} - {(showClaudeUsage || showCodexUsage) && ( + {/* Usage Bar - show if any provider is authenticated */} + {(showClaudeUsage || showCodexUsage || showZaiUsage) && (
Usage - +
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 918988e91..28225b507 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,11 +4,12 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -18,15 +19,51 @@ function getProgressBarColor(percentage: number): string { return 'bg-green-500'; } +// Helper to format large numbers with K/M suffixes +function formatNumber(num: number): string { + if (num >= 1_000_000_000) { + return `${(num / 1_000_000_000).toFixed(1)}B`; + } + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +// Helper to format reset time +function formatResetTime(unixTimestamp: number, isMilliseconds = false): string { + const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 3600000) { + const mins = Math.ceil(diff / 60000); + return `Resets in ${mins}m`; + } + if (diff < 86400000) { + const hours = Math.floor(diff / 3600000); + const mins = Math.ceil((diff % 3600000) / 60000); + return `Resets in ${hours}h${mins > 0 ? ` ${mins}m` : ''}`; + } + return `Resets ${date.toLocaleDateString()}`; +} + // Individual usage bar component function UsageBar({ label, percentage, isStale, + details, + resetText, }: { label: string; percentage: number; isStale: boolean; + details?: string; + resetText?: string; }) { return (
@@ -58,6 +95,14 @@ function UsageBar({ style={{ width: `${Math.min(percentage, 100)}%` }} />
+ {(details || resetText) && ( +
+ {details && {details}} + {resetText && ( + {resetText} + )} +
+ )} ); } @@ -103,16 +148,23 @@ function UsageItem({ ); } -export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) { +export function MobileUsageBar({ + showClaudeUsage, + showCodexUsage, + showZaiUsage = false, +}: MobileUsageBarProps) { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore(); const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); + const [isZaiLoading, setIsZaiLoading] = useState(false); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; + const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -146,6 +198,22 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [setCodexUsage]); + const fetchZaiUsage = useCallback(async () => { + setIsZaiLoading(true); + try { + const api = getElectronAPI(); + if (!api.zai) return; + const data = await api.zai.getUsage(); + if (!('error' in data)) { + setZaiUsage(data); + } + } catch { + // Silently fail - usage display is optional + } finally { + setIsZaiLoading(false); + } + }, [setZaiUsage]); + const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -165,8 +233,14 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [showCodexUsage, isCodexStale, fetchCodexUsage]); + useEffect(() => { + if (showZaiUsage && isZaiStale) { + fetchZaiUsage(); + } + }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { return null; } @@ -227,6 +301,45 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB )} )} + + {showZaiUsage && ( + + {zaiUsage?.quotaLimits && (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + {zaiUsage.quotaLimits.mcp && ( + + )} + + ) : zaiUsage ? ( +

No usage data from z.ai API

+ ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index 0290ec9e2..1b6738ece 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -1,7 +1,11 @@ // @ts-nocheck - API key management state with validation and persistence import { useState, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { createLogger } from '@automaker/utils/logger'; import { useAppStore } from '@/store/app-store'; +import { useSetupStore, type ZaiAuthMethod } from '@/store/setup-store'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { queryKeys } from '@/lib/query-keys'; const logger = createLogger('ApiKeyManagement'); import { getElectronAPI } from '@/lib/electron'; @@ -16,6 +20,7 @@ interface ApiKeyStatus { hasAnthropicKey: boolean; hasGoogleKey: boolean; hasOpenaiKey: boolean; + hasZaiKey: boolean; } /** @@ -24,16 +29,20 @@ interface ApiKeyStatus { */ export function useApiKeyManagement() { const { apiKeys, setApiKeys } = useAppStore(); + const { setZaiAuthStatus } = useSetupStore(); + const queryClient = useQueryClient(); // API key values const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [googleKey, setGoogleKey] = useState(apiKeys.google); const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); + const [zaiKey, setZaiKey] = useState(apiKeys.zai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false); const [showOpenaiKey, setShowOpenaiKey] = useState(false); + const [showZaiKey, setShowZaiKey] = useState(false); // Test connection states const [testingConnection, setTestingConnection] = useState(false); @@ -42,6 +51,8 @@ export function useApiKeyManagement() { const [geminiTestResult, setGeminiTestResult] = useState(null); const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); const [openaiTestResult, setOpenaiTestResult] = useState(null); + const [testingZaiConnection, setTestingZaiConnection] = useState(false); + const [zaiTestResult, setZaiTestResult] = useState(null); // API key status from environment const [apiKeyStatus, setApiKeyStatus] = useState(null); @@ -54,6 +65,7 @@ export function useApiKeyManagement() { setAnthropicKey(apiKeys.anthropic); setGoogleKey(apiKeys.google); setOpenaiKey(apiKeys.openai); + setZaiKey(apiKeys.zai); }, [apiKeys]); // Check API key status from environment on mount @@ -68,6 +80,7 @@ export function useApiKeyManagement() { hasAnthropicKey: status.hasAnthropicKey, hasGoogleKey: status.hasGoogleKey, hasOpenaiKey: status.hasOpenaiKey, + hasZaiKey: status.hasZaiKey || false, }); } } catch (error) { @@ -173,13 +186,89 @@ export function useApiKeyManagement() { } }; + // Test z.ai connection + const handleTestZaiConnection = async () => { + setTestingZaiConnection(true); + setZaiTestResult(null); + + // Validate input first + if (!zaiKey || zaiKey.trim().length === 0) { + setZaiTestResult({ + success: false, + message: 'Please enter an API key to test.', + }); + setTestingZaiConnection(false); + return; + } + + try { + const api = getElectronAPI(); + // Use the verify endpoint to test the key without storing it + const response = await api.zai?.verify(zaiKey); + + if (response?.success && response?.authenticated) { + setZaiTestResult({ + success: true, + message: response.message || 'Connection successful! z.ai API responded.', + }); + } else { + setZaiTestResult({ + success: false, + message: response?.error || 'Failed to connect to z.ai API.', + }); + } + } catch { + setZaiTestResult({ + success: false, + message: 'Network error. Please check your connection.', + }); + } finally { + setTestingZaiConnection(false); + } + }; + // Save API keys - const handleSave = () => { + const handleSave = async () => { setApiKeys({ anthropic: anthropicKey, google: googleKey, openai: openaiKey, + zai: zaiKey, }); + + // Configure z.ai service on the server with the new key + if (zaiKey && zaiKey.trim().length > 0) { + try { + const api = getHttpApiClient(); + const result = await api.zai.configure(zaiKey.trim()); + + if (result.success || result.isAvailable) { + // Update z.ai auth status in the store + setZaiAuthStatus({ + authenticated: true, + method: 'api_key' as ZaiAuthMethod, + hasApiKey: true, + hasEnvApiKey: false, + }); + // Invalidate the z.ai usage query so it refetches with the new key + await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); + logger.info('z.ai API key configured successfully'); + } + } catch (error) { + logger.error('Failed to configure z.ai API key:', error); + } + } else { + // Clear z.ai auth status if key is removed + setZaiAuthStatus({ + authenticated: false, + method: 'none' as ZaiAuthMethod, + hasApiKey: false, + hasEnvApiKey: false, + }); + // Invalidate the query to clear any cached data + await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() }); + } + setSaved(true); setTimeout(() => setSaved(false), 2000); }; @@ -214,6 +303,15 @@ export function useApiKeyManagement() { onTest: handleTestOpenaiConnection, result: openaiTestResult, }, + zai: { + value: zaiKey, + setValue: setZaiKey, + show: showZaiKey, + setShow: setShowZaiKey, + testing: testingZaiConnection, + onTest: handleTestZaiConnection, + result: zaiTestResult, + }, }; return { diff --git a/apps/ui/src/config/api-providers.ts b/apps/ui/src/config/api-providers.ts index e3cc2a51b..140d0c24d 100644 --- a/apps/ui/src/config/api-providers.ts +++ b/apps/ui/src/config/api-providers.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { ApiKeys } from '@/store/app-store'; -export type ProviderKey = 'anthropic' | 'google' | 'openai'; +export type ProviderKey = 'anthropic' | 'google' | 'openai' | 'zai'; export interface ProviderConfig { key: ProviderKey; @@ -59,12 +59,22 @@ export interface ProviderConfigParams { onTest: () => Promise; result: { success: boolean; message: string } | null; }; + zai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; } export const buildProviderConfigs = ({ apiKeys, anthropic, openai, + zai, }: ProviderConfigParams): ProviderConfig[] => [ { key: 'anthropic', @@ -118,6 +128,32 @@ export const buildProviderConfigs = ({ descriptionLinkText: 'platform.openai.com', descriptionSuffix: '.', }, + { + key: 'zai', + label: 'z.ai API Key', + inputId: 'zai-key', + placeholder: 'Enter your z.ai API key', + value: zai.value, + setValue: zai.setValue, + showValue: zai.show, + setShowValue: zai.setShow, + hasStoredKey: apiKeys.zai, + inputTestId: 'zai-api-key-input', + toggleTestId: 'toggle-zai-visibility', + testButton: { + onClick: zai.onTest, + disabled: !zai.value || zai.testing, + loading: zai.testing, + testId: 'test-zai-connection', + }, + result: zai.result, + resultTestId: 'zai-test-connection-result', + resultMessageTestId: 'zai-test-connection-message', + descriptionPrefix: 'Used for z.ai usage tracking and GLM models. Get your key at', + descriptionLinkHref: 'https://z.ai', + descriptionLinkText: 'z.ai', + descriptionSuffix: '.', + }, // { // key: "google", // label: "Google API Key (Gemini)", diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 8cfdf745f..186b5b4e7 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 523c53f19..c159ac068 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude and Codex API usage data. + * React Query hooks for fetching Claude, Codex, and z.ai API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -87,3 +87,36 @@ export function useCodexUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch z.ai API usage data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with z.ai usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useZaiUsage(isPopoverOpen); + * ``` + */ +export function useZaiUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.zai(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + const result = await api.zai.getUsage(); + // Check if result is an error response + if ('error' in result) { + throw new Error(result.message || result.error); + } + return result; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index ae95d1212..c784e7bd4 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -1,18 +1,29 @@ import { useEffect, useRef, useCallback } from 'react'; -import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } from '@/store/setup-store'; +import { + useSetupStore, + type ClaudeAuthMethod, + type CodexAuthMethod, + type ZaiAuthMethod, +} from '@/store/setup-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('ProviderAuthInit'); /** - * Hook to initialize Claude and Codex authentication statuses on app startup. + * Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup. * This ensures that usage tracking information is available in the board header * without needing to visit the settings page first. */ export function useProviderAuthInit() { - const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } = - useSetupStore(); + const { + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + claudeAuthStatus, + codexAuthStatus, + zaiAuthStatus, + } = useSetupStore(); const initialized = useRef(false); const refreshStatuses = useCallback(async () => { @@ -88,15 +99,40 @@ export function useProviderAuthInit() { } catch (error) { logger.error('Failed to init Codex auth status:', error); } - }, [setClaudeAuthStatus, setCodexAuthStatus]); + + // 3. z.ai Auth Status + try { + const result = await api.zai.getStatus(); + if (result.success || result.available !== undefined) { + let method: ZaiAuthMethod = 'none'; + if (result.hasEnvApiKey) { + method = 'api_key_env'; + } else if (result.hasApiKey || result.available) { + method = 'api_key'; + } + + setZaiAuthStatus({ + authenticated: result.available, + method, + hasApiKey: result.hasApiKey ?? result.available, + hasEnvApiKey: result.hasEnvApiKey ?? false, + }); + } + } catch (error) { + logger.error('Failed to init z.ai auth status:', error); + } + }, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]); useEffect(() => { // Only initialize once per session if not already set - if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) { + if ( + initialized.current || + (claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null) + ) { return; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 89aa07baa..a02f58542 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,6 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; +import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store'; import type { IssueValidationVerdict, IssueValidationConfidence, @@ -865,6 +865,15 @@ export interface ElectronAPI { error?: string; }>; }; + zai?: { + getUsage: () => Promise; + verify: (apiKey: string) => Promise<{ + success: boolean; + authenticated: boolean; + message?: string; + error?: string; + }>; + }; settings?: { getStatus: () => Promise<{ success: boolean; @@ -1364,6 +1373,51 @@ const _getMockElectronAPI = (): ElectronAPI => { }; }, }, + + // Mock z.ai API + zai: { + getUsage: async () => { + console.log('[Mock] Getting z.ai usage'); + return { + quotaLimits: { + tokens: { + limitType: 'TOKENS_LIMIT', + limit: 1000000, + used: 250000, + remaining: 750000, + usedPercent: 25, + nextResetTime: Date.now() + 86400000, + }, + time: { + limitType: 'TIME_LIMIT', + limit: 3600, + used: 900, + remaining: 2700, + usedPercent: 25, + nextResetTime: Date.now() + 3600000, + }, + planType: 'standard', + }, + lastUpdated: new Date().toISOString(), + }; + }, + verify: async (apiKey: string) => { + console.log('[Mock] Verifying z.ai API key'); + // Mock successful verification if key is provided + if (apiKey && apiKey.trim().length > 0) { + return { + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }; + } + return { + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1f79ff075..5598fea00 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1736,6 +1736,67 @@ export class HttpApiClient implements ElectronAPI { }, }; + // z.ai API + zai = { + getStatus: (): Promise<{ + success: boolean; + available: boolean; + message?: string; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; + }> => this.get('/api/zai/status'), + + getUsage: (): Promise<{ + quotaLimits?: { + tokens?: { + limitType: string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; + }; + time?: { + limitType: string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; + }; + planType: string; + } | null; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + lastUpdated: string; + error?: string; + message?: string; + }> => this.get('/api/zai/usage'), + + configure: ( + apiToken?: string, + apiHost?: string + ): Promise<{ + success: boolean; + message?: string; + isAvailable?: boolean; + error?: string; + }> => this.post('/api/zai/configure', { apiToken, apiHost }), + + verify: ( + apiKey: string + ): Promise<{ + success: boolean; + authenticated: boolean; + message?: string; + error?: string; + }> => this.post('/api/zai/verify', { apiKey }), + }; + // Features API features: FeaturesAPI & { bulkUpdate: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index afe4b5b09..aad0208d9 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -99,6 +99,8 @@ export const queryKeys = { claude: () => ['usage', 'claude'] as const, /** Codex API usage */ codex: () => ['usage', 'codex'] as const, + /** z.ai API usage */ + zai: () => ['usage', 'zai'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c07353554..4d4868b63 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -94,6 +94,10 @@ import { type CodexRateLimitWindow, type CodexUsage, type CodexUsageResponse, + type ZaiPlanType, + type ZaiQuotaLimit, + type ZaiUsage, + type ZaiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -173,6 +177,10 @@ export type { CodexRateLimitWindow, CodexUsage, CodexUsageResponse, + ZaiPlanType, + ZaiQuotaLimit, + ZaiUsage, + ZaiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -234,6 +242,7 @@ const initialState: AppState = { anthropic: '', google: '', openai: '', + zai: '', }, chatSessions: [], currentChatSession: null, @@ -314,6 +323,8 @@ const initialState: AppState = { claudeUsageLastUpdated: null, codexUsage: null, codexUsageLastUpdated: null, + zaiUsage: null, + zaiUsageLastUpdated: null, codexModels: [], codexModelsLoading: false, codexModelsError: null, @@ -2400,6 +2411,9 @@ export const useAppStore = create()((set, get) => ({ // Codex Usage Tracking actions setCodexUsage: (usage) => set({ codexUsage: usage, codexUsageLastUpdated: Date.now() }), + // z.ai Usage Tracking actions + setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }), + // Codex Models actions fetchCodexModels: async (forceRefresh = false) => { const state = get(); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index f354e5b1e..27a9bdac8 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -112,6 +112,21 @@ export interface CodexAuthStatus { error?: string; } +// z.ai Auth Method +export type ZaiAuthMethod = + | 'api_key_env' // Z_AI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'none'; + +// z.ai Auth Status +export interface ZaiAuthStatus { + authenticated: boolean; + method: ZaiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -189,6 +204,9 @@ export interface SetupState { // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; + // z.ai API state + zaiAuthStatus: ZaiAuthStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -229,6 +247,9 @@ export interface SetupActions { // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; + // z.ai API + setZaiAuthStatus: (status: ZaiAuthStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -266,6 +287,8 @@ const initialState: SetupState = { copilotCliStatus: null, + zaiAuthStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -344,6 +367,9 @@ export const useSetupStore = create()((set, get) => ( // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), + // z.ai API + setZaiAuthStatus: (status) => set({ zaiAuthStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/apps/ui/src/store/types/settings-types.ts b/apps/ui/src/store/types/settings-types.ts index 6adb80973..bf371fd0b 100644 --- a/apps/ui/src/store/types/settings-types.ts +++ b/apps/ui/src/store/types/settings-types.ts @@ -2,4 +2,5 @@ export interface ApiKeys { anthropic: string; google: string; openai: string; + zai: string; } diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 4febb1caa..7bf019687 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -36,7 +36,7 @@ import type { ApiKeys } from './settings-types'; import type { ChatMessage, ChatSession, FeatureImage } from './chat-types'; import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types'; import type { Feature, ProjectAnalysis } from './project-types'; -import type { ClaudeUsage, CodexUsage } from './usage-types'; +import type { ClaudeUsage, CodexUsage, ZaiUsage } from './usage-types'; /** State for worktree init script execution */ export interface InitScriptState { @@ -297,6 +297,10 @@ export interface AppState { codexUsage: CodexUsage | null; codexUsageLastUpdated: number | null; + // z.ai Usage Tracking + zaiUsage: ZaiUsage | null; + zaiUsageLastUpdated: number | null; + // Codex Models (dynamically fetched) codexModels: Array<{ id: string; @@ -764,6 +768,9 @@ export interface AppActions { // Codex Usage Tracking actions setCodexUsage: (usage: CodexUsage | null) => void; + // z.ai Usage Tracking actions + setZaiUsage: (usage: ZaiUsage | null) => void; + // Codex Models actions fetchCodexModels: (forceRefresh?: boolean) => Promise; setCodexModels: ( diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e097526c2..e7c47a5d2 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -58,3 +58,27 @@ export interface CodexUsage { // Response type for Codex usage API (can be success or error) export type CodexUsageResponse = CodexUsage | { error: string; message?: string }; + +// z.ai Usage types +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; // Percentage used (0-100) + nextResetTime: number; // Epoch milliseconds +} + +export interface ZaiUsage { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + lastUpdated: string; +} + +// Response type for z.ai usage API (can be success or error) +export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string }; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index eb53564df..16a443d23 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1266,6 +1266,8 @@ export interface Credentials { google: string; /** OpenAI API key (for compatibility or alternative providers) */ openai: string; + /** z.ai API key (for GLM models and usage tracking) */ + zai: string; }; } @@ -1594,6 +1596,7 @@ export const DEFAULT_CREDENTIALS: Credentials = { anthropic: '', google: '', openai: '', + zai: '', }, }; From e777eb83527751bfcd417b6b90013e5cdc2bd562 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Wed, 21 Jan 2026 08:29:20 -0800 Subject: [PATCH 02/12] Feat: Add ability to duplicate a feature and duplicate as a child --- .../src/routes/features/routes/create.ts | 13 - .../src/routes/features/routes/update.ts | 17 -- apps/ui/src/components/views/board-view.tsx | 4 + .../components/kanban-card/card-header.tsx | 228 +++++++++++++++--- .../components/kanban-card/kanban-card.tsx | 6 + .../components/list-view/list-view.tsx | 14 ++ .../components/list-view/row-actions.tsx | 137 +++++++++++ .../board-view/hooks/use-board-actions.ts | 21 ++ .../views/board-view/kanban-board.tsx | 6 + 9 files changed, 386 insertions(+), 60 deletions(-) diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 29f7d0755..c607e72e4 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -24,19 +24,6 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event return; } - // Check for duplicate title if title is provided - if (feature.title && feature.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${feature.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - const created = await featureLoader.create(projectPath, feature); // Emit feature_created event for hooks diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index a5b532c1d..4d5e7a00e 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -40,23 +40,6 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { return; } - // Check for duplicate title if title is being updated - if (updates.title && updates.title.trim()) { - const duplicate = await featureLoader.findDuplicateTitle( - projectPath, - updates.title, - featureId // Exclude the current feature from duplicate check - ); - if (duplicate) { - res.status(409).json({ - success: false, - error: `A feature with title "${updates.title}" already exists`, - duplicateFeatureId: duplicate.id, - }); - return; - } - } - // Get the current feature to detect status changes const currentFeature = await featureLoader.get(projectPath, featureId); const previousStatus = currentFeature?.status as FeatureStatus | undefined; diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index d8be006dd..1266ea77c 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -590,6 +590,7 @@ export function BoardView() { handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, } = useBoardActions({ currentProject, features: hookFeatures, @@ -1465,6 +1466,8 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }, + onDuplicate: (feature) => handleDuplicateFeature(feature, false), + onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true), }} runningAutoTasks={runningAutoTasks} pipelineConfig={pipelineConfig} @@ -1504,6 +1507,7 @@ export function BoardView() { setSpawnParentFeature(feature); setShowAddDialog(true); }} + onDuplicate={handleDuplicateFeature} featuresWithContext={featuresWithContext} runningAutoTasks={runningAutoTasks} onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index 793c31914..e3575c55a 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -8,6 +8,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -19,6 +22,7 @@ import { ChevronDown, ChevronUp, GitFork, + Copy, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { CountUpTimer } from '@/components/ui/count-up-timer'; @@ -35,6 +39,8 @@ interface CardHeaderProps { onDelete: () => void; onViewOutput?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; } export const CardHeaderSection = memo(function CardHeaderSection({ @@ -46,6 +52,8 @@ export const CardHeaderSection = memo(function CardHeaderSection({ onDelete, onViewOutput, onSpawnTask, + onDuplicate, + onDuplicateAsChild, }: CardHeaderProps) { const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -109,6 +117,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); @@ -129,20 +170,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ {/* Backlog header */} {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && (
- + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-backlog-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -178,22 +265,6 @@ export const CardHeaderSection = memo(function CardHeaderSection({ > - {onViewOutput && ( + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-${ + feature.status === 'waiting_approval' ? 'waiting' : 'verified' + }-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
)} @@ -293,6 +428,39 @@ export const CardHeaderSection = memo(function CardHeaderSection({ Spawn Sub-Task + {onDuplicate && ( + +
+ { + e.stopPropagation(); + onDuplicate(); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + Duplicate + + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} {/* Model info in dropdown */} {(() => { const ProviderIcon = getProviderIconForModel(feature.model); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index a332f3059..4859331f7 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -52,6 +52,8 @@ interface KanbanCardProps { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; hasContext?: boolean; isCurrentAutoTask?: boolean; shortcutKey?: string; @@ -86,6 +88,8 @@ export const KanbanCard = memo(function KanbanCard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, + onDuplicateAsChild, hasContext, isCurrentAutoTask, shortcutKey, @@ -249,6 +253,8 @@ export const KanbanCard = memo(function KanbanCard({ onDelete={onDelete} onViewOutput={onViewOutput} onSpawnTask={onSpawnTask} + onDuplicate={onDuplicate} + onDuplicateAsChild={onDuplicateAsChild} /> diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index 0a08b1270..cac687eb4 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -42,6 +42,8 @@ export interface ListViewActionHandlers { onViewPlan?: (feature: Feature) => void; onApprovePlan?: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature) => void; + onDuplicateAsChild?: (feature: Feature) => void; } export interface ListViewProps { @@ -313,6 +315,18 @@ export const ListView = memo(function ListView({ if (f) actionHandlers.onSpawnTask?.(f); } : undefined, + duplicate: actionHandlers.onDuplicate + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicate?.(f); + } + : undefined, + duplicateAsChild: actionHandlers.onDuplicateAsChild + ? (id) => { + const f = allFeatures.find((f) => f.id === id); + if (f) actionHandlers.onDuplicateAsChild?.(f); + } + : undefined, }); }, [actionHandlers, allFeatures] diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index bb5c53d16..60158d0fd 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -14,6 +14,7 @@ import { GitBranch, GitFork, ExternalLink, + Copy, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -22,6 +23,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import type { Feature } from '@/store/app-store'; @@ -43,6 +47,8 @@ export interface RowActionHandlers { onViewPlan?: () => void; onApprovePlan?: () => void; onSpawnTask?: () => void; + onDuplicate?: () => void; + onDuplicateAsChild?: () => void; } export interface RowActionsProps { @@ -405,6 +411,31 @@ export const RowActions = memo(function RowActions({ onClick={withClose(handlers.onSpawnTask)} /> )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
+ {handlers.onDuplicateAsChild && ( + + + + )} +
+ )} void; approvePlan?: (id: string) => void; spawnTask?: (id: string) => void; + duplicate?: (id: string) => void; + duplicateAsChild?: (id: string) => void; } ): RowActionHandlers { return { @@ -631,5 +764,9 @@ export function createRowActionHandlers( onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined, onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined, onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined, + onDuplicate: actions.duplicate ? () => actions.duplicate!(featureId) : undefined, + onDuplicateAsChild: actions.duplicateAsChild + ? () => actions.duplicateAsChild!(featureId) + : undefined, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index ebd805911..4f3c05179 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -1083,6 +1083,26 @@ export function useBoardActions({ }); }, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]); + const handleDuplicateFeature = useCallback( + async (feature: Feature, asChild: boolean = false) => { + // Copy all feature data, only override id/status (handled by create) and dependencies if as child + const { id: _id, status: _status, ...featureData } = feature; + const duplicatedFeatureData = { + ...featureData, + // If duplicating as child, set source as dependency; otherwise keep existing + ...(asChild && { dependencies: [feature.id] }), + }; + + // Reuse the existing handleAddFeature logic + await handleAddFeature(duplicatedFeatureData); + + toast.success(asChild ? 'Duplicated as child' : 'Feature duplicated', { + description: `Created copy of: ${truncateDescription(feature.description || feature.title || '')}`, + }); + }, + [handleAddFeature] + ); + return { handleAddFeature, handleUpdateFeature, @@ -1103,5 +1123,6 @@ export function useBoardActions({ handleForceStopFeature, handleStartNextFeatures, handleArchiveAllVerified, + handleDuplicateFeature, }; } diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 7f8573923..ef0918721 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -46,6 +46,7 @@ interface KanbanBoardProps { onViewPlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void; + onDuplicate?: (feature: Feature, asChild: boolean) => void; featuresWithContext: Set; runningAutoTasks: string[]; onArchiveAllVerified: () => void; @@ -282,6 +283,7 @@ export function KanbanBoard({ onViewPlan, onApprovePlan, onSpawnTask, + onDuplicate, featuresWithContext, runningAutoTasks, onArchiveAllVerified, @@ -569,6 +571,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature, false)} + onDuplicateAsChild={() => onDuplicate?.(feature, true)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} @@ -611,6 +615,8 @@ export function KanbanBoard({ onViewPlan={() => onViewPlan(feature)} onApprovePlan={() => onApprovePlan(feature)} onSpawnTask={() => onSpawnTask?.(feature)} + onDuplicate={() => onDuplicate?.(feature, false)} + onDuplicateAsChild={() => onDuplicate?.(feature, true)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} shortcutKey={shortcutKey} From 06eb0c588db7a0f8bdfb90e80ac9a30fe0ef8172 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Thu, 22 Jan 2026 10:26:26 -0800 Subject: [PATCH 03/12] Feat: Add scheduled feature support --- apps/server/package.json | 1 + apps/server/src/index.ts | 17 + .../src/routes/features/routes/create.ts | 28 ++ .../src/routes/features/routes/update.ts | 45 ++ apps/server/src/routes/schedule/index.ts | 23 + .../src/routes/schedule/routes/presets.ts | 54 +++ .../src/routes/schedule/routes/validate.ts | 64 +++ apps/server/src/services/auto-mode-service.ts | 242 +++++++++- apps/server/src/services/feature-loader.ts | 20 + apps/server/src/services/scheduler-service.ts | 318 +++++++++++++ .../kanban-card/agent-info-panel.tsx | 27 +- .../components/kanban-card/card-actions.tsx | 21 +- .../components/kanban-card/card-badges.tsx | 65 ++- .../kanban-card/card-content-sections.tsx | 40 +- .../components/kanban-card/card-header.tsx | 140 +++--- .../components/list-view/row-actions.tsx | 115 ++--- .../components/list-view/status-badge.tsx | 1 + .../components/views/board-view/constants.ts | 10 + .../board-view/dialogs/add-feature-dialog.tsx | 43 +- .../dialogs/edit-feature-dialog.tsx | 31 +- .../board-view/hooks/use-board-actions.ts | 32 +- .../hooks/use-board-column-features.ts | 6 +- .../board-view/hooks/use-board-drag-drop.ts | 4 +- .../views/board-view/kanban-board.tsx | 21 +- .../views/board-view/shared/index.ts | 1 + .../board-view/shared/schedule-selector.tsx | 442 ++++++++++++++++++ apps/ui/src/hooks/use-auto-mode.ts | 43 +- apps/ui/src/styles/global.css | 2 + libs/types/src/feature.ts | 32 +- libs/types/src/index.ts | 2 + libs/types/src/pipeline.ts | 1 + package-lock.json | 24 +- 32 files changed, 1725 insertions(+), 190 deletions(-) create mode 100644 apps/server/src/routes/schedule/index.ts create mode 100644 apps/server/src/routes/schedule/routes/presets.ts create mode 100644 apps/server/src/routes/schedule/routes/validate.ts create mode 100644 apps/server/src/services/scheduler-service.ts create mode 100644 apps/ui/src/components/views/board-view/shared/schedule-selector.tsx diff --git a/apps/server/package.json b/apps/server/package.json index c9015aeaa..c97b3e55e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -37,6 +37,7 @@ "@openai/codex-sdk": "^0.77.0", "cookie-parser": "1.4.7", "cors": "2.8.5", + "cron-parser": "^5.5.0", "dotenv": "17.2.3", "express": "5.2.1", "morgan": "1.10.1", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 5ba3dff3b..0bc7fb2a6 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -76,6 +76,7 @@ import { createMCPRoutes } from './routes/mcp/index.js'; import { MCPTestService } from './services/mcp-test-service.js'; import { createPipelineRoutes } from './routes/pipeline/index.js'; import { pipelineService } from './services/pipeline-service.js'; +import { createScheduleRoutes } from './routes/schedule/index.js'; import { createIdeationRoutes } from './routes/ideation/index.js'; import { IdeationService } from './services/ideation-service.js'; import { getDevServerService } from './services/dev-server-service.js'; @@ -86,6 +87,7 @@ import { createEventHistoryRoutes } from './routes/event-history/index.js'; import { getEventHistoryService } from './services/event-history-service.js'; import { getTestRunnerService } from './services/test-runner-service.js'; import { createProjectsRoutes } from './routes/projects/index.js'; +import { SchedulerService, setSchedulerService } from './services/scheduler-service.js'; // Load environment variables dotenv.config(); @@ -261,6 +263,13 @@ const settingsService = new SettingsService(DATA_DIR); const agentService = new AgentService(DATA_DIR, events, settingsService); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events, settingsService); +const schedulerService = new SchedulerService( + events, + featureLoader, + autoModeService, + settingsService +); +setSchedulerService(schedulerService); const claudeUsageService = new ClaudeUsageService(); const codexAppServerService = new CodexAppServerService(); const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); @@ -387,6 +396,7 @@ app.use( '/api/projects', createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService) ); +app.use('/api/schedule', createScheduleRoutes()); // Create HTTP server const server = createServer(app); @@ -735,6 +745,12 @@ const startServer = (port: number, host: string) => { ║ ║ ╚═════════════════════════════════════════════════════════════════════╝ `); + + // Start the scheduler service for recurring tasks + schedulerService.start(); + schedulerService.recalculateNextRunTimes().catch((err) => { + logger.error('Error recalculating scheduled task run times:', err); + }); }); server.on('error', (error: NodeJS.ErrnoException) => { @@ -822,6 +838,7 @@ const gracefulShutdown = async (signal: string) => { // Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`); + schedulerService.stop(); terminalService.cleanup(); server.close(() => { clearTimeout(forceExitTimeout); diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index c607e72e4..787be5b36 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -3,10 +3,14 @@ */ import type { Request, Response } from 'express'; +import { CronExpressionParser } from 'cron-parser'; import { FeatureLoader } from '../../../services/feature-loader.js'; import type { EventEmitter } from '../../../lib/events.js'; import type { Feature } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('features/create'); export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) { return async (req: Request, res: Response): Promise => { @@ -24,6 +28,30 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event return; } + // Calculate nextRun and set status to 'scheduled' if schedule is provided and enabled + if (feature.schedule?.enabled && feature.schedule?.crontab) { + try { + const interval = CronExpressionParser.parse(feature.schedule.crontab, { + currentDate: new Date(), + }); + const nextRun = interval.next().toDate(); + feature.schedule = { + ...feature.schedule, + nextRun: nextRun.toISOString(), + }; + // Set status to 'scheduled' so the scheduler will pick it up + feature.status = 'scheduled'; + logger.debug( + `Calculated nextRun for new feature: ${nextRun.toISOString()}, status set to 'scheduled'` + ); + } catch (err) { + logger.warn( + `Invalid crontab expression in new feature: ${feature.schedule.crontab}`, + err + ); + } + } + const created = await featureLoader.create(projectPath, feature); // Emit feature_created event for hooks diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 4d5e7a00e..e5d2d0c0c 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -3,6 +3,7 @@ */ import type { Request, Response } from 'express'; +import { CronExpressionParser } from 'cron-parser'; import { FeatureLoader } from '../../../services/feature-loader.js'; import type { Feature, FeatureStatus } from '@automaker/types'; import { getErrorMessage, logError } from '../common.js'; @@ -45,6 +46,50 @@ export function createUpdateHandler(featureLoader: FeatureLoader) { const previousStatus = currentFeature?.status as FeatureStatus | undefined; const newStatus = updates.status as FeatureStatus | undefined; + // Handle schedule updates + // Check if schedule is being removed or disabled + const isScheduleBeingRemoved = + 'schedule' in updates && + (updates.schedule === undefined || + updates.schedule === null || + updates.schedule?.enabled === false); + + if (isScheduleBeingRemoved) { + // If currently scheduled, move back to backlog + if (currentFeature?.status === 'scheduled') { + updates.status = 'backlog'; + logger.debug( + `Moving feature ${featureId} from 'scheduled' to 'backlog' (schedule removed/disabled)` + ); + } + // Clear the schedule + updates.schedule = undefined; + } else if (updates.schedule?.enabled && updates.schedule?.crontab) { + // Calculate nextRun if schedule is being updated and enabled + try { + const interval = CronExpressionParser.parse(updates.schedule.crontab, { + currentDate: new Date(), + }); + const nextRun = interval.next().toDate(); + updates.schedule = { + ...updates.schedule, + nextRun: nextRun.toISOString(), + }; + // If enabling a schedule on a feature that's not in_progress, move it to 'scheduled' + const currentStatus = currentFeature?.status; + if (currentStatus && currentStatus !== 'in_progress' && currentStatus !== 'scheduled') { + updates.status = 'scheduled'; + logger.debug(`Moving feature ${featureId} to 'scheduled' status`); + } + logger.debug(`Calculated nextRun for feature ${featureId}: ${nextRun.toISOString()}`); + } catch (err) { + logger.warn( + `Invalid crontab expression for feature ${featureId}: ${updates.schedule.crontab}`, + err + ); + } + } + const updated = await featureLoader.update( projectPath, featureId, diff --git a/apps/server/src/routes/schedule/index.ts b/apps/server/src/routes/schedule/index.ts new file mode 100644 index 000000000..969bfd134 --- /dev/null +++ b/apps/server/src/routes/schedule/index.ts @@ -0,0 +1,23 @@ +/** + * Schedule routes - API endpoints for crontab schedule management + * + * Routes: + * - POST /validate - Validate a crontab expression and get next run time + * - GET /presets - Get available schedule presets + */ + +import { Router } from 'express'; +import { createValidateHandler } from './routes/validate.js'; +import { createPresetsHandler } from './routes/presets.js'; + +export function createScheduleRoutes(): Router { + const router = Router(); + + // Validate crontab expression + router.post('/validate', createValidateHandler()); + + // Get available presets + router.get('/presets', createPresetsHandler()); + + return router; +} diff --git a/apps/server/src/routes/schedule/routes/presets.ts b/apps/server/src/routes/schedule/routes/presets.ts new file mode 100644 index 000000000..7fe8d97b5 --- /dev/null +++ b/apps/server/src/routes/schedule/routes/presets.ts @@ -0,0 +1,54 @@ +/** + * GET /schedule/presets endpoint - Get available schedule presets + * + * Returns the standard preset crontab expressions. + */ + +import type { Request, Response } from 'express'; +import type { SchedulePreset } from '@automaker/types'; + +export interface PresetInfo { + id: SchedulePreset; + label: string; + crontab: string; + description: string; +} + +export interface PresetsResponse { + presets: PresetInfo[]; +} + +const SCHEDULE_PRESETS: PresetInfo[] = [ + { + id: 'hourly', + label: 'Hourly', + crontab: '0 * * * *', + description: 'Run at the start of every hour', + }, + { + id: 'daily', + label: 'Daily', + crontab: '0 9 * * *', + description: 'Run every day at 9:00 AM', + }, + { + id: 'weekly', + label: 'Weekly', + crontab: '0 9 * * 1', + description: 'Run every Monday at 9:00 AM', + }, + { + id: 'monthly', + label: 'Monthly', + crontab: '0 9 1 * *', + description: 'Run on the 1st of every month at 9:00 AM', + }, +]; + +export function createPresetsHandler() { + return (_req: Request, res: Response): void => { + res.json({ + presets: SCHEDULE_PRESETS, + } satisfies PresetsResponse); + }; +} diff --git a/apps/server/src/routes/schedule/routes/validate.ts b/apps/server/src/routes/schedule/routes/validate.ts new file mode 100644 index 000000000..43b4f58c5 --- /dev/null +++ b/apps/server/src/routes/schedule/routes/validate.ts @@ -0,0 +1,64 @@ +/** + * POST /schedule/validate endpoint - Validate a crontab expression + * + * Returns whether the crontab is valid and the next run time if valid. + */ + +import type { Request, Response } from 'express'; +import { CronExpressionParser } from 'cron-parser'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ScheduleValidate'); + +export interface ValidateRequest { + crontab: string; +} + +export interface ValidateResponse { + valid: boolean; + nextRun?: string; + error?: string; +} + +export function createValidateHandler() { + return (req: Request, res: Response): void => { + try { + const { crontab } = req.body as ValidateRequest; + + if (!crontab || typeof crontab !== 'string') { + res.status(400).json({ + valid: false, + error: 'Missing or invalid crontab expression', + } satisfies ValidateResponse); + return; + } + + const trimmedCrontab = crontab.trim(); + + try { + const interval = CronExpressionParser.parse(trimmedCrontab, { + currentDate: new Date(), + }); + const nextRun = interval.next().toDate(); + + res.json({ + valid: true, + nextRun: nextRun.toISOString(), + } satisfies ValidateResponse); + } catch (parseErr) { + const errorMessage = + parseErr instanceof Error ? parseErr.message : 'Invalid crontab expression'; + res.json({ + valid: false, + error: errorMessage, + } satisfies ValidateResponse); + } + } catch (err) { + logger.error('Error validating crontab:', err); + res.status(500).json({ + valid: false, + error: 'Internal server error', + } satisfies ValidateResponse); + } + }; +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index ffb875913..f3f59d7be 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -9,11 +9,13 @@ * - Verification and merge workflows */ +import { CronExpressionParser } from 'cron-parser'; import { ProviderFactory } from '../providers/provider-factory.js'; import { simpleQuery } from '../providers/simple-query-service.js'; import type { ExecuteOptions, Feature, + FeatureSchedule, ModelProvider, PipelineStep, FeatureStatusWithPipeline, @@ -774,6 +776,103 @@ export class AutoModeService { this.consecutiveFailures = []; } + /** + * Determine the final status for a completed feature + * Takes into account: + * - skipTests setting (manual vs automated verification) + * - schedule configuration (recurring features go to 'scheduled') + * + * @returns Object containing the final status and updated schedule if applicable + */ + private async determineFinalStatus( + projectPath: string, + feature: Feature + ): Promise<{ + status: FeatureStatusWithPipeline; + updatedSchedule?: FeatureSchedule; + }> { + // If feature has an active schedule, move to 'scheduled' status + if (feature.schedule?.enabled) { + const now = new Date(); + let nextRun: Date | null = null; + + try { + const interval = CronExpressionParser.parse(feature.schedule.crontab, { + currentDate: now, + }); + nextRun = interval.next().toDate(); + } catch (err) { + logger.warn(`Invalid crontab for feature ${feature.id}: ${feature.schedule.crontab}`); + } + + const updatedSchedule: FeatureSchedule = { + ...feature.schedule, + lastRun: now.toISOString(), + nextRun: nextRun?.toISOString(), + runCount: (feature.schedule.runCount || 0) + 1, + }; + + logger.info( + `Feature ${feature.id} completed with schedule. Next run: ${nextRun?.toISOString() || 'unknown'}` + ); + + return { + status: 'scheduled', + updatedSchedule, + }; + } + + // No schedule - use normal flow based on testing mode + const status = feature.skipTests ? 'waiting_approval' : 'verified'; + return { status }; + } + + /** + * Determine final status for an aborted/errored scheduled feature. + * Similar to determineFinalStatus but doesn't increment runCount since the feature + * was stopped/errored, not completed successfully. + */ + private async determineFinalStatusForAbort( + projectPath: string, + feature: Feature + ): Promise<{ + status: FeatureStatusWithPipeline; + updatedSchedule?: FeatureSchedule; + }> { + // If feature has an active schedule, move to 'scheduled' status + if (feature.schedule?.enabled) { + const now = new Date(); + let nextRun: Date | null = null; + + try { + const interval = CronExpressionParser.parse(feature.schedule.crontab, { + currentDate: now, + }); + nextRun = interval.next().toDate(); + } catch (err) { + logger.warn(`Invalid crontab for feature ${feature.id}: ${feature.schedule.crontab}`); + } + + // Don't increment runCount for aborted/errored features + const updatedSchedule: FeatureSchedule = { + ...feature.schedule, + nextRun: nextRun?.toISOString(), + }; + + logger.info( + `Feature ${feature.id} aborted/errored with schedule. Next run: ${nextRun?.toISOString() || 'unknown'}` + ); + + return { + status: 'scheduled', + updatedSchedule, + }; + } + + // No schedule - return backlog for non-scheduled features + return { status: 'backlog' }; + } + private async resolveMaxConcurrency( projectPath: string, branchName: string | null, @@ -1569,11 +1668,23 @@ export class AutoModeService { ); } - // Determine final status based on testing mode: + // Determine final status based on testing mode and schedule: + // - Features with schedule go to 'scheduled' // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=true (manual verification): go to 'waiting_approval' for manual review - const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); + const { status: finalStatus, updatedSchedule } = await this.determineFinalStatus( + projectPath, + feature + ); + if (updatedSchedule) { + // Update the feature with the new schedule information + await this.featureLoader.update(projectPath, featureId, { + status: finalStatus, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + } // Record success to reset consecutive failure tracking this.recordSuccess(); @@ -1633,6 +1744,20 @@ export class AutoModeService { const errorInfo = classifyError(error); if (errorInfo.isAbort) { + // Handle status update on server (don't rely on UI) + // Scheduled features go back to 'scheduled', others go to 'backlog' + const { status, updatedSchedule } = await this.determineFinalStatusForAbort( + projectPath, + feature! + ); + if (updatedSchedule) { + await this.featureLoader.update(projectPath, featureId, { + status, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, status); + } this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, @@ -1643,7 +1768,20 @@ export class AutoModeService { }); } else { logger.error(`Feature ${featureId} failed:`, error); - await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + // Handle status update on server + // Scheduled features go back to 'scheduled', others go to 'backlog' + const { status, updatedSchedule } = await this.determineFinalStatusForAbort( + projectPath, + feature! + ); + if (updatedSchedule) { + await this.featureLoader.update(projectPath, featureId, { + status, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, status); + } this.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature?.title, @@ -1848,8 +1986,11 @@ Complete the pipeline step instructions above. Review the previous work and appl // Cancel any pending plan approval for this feature this.cancelPlanApproval(featureId); - - running.abortController.abort(); + try { + running.abortController.abort(); + } catch (error) { + logger.error(`Error aborting feature ${featureId}:`, error); + } // Remove from running features immediately to allow resume // The abort signal will still propagate to stop any ongoing execution @@ -2035,9 +2176,18 @@ Complete the pipeline step instructions above. Review the previous work and appl `[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline` ); - const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - - await this.updateFeatureStatus(projectPath, featureId, finalStatus); + const { status: finalStatus, updatedSchedule } = await this.determineFinalStatus( + projectPath, + feature + ); + if (updatedSchedule) { + await this.featureLoader.update(projectPath, featureId, { + status: finalStatus, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + } this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, @@ -2242,9 +2392,19 @@ Complete the pipeline step instructions above. Review the previous work and appl autoLoadClaudeMd ); - // Determine final status - const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Determine final status (considers schedule) + const { status: finalStatus, updatedSchedule } = await this.determineFinalStatus( + projectPath, + feature + ); + if (updatedSchedule) { + await this.featureLoader.update(projectPath, featureId, { + status: finalStatus, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + } logger.info(`Pipeline resume completed successfully for feature ${featureId}`); @@ -2253,13 +2413,30 @@ Complete the pipeline step instructions above. Review the previous work and appl featureName: feature.title, branchName: feature.branchName ?? null, passes: true, - message: 'Pipeline resumed and completed successfully', + message: + finalStatus === 'scheduled' + ? 'Pipeline resumed and scheduled for next run' + : 'Pipeline resumed and completed successfully', projectPath, }); } catch (error) { const errorInfo = classifyError(error); if (errorInfo.isAbort) { + // Handle status update on server (don't rely on UI) + // Scheduled features go back to 'scheduled', others go to 'backlog' + const { status, updatedSchedule } = await this.determineFinalStatusForAbort( + projectPath, + feature + ); + if (updatedSchedule) { + await this.featureLoader.update(projectPath, featureId, { + status, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, status); + } this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, @@ -2270,7 +2447,20 @@ Complete the pipeline step instructions above. Review the previous work and appl }); } else { logger.error(`Pipeline resume failed for feature ${featureId}:`, error); - await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + // Handle status update on server + // Scheduled features go back to 'scheduled', others go to 'backlog' + const { status, updatedSchedule } = await this.determineFinalStatusForAbort( + projectPath, + feature + ); + if (updatedSchedule) { + await this.featureLoader.update(projectPath, featureId, { + status, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, status); + } this.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature.title, @@ -2491,11 +2681,27 @@ Address the follow-up instructions above. Review the previous work and make the } ); - // Determine final status based on testing mode: + // Determine final status based on testing mode and schedule: + // - Features with schedule go to 'scheduled' // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=true (manual verification): go to 'waiting_approval' for manual review - const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; - await this.updateFeatureStatus(projectPath, featureId, finalStatus); + let finalStatus: FeatureStatusWithPipeline = feature?.skipTests + ? 'waiting_approval' + : 'verified'; + if (feature) { + const { status, updatedSchedule } = await this.determineFinalStatus(projectPath, feature); + finalStatus = status; + if (updatedSchedule) { + await this.featureLoader.update(projectPath, featureId, { + status: finalStatus, + schedule: updatedSchedule, + }); + } else { + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + } + } else { + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + } // Record success to reset consecutive failure tracking this.recordSuccess(); @@ -2505,7 +2711,7 @@ Address the follow-up instructions above. Review the previous work and make the featureName: feature?.title, branchName: branchName ?? null, passes: true, - message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`, + message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : finalStatus === 'scheduled' ? ' - scheduled for next run' : ''}`, projectPath, model, provider, diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index b40a85f07..b28ead090 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -451,6 +451,12 @@ export class FeatureLoader { descriptionHistory: updatedHistory, }; + // Explicitly remove schedule if it's undefined/null (to ensure it's removed from JSON) + // This handles the case where schedule is being explicitly cleared + if (updatedFeature.schedule === undefined || updatedFeature.schedule === null) { + delete (updatedFeature as Partial).schedule; + } + // Write back to file atomically with backup support const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); await atomicWriteJson(featureJsonPath, updatedFeature, { backupCount: DEFAULT_BACKUP_COUNT }); @@ -533,6 +539,20 @@ export class FeatureLoader { } } + /** + * Delete raw output for a feature + */ + async deleteRawOutput(projectPath: string, featureId: string): Promise { + try { + const rawOutputPath = this.getRawOutputPath(projectPath, featureId); + await secureFs.unlink(rawOutputPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + /** * Sync a completed feature to the app_spec.txt implemented_features section * diff --git a/apps/server/src/services/scheduler-service.ts b/apps/server/src/services/scheduler-service.ts new file mode 100644 index 000000000..33aeb1ddf --- /dev/null +++ b/apps/server/src/services/scheduler-service.ts @@ -0,0 +1,318 @@ +/** + * Scheduler Service - Manages recurring feature schedules + * + * This service: + * - Periodically checks for scheduled features that are due to run + * - Triggers execution when crontab schedules match + * - Updates schedule metadata (lastRun, nextRun, runCount) + */ + +import { CronExpressionParser } from 'cron-parser'; +import type { Feature, FeatureSchedule } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import { FeatureLoader } from './feature-loader.js'; +import { AutoModeService } from './auto-mode-service.js'; +import type { SettingsService } from './settings-service.js'; + +const logger = createLogger('SchedulerService'); + +// Check interval: 60 seconds +const CHECK_INTERVAL_MS = 60 * 1000; + +export class SchedulerService { + private events: EventEmitter; + private featureLoader: FeatureLoader; + private autoModeService: AutoModeService; + private settingsService: SettingsService; + private checkInterval: ReturnType | null = null; + private isRunning = false; + + constructor( + events: EventEmitter, + featureLoader: FeatureLoader, + autoModeService: AutoModeService, + settingsService: SettingsService + ) { + this.events = events; + this.featureLoader = featureLoader; + this.autoModeService = autoModeService; + this.settingsService = settingsService; + } + + /** + * Start the scheduler service + */ + start(): void { + if (this.isRunning) { + logger.warn('Scheduler already running'); + return; + } + + this.isRunning = true; + logger.info('Starting scheduler service'); + + // Run initial check + this.checkScheduledFeatures().catch((err) => { + logger.error('Error in initial schedule check:', err); + }); + + // Set up periodic checks + this.checkInterval = setInterval(() => { + this.checkScheduledFeatures().catch((err) => { + logger.error('Error in periodic schedule check:', err); + }); + }, CHECK_INTERVAL_MS); + } + + /** + * Stop the scheduler service + */ + stop(): void { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + this.isRunning = false; + logger.info('Scheduler service stopped'); + } + + /** + * Calculate the next run time from a crontab expression + */ + calculateNextRun(crontab: string, fromDate?: Date): Date | null { + try { + const interval = CronExpressionParser.parse(crontab, { + currentDate: fromDate || new Date(), + }); + return interval.next().toDate(); + } catch (err) { + logger.warn(`Invalid crontab expression: ${crontab}`, err); + return null; + } + } + + /** + * Check if a scheduled feature is due to run + */ + private isFeatureDue(schedule: FeatureSchedule): boolean { + if (!schedule.enabled || !schedule.nextRun) { + return false; + } + + const now = new Date(); + const nextRun = new Date(schedule.nextRun); + return now >= nextRun; + } + + /** + * Check all projects for scheduled features that need to run + */ + private async checkScheduledFeatures(): Promise { + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + const projects = globalSettings.projects || []; + + for (const project of projects) { + await this.checkProjectSchedules(project.path); + } + } catch (err) { + logger.error('Error checking scheduled features:', err); + } + } + + /** + * Check a single project for scheduled features + */ + private async checkProjectSchedules(projectPath: string): Promise { + try { + const features = await this.featureLoader.getAll(projectPath); + const scheduledFeatures = features.filter( + (f) => f.status === 'scheduled' && f.schedule?.enabled + ); + + if (scheduledFeatures.length === 0) { + return; + } + + logger.info(`Checking ${scheduledFeatures.length} scheduled features in ${projectPath}`); + + for (const feature of scheduledFeatures) { + const isDue = this.isFeatureDue(feature.schedule!); + const nextRun = feature.schedule?.nextRun + ? new Date(feature.schedule.nextRun).toISOString() + : 'not set'; + logger.debug(`Feature ${feature.id}: nextRun=${nextRun}, isDue=${isDue}`); + + if (isDue) { + await this.triggerScheduledFeature(projectPath, feature); + } + } + } catch (err) { + logger.error(`Error checking schedules for ${projectPath}:`, err); + } + } + + /** + * Trigger a scheduled feature to run + */ + private async triggerScheduledFeature(projectPath: string, feature: Feature): Promise { + logger.info(`Triggering scheduled feature: ${feature.title || feature.id}`); + + try { + // Check if the feature is already running (prevent concurrent execution) + const { runningFeatures } = this.autoModeService.getStatus(); + if (runningFeatures.includes(feature.id)) { + logger.warn(`Feature ${feature.id} is already running, skipping scheduled execution`); + return; + } + + // Check worktree capacity before starting + const capacity = await this.autoModeService.checkWorktreeCapacity(projectPath, feature.id); + if (!capacity.hasCapacity) { + logger.warn( + `Scheduler: Agent limit reached for feature ${feature.id}, will retry next cycle` + ); + return; + } + + // Clear agent output if keepPriorContext is false (start fresh) + // Default is true (keep context) if not specified + if (feature.schedule?.keepPriorContext === false) { + logger.info(`Clearing agent output for feature ${feature.id} (start fresh mode)`); + await this.featureLoader.deleteAgentOutput(projectPath, feature.id); + await this.featureLoader.deleteRawOutput(projectPath, feature.id); + } + + // Emit feature:started event for UI update + this.events.emit('feature:started', { + featureId: feature.id, + featureName: feature.title, + projectPath, + triggeredBy: 'scheduler', + }); + + // Directly execute the feature (this works even if auto-mode is off) + // Use worktrees based on the feature's branchName + const useWorktrees = !!feature.branchName; + + logger.info(`Scheduler starting execution of feature ${feature.id}`); + + // Execute in background - don't await + this.autoModeService + .executeFeature(projectPath, feature.id, useWorktrees, false) + .catch((err) => { + logger.error(`Scheduler: Feature ${feature.id} execution error:`, err); + }); + } catch (err) { + logger.error(`Error triggering scheduled feature ${feature.id}:`, err); + } + } + + /** + * Handle feature completion - update schedule if feature has one + * Called by auto-mode service when a feature completes + */ + async handleFeatureCompletion( + projectPath: string, + featureId: string, + feature: Feature + ): Promise<{ shouldMoveToScheduled: boolean; updatedSchedule?: FeatureSchedule }> { + if (!feature.schedule?.enabled) { + return { shouldMoveToScheduled: false }; + } + + const now = new Date(); + const nextRun = this.calculateNextRun(feature.schedule.crontab, now); + + if (!nextRun) { + logger.warn(`Could not calculate next run for feature ${featureId}`); + return { shouldMoveToScheduled: false }; + } + + const updatedSchedule: FeatureSchedule = { + ...feature.schedule, + lastRun: now.toISOString(), + nextRun: nextRun.toISOString(), + runCount: (feature.schedule.runCount || 0) + 1, + }; + + logger.info(`Feature ${featureId} completed with schedule. Next run: ${nextRun.toISOString()}`); + + return { + shouldMoveToScheduled: true, + updatedSchedule, + }; + } + + /** + * Recalculate next run times for all scheduled features + * Called on server startup to handle missed schedules + */ + async recalculateNextRunTimes(): Promise { + logger.info('Recalculating next run times for scheduled features'); + + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + const projects = globalSettings.projects || []; + + for (const project of projects) { + await this.recalculateProjectSchedules(project.path); + } + } catch (err) { + logger.error('Error recalculating next run times:', err); + } + } + + /** + * Recalculate next run times for a single project + * Note: If a feature's nextRun is in the past, we keep it so it gets executed + */ + private async recalculateProjectSchedules(projectPath: string): Promise { + try { + const features = await this.featureLoader.getAll(projectPath); + const scheduledFeatures = features.filter( + (f) => f.status === 'scheduled' && f.schedule?.enabled + ); + + const now = new Date(); + + for (const feature of scheduledFeatures) { + // If nextRun is in the past, keep it so scheduler will trigger it + if (feature.schedule?.nextRun) { + const existingNextRun = new Date(feature.schedule.nextRun); + if (existingNextRun <= now) { + logger.info( + `Feature ${feature.id} has past-due schedule (${existingNextRun.toISOString()}), will be triggered` + ); + continue; // Don't update, let the scheduler pick it up + } + } + + // Only recalculate if nextRun is missing or in the future + const nextRun = this.calculateNextRun(feature.schedule!.crontab); + if (nextRun) { + await this.featureLoader.update(projectPath, feature.id, { + schedule: { + ...feature.schedule!, + nextRun: nextRun.toISOString(), + }, + }); + } + } + } catch (err) { + logger.error(`Error recalculating schedules for ${projectPath}:`, err); + } + } +} + +// Singleton instance (created and managed by server index) +let schedulerService: SchedulerService | null = null; + +export function getSchedulerService(): SchedulerService | null { + return schedulerService; +} + +export function setSchedulerService(service: SchedulerService): void { + schedulerService = service; +} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 03b2b0f54..1f5156020 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -207,6 +207,14 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ (freshPlanSpec?.tasks?.length ?? 0) > 0 || (feature.planSpec?.tasks?.length ?? 0) > 0; const shouldListenToEvents = feature.status === 'in_progress' && hasPlanSpecTasks; + // Clear taskStatusMap when the feature is no longer running + // This prevents stale "in_progress" task states from showing after the feature is stopped + useEffect(() => { + if (!isCurrentAutoTask) { + setTaskStatusMap((prev) => (prev.size > 0 ? new Map() : prev)); + } + }, [isCurrentAutoTask]); + useEffect(() => { if (!shouldListenToEvents) return; @@ -249,8 +257,12 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ return unsubscribe; }, [feature.id, shouldListenToEvents]); - // Model/Preset Info for Backlog Cards - if (feature.status === 'backlog') { + // Model/Preset Info for Backlog Cards and non-running Scheduled Cards + // Scheduled features that aren't running shouldn't show stale task lists from previous runs + const showSimpleModelInfo = + feature.status === 'backlog' || (feature.status === 'scheduled' && !isCurrentAutoTask); + + if (showSimpleModelInfo) { const provider = getProviderFromModel(feature.model); const isCodex = provider === 'codex'; const isClaude = provider === 'claude'; @@ -286,11 +298,10 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ ); } - // Agent Info Panel for non-backlog cards + // Agent Info Panel for active/completed cards // Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode) // Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec - // (The backlog case was already handled above and returned early) - if (agentInfo || hasPlanSpecTasks) { + if (feature.status !== 'backlog' && !showSimpleModelInfo && (agentInfo || hasPlanSpecTasks)) { return ( <>
@@ -308,11 +319,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ className={cn( 'px-1.5 py-0.5 rounded-md text-[10px] font-medium', agentInfo.currentPhase === 'planning' && - 'bg-[var(--status-info-bg)] text-[var(--status-info)]', + 'bg-[var(--status-info-bg)] text-[var(--status-info)]', agentInfo.currentPhase === 'action' && - 'bg-[var(--status-warning-bg)] text-[var(--status-warning)]', + 'bg-[var(--status-warning-bg)] text-[var(--status-warning)]', agentInfo.currentPhase === 'verification' && - 'bg-[var(--status-success-bg)] text-[var(--status-success)]' + 'bg-[var(--status-success-bg)] text-[var(--status-success)]' )} > {agentInfo.currentPhase} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 9348a3217..af619eeff 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -293,7 +293,7 @@ export const CardActions = memo(function CardActions({ ) : null} )} - {!isCurrentAutoTask && feature.status === 'backlog' && ( + {!isCurrentAutoTask && (feature.status === 'backlog' || feature.status === 'scheduled') && ( <> + {/* View Logs button for scheduled features with prior runs */} + {feature.status === 'scheduled' && onViewOutput && ( + + )} {feature.planSpec?.content && onViewPlan && ( )} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx index d9df8ad9e..4c7199cac 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-badges.tsx @@ -3,7 +3,7 @@ import { memo, useEffect, useMemo, useState } from 'react'; import { Feature, useAppStore } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react'; +import { AlertCircle, Lock, Hand, Sparkles, SkipForward, CalendarClock } from 'lucide-react'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; import { useShallow } from 'zustand/react/shallow'; import { usePipelineConfig } from '@/hooks/queries/use-pipeline'; @@ -12,6 +12,31 @@ import { usePipelineConfig } from '@/hooks/queries/use-pipeline'; const uniformBadgeClass = 'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px]'; +/** Format next run time for schedule badge tooltip */ +function formatNextRun(nextRun: string): string { + const date = new Date(nextRun); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return 'Due now'; + if (diff < 60 * 1000) return 'In less than a minute'; + if (diff < 60 * 60 * 1000) { + const minutes = Math.round(diff / (60 * 1000)); + return `In ${minutes} minute${minutes === 1 ? '' : 's'}`; + } + if (diff < 24 * 60 * 60 * 1000) { + const hours = Math.round(diff / (60 * 60 * 1000)); + return `In ${hours} hour${hours === 1 ? '' : 's'}`; + } + + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + interface CardBadgesProps { feature: Feature; } @@ -111,6 +136,7 @@ export const PriorityBadges = memo(function PriorityBadges({ blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog'; const showManualVerification = feature.skipTests && !feature.error && feature.status === 'backlog'; + const hasSchedule = feature.schedule?.enabled; // Check if feature has excluded pipeline steps const excludedStepCount = feature.excludedPipelineSteps?.length || 0; @@ -124,6 +150,7 @@ export const PriorityBadges = memo(function PriorityBadges({ showManualVerification || isBlocked || isJustFinished || + hasSchedule || hasPipelineExclusions; if (!showBadges) { @@ -140,11 +167,11 @@ export const PriorityBadges = memo(function PriorityBadges({ className={cn( uniformBadgeClass, feature.priority === 1 && - 'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]', + 'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]', feature.priority === 2 && - 'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]', + 'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]', feature.priority === 3 && - 'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]' + 'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]' )} data-testid={`priority-badge-${feature.id}`} > @@ -266,6 +293,36 @@ export const PriorityBadges = memo(function PriorityBadges({ )} + + {/* Schedule badge */} + {hasSchedule && ( + + +
+ +
+
+ +

Recurring Schedule

+ {feature.schedule?.nextRun && ( +

+ Next: {formatNextRun(feature.schedule.nextRun)} +

+ )} + {feature.schedule?.lastRun && ( +

+ Last: {new Date(feature.schedule.lastRun).toLocaleString()} +

+ )} +
+
+ )}
); }); diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx index 1846c3a5c..01f520caf 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-content-sections.tsx @@ -1,13 +1,40 @@ // @ts-nocheck - content section prop typing with feature data extraction import { memo } from 'react'; import { Feature } from '@/store/app-store'; -import { GitBranch, GitPullRequest, ExternalLink } from 'lucide-react'; +import { GitBranch, GitPullRequest, ExternalLink, CalendarClock } from 'lucide-react'; interface CardContentSectionsProps { feature: Feature; useWorktrees: boolean; } +/** Format next run time for display on card */ +function formatNextRunTime(nextRun: string): string { + const date = new Date(nextRun); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return 'Due now'; + if (diff < 60 * 1000) return 'In < 1 min'; + if (diff < 60 * 60 * 1000) { + const minutes = Math.round(diff / (60 * 1000)); + return `In ${minutes} min`; + } + if (diff < 24 * 60 * 60 * 1000) { + const hours = Math.round(diff / (60 * 60 * 1000)); + return `In ${hours}h`; + } + if (diff < 7 * 24 * 60 * 60 * 1000) { + const days = Math.round(diff / (24 * 60 * 60 * 1000)); + return `In ${days}d`; + } + + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); +} + export const CardContentSections = memo(function CardContentSections({ feature, useWorktrees, @@ -24,6 +51,17 @@ export const CardContentSections = memo(function CardContentSections({ )} + {/* Next Run Time Display for scheduled features */} + {feature.schedule?.enabled && feature.schedule?.nextRun && ( +
+ + Next: {formatNextRunTime(feature.schedule.nextRun)} +
+ )} + {/* PR URL Display */} {typeof feature.prUrl === 'string' && /^https?:\/\//i.test(feature.prUrl) && diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index e3575c55a..8a0c4ec3a 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -167,81 +167,83 @@ export const CardHeaderSection = memo(function CardHeaderSection({ )} - {/* Backlog header */} - {!isCurrentAutoTask && !isSelectionMode && feature.status === 'backlog' && ( -
- - - - - - - { - e.stopPropagation(); - onSpawnTask?.(); - }} - data-testid={`spawn-backlog-${feature.id}`} - className="text-xs" - > - - Spawn Sub-Task - - {onDuplicate && ( - -
- { - e.stopPropagation(); - onDuplicate(); - }} - className="text-xs flex-1 pr-0 rounded-r-none" - > - - Duplicate - - {onDuplicateAsChild && ( - - )} -
- {onDuplicateAsChild && ( - + {/* Backlog and Scheduled header */} + {!isCurrentAutoTask && + !isSelectionMode && + (feature.status === 'backlog' || feature.status === 'scheduled') && ( +
+ + + + + + + { + e.stopPropagation(); + onSpawnTask?.(); + }} + data-testid={`spawn-${feature.status}-${feature.id}`} + className="text-xs" + > + + Spawn Sub-Task + + {onDuplicate && ( + +
{ e.stopPropagation(); - onDuplicateAsChild(); + onDuplicate(); }} - className="text-xs" + className="text-xs flex-1 pr-0 rounded-r-none" > - - Duplicate as Child + + Duplicate - - )} - - )} - - -
- )} + {onDuplicateAsChild && ( + + )} +
+ {onDuplicateAsChild && ( + + { + e.stopPropagation(); + onDuplicateAsChild(); + }} + className="text-xs" + > + + Duplicate as Child + + + )} +
+ )} +
+
+
+ )} {/* Waiting approval / Verified header */} {!isCurrentAutoTask && diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index 60158d0fd..a86f511c4 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -133,11 +133,11 @@ function getPrimaryAction( return null; } - // Backlog - implement is primary - if (feature.status === 'backlog' && handlers.onImplement) { + // Backlog or Scheduled - implement is primary + if ((feature.status === 'backlog' || feature.status === 'scheduled') && handlers.onImplement) { return { icon: PlayCircle, - label: 'Make', + label: feature.status === 'scheduled' ? 'Run Now' : 'Make', onClick: handlers.onImplement, variant: 'primary', }; @@ -389,62 +389,63 @@ export const RowActions = memo(function RowActions({ )} - {/* Backlog actions */} - {!isCurrentAutoTask && feature.status === 'backlog' && ( - <> - - {feature.planSpec?.content && handlers.onViewPlan && ( - - )} - {handlers.onImplement && ( - - )} - {handlers.onSpawnTask && ( - - )} - {handlers.onDuplicate && ( - -
- - - Duplicate - + {/* Backlog and Scheduled actions */} + {!isCurrentAutoTask && + (feature.status === 'backlog' || feature.status === 'scheduled') && ( + <> + + {feature.planSpec?.content && handlers.onViewPlan && ( + + )} + {handlers.onImplement && ( + + )} + {handlers.onSpawnTask && ( + + )} + {handlers.onDuplicate && ( + +
+ + + Duplicate + + {handlers.onDuplicateAsChild && ( + + )} +
{handlers.onDuplicateAsChild && ( - + + + )} -
- {handlers.onDuplicateAsChild && ( - - - - )} -
- )} - - - - )} + + )} + + + + )} {/* In Progress actions */} {!isCurrentAutoTask && feature.status === 'in_progress' && ( diff --git a/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx index a5ddca97f..c935fee72 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx @@ -207,6 +207,7 @@ export function getStatusOrder(status: FeatureStatusWithPipeline): number { in_progress: 1, waiting_approval: 2, verified: 3, + scheduled: 4, }; if (isPipelineStatus(status)) { diff --git a/apps/ui/src/components/views/board-view/constants.ts b/apps/ui/src/components/views/board-view/constants.ts index fda19ebfb..fdf737afa 100644 --- a/apps/ui/src/components/views/board-view/constants.ts +++ b/apps/ui/src/components/views/board-view/constants.ts @@ -48,6 +48,11 @@ export const EMPTY_STATE_CONFIGS: Record = { description: 'Approved features will appear here. They can then be completed and archived.', icon: 'check', }, + scheduled: { + title: 'No Scheduled Features', + description: 'Features with recurring schedules will appear here between runs.', + icon: 'clock', + }, // Pipeline step default configuration pipeline_default: { title: 'Pipeline Step Empty', @@ -96,6 +101,11 @@ const END_COLUMNS: Column[] = [ title: 'Verified', colorClass: 'bg-[var(--status-success)]', }, + { + id: 'scheduled', + title: 'Scheduled', + colorClass: 'bg-[var(--status-scheduled)]', + }, ]; // Static COLUMNS for backwards compatibility (no pipeline) diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index b8dd8776c..4d2cf6d14 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -21,14 +21,26 @@ import { FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; -import { Play, Cpu, FolderKanban, Settings2 } from 'lucide-react'; +import { Play, Cpu, FolderKanban, Settings2, CalendarClock } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { modelSupportsThinking } from '@/lib/utils'; -import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store'; -import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types'; -import { supportsReasoningEffort } from '@automaker/types'; +import { + useAppStore, + ModelAlias, + ThinkingLevel, + FeatureImage, + PlanningMode, + Feature, +} from '@/store/app-store'; +import type { + ReasoningEffort, + PhaseModelEntry, + AgentModel, + FeatureSchedule +} from '@automaker/types'; +import { supportsReasoningEffort, isClaudeModel } from '@automaker/types'; import { PrioritySelector, WorkModeSelector, @@ -37,6 +49,7 @@ import { EnhanceWithAI, EnhancementHistoryButton, PipelineExclusionControls, + ScheduleSelector, type BaseHistoryEntry, } from '../shared'; import type { WorkMode } from '../shared'; @@ -93,6 +106,7 @@ type FeatureData = { childDependencies?: string[]; // Feature IDs that should depend on this feature excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature workMode: WorkMode; + schedule?: FeatureSchedule; }; interface AddFeatureDialogProps { @@ -190,6 +204,9 @@ export function AddFeatureDialog({ // Pipeline exclusion state const [excludedPipelineSteps, setExcludedPipelineSteps] = useState([]); + // Schedule state (optional) - null means explicitly no schedule + const [schedule, setSchedule] = useState(undefined); + // Get defaults from store const { defaultPlanningMode, @@ -239,6 +256,9 @@ export function AddFeatureDialog({ setParentDependencies([]); setChildDependencies([]); + // Reset schedule + setSchedule(undefined); + // Reset pipeline exclusions (all pipelines enabled by default) setExcludedPipelineSteps([]); } @@ -344,6 +364,7 @@ export function AddFeatureDialog({ childDependencies: childDependencies.length > 0 ? childDependencies : undefined, excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined, workMode, + schedule, }; }; @@ -369,6 +390,7 @@ export function AddFeatureDialog({ setDescriptionHistory([]); setParentDependencies([]); setChildDependencies([]); + setSchedule(undefined); setExcludedPipelineSteps([]); onOpenChange(false); }; @@ -687,6 +709,19 @@ export function AddFeatureDialog({ /> + + {/* Recurring Schedule Section (optional) */} +
+
+ + Recurring Schedule (Optional) +
+ +
diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index e7a6b5ec8..a57842304 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -21,12 +21,17 @@ import { FeatureTextFilePath as DescriptionTextFilePath, ImagePreviewMap, } from '@/components/ui/description-image-dropzone'; -import { GitBranch, Cpu, FolderKanban, Settings2 } from 'lucide-react'; +import { GitBranch, Cpu, FolderKanban, Settings2, CalendarClock } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; import { cn, modelSupportsThinking } from '@/lib/utils'; -import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store'; -import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types'; +import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store'; +import type { + ReasoningEffort, + PhaseModelEntry, + DescriptionHistoryEntry, + FeatureSchedule +} from '@automaker/types'; import { migrateModelId } from '@automaker/types'; import { PrioritySelector, @@ -35,6 +40,7 @@ import { EnhanceWithAI, EnhancementHistoryButton, PipelineExclusionControls, + ScheduleSelector, type EnhancementMode, } from '../shared'; import type { WorkMode } from '../shared'; @@ -148,6 +154,9 @@ export function EditFeatureDialog({ feature?.excludedPipelineSteps ?? [] ); + // Schedule state - null means explicitly remove schedule (survives JSON serialization) + const [schedule, setSchedule] = useState(feature?.schedule); + useEffect(() => { setEditingFeature(feature); if (feature) { @@ -175,6 +184,8 @@ export function EditFeatureDialog({ setOriginalChildDependencies(childDeps); // Reset pipeline exclusion state setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []); + // Reset schedule state + setSchedule(feature.schedule); } else { setEditFeaturePreviewMap(new Map()); setDescriptionChangeSource(null); @@ -184,6 +195,7 @@ export function EditFeatureDialog({ setChildDependencies([]); setOriginalChildDependencies([]); setExcludedPipelineSteps([]); + setSchedule(undefined); } }, [feature, allFeatures]); @@ -245,6 +257,7 @@ export function EditFeatureDialog({ dependencies: parentDependencies, childDependencies: childDepsChanged ? childDependencies : undefined, excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined, + schedule, }; // Determine if description changed and what source to use @@ -605,6 +618,18 @@ export function EditFeatureDialog({ testIdPrefix="edit-feature-pipeline" /> + {/* Schedule Section */} +
+
+ + Recurring Schedule +
+ +
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 4f3c05179..ee2597181 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -120,6 +120,7 @@ export function useBoardActions({ dependencies?: string[]; childDependencies?: string[]; // Feature IDs that should depend on this feature workMode?: 'current' | 'auto' | 'custom'; + schedule?: import('@automaker/types').FeatureSchedule; }) => { const workMode = featureData.workMode || 'current'; @@ -219,9 +220,11 @@ export function useBoardActions({ ...featureData, title: titleWasGenerated ? titleForBranch : featureData.title, titleGenerating: needsTitleGeneration, - status: 'backlog' as const, + // Status will be set by server: 'scheduled' if schedule is enabled, otherwise 'backlog' + status: (featureData.schedule?.enabled ? 'scheduled' : 'backlog') as const, branchName: finalBranchName, dependencies: featureData.dependencies || [], + schedule: featureData.schedule, }; const createdFeature = addFeature(newFeatureData); // Must await to ensure feature exists on server before user can drag it @@ -934,15 +937,24 @@ export function useBoardActions({ try { await autoMode.stopFeature(feature.id); - const targetStatus = - feature.skipTests && feature.status === 'waiting_approval' - ? 'waiting_approval' - : 'backlog'; + // Determine the target status when stopping (for UI feedback): + // - waiting_approval features that are skipTests stay in waiting_approval + // - scheduled features (with enabled schedule) go back to scheduled + // - everything else goes to backlog + // Note: Server handles the actual status update, this is just for optimistic UI update + let targetStatus: 'waiting_approval' | 'scheduled' | 'backlog'; + if (feature.skipTests && feature.status === 'waiting_approval') { + targetStatus = 'waiting_approval'; + } else if (feature.schedule?.enabled) { + targetStatus = 'scheduled'; + } else { + targetStatus = 'backlog'; + } + // Update local state for immediate UI feedback + // Server handles the persistent status update via abort handler if (targetStatus !== feature.status) { moveFeature(feature.id, targetStatus); - // Must await to ensure file is written before user can restart - await persistFeatureUpdate(feature.id, { status: targetStatus }); } toast.success('Agent stopped', { @@ -951,7 +963,9 @@ export function useBoardActions({ ? `Stopped commit - returned to waiting approval: ${truncateDescription( feature.description )}` - : `Stopped working on: ${truncateDescription(feature.description)}`, + : targetStatus === 'scheduled' + ? `Stopped - returned to scheduled: ${truncateDescription(feature.description)}` + : `Stopped working on: ${truncateDescription(feature.description)}`, }); } catch (error) { logger.error('Error stopping feature:', error); @@ -960,7 +974,7 @@ export function useBoardActions({ }); } }, - [autoMode, moveFeature, persistFeatureUpdate] + [autoMode, moveFeature] ); const handleStartNextFeatures = useCallback(async () => { diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 508cb9483..54ffcc0f0 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -34,6 +34,7 @@ export function useBoardColumnFeatures({ in_progress: [], waiting_approval: [], verified: [], + scheduled: [], // Features with recurring schedules completed: [], // Completed features are shown in the archive modal, not as a column }; const featureMap = createFeatureMap(features); @@ -128,9 +129,10 @@ export function useBoardColumnFeatures({ return; } - // If it's running and has a known non-backlog status, keep it in that status. + // If it's running and has a known non-backlog/non-scheduled status, keep it in that status. + // Running scheduled features should move to in_progress (they're actively running, not waiting). // Otherwise, fallback to in_progress as the "active work" column. - if (status !== 'backlog' && map[status]) { + if (status !== 'backlog' && status !== 'scheduled' && map[status]) { map[status].push(f); } else { map.in_progress.push(f); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index dd00e3e0f..522216ec4 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -190,8 +190,8 @@ export function useBoardDragDrop({ // Handle different drag scenarios // Note: Worktrees are created server-side at execution time based on feature.branchName - if (draggedFeature.status === 'backlog') { - // From backlog + if (draggedFeature.status === 'backlog' || draggedFeature.status === 'scheduled') { + // From backlog or scheduled if (targetStatus === 'in_progress') { // Use helper function to handle concurrency check and start implementation // Server will derive workDir from feature.branchName diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index ef0918721..719ee5917 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -303,7 +303,26 @@ export function KanbanBoard({ className, }: KanbanBoardProps) { // Generate columns including pipeline steps - const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]); + // Filter out scheduled column if it has no features (and no features have schedules) + const columns = useMemo(() => { + const allColumns = getColumnsWithPipeline(pipelineConfig); + const scheduledFeatures = getColumnFeatures('scheduled' as ColumnId); + + // Check if any features in other columns have schedules + const hasScheduledFeatures = + scheduledFeatures.length > 0 || + ['backlog', 'in_progress', 'waiting_approval', 'verified'].some((colId) => { + const features = getColumnFeatures(colId as ColumnId); + return features.some((f) => f.schedule?.enabled); + }); + + // Hide scheduled column if there are no scheduled features + if (!hasScheduledFeatures) { + return allColumns.filter((col) => col.id !== 'scheduled'); + } + + return allColumns; + }, [pipelineConfig, getColumnFeatures]); // Get the keyboard shortcut for adding features const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); diff --git a/apps/ui/src/components/views/board-view/shared/index.ts b/apps/ui/src/components/views/board-view/shared/index.ts index f8da6b08c..4d2b24576 100644 --- a/apps/ui/src/components/views/board-view/shared/index.ts +++ b/apps/ui/src/components/views/board-view/shared/index.ts @@ -12,3 +12,4 @@ export * from './ancestor-context-section'; export * from './work-mode-selector'; export * from './enhancement'; export * from './pipeline-exclusion-controls'; +export * from './schedule-selector'; diff --git a/apps/ui/src/components/views/board-view/shared/schedule-selector.tsx b/apps/ui/src/components/views/board-view/shared/schedule-selector.tsx new file mode 100644 index 000000000..0f35c5539 --- /dev/null +++ b/apps/ui/src/components/views/board-view/shared/schedule-selector.tsx @@ -0,0 +1,442 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Clock, CalendarClock, RefreshCw, Calendar, Timer } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import type { FeatureSchedule, SchedulePreset } from '@automaker/types'; + +interface ScheduleSelectorProps { + value: FeatureSchedule | undefined | null; + onChange: (schedule: FeatureSchedule | null) => void; + disabled?: boolean; + testIdPrefix?: string; +} + +const SCHEDULE_PRESETS: Record, string> = { + hourly: '0 * * * *', + daily: '0 9 * * *', + weekly: '0 9 * * 1', + monthly: '0 9 1 * *', +}; + +const PRESET_OPTIONS = [ + { + value: 'none' as const, + label: 'No Schedule', + description: 'Feature runs once', + icon: Clock, + color: 'text-muted-foreground', + }, + { + value: 'hourly' as const, + label: 'Hourly', + description: 'Every hour at :00', + icon: Timer, + color: 'text-blue-500', + }, + { + value: 'daily' as const, + label: 'Daily', + description: 'Every day at 9:00 AM', + icon: CalendarClock, + color: 'text-green-500', + }, + { + value: 'weekly' as const, + label: 'Weekly', + description: 'Every Monday at 9:00 AM', + icon: Calendar, + color: 'text-purple-500', + }, + { + value: 'monthly' as const, + label: 'Monthly', + description: '1st of each month at 9:00 AM', + icon: RefreshCw, + color: 'text-amber-500', + }, + { + value: 'custom' as const, + label: 'Custom', + description: 'Enter a crontab expression', + icon: Clock, + color: 'text-cyan-500', + }, +]; + +/** + * Get human-readable description of a crontab expression + */ +function describeCrontab(crontab: string): string { + const parts = crontab.trim().split(/\s+/); + if (parts.length !== 5) return 'Invalid crontab format'; + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + + // Check against known presets + if (crontab === SCHEDULE_PRESETS.hourly) return 'Every hour at :00'; + if (crontab === SCHEDULE_PRESETS.daily) return 'Every day at 9:00 AM'; + if (crontab === SCHEDULE_PRESETS.weekly) return 'Every Monday at 9:00 AM'; + if (crontab === SCHEDULE_PRESETS.monthly) return '1st of each month at 9:00 AM'; + + // Build description for custom crontabs + let desc = ''; + + // Check for common patterns + if (minute === '0' && hour === '*') { + desc = 'Every hour'; + } else if (minute === '*' && hour === '*') { + desc = 'Every minute'; + } else if (hour !== '*' && minute !== '*') { + const hourNum = parseInt(hour, 10); + const minuteNum = parseInt(minute, 10); + if (!isNaN(hourNum) && !isNaN(minuteNum)) { + const ampm = hourNum >= 12 ? 'PM' : 'AM'; + const hour12 = hourNum % 12 || 12; + const minStr = minuteNum.toString().padStart(2, '0'); + desc = `At ${hour12}:${minStr} ${ampm}`; + } + } + + if (dayOfWeek !== '*' && dayOfWeek !== '?') { + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const dayNum = parseInt(dayOfWeek, 10); + if (!isNaN(dayNum) && dayNum >= 0 && dayNum <= 6) { + desc += ` on ${days[dayNum]}`; + } + } + + if (dayOfMonth !== '*' && dayOfMonth !== '?') { + const day = parseInt(dayOfMonth, 10); + if (!isNaN(day)) { + const suffix = day === 1 ? 'st' : day === 2 ? 'nd' : day === 3 ? 'rd' : 'th'; + desc += ` on the ${day}${suffix}`; + } + } + + if (month !== '*') { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const monthNum = parseInt(month, 10); + if (!isNaN(monthNum) && monthNum >= 1 && monthNum <= 12) { + desc += ` in ${months[monthNum - 1]}`; + } + } + + return desc || `Cron: ${crontab}`; +} + +/** + * Detect which preset matches a crontab, or 'custom' if none match + */ +function detectPreset(crontab: string | undefined): SchedulePreset | 'none' { + if (!crontab) return 'none'; + + for (const [preset, value] of Object.entries(SCHEDULE_PRESETS)) { + if (value === crontab) { + return preset as SchedulePreset; + } + } + return 'custom'; +} + +/** + * Validate a crontab expression (basic validation) + */ +function validateCrontab(crontab: string): { valid: boolean; error?: string } { + const parts = crontab.trim().split(/\s+/); + if (parts.length !== 5) { + return { valid: false, error: 'Crontab must have 5 fields (minute hour day month weekday)' }; + } + + const ranges = [ + { min: 0, max: 59, name: 'minute' }, + { min: 0, max: 23, name: 'hour' }, + { min: 1, max: 31, name: 'day' }, + { min: 1, max: 12, name: 'month' }, + { min: 0, max: 6, name: 'weekday' }, + ]; + + for (let i = 0; i < 5; i++) { + const part = parts[i]; + const range = ranges[i]; + + if (part === '*') continue; + + // Handle ranges like 1-5 + if (part.includes('-')) { + const [start, end] = part.split('-').map(Number); + if (isNaN(start) || isNaN(end) || start < range.min || end > range.max || start > end) { + return { valid: false, error: `Invalid ${range.name} range: ${part}` }; + } + continue; + } + + // Handle step values like */5 + if (part.includes('/')) { + const [base, step] = part.split('/'); + if (base !== '*' && isNaN(Number(base))) { + return { valid: false, error: `Invalid ${range.name} step base: ${part}` }; + } + if (isNaN(Number(step)) || Number(step) <= 0) { + return { valid: false, error: `Invalid ${range.name} step: ${part}` }; + } + continue; + } + + // Handle lists like 1,3,5 + if (part.includes(',')) { + const values = part.split(',').map(Number); + for (const val of values) { + if (isNaN(val) || val < range.min || val > range.max) { + return { valid: false, error: `Invalid ${range.name} value in list: ${val}` }; + } + } + continue; + } + + // Single value + const num = Number(part); + if (isNaN(num) || num < range.min || num > range.max) { + return { + valid: false, + error: `Invalid ${range.name}: ${part} (must be ${range.min}-${range.max})`, + }; + } + } + + return { valid: true }; +} + +export function ScheduleSelector({ + value, + onChange, + disabled = false, + testIdPrefix = 'schedule', +}: ScheduleSelectorProps) { + const [preset, setPreset] = useState(() => detectPreset(value?.crontab)); + const [customCrontab, setCustomCrontab] = useState( + preset === 'custom' ? (value?.crontab ?? '') : '' + ); + const [validationError, setValidationError] = useState(); + + // Update local state when value changes from outside + useEffect(() => { + const detectedPreset = detectPreset(value?.crontab); + setPreset(detectedPreset); + if (detectedPreset === 'custom' && value?.crontab) { + setCustomCrontab(value.crontab); + } + }, [value?.crontab]); + + const handlePresetChange = useCallback( + (newPreset: SchedulePreset | 'none') => { + setPreset(newPreset); + setValidationError(undefined); + + if (newPreset === 'none') { + // Use null instead of undefined so it survives JSON serialization + // and the server can detect that schedule is being explicitly removed + onChange(null); + return; + } + + // Preserve existing keepPriorContext value, default to true for new schedules + const keepPriorContext = value?.keepPriorContext ?? true; + + if (newPreset === 'custom') { + // Use existing crontab or default to midnight daily (0 0 * * *) + // Note: Using 0 0 instead of 0 * to avoid matching the 'hourly' preset + const crontabToUse = customCrontab || '0 0 * * *'; + setCustomCrontab(crontabToUse); + const validation = validateCrontab(crontabToUse); + if (validation.valid) { + onChange({ + crontab: crontabToUse, + enabled: true, + keepPriorContext, + }); + } else { + setValidationError(validation.error); + } + return; + } + + // Set preset crontab + const crontab = SCHEDULE_PRESETS[newPreset]; + onChange({ + crontab, + enabled: true, + keepPriorContext, + }); + }, + [onChange, customCrontab, value?.keepPriorContext] + ); + + const handleCustomCrontabChange = useCallback( + (crontab: string) => { + setCustomCrontab(crontab); + + if (!crontab.trim()) { + setValidationError(undefined); + return; + } + + const validation = validateCrontab(crontab); + if (validation.valid) { + setValidationError(undefined); + onChange({ + crontab, + enabled: true, + keepPriorContext: value?.keepPriorContext ?? true, + }); + } else { + setValidationError(validation.error); + } + }, + [onChange, value?.keepPriorContext] + ); + + const handleKeepPriorContextChange = useCallback( + (keepPriorContext: boolean) => { + if (!value) return; + onChange({ + ...value, + keepPriorContext, + }); + }, + [onChange, value] + ); + + const selectedOption = PRESET_OPTIONS.find((o) => o.value === preset); + const currentCrontab = + preset === 'custom' ? customCrontab : preset !== 'none' ? SCHEDULE_PRESETS[preset] : undefined; + + return ( +
+ {/* Preset Selector */} +
+ + +
+ + {/* Custom Crontab Input */} + {preset === 'custom' && ( +
+ + handleCustomCrontabChange(e.target.value)} + placeholder="* * * * * (min hour day month weekday)" + disabled={disabled} + className={cn(validationError && 'border-red-500')} + data-testid={`${testIdPrefix}-crontab-input`} + /> + {validationError &&

{validationError}

} +

+ Format: minute (0-59) hour (0-23) day (1-31) month (1-12) weekday (0-6, Sun=0) +

+
+ )} + + {/* Schedule Preview */} + {preset !== 'none' && currentCrontab && !validationError && ( +
+
+ + {describeCrontab(currentCrontab)} +
+
+ )} + + {/* Keep Prior Context Toggle */} + {preset !== 'none' && !validationError && ( +
+
+ +

+ When off, agent output is cleared before each scheduled run +

+
+ +
+ )} + + {/* Last/Next Run Info */} + {value?.lastRun && ( +

+ Last run: {new Date(value.lastRun).toLocaleString()} + {value.runCount !== undefined && ` (${value.runCount} runs)`} +

+ )} + {value?.nextRun && value.enabled && ( +

+ Next run: {new Date(value.nextRun).toLocaleString()} +

+ )} +
+ ); +} diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index cb6834176..19e7d1392 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -1,9 +1,11 @@ import { useEffect, useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; +import { useQueryClient } from '@tanstack/react-query'; import { createLogger } from '@automaker/utils/logger'; import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { getElectronAPI } from '@/lib/electron'; +import { queryKeys } from '@/lib/query-keys'; import type { AutoModeEvent } from '@/types/electron'; import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types'; import { getGlobalEventsRecent } from '@/hooks/use-event-recency'; @@ -72,6 +74,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { setAutoModeRunning, addRunningTask, removeRunningTask, + removeRunningTaskFromAllWorktrees, currentProject, addAutoModeActivity, projects, @@ -86,6 +89,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { setAutoModeRunning: state.setAutoModeRunning, addRunningTask: state.addRunningTask, removeRunningTask: state.removeRunningTask, + removeRunningTaskFromAllWorktrees: state.removeRunningTaskFromAllWorktrees, currentProject: state.currentProject, addAutoModeActivity: state.addAutoModeActivity, projects: state.projects, @@ -97,6 +101,9 @@ export function useAutoMode(worktree?: WorktreeInfo) { })) ); + // Get query client for invalidating features when events are received + const queryClient = useQueryClient(); + // Derive branchName from worktree: // If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch) // If not provided, default to null (main worktree default) @@ -290,14 +297,23 @@ export function useAutoMode(worktree?: WorktreeInfo) { type: 'start', message: `Started working on feature`, }); + // Invalidate features query to refresh feature status (e.g., scheduled -> in_progress) + // This ensures UI updates when scheduler triggers a feature + const projectPath = 'projectPath' in event ? event.projectPath : currentProject?.path; + if (projectPath) { + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + } } break; case 'auto_mode_feature_complete': - // Feature completed - remove from running tasks and UI will reload features on its own + // Feature completed - remove from running tasks and refresh features if (event.featureId) { logger.info('Feature completed:', event.featureId, 'passes:', event.passes); - removeRunningTask(eventProjectId, eventBranchName, event.featureId); + // Use removeFromAllWorktrees to ensure removal regardless of worktree state + if (eventProjectId) { + removeRunningTaskFromAllWorktrees(eventProjectId, event.featureId); + } addAutoModeActivity({ featureId: event.featureId, type: 'complete', @@ -306,6 +322,12 @@ export function useAutoMode(worktree?: WorktreeInfo) { : 'Feature completed with failures', passes: event.passes, }); + // Invalidate features query to refresh feature status + // This ensures scheduled features move back to 'scheduled' column after completion + const projectPath = 'projectPath' in event ? event.projectPath : currentProject?.path; + if (projectPath) { + queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) }); + } } break; @@ -315,9 +337,9 @@ export function useAutoMode(worktree?: WorktreeInfo) { if (event.errorType === 'cancellation' || event.errorType === 'abort') { // User cancelled/aborted the feature - just log as info, not an error logger.info('Feature cancelled/aborted:', event.error); - // Remove from running tasks + // Remove from running tasks - use removeFromAllWorktrees to ensure removal if (eventProjectId) { - removeRunningTask(eventProjectId, eventBranchName, event.featureId); + removeRunningTaskFromAllWorktrees(eventProjectId, event.featureId); } break; } @@ -342,9 +364,9 @@ export function useAutoMode(worktree?: WorktreeInfo) { errorType: isAuthError ? 'authentication' : 'execution', }); - // Remove the task from running since it failed + // Remove the task from running since it failed - use removeFromAllWorktrees to ensure removal if (eventProjectId) { - removeRunningTask(eventProjectId, eventBranchName, event.featureId); + removeRunningTaskFromAllWorktrees(eventProjectId, event.featureId); } } break; @@ -541,6 +563,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { projectId, addRunningTask, removeRunningTask, + removeRunningTaskFromAllWorktrees, addAutoModeActivity, getProjectIdFromPath, setPendingPlanApproval, @@ -549,6 +572,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { getMaxConcurrencyForWorktree, setMaxConcurrencyForWorktree, isPrimaryWorktreeBranch, + queryClient, ]); // Start auto mode - calls backend to start the auto loop for this worktree @@ -657,7 +681,10 @@ export function useAutoMode(worktree?: WorktreeInfo) { const result = await api.autoMode.stopFeature(featureId); if (result.success) { - removeRunningTask(currentProject.id, branchName, featureId); + // Use removeFromAllWorktrees to ensure the task is removed regardless of which + // worktree it was running in (the feature may be on a different branch than + // the currently selected worktree) + removeRunningTaskFromAllWorktrees(currentProject.id, featureId); logger.info('Feature stopped successfully:', featureId); addAutoModeActivity({ featureId, @@ -674,7 +701,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { throw error; } }, - [currentProject, branchName, removeRunningTask, addAutoModeActivity] + [currentProject, removeRunningTaskFromAllWorktrees, addAutoModeActivity] ); return { diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 6e942b888..9877a8d89 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -199,6 +199,7 @@ --status-backlog: oklch(0.5 0 0); --status-in-progress: oklch(0.7 0.15 70); --status-waiting: oklch(0.65 0.18 50); + --status-scheduled: oklch(0.55 0.2 290); /* Shadow tokens */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); @@ -297,6 +298,7 @@ --status-backlog: oklch(0.6 0 0); --status-in-progress: oklch(0.75 0.15 70); --status-waiting: oklch(0.7 0.18 50); + --status-scheduled: oklch(0.65 0.2 290); /* Shadow tokens - darker for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index a053345bf..31140e360 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -5,6 +5,29 @@ import type { PlanningMode, ThinkingLevel } from './settings.js'; import type { ReasoningEffort } from './provider.js'; +/** + * Schedule configuration for recurring features + */ +export interface FeatureSchedule { + /** Crontab expression (e.g., "0 9 * * *" for daily at 9 AM) */ + crontab: string; + /** Whether the schedule is currently enabled */ + enabled: boolean; + /** Timestamp of the last successful run */ + lastRun?: string; // ISO date string + /** Timestamp of the next scheduled run (calculated from crontab) */ + nextRun?: string; // ISO date string + /** Number of times this schedule has been triggered */ + runCount?: number; + /** Whether to keep prior context (agent output) between runs. Defaults to true. */ + keepPriorContext?: boolean; +} + +/** + * Preset interval options for schedule UI + */ +export type SchedulePreset = 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; + /** * A single entry in the description history */ @@ -103,10 +126,17 @@ export interface Feature { summary?: string; startedAt?: string; descriptionHistory?: DescriptionHistoryEntry[]; // History of description changes + schedule?: FeatureSchedule; // Recurring schedule configuration [key: string]: unknown; // Keep catch-all for extensibility } -export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified'; +export type FeatureStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'verified' + | 'scheduled'; /** * Export format for a feature, used when exporting features to share or backup diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index d6d305fe0..c938eacf3 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -67,6 +67,8 @@ export type { FeatureExport, FeatureImport, FeatureImportResult, + FeatureSchedule, + SchedulePreset, ParsedTask, PlanSpec, } from './feature.js'; diff --git a/libs/types/src/pipeline.ts b/libs/types/src/pipeline.ts index 05a4b4aad..580ceeea3 100644 --- a/libs/types/src/pipeline.ts +++ b/libs/types/src/pipeline.ts @@ -26,4 +26,5 @@ export type FeatureStatusWithPipeline = | 'waiting_approval' | 'verified' | 'completed' + | 'scheduled' | PipelineStatus; diff --git a/package-lock.json b/package-lock.json index 9f4f4d28a..5391f81d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@openai/codex-sdk": "^0.77.0", "cookie-parser": "1.4.7", "cors": "2.8.5", + "cron-parser": "^5.5.0", "dotenv": "17.2.3", "express": "5.2.1", "morgan": "1.10.1", @@ -1549,7 +1550,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -8546,6 +8547,18 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cron-parser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", + "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -12200,6 +12213,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", From c5dbcfa17d33ecf7f3ebdfd86accb11c039af03a Mon Sep 17 00:00:00 2001 From: eclipxe Date: Thu, 22 Jan 2026 22:56:17 -0800 Subject: [PATCH 04/12] Feat: Watchdog support for script --- start-automaker.sh | 274 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 269 insertions(+), 5 deletions(-) diff --git a/start-automaker.sh b/start-automaker.sh index 6770db2ca..bc025c805 100755 --- a/start-automaker.sh +++ b/start-automaker.sh @@ -42,6 +42,12 @@ PORT_SEARCH_MAX_ATTEMPTS=100 WEB_PORT=$DEFAULT_WEB_PORT SERVER_PORT=$DEFAULT_SERVER_PORT +# Watchdog configuration (can be overridden by .env) +WATCHDOG_ENABLED="${WATCHDOG_ENABLED:-false}" +WATCHDOG_CHECK_INTERVAL="${WATCHDOG_CHECK_INTERVAL:-30}" # Seconds between health checks +WATCHDOG_MAX_RESTARTS="${WATCHDOG_MAX_RESTARTS:-3}" # Max restarts within window +WATCHDOG_WINDOW_SECONDS="${WATCHDOG_WINDOW_SECONDS:-300}" # Time window for counting restarts (5 min default) + # Port validation function # Returns 0 if valid, 1 if invalid (with error message printed) validate_port() { @@ -146,6 +152,16 @@ HISTORY: Your last selected mode is remembered in: ~/.automaker_launcher_history Use --no-history to disable this feature +WATCHDOG (.env configuration): + WATCHDOG_ENABLED=true Enable watchdog monitoring (default: false) + WATCHDOG_CHECK_INTERVAL=30 Seconds between health checks (default: 30) + WATCHDOG_MAX_RESTARTS=3 Max restart attempts in window (default: 3) + WATCHDOG_WINDOW_SECONDS=300 Time window for counting restarts (default: 300) + + The watchdog monitors both API and web servers in web mode, automatically + restarting them if they become unresponsive. If max restarts are exceeded + within the time window, the watchdog will give up and exit. + PLATFORMS: Linux, macOS, Windows (Git Bash, WSL, MSYS2, Cygwin) @@ -603,10 +619,16 @@ cleanup() { show_cursor # Restore terminal settings (echo and canonical mode) stty echo icanon 2>/dev/null || true - # Kill server process if running in production mode + # Kill all managed processes + if [ -n "${WATCHDOG_PID:-}" ]; then + kill $WATCHDOG_PID 2>/dev/null || true + fi if [ -n "${SERVER_PID:-}" ]; then kill $SERVER_PID 2>/dev/null || true fi + if [ -n "${WEB_PID:-}" ]; then + kill $WEB_PID 2>/dev/null || true + fi printf "${RESET}\n" } @@ -968,6 +990,176 @@ get_last_mode_from_history() { fi } +# ============================================================================ +# WATCHDOG FUNCTIONALITY +# ============================================================================ + +# Arrays to track restart timestamps (for rate limiting) +declare -a SERVER_RESTART_TIMESTAMPS=() +declare -a WEB_RESTART_TIMESTAMPS=() + +# Check if a service is healthy by making an HTTP request +check_service_health() { + local port="$1" + local endpoint="${2:-/}" + curl -s --max-time 5 "http://localhost:$port$endpoint" > /dev/null 2>&1 +} + +# Count restarts within the time window +count_recent_restarts() { + local -n timestamps=$1 + local window=$2 + local now + now=$(date +%s) + local count=0 + local new_timestamps=() + + for ts in "${timestamps[@]}"; do + if (( now - ts < window )); then + ((count++)) + new_timestamps+=("$ts") + fi + done + + # Update the array to only keep recent timestamps + timestamps=("${new_timestamps[@]}") + echo "$count" +} + +# Add a restart timestamp +record_restart() { + local -n timestamps=$1 + timestamps+=("$(date +%s)") +} + +# Start the API server +start_api_server() { + if [ "$PRODUCTION_MODE" = true ]; then + npm run start --workspace=apps/server & + else + npm run _dev:server & + fi + SERVER_PID=$! + echo "$SERVER_PID" +} + +# Start the web server (Vite dev or preview) +start_web_server() { + if [ "$PRODUCTION_MODE" = true ]; then + npm run preview --workspace=apps/ui -- --port "$WEB_PORT" & + else + npm run _dev:web & + fi + WEB_PID=$! + echo "$WEB_PID" +} + +# Watchdog monitoring loop +run_watchdog() { + local server_port="$1" + local web_port="$2" + local check_interval="$WATCHDOG_CHECK_INTERVAL" + local max_restarts="$WATCHDOG_MAX_RESTARTS" + local window="$WATCHDOG_WINDOW_SECONDS" + + echo "" + center_print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" "$C_GRAY" + center_print "Watchdog Active" "$C_GREEN" + center_print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" "$C_GRAY" + center_print "Check interval: ${check_interval}s | Max restarts: $max_restarts in ${window}s" "$C_MUTE" + echo "" + + while true; do + sleep "$check_interval" + + # Check API server health + if ! check_service_health "$server_port" "/api/health"; then + echo "" + center_print "⚠ API server not responding on port $server_port" "$C_YELLOW" + + local server_restart_count + server_restart_count=$(count_recent_restarts SERVER_RESTART_TIMESTAMPS "$window") + + if (( server_restart_count >= max_restarts )); then + center_print "✗ API server exceeded max restarts ($max_restarts) within ${window}s window" "$C_RED" + center_print "Watchdog giving up. Please check server logs." "$C_RED" + # Kill web server too if it's running + [ -n "${WEB_PID:-}" ] && kill "$WEB_PID" 2>/dev/null || true + exit 1 + fi + + center_print "Attempting to restart API server (attempt $((server_restart_count + 1))/$max_restarts)..." "$C_YELLOW" + record_restart SERVER_RESTART_TIMESTAMPS + + # Kill existing server process if still running + [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null || true + sleep 2 + + # Restart server + SERVER_PID=$(start_api_server) + + # Wait for server to come back up + local retries=0 + local max_wait=30 + while (( retries < max_wait )); do + if check_service_health "$server_port" "/api/health"; then + center_print "✓ API server restarted successfully (PID: $SERVER_PID)" "$C_GREEN" + break + fi + sleep 1 + ((retries++)) + done + + if (( retries >= max_wait )); then + center_print "✗ API server failed to restart within ${max_wait}s" "$C_RED" + fi + fi + + # Check web server health (only for web mode with separate web server) + if [ -n "${WEB_PID:-}" ] && ! check_service_health "$web_port"; then + echo "" + center_print "⚠ Web server not responding on port $web_port" "$C_YELLOW" + + local web_restart_count + web_restart_count=$(count_recent_restarts WEB_RESTART_TIMESTAMPS "$window") + + if (( web_restart_count >= max_restarts )); then + center_print "✗ Web server exceeded max restarts ($max_restarts) within ${window}s window" "$C_RED" + center_print "Watchdog giving up. Please check web server logs." "$C_RED" + # Kill API server too + [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null || true + exit 1 + fi + + center_print "Attempting to restart web server (attempt $((web_restart_count + 1))/$max_restarts)..." "$C_YELLOW" + record_restart WEB_RESTART_TIMESTAMPS + + # Kill existing web process if still running + kill "$WEB_PID" 2>/dev/null || true + sleep 2 + + # Restart web server + WEB_PID=$(start_web_server) + + # Wait for web server to come back up + local retries=0 + local max_wait=30 + while (( retries < max_wait )); do + if check_service_health "$web_port"; then + center_print "✓ Web server restarted successfully (PID: $WEB_PID)" "$C_GREEN" + break + fi + sleep 1 + ((retries++)) + done + + if (( retries >= max_wait )); then + center_print "✗ Web server failed to restart within ${max_wait}s" "$C_RED" + fi + fi + done +} + # ============================================================================ # PRODUCTION BUILD # ============================================================================ @@ -1203,10 +1395,48 @@ case $MODE in # Start UI preview center_print "Starting UI preview on port $WEB_PORT..." "$C_YELLOW" - npm run preview --workspace=apps/ui -- --port "$WEB_PORT" - # Cleanup server on exit - kill $SERVER_PID 2>/dev/null || true + if [ "$WATCHDOG_ENABLED" = true ]; then + # Run web server in background and start watchdog + npm run preview --workspace=apps/ui -- --port "$WEB_PORT" & + WEB_PID=$! + + # Wait for web server to be ready + max_retries=30 + web_ready=false + for ((i=0; i /dev/null 2>&1; then + web_ready=true + break + fi + sleep 1 + done + + if [ "$web_ready" = false ]; then + center_print "✗ Web server failed to start" "$C_RED" + kill $SERVER_PID 2>/dev/null || true + kill $WEB_PID 2>/dev/null || true + exit 1 + fi + center_print "✓ Web server is ready!" "$C_GREEN" + + # Start watchdog in background + run_watchdog "$SERVER_PORT" "$WEB_PORT" & + WATCHDOG_PID=$! + + # Wait for any process to exit + wait -n $SERVER_PID $WEB_PID 2>/dev/null || wait $SERVER_PID $WEB_PID + + # Cleanup + kill $WATCHDOG_PID 2>/dev/null || true + kill $SERVER_PID 2>/dev/null || true + kill $WEB_PID 2>/dev/null || true + else + npm run preview --workspace=apps/ui -- --port "$WEB_PORT" + + # Cleanup server on exit + kill $SERVER_PID 2>/dev/null || true + fi else # Development: build packages, start server, then start UI with Vite dev server echo "" @@ -1247,7 +1477,41 @@ case $MODE in # Start web app with Vite dev server (HMR enabled) export VITE_APP_MODE="1" - npm run _dev:web + + if [ "$WATCHDOG_ENABLED" = true ]; then + # Run web server in background and start watchdog + npm run _dev:web & + WEB_PID=$! + + # Wait for web server to be ready + max_retries=30 + web_ready=false + for ((i=0; i /dev/null 2>&1; then + web_ready=true + break + fi + sleep 1 + done + + if [ "$web_ready" = false ]; then + center_print "⚠ Web server may still be starting..." "$C_YELLOW" + fi + + # Start watchdog in background + run_watchdog "$SERVER_PORT" "$WEB_PORT" & + WATCHDOG_PID=$! + + # Wait for any process to exit + wait -n $SERVER_PID $WEB_PID 2>/dev/null || wait $SERVER_PID $WEB_PID + + # Cleanup + kill $WATCHDOG_PID 2>/dev/null || true + kill $SERVER_PID 2>/dev/null || true + kill $WEB_PID 2>/dev/null || true + else + npm run _dev:web + fi fi ;; electron) From 5719f1869b69acf0b160fe08c54f18d4f4ec533e Mon Sep 17 00:00:00 2001 From: eclipxe Date: Sun, 25 Jan 2026 09:44:03 -0800 Subject: [PATCH 05/12] Feat: Show Gemini Usage in usage dropdown and mobile sidebar --- apps/server/src/index.ts | 2 + apps/server/src/routes/gemini/index.ts | 60 ++ .../src/services/gemini-usage-service.ts | 761 ++++++++++++++++++ apps/ui/src/components/usage-popover.tsx | 259 +++++- .../views/board-view/board-header.tsx | 13 +- .../views/board-view/header-mobile-menu.tsx | 5 +- .../views/board-view/mobile-usage-bar.tsx | 87 +- apps/ui/src/hooks/queries/index.ts | 2 +- apps/ui/src/hooks/queries/use-usage.ts | 45 +- apps/ui/src/hooks/use-provider-auth-init.ts | 68 +- apps/ui/src/lib/electron.ts | 24 +- apps/ui/src/lib/http-api-client.ts | 11 +- apps/ui/src/lib/query-keys.ts | 2 + apps/ui/src/store/app-store.ts | 13 +- apps/ui/src/store/setup-store.ts | 20 + apps/ui/src/store/types/usage-types.ts | 52 ++ package-lock.json | 13 +- 17 files changed, 1375 insertions(+), 62 deletions(-) create mode 100644 apps/server/src/routes/gemini/index.ts create mode 100644 apps/server/src/services/gemini-usage-service.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0bc7fb2a6..2e9276da3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -68,6 +68,7 @@ import { CodexAppServerService } from './services/codex-app-server-service.js'; import { CodexModelCacheService } from './services/codex-model-cache-service.js'; import { createZaiRoutes } from './routes/zai/index.js'; import { ZaiUsageService } from './services/zai-usage-service.js'; +import { createGeminiRoutes } from './routes/gemini/index.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -384,6 +385,7 @@ app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); +app.use('/api/gemini', createGeminiRoutes()); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); diff --git a/apps/server/src/routes/gemini/index.ts b/apps/server/src/routes/gemini/index.ts new file mode 100644 index 000000000..c543d827b --- /dev/null +++ b/apps/server/src/routes/gemini/index.ts @@ -0,0 +1,60 @@ +import { Router, Request, Response } from 'express'; +import { GeminiProvider } from '../../providers/gemini-provider.js'; +import { getGeminiUsageService } from '../../services/gemini-usage-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Gemini'); + +export function createGeminiRoutes(): Router { + const router = Router(); + + // Get current usage/quota data from Google Cloud API + router.get('/usage', async (_req: Request, res: Response) => { + try { + const usageService = getGeminiUsageService(); + const usageData = await usageService.fetchUsageData(); + + res.json(usageData); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error fetching Gemini usage:', error); + + // Return error in a format the UI expects + res.status(200).json({ + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch Gemini usage: ${message}`, + }); + } + }); + + // Check if Gemini is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const provider = new GeminiProvider(); + const status = await provider.detectInstallation(); + + const authMethod = + (status as any).authMethod || + (status.authenticated ? (status.hasApiKey ? 'api_key' : 'cli_login') : 'none'); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + authenticated: status.authenticated || false, + authMethod, + hasCredentialsFile: (status as any).hasCredentialsFile || false, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/apps/server/src/services/gemini-usage-service.ts b/apps/server/src/services/gemini-usage-service.ts new file mode 100644 index 000000000..966d09a49 --- /dev/null +++ b/apps/server/src/services/gemini-usage-service.ts @@ -0,0 +1,761 @@ +/** + * Gemini Usage Service + * + * Service for tracking Gemini CLI usage and quota. + * Uses the internal Google Cloud quota API (same as CodexBar). + * See: https://github.com/steipete/CodexBar/blob/main/docs/gemini.md + * + * OAuth credentials are extracted from the Gemini CLI installation, + * not hardcoded, to ensure compatibility with CLI updates. + */ + +import { createLogger } from '@automaker/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; + +const logger = createLogger('GeminiUsage'); + +// Quota API endpoint (internal Google Cloud API) +const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + +// Code Assist endpoint for getting project ID and tier info +const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; + +// Google OAuth endpoints for token refresh +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsageData { + /** Whether authenticated via CLI */ + authenticated: boolean; + /** Authentication method */ + authMethod: 'cli_login' | 'api_key' | 'none'; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +interface OAuthCredentials { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expiry_date?: number; + client_id?: string; + client_secret?: string; +} + +interface OAuthClientCredentials { + clientId: string; + clientSecret: string; +} + +interface QuotaResponse { + // The actual API returns 'buckets', not 'quotaBuckets' + buckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; + // Legacy field name (in case API changes) + quotaBuckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; +} + +/** + * Gemini Usage Service + * + * Provides real usage/quota data for Gemini CLI users. + * Extracts OAuth credentials from the Gemini CLI installation. + */ +export class GeminiUsageService { + private cachedCredentials: OAuthCredentials | null = null; + private cachedClientCredentials: OAuthClientCredentials | null = null; + private credentialsPath: string; + + constructor() { + // Default credentials path for Gemini CLI + this.credentialsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + } + + /** + * Check if Gemini CLI is authenticated + */ + async isAvailable(): Promise { + const creds = await this.loadCredentials(); + return Boolean(creds?.access_token || creds?.refresh_token); + } + + /** + * Fetch quota/usage data from Google Cloud API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const creds = await this.loadCredentials(); + + if (!creds || (!creds.access_token && !creds.refresh_token)) { + logger.info('[fetchUsageData] No credentials found'); + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Not authenticated. Run "gemini auth login" to authenticate.', + }; + } + + try { + // Get a valid access token (refresh if needed) + const accessToken = await this.getValidAccessToken(creds); + + if (!accessToken) { + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Failed to obtain access token. Try running "gemini auth login" again.', + }; + } + + // First, get the project ID from loadCodeAssist endpoint + // This is required to get accurate quota data + let projectId: string | undefined; + try { + const codeAssistResponse = await fetch(CODE_ASSIST_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + if (codeAssistResponse.ok) { + const codeAssistData = (await codeAssistResponse.json()) as { + cloudaicompanionProject?: string; + currentTier?: { id?: string; name?: string }; + }; + projectId = codeAssistData.cloudaicompanionProject; + logger.debug('[fetchUsageData] Got project ID:', projectId); + } + } catch (e) { + logger.debug('[fetchUsageData] Failed to get project ID:', e); + } + + // Fetch quota from Google Cloud API + // Pass project ID to get accurate quota (without it, returns default 100%) + const response = await fetch(QUOTA_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectId ? { project: projectId } : {}), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + logger.error('[fetchUsageData] Quota API error:', response.status, errorText); + + // Still authenticated, but quota API failed + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Quota API unavailable (${response.status})`, + }; + } + + const data = (await response.json()) as QuotaResponse; + + // API returns 'buckets', with fallback to 'quotaBuckets' for compatibility + const apiBuckets = data.buckets || data.quotaBuckets; + + logger.debug('[fetchUsageData] Raw buckets:', JSON.stringify(apiBuckets)); + + if (!apiBuckets || apiBuckets.length === 0) { + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + } + + // Group buckets into Flash and Pro tiers + // Flash: any model with "flash" in the name + // Pro: any model with "pro" in the name + let flashLowestRemaining = 1.0; + let flashResetTime: string | undefined; + let hasFlashModels = false; + let proLowestRemaining = 1.0; + let proResetTime: string | undefined; + let hasProModels = false; + let overallLowestRemaining = 1.0; + let constrainedModel: string | undefined; + let overallResetTime: string | undefined; + + const quotaBuckets: GeminiQuotaBucket[] = apiBuckets.map((bucket) => { + const remaining = bucket.remainingFraction ?? 1.0; + const modelId = bucket.modelId?.toLowerCase() || ''; + + // Track overall lowest + if (remaining < overallLowestRemaining) { + overallLowestRemaining = remaining; + constrainedModel = bucket.modelId; + overallResetTime = bucket.resetTime; + } + + // Group into Flash or Pro tier + if (modelId.includes('flash')) { + hasFlashModels = true; + if (remaining < flashLowestRemaining) { + flashLowestRemaining = remaining; + flashResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!flashResetTime && bucket.resetTime) { + flashResetTime = bucket.resetTime; + } + } else if (modelId.includes('pro')) { + hasProModels = true; + if (remaining < proLowestRemaining) { + proLowestRemaining = remaining; + proResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!proResetTime && bucket.resetTime) { + proResetTime = bucket.resetTime; + } + } + + return { + modelId: bucket.modelId || 'unknown', + remainingFraction: remaining, + resetTime: bucket.resetTime || '', + }; + }); + + const usedPercent = Math.round((1 - overallLowestRemaining) * 100); + const remainingPercent = Math.round(overallLowestRemaining * 100); + + // Build tier quotas (only include if we found models for that tier) + const flashQuota: GeminiTierQuota | undefined = hasFlashModels + ? { + usedPercent: Math.round((1 - flashLowestRemaining) * 100), + remainingPercent: Math.round(flashLowestRemaining * 100), + resetText: flashResetTime ? this.formatResetTime(flashResetTime) : undefined, + resetTime: flashResetTime, + } + : undefined; + + const proQuota: GeminiTierQuota | undefined = hasProModels + ? { + usedPercent: Math.round((1 - proLowestRemaining) * 100), + remainingPercent: Math.round(proLowestRemaining * 100), + resetText: proResetTime ? this.formatResetTime(proResetTime) : undefined, + resetTime: proResetTime, + } + : undefined; + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent, + remainingPercent, + resetText: overallResetTime ? this.formatResetTime(overallResetTime) : undefined, + resetTime: overallResetTime, + constrainedModel, + flashQuota, + proQuota, + quotaBuckets, + lastUpdated: new Date().toISOString(), + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[fetchUsageData] Error:', errorMsg); + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch quota: ${errorMsg}`, + }; + } + } + + /** + * Load OAuth credentials from file + */ + private async loadCredentials(): Promise { + if (this.cachedCredentials) { + return this.cachedCredentials; + } + + // Check multiple possible paths + const possiblePaths = [ + this.credentialsPath, + path.join(os.homedir(), '.gemini', 'oauth_creds.json'), + path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'), + ]; + + for (const credPath of possiblePaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + + // Handle different credential formats + if (creds.access_token || creds.refresh_token) { + this.cachedCredentials = creds; + logger.info('[loadCredentials] Loaded from:', credPath); + return creds; + } + + // Some formats nest credentials under 'web' or 'installed' + if (creds.web?.client_id || creds.installed?.client_id) { + const clientCreds = creds.web || creds.installed; + this.cachedCredentials = { + client_id: clientCreds.client_id, + client_secret: clientCreds.client_secret, + }; + return this.cachedCredentials; + } + } + } catch (error) { + logger.debug('[loadCredentials] Failed to load from', credPath, error); + } + } + + return null; + } + + /** + * Find the Gemini CLI binary path + */ + private findGeminiBinaryPath(): string | null { + try { + // Try 'which' on Unix-like systems + const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim(); + if (whichResult && fs.existsSync(whichResult)) { + return whichResult; + } + } catch { + // Ignore errors from 'which' + } + + // Check common installation paths + const possiblePaths = [ + // npm global installs + path.join(os.homedir(), '.npm-global', 'bin', 'gemini'), + '/usr/local/bin/gemini', + '/usr/bin/gemini', + // Homebrew + '/opt/homebrew/bin/gemini', + '/usr/local/opt/gemini/bin/gemini', + // nvm/fnm node installs + path.join(os.homedir(), '.nvm', 'versions', 'node'), + path.join(os.homedir(), '.fnm', 'node-versions'), + // Windows + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini'), + ]; + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; + } + + /** + * Extract OAuth client credentials from Gemini CLI installation + * This mimics CodexBar's approach of finding oauth2.js in the CLI + */ + private extractOAuthClientCredentials(): OAuthClientCredentials | null { + if (this.cachedClientCredentials) { + return this.cachedClientCredentials; + } + + const geminiBinary = this.findGeminiBinaryPath(); + if (!geminiBinary) { + logger.debug('[extractOAuthClientCredentials] Gemini binary not found'); + return null; + } + + // Resolve symlinks to find actual location + let resolvedPath = geminiBinary; + try { + resolvedPath = fs.realpathSync(geminiBinary); + } catch { + // Use original path if realpath fails + } + + const baseDir = path.dirname(resolvedPath); + logger.debug('[extractOAuthClientCredentials] Base dir:', baseDir); + + // Possible locations for oauth2.js relative to the binary + // Based on CodexBar's search patterns + const possibleOAuth2Paths = [ + // npm global install structure + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Homebrew/libexec structure + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Direct sibling + path.join(baseDir, '..', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'), + path.join(baseDir, '..', 'gemini-cli', 'dist', 'src', 'code_assist', 'oauth2.js'), + // Alternative node_modules structures + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + ]; + + for (const oauth2Path of possibleOAuth2Paths) { + try { + const normalizedPath = path.normalize(oauth2Path); + if (fs.existsSync(normalizedPath)) { + logger.debug('[extractOAuthClientCredentials] Found oauth2.js at:', normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info('[extractOAuthClientCredentials] Extracted credentials from CLI'); + return creds; + } + } + } catch (error) { + logger.debug('[extractOAuthClientCredentials] Failed to read', oauth2Path, error); + } + } + + // Try finding oauth2.js by searching in node_modules + try { + const searchResult = execSync( + `find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`, + { encoding: 'utf8', timeout: 5000 } + ).trim(); + + if (searchResult && fs.existsSync(searchResult)) { + logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); + const content = fs.readFileSync(searchResult, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info( + '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' + ); + return creds; + } + } + } catch { + // Ignore search errors + } + + logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI'); + return null; + } + + /** + * Parse OAuth client credentials from oauth2.js source code + */ + private parseOAuthCredentialsFromSource(content: string): OAuthClientCredentials | null { + // Patterns based on CodexBar's regex extraction + // Look for: OAUTH_CLIENT_ID = "..." or const clientId = "..." + const clientIdPatterns = [ + /OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/, + /clientId\s*[:=]\s*["']([^"']+)["']/, + /client_id\s*[:=]\s*["']([^"']+)["']/, + /"clientId"\s*:\s*["']([^"']+)["']/, + ]; + + const clientSecretPatterns = [ + /OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/, + /clientSecret\s*[:=]\s*["']([^"']+)["']/, + /client_secret\s*[:=]\s*["']([^"']+)["']/, + /"clientSecret"\s*:\s*["']([^"']+)["']/, + ]; + + let clientId: string | null = null; + let clientSecret: string | null = null; + + for (const pattern of clientIdPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientId = match[1]; + break; + } + } + + for (const pattern of clientSecretPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientSecret = match[1]; + break; + } + } + + if (clientId && clientSecret) { + logger.debug('[parseOAuthCredentialsFromSource] Found client credentials'); + return { clientId, clientSecret }; + } + + return null; + } + + /** + * Get a valid access token, refreshing if necessary + */ + private async getValidAccessToken(creds: OAuthCredentials): Promise { + // Check if current token is still valid (with 5 min buffer) + if (creds.access_token && creds.expiry_date) { + const now = Date.now(); + if (creds.expiry_date > now + 5 * 60 * 1000) { + logger.debug('[getValidAccessToken] Using existing token (not expired)'); + return creds.access_token; + } + } + + // If we have a refresh token, try to refresh + if (creds.refresh_token) { + // Try to extract credentials from CLI first + const extractedCreds = this.extractOAuthClientCredentials(); + + // Use extracted credentials, then fall back to credentials in file + const clientId = extractedCreds?.clientId || creds.client_id; + const clientSecret = extractedCreds?.clientSecret || creds.client_secret; + + if (!clientId || !clientSecret) { + logger.error('[getValidAccessToken] No client credentials available for token refresh'); + // Return existing token even if expired - it might still work + return creds.access_token || null; + } + + try { + logger.debug('[getValidAccessToken] Refreshing token...'); + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: creds.refresh_token, + grant_type: 'refresh_token', + }), + }); + + if (response.ok) { + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + const newAccessToken = data.access_token; + const expiresIn = data.expires_in || 3600; + + if (newAccessToken) { + logger.info('[getValidAccessToken] Token refreshed successfully'); + + // Update cached credentials + this.cachedCredentials = { + ...creds, + access_token: newAccessToken, + expiry_date: Date.now() + expiresIn * 1000, + }; + + // Save back to file + try { + fs.writeFileSync( + this.credentialsPath, + JSON.stringify(this.cachedCredentials, null, 2) + ); + } catch (e) { + logger.debug('[getValidAccessToken] Could not save refreshed token:', e); + } + + return newAccessToken; + } + } else { + const errorText = await response.text().catch(() => ''); + logger.error('[getValidAccessToken] Token refresh failed:', response.status, errorText); + } + } catch (error) { + logger.error('[getValidAccessToken] Token refresh error:', error); + } + } + + // Return current access token even if it might be expired + return creds.access_token || null; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(isoTime: string): string { + try { + const resetDate = new Date(isoTime); + const now = new Date(); + const diff = resetDate.getTime() - now.getTime(); + + if (diff < 0) { + return 'Resetting soon'; + } + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMins = minutes % 60; + return remainingMins > 0 ? `Resets in ${hours}h ${remainingMins}m` : `Resets in ${hours}h`; + } + + return `Resets in ${minutes}m`; + } catch { + return ''; + } + } + + /** + * Clear cached credentials (useful after logout) + */ + clearCache(): void { + this.cachedCredentials = null; + this.cachedClientCredentials = null; + } +} + +// Singleton instance +let usageServiceInstance: GeminiUsageService | null = null; + +/** + * Get the singleton instance of GeminiUsageService + */ +export function getGeminiUsageService(): GeminiUsageService { + if (!usageServiceInstance) { + usageServiceInstance = new GeminiUsageService(); + } + return usageServiceInstance; +} diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 31bb6d5af..58c6fd274 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -81,14 +81,16 @@ export function UsagePopover() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; const isZaiAuthenticated = zaiAuthStatus?.authenticated; + const isGeminiAuthenticated = geminiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -116,6 +118,14 @@ export function UsagePopover() { refetch: refetchZai, } = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated); + const { + data: geminiUsage, + isLoading: geminiLoading, + error: geminiQueryError, + dataUpdatedAt: geminiUsageLastUpdated, + refetch: refetchGemini, + } = useGeminiUsage(open && activeTab === 'gemini' && isGeminiAuthenticated); + // Parse errors into structured format const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -157,6 +167,19 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [zaiQueryError]); + const geminiError = useMemo((): UsageError | null => { + if (!geminiQueryError) return null; + const message = + geminiQueryError instanceof Error ? geminiQueryError.message : String(geminiQueryError); + if (message.includes('not configured') || message.includes('not authenticated')) { + return { code: ERROR_CODES.NOT_AVAILABLE, message }; + } + if (message.includes('API bridge')) { + return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message }; + } + return { code: ERROR_CODES.AUTH_ERROR, message }; + }, [geminiQueryError]); + // Determine which tab to show by default useEffect(() => { if (isClaudeAuthenticated) { @@ -165,8 +188,10 @@ export function UsagePopover() { setActiveTab('codex'); } else if (isZaiAuthenticated) { setActiveTab('zai'); + } else if (isGeminiAuthenticated) { + setActiveTab('gemini'); } - }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -181,10 +206,15 @@ export function UsagePopover() { return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; }, [zaiUsageLastUpdated]); + const isGeminiStale = useMemo(() => { + return !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; + }, [geminiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); const fetchZaiUsage = () => refetchZai(); + const fetchGeminiUsage = () => refetchGemini(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -275,6 +305,23 @@ export function UsagePopover() { // Calculate max percentage for header button const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0; + const codexMaxPercentage = codexUsage?.rateLimits + ? Math.max( + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) + : 0; + + const zaiMaxPercentage = zaiUsage?.quotaLimits + ? Math.max( + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) + : 0; + + // Gemini quota from Google Cloud API (if available) + const geminiMaxPercentage = geminiUsage?.usedPercent ?? (geminiUsage?.authenticated ? 0 : 100); + const getProgressBarColor = (percentage: number) => { if (percentage >= 80) return 'bg-red-500'; if (percentage >= 50) return 'bg-yellow-500'; @@ -299,33 +346,43 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } : null; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' + ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } + : activeTab === 'zai' + ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } + : activeTab === 'gemini' + ? { + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } + : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {geminiError ? ( +
+ +
+

+ {geminiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'Gemini not configured' + : geminiError.message} +

+

+ {geminiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : geminiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Run{' '} + gemini auth login{' '} + to authenticate with your Google account + + ) : ( + <>Check your Gemini CLI configuration + )} +

+
+
+ ) : !geminiUsage ? ( +
+ +

Loading usage data...

+
+ ) : geminiUsage.authenticated ? ( + <> + {/* Show Flash and Pro quota tiers */} + {geminiUsage.flashQuota || geminiUsage.proQuota ? ( +
+ {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} +
+ ) : ( + <> + {/* No quota data available - show connected status */} +
+
+ +
+
+

Connected

+

+ Authenticated via{' '} + + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key_env' + ? 'API Key (Environment)' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : 'Unknown'} + +

+
+
+ +
+

+ {geminiUsage.error ? ( + <>Quota API: {geminiUsage.error} + ) : ( + <>No usage yet or quota data unavailable + )} +

+
+ + )} + + ) : ( +
+ +

Not authenticated

+

+ Run gemini auth login{' '} + to authenticate +

+
+ )} +
+ + {/* Footer */} +
+ + Google AI + + Updates every minute +
+ diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 05303b85d..8e3654e36 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -82,6 +82,7 @@ export function BoardHeader({ ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); + const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -116,6 +117,9 @@ export function BoardHeader({ // z.ai usage tracking visibility logic const showZaiUsage = !!zaiAuthStatus?.authenticated; + // Gemini usage tracking visibility logic + const showGeminiUsage = !!geminiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -163,9 +167,11 @@ export function BoardHeader({ )} {/* Usage Popover - show if any provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( - - )} + {isMounted && + !isTablet && + (showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -185,6 +191,7 @@ export function BoardHeader({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index 184e436a3..3eed7c0e7 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -31,6 +31,7 @@ interface HeaderMobileMenuProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } export function HeaderMobileMenu({ @@ -49,13 +50,14 @@ export function HeaderMobileMenu({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: HeaderMobileMenuProps) { return ( <> {/* Usage Bar - show if any provider is authenticated */} - {(showClaudeUsage || showCodexUsage || showZaiUsage) && ( + {(showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && (
Usage @@ -64,6 +66,7 @@ export function HeaderMobileMenu({ showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} showZaiUsage={showZaiUsage} + showGeminiUsage={showGeminiUsage} />
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 28225b507..4755dfbb6 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,12 +4,14 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon'; +import type { GeminiUsage } from '@/store/app-store'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; showZaiUsage?: boolean; + showGeminiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -152,6 +154,7 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage, showZaiUsage = false, + showGeminiUsage = false, }: MobileUsageBarProps) { const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore(); const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); @@ -159,12 +162,17 @@ export function MobileUsageBar({ const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); const [isZaiLoading, setIsZaiLoading] = useState(false); + const [isGeminiLoading, setIsGeminiLoading] = useState(false); + const [geminiUsage, setGeminiUsage] = useState(null); + const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState(null); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + const isGeminiStale = + !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -214,6 +222,23 @@ export function MobileUsageBar({ } }, [setZaiUsage]); + const fetchGeminiUsage = useCallback(async () => { + setIsGeminiLoading(true); + try { + const api = getElectronAPI(); + if (!api.gemini) return; + const data = await api.gemini.getUsage(); + if (!('error' in data)) { + setGeminiUsage(data); + setGeminiUsageLastUpdated(Date.now()); + } + } catch { + // Silently fail - usage display is optional + } finally { + setIsGeminiLoading(false); + } + }, []); + const getCodexWindowLabel = (durationMins: number) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -239,8 +264,14 @@ export function MobileUsageBar({ } }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + useEffect(() => { + if (showGeminiUsage && isGeminiStale) { + fetchGeminiUsage(); + } + }, [showGeminiUsage, isGeminiStale, fetchGeminiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage && !showGeminiUsage) { return null; } @@ -340,6 +371,58 @@ export function MobileUsageBar({ )} )} + + {showGeminiUsage && ( + + {geminiUsage ? ( + geminiUsage.authenticated ? ( + geminiUsage.flashQuota || geminiUsage.proQuota ? ( + <> + {geminiUsage.flashQuota && ( + + )} + {geminiUsage.proQuota && ( + + )} + + ) : ( +
+

+ Connected via{' '} + {geminiUsage.authMethod === 'cli_login' + ? 'CLI Login' + : geminiUsage.authMethod === 'api_key' + ? 'API Key' + : geminiUsage.authMethod} +

+

+ {geminiUsage.error || 'No usage yet'} +

+
+ ) + ) : ( +

Not authenticated

+ ) + ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 186b5b4e7..5a5730ac4 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index c159ac068..18fedfa77 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude, Codex, and z.ai API usage data. + * React Query hooks for fetching Claude, Codex, z.ai, and Gemini API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -33,7 +33,7 @@ export function useClaudeUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.claude) { - throw new Error('Claude API not available'); + throw new Error('Claude API bridge unavailable'); } const result = await api.claude.getUsage(); // Check if result is an error response @@ -69,7 +69,7 @@ export function useCodexUsage(enabled = true) { queryFn: async (): Promise => { const api = getElectronAPI(); if (!api.codex) { - throw new Error('Codex API not available'); + throw new Error('Codex API bridge unavailable'); } const result = await api.codex.getUsage(); // Check if result is an error response @@ -104,6 +104,9 @@ export function useZaiUsage(enabled = true) { queryKey: queryKeys.usage.zai(), queryFn: async (): Promise => { const api = getElectronAPI(); + if (!api.zai) { + throw new Error('z.ai API bridge unavailable'); + } const result = await api.zai.getUsage(); // Check if result is an error response if ('error' in result) { @@ -120,3 +123,37 @@ export function useZaiUsage(enabled = true) { refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, }); } + +/** + * Fetch Gemini API usage/status data + * + * @param enabled - Whether the query should run (default: true) + * @returns Query result with Gemini usage data + * + * @example + * ```tsx + * const { data: usage, isLoading } = useGeminiUsage(isPopoverOpen); + * ``` + */ +export function useGeminiUsage(enabled = true) { + return useQuery({ + queryKey: queryKeys.usage.gemini(), + queryFn: async (): Promise => { + const api = getElectronAPI(); + if (!api.gemini) { + throw new Error('Gemini API bridge unavailable'); + } + const result = await api.gemini.getUsage(); + // Server always returns a response with 'authenticated' field, even on error + // So we can safely cast to GeminiUsage + return result as GeminiUsage; + }, + enabled, + staleTime: STALE_TIMES.USAGE, + refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false, + // Keep previous data while refetching + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS, + refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index c784e7bd4..f8919b1e0 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -4,6 +4,7 @@ import { type ClaudeAuthMethod, type CodexAuthMethod, type ZaiAuthMethod, + type GeminiAuthMethod, } from '@/store/setup-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; @@ -11,7 +12,7 @@ import { createLogger } from '@automaker/utils/logger'; const logger = createLogger('ProviderAuthInit'); /** - * Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup. + * Hook to initialize Claude, Codex, z.ai, and Gemini authentication statuses on app startup. * This ensures that usage tracking information is available in the board header * without needing to visit the settings page first. */ @@ -20,9 +21,12 @@ export function useProviderAuthInit() { setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, + geminiAuthStatus, } = useSetupStore(); const initialized = useRef(false); @@ -121,18 +125,74 @@ export function useProviderAuthInit() { } catch (error) { logger.error('Failed to init z.ai auth status:', error); } - }, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]); + + // 4. Gemini Auth Status + try { + const result = await api.setup.getGeminiStatus(); + if (result.success) { + // Set CLI status + setGeminiCliStatus({ + installed: result.installed ?? false, + version: result.version, + path: result.status, + }); + + // Set Auth status - always set a status to mark initialization as complete + if (result.auth) { + const auth = result.auth; + const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none']; + + const method = validMethods.includes(auth.method as GeminiAuthMethod) + ? (auth.method as GeminiAuthMethod) + : ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod); + + setGeminiAuthStatus({ + authenticated: auth.authenticated, + method, + hasApiKey: auth.hasApiKey ?? false, + hasEnvApiKey: auth.hasEnvApiKey ?? false, + }); + } else { + // No auth info available, set default unauthenticated status + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + } + } catch (error) { + logger.error('Failed to init Gemini auth status:', error); + // Set default status on error to prevent infinite retries + setGeminiAuthStatus({ + authenticated: false, + method: 'none', + hasApiKey: false, + hasEnvApiKey: false, + }); + } + }, [ + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + setGeminiCliStatus, + setGeminiAuthStatus, + ]); useEffect(() => { // Only initialize once per session if not already set if ( initialized.current || - (claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null) + (claudeAuthStatus !== null && + codexAuthStatus !== null && + zaiAuthStatus !== null && + geminiAuthStatus !== null) ) { return; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index a02f58542..88e4236c8 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,11 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + ZaiUsageResponse, + GeminiUsageResponse, +} from '@/store/app-store'; import type { IssueValidationVerdict, IssueValidationConfidence, @@ -874,6 +879,9 @@ export interface ElectronAPI { error?: string; }>; }; + gemini?: { + getUsage: () => Promise; + }; settings?: { getStatus: () => Promise<{ success: boolean; @@ -1418,6 +1426,20 @@ const _getMockElectronAPI = (): ElectronAPI => { }; }, }, + + // Mock Gemini API + gemini: { + getUsage: async () => { + console.log('[Mock] Getting Gemini usage'); + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 5598fea00..0031278ef 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -41,7 +41,11 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; +import type { + ClaudeUsageResponse, + CodexUsageResponse, + GeminiUsage, +} from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -2687,6 +2691,11 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Gemini API + gemini = { + getUsage: (): Promise => this.get('/api/gemini/usage'), + }; + // Context API context = { describeImage: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index aad0208d9..70c2679a1 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -101,6 +101,8 @@ export const queryKeys = { codex: () => ['usage', 'codex'] as const, /** z.ai API usage */ zai: () => ['usage', 'zai'] as const, + /** Gemini API usage */ + gemini: () => ['usage', 'gemini'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 4d4868b63..cc5fd64bc 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -98,6 +98,10 @@ import { type ZaiQuotaLimit, type ZaiUsage, type ZaiUsageResponse, + type GeminiQuotaBucket, + type GeminiTierQuota, + type GeminiUsage, + type GeminiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -181,6 +185,10 @@ export type { ZaiQuotaLimit, ZaiUsage, ZaiUsageResponse, + GeminiQuotaBucket, + GeminiTierQuota, + GeminiUsage, + GeminiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -210,7 +218,7 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - Terminal types (./types/terminal-types.ts) // - ClaudeModel, Feature, FileTreeNode, ProjectAnalysis (./types/project-types.ts) // - InitScriptState, AutoModeActivity, AppState, AppActions (./types/state-types.ts) -// - Claude/Codex usage types (./types/usage-types.ts) +// - Claude/Codex/Zai/Gemini usage types (./types/usage-types.ts) // The following utility functions have been moved to ./utils/: // - Theme utilities: THEME_STORAGE_KEY, getStoredTheme, getStoredFontSans, getStoredFontMono, etc. (./utils/theme-utils.ts) // - Shortcut utilities: parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS (./utils/shortcut-utils.ts) @@ -220,6 +228,9 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES // - defaultBackgroundSettings (./defaults/background-settings.ts) // - defaultTerminalState (./defaults/terminal-defaults.ts) +// Type definitions are imported from ./types/state-types.ts +// AppActions interface is defined in ./types/state-types.ts + const initialState: AppState = { projects: [], currentProject: null, diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 27a9bdac8..aae357ea0 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -127,6 +127,22 @@ export interface ZaiAuthStatus { error?: string; } +// Gemini Auth Method +export type GeminiAuthMethod = + | 'cli_login' // Gemini CLI is installed and authenticated + | 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'none'; + +// Gemini Auth Status +export interface GeminiAuthStatus { + authenticated: boolean; + method: GeminiAuthMethod; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -200,6 +216,7 @@ export interface SetupState { // Gemini CLI state geminiCliStatus: GeminiCliStatus | null; + geminiAuthStatus: GeminiAuthStatus | null; // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; @@ -243,6 +260,7 @@ export interface SetupActions { // Gemini CLI setGeminiCliStatus: (status: GeminiCliStatus | null) => void; + setGeminiAuthStatus: (status: GeminiAuthStatus | null) => void; // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; @@ -284,6 +302,7 @@ const initialState: SetupState = { opencodeCliStatus: null, geminiCliStatus: null, + geminiAuthStatus: null, copilotCliStatus: null, @@ -363,6 +382,7 @@ export const useSetupStore = create()((set, get) => ( // Gemini CLI setGeminiCliStatus: (status) => set({ geminiCliStatus: status }), + setGeminiAuthStatus: (status) => set({ geminiAuthStatus: status }), // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e7c47a5d2..0b6536f3a 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -82,3 +82,55 @@ export interface ZaiUsage { // Response type for z.ai usage API (can be success or error) export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string }; + +// Gemini Usage types - uses internal Google Cloud quota API +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsage { + /** Whether the user is authenticated (via CLI or API key) */ + authenticated: boolean; + /** Authentication method: 'cli_login' | 'api_key' | 'api_key_env' | 'none' */ + authMethod: string; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +// Response type for Gemini usage API (can be success or error) +export type GeminiUsageResponse = GeminiUsage | { error: string; message?: string }; diff --git a/package-lock.json b/package-lock.json index 5391f81d7..7c187c224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1550,7 +1550,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -11488,7 +11488,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11510,7 +11509,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11532,7 +11530,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11554,7 +11551,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11576,7 +11572,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11598,7 +11593,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11620,7 +11614,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11642,7 +11635,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11664,7 +11656,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11686,7 +11677,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11708,7 +11698,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, From bc41c06552d81ad3e084ffef5bd99ee86fe5760b Mon Sep 17 00:00:00 2001 From: "Chris L." Date: Mon, 2 Feb 2026 18:49:03 -0800 Subject: [PATCH 06/12] fix: ScheduledTasks - Add removeRunningTaskFromWorkTrees function --- apps/ui/src/store/app-store.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index cc5fd64bc..224004288 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -973,6 +973,26 @@ export const useAppStore = create()((set, get) => ({ }); }, + removeRunningTaskFromAllWorktrees: (projectId: string, taskId: string) => { + const current = get().autoModeByWorktree; + const projectPrefix = `${projectId}::`; + const updated: typeof current = {}; + + // Iterate through all worktree states and remove the task from any that contain it + for (const [key, worktreeState] of Object.entries(current)) { + if (key.startsWith(projectPrefix) && worktreeState.runningTasks.includes(taskId)) { + updated[key] = { + ...worktreeState, + runningTasks: worktreeState.runningTasks.filter((id) => id !== taskId), + }; + } else { + updated[key] = worktreeState; + } + } + + set({ autoModeByWorktree: updated }); + }, + clearRunningTasks: (projectId: string, branchName: string | null) => { const key = get().getWorktreeKey(projectId, branchName); set((state) => { From 197a5bd187be7bcd39afd9f9215d9f8d15c3cdc1 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Tue, 20 Jan 2026 14:34:15 -0800 Subject: [PATCH 07/12] Feat: Add z.ai usage tracking --- apps/ui/src/components/usage-popover.tsx | 50 ++++++++++++------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 58c6fd274..bb3b35675 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -307,16 +307,16 @@ export function UsagePopover() { const codexMaxPercentage = codexUsage?.rateLimits ? Math.max( - codexUsage.rateLimits.primary?.usedPercent || 0, - codexUsage.rateLimits.secondary?.usedPercent || 0 - ) + codexUsage.rateLimits.primary?.usedPercent || 0, + codexUsage.rateLimits.secondary?.usedPercent || 0 + ) : 0; const zaiMaxPercentage = zaiUsage?.quotaLimits ? Math.max( - zaiUsage.quotaLimits.tokens?.usedPercent || 0, - zaiUsage.quotaLimits.mcp?.usedPercent || 0 - ) + zaiUsage.quotaLimits.tokens?.usedPercent || 0, + zaiUsage.quotaLimits.mcp?.usedPercent || 0 + ) : 0; // Gemini quota from Google Cloud API (if available) @@ -346,31 +346,31 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } : activeTab === 'codex' ? { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - } + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + } : activeTab === 'zai' ? { - icon: ZaiIcon, - percentage: zaiMaxPercentage, - isStale: isZaiStale, - title: `Usage (z.ai)`, - } + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : activeTab === 'gemini' ? { - icon: GeminiIcon, - percentage: geminiMaxPercentage, - isStale: isGeminiStale, - title: `Usage (Gemini)`, - } + icon: GeminiIcon, + percentage: geminiMaxPercentage, + isStale: isGeminiStale, + title: `Usage (Gemini)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; From faee4611e54270144f06d90dd147678c82b83e50 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Thu, 22 Jan 2026 10:26:26 -0800 Subject: [PATCH 08/12] Feat: Add scheduled feature support --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 7c187c224..4675e222c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1550,7 +1550,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", From b3761681eda1ed542f61c006eb3c1d1d66fa74e8 Mon Sep 17 00:00:00 2001 From: eclipxe Date: Sun, 25 Jan 2026 09:44:03 -0800 Subject: [PATCH 09/12] Feat: Show Gemini Usage in usage dropdown and mobile sidebar --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 4675e222c..7c187c224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1550,7 +1550,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", From 33e5dc23c742326c2f3d00c772abe9c233f854da Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 13 Feb 2026 17:02:07 -0800 Subject: [PATCH 10/12] feat: Add GLM-5 model support to z.AI provider - Updated CLAUDE_PROVIDER_TEMPLATES to include GLM-5 model - Mapped GLM-5 to opus tier for maximum capability - Updated deprecated template for backward compatibility - GLM-5 is the latest Zhipu model with improved performance --- .../src/services/claude-usage-service.ts | 21 ++++++++++++++++++- libs/types/src/settings.ts | 4 ++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index aa8afc1c4..9f3b06e12 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -409,7 +409,26 @@ export class ClaudeUsageService { (cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) || // Detect model indicator which appears when REPL is ready (cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) || - (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')); + (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')) || + // Additional patterns for Claude CLI v2.x + // The prompt often shows model name followed by a cursor/prompt indicator + cleanOutput.includes('claude-') || + cleanOutput.includes('Claude Code') || + // Look for session start indicators (Claude v2.x) + cleanOutput.includes('session started') || + cleanOutput.includes('Session started') || + // Look for the compact prompt format (model name at prompt) + /claude-\d+-\d+-sonnet/i.test(cleanOutput) || + /claude-\d+-\d+-opus/i.test(cleanOutput) || + /opus-\d+/i.test(cleanOutput) || + /sonnet-\d+/i.test(cleanOutput) || + // Look for cost indicator which appears in the prompt + cleanOutput.includes('$0.') || + // Look for common prompt elements in v2.x + cleanOutput.includes('Type a message') || + cleanOutput.includes('Enter a message') || + // Detect when waiting for input (common REPL state) + /\s+>\s*$/.test(cleanOutput); if (!hasSentCommand && isReplReady) { hasSentCommand = true; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 16a443d23..97694780b 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -466,7 +466,7 @@ export const CLAUDE_PROVIDER_TEMPLATES: ClaudeCompatibleProviderTemplate[] = [ defaultModels: [ { id: 'GLM-4.5-Air', displayName: 'GLM 4.5 Air', mapsToClaudeModel: 'haiku' }, { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'sonnet' }, - { id: 'GLM-4.7', displayName: 'GLM 4.7', mapsToClaudeModel: 'opus' }, + { id: 'GLM-5', displayName: 'GLM 5', mapsToClaudeModel: 'opus' }, ], }, { @@ -549,7 +549,7 @@ export const CLAUDE_API_PROFILE_TEMPLATES: ClaudeApiProfileTemplate[] = [ modelMappings: { haiku: 'GLM-4.5-Air', sonnet: 'GLM-4.7', - opus: 'GLM-4.7', + opus: 'GLM-5', }, disableNonessentialTraffic: true, description: '3× usage at fraction of cost via GLM Coding Plan', From 308f357c3920250999d5330864506f1e6808d0e0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 13 Feb 2026 21:57:47 -0800 Subject: [PATCH 11/12] feat: Add automatic port conflict resolution and new file operation handlers --- apps/server/src/index.ts | 93 +- apps/server/src/routes/fs/index.ts | 12 + apps/server/src/routes/fs/routes/git-diff.ts | 115 ++ apps/server/src/routes/fs/routes/git-stage.ts | 54 + .../server/src/routes/fs/routes/git-status.ts | 41 + apps/server/src/routes/fs/routes/rename.ts | 42 + .../src/routes/fs/routes/search-content.ts | 244 +++ .../src/routes/fs/routes/search-files.ts | 207 +++ .../server/src/services/dev-server-service.ts | 23 +- apps/ui/package.json | 13 + .../layout/sidebar/hooks/use-navigation.ts | 10 + apps/ui/src/components/ui/code-editor.tsx | 633 ++++++++ .../src/components/ui/file-search-dialog.tsx | 469 ++++++ apps/ui/src/components/ui/file-tree.tsx | 1379 +++++++++++++++++ apps/ui/src/components/ui/keyboard-map.tsx | 2 + apps/ui/src/components/views/files-view.tsx | 1317 ++++++++++++++++ .../ui/src/components/views/settings-view.tsx | 3 + .../views/settings-view/config/navigation.ts | 2 + .../settings-view/editor/editor-section.tsx | 405 +++++ .../settings-view/hooks/use-settings-view.ts | 1 + apps/ui/src/hooks/use-settings-sync.ts | 15 + apps/ui/src/lib/electron.ts | 120 ++ apps/ui/src/lib/http-api-client.ts | 47 +- apps/ui/src/routes/files.tsx | 6 + apps/ui/src/store/app-store.ts | 203 ++- apps/ui/src/store/defaults/constants.ts | 11 + apps/ui/src/store/defaults/index.ts | 9 +- apps/ui/src/store/types/state-types.ts | 42 + apps/ui/src/store/types/ui-types.ts | 48 + apps/ui/src/store/utils/shortcut-utils.ts | 1 + apps/ui/tests/files/git-integration.spec.ts | 286 ++++ libs/prompts/src/defaults.ts | 4 +- libs/types/src/index.ts | 9 +- libs/types/src/ports.ts | 28 +- package-lock.json | 291 ++++ 35 files changed, 6126 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/routes/fs/routes/git-diff.ts create mode 100644 apps/server/src/routes/fs/routes/git-stage.ts create mode 100644 apps/server/src/routes/fs/routes/git-status.ts create mode 100644 apps/server/src/routes/fs/routes/rename.ts create mode 100644 apps/server/src/routes/fs/routes/search-content.ts create mode 100644 apps/server/src/routes/fs/routes/search-files.ts create mode 100644 apps/ui/src/components/ui/code-editor.tsx create mode 100644 apps/ui/src/components/ui/file-search-dialog.tsx create mode 100644 apps/ui/src/components/ui/file-tree.tsx create mode 100644 apps/ui/src/components/views/files-view.tsx create mode 100644 apps/ui/src/components/views/settings-view/editor/editor-section.tsx create mode 100644 apps/ui/src/routes/files.tsx create mode 100644 apps/ui/tests/files/git-integration.spec.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2e9276da3..778fb5a76 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -13,11 +13,13 @@ import cookieParser from 'cookie-parser'; import cookie from 'cookie'; import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; +import net from 'net'; import dotenv from 'dotenv'; import { createEventEmitter, type EventEmitter } from './lib/events.js'; import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform'; import { createLogger, setLogLevel, LogLevel } from '@automaker/utils'; +import { registerRuntimePort } from '@automaker/types'; const logger = createLogger('Server'); @@ -709,8 +711,52 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage }); }); -// Start server with error handling for port conflicts -const startServer = (port: number, host: string) => { +// Port conflict resolution: find an available port instead of crashing +const MAX_PORT_SEARCH_ATTEMPTS = 100; + +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const testServer = net.createServer(); + testServer.once('error', () => resolve(false)); + testServer.once('listening', () => { + testServer.close(() => resolve(true)); + }); + testServer.listen(port); + }); +} + +async function findAvailablePort(preferredPort: number): Promise { + for (let offset = 0; offset < MAX_PORT_SEARCH_ATTEMPTS; offset++) { + const port = preferredPort + offset; + if (await isPortAvailable(port)) { + return port; + } + } + throw new Error( + `Could not find an available port in range ${preferredPort}-${preferredPort + MAX_PORT_SEARCH_ATTEMPTS - 1}` + ); +} + +// Start server with automatic port conflict resolution +const startServer = async (preferredPort: number, host: string) => { + let port: number; + try { + port = await findAvailablePort(preferredPort); + } catch { + logger.error( + `Could not find an available port starting from ${preferredPort}. All ports in range ${preferredPort}-${preferredPort + MAX_PORT_SEARCH_ATTEMPTS - 1} are in use.` + ); + process.exit(1); + return; // unreachable, but satisfies TypeScript + } + + if (port !== preferredPort) { + logger.info(`Default port ${preferredPort} is in use, using port ${port} instead`); + } + + // Register the actual port so dev-server-service won't kill it + registerRuntimePort(port); + server.listen(port, host, () => { const terminalStatus = isTerminalEnabled() ? isTerminalPasswordRequired() @@ -756,47 +802,8 @@ const startServer = (port: number, host: string) => { }); server.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - const portStr = port.toString(); - const nextPortStr = (port + 1).toString(); - const killCmd = `lsof -ti:${portStr} | xargs kill -9`; - const altCmd = `PORT=${nextPortStr} npm run dev:server`; - - const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH); - const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH); - const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH); - const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH); - const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH); - const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH); - const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH); - const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH); - const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH); - - logger.error(` -╔═════════════════════════════════════════════════════════════════════╗ -║ ${eHeader}║ -╠═════════════════════════════════════════════════════════════════════╣ -║ ║ -║ ${e1}║ -║ ║ -║ ${e2}║ -║ ║ -║ ${e3}║ -║ ${e4}║ -║ ║ -║ ${e5}║ -║ ${e6}║ -║ ║ -║ ${e7}║ -║ ${e8}║ -║ ║ -╚═════════════════════════════════════════════════════════════════════╝ -`); - process.exit(1); - } else { - logger.error('Error starting server:', error); - process.exit(1); - } + logger.error('Error starting server:', error); + process.exit(1); }); }; diff --git a/apps/server/src/routes/fs/index.ts b/apps/server/src/routes/fs/index.ts index 58732b3a9..6e4129a52 100644 --- a/apps/server/src/routes/fs/index.ts +++ b/apps/server/src/routes/fs/index.ts @@ -19,6 +19,12 @@ import { createBrowseHandler } from './routes/browse.js'; import { createImageHandler } from './routes/image.js'; import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js'; import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js'; +import { createRenameHandler } from './routes/rename.js'; +import { createGitStatusHandler } from './routes/git-status.js'; +import { createGitDiffHandler } from './routes/git-diff.js'; +import { createGitStageHandler } from './routes/git-stage.js'; +import { createSearchFilesHandler } from './routes/search-files.js'; +import { createSearchContentHandler } from './routes/search-content.js'; export function createFsRoutes(_events: EventEmitter): Router { const router = Router(); @@ -30,6 +36,7 @@ export function createFsRoutes(_events: EventEmitter): Router { router.post('/exists', createExistsHandler()); router.post('/stat', createStatHandler()); router.post('/delete', createDeleteHandler()); + router.post('/rename', createRenameHandler()); router.post('/validate-path', createValidatePathHandler()); router.post('/resolve-directory', createResolveDirectoryHandler()); router.post('/save-image', createSaveImageHandler()); @@ -37,6 +44,11 @@ export function createFsRoutes(_events: EventEmitter): Router { router.get('/image', createImageHandler()); router.post('/save-board-background', createSaveBoardBackgroundHandler()); router.post('/delete-board-background', createDeleteBoardBackgroundHandler()); + router.post('/git-status', createGitStatusHandler()); + router.post('/git-diff', createGitDiffHandler()); + router.post('/git-stage', createGitStageHandler()); + router.post('/search-files', createSearchFilesHandler()); + router.post('/search-content', createSearchContentHandler()); return router; } diff --git a/apps/server/src/routes/fs/routes/git-diff.ts b/apps/server/src/routes/fs/routes/git-diff.ts new file mode 100644 index 000000000..e24212eab --- /dev/null +++ b/apps/server/src/routes/fs/routes/git-diff.ts @@ -0,0 +1,115 @@ +/** + * POST /git-diff endpoint - Get git diff for a specific file + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { isGitRepo } from '@automaker/git-utils'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createGitDiffHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { repoPath, filePath } = req.body as { repoPath: string; filePath?: string }; + + if (!repoPath) { + res.status(400).json({ success: false, error: 'repoPath is required' }); + return; + } + + const isRepo = await isGitRepo(repoPath); + if (!isRepo) { + res.json({ success: true, diff: '', hunks: [] }); + return; + } + + // Get diff for a specific file or all files + const diffCmd = filePath ? `git diff HEAD -- "${filePath}"` : 'git diff HEAD'; + + const { stdout: diff } = await execAsync(diffCmd, { + cwd: repoPath, + maxBuffer: 10 * 1024 * 1024, + }); + + // Parse diff into hunks for gutter display + const hunks = parseDiffHunks(diff); + + res.json({ success: true, diff, hunks }); + } catch (error) { + logError(error, 'Git diff failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export interface DiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + changes: DiffChange[]; +} + +export interface DiffChange { + type: 'add' | 'delete' | 'context'; + line: number; + content: string; +} + +/** + * Parse a unified diff string into structured hunks + */ +function parseDiffHunks(diff: string): DiffHunk[] { + const hunks: DiffHunk[] = []; + const lines = diff.split('\n'); + + let currentHunk: DiffHunk | null = null; + let newLineNum = 0; + + for (const line of lines) { + // Match hunk header: @@ -oldStart,oldLines +newStart,newLines @@ + const hunkMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/); + if (hunkMatch) { + currentHunk = { + oldStart: parseInt(hunkMatch[1]), + oldLines: parseInt(hunkMatch[2] || '1'), + newStart: parseInt(hunkMatch[3]), + newLines: parseInt(hunkMatch[4] || '1'), + changes: [], + }; + newLineNum = currentHunk.newStart; + hunks.push(currentHunk); + continue; + } + + if (!currentHunk) continue; + + if (line.startsWith('+') && !line.startsWith('+++')) { + currentHunk.changes.push({ + type: 'add', + line: newLineNum, + content: line.slice(1), + }); + newLineNum++; + } else if (line.startsWith('-') && !line.startsWith('---')) { + currentHunk.changes.push({ + type: 'delete', + line: newLineNum, + content: line.slice(1), + }); + // Don't increment newLineNum for deleted lines + } else if (line.startsWith(' ')) { + currentHunk.changes.push({ + type: 'context', + line: newLineNum, + content: line.slice(1), + }); + newLineNum++; + } + } + + return hunks; +} diff --git a/apps/server/src/routes/fs/routes/git-stage.ts b/apps/server/src/routes/fs/routes/git-stage.ts new file mode 100644 index 000000000..c1aea4da7 --- /dev/null +++ b/apps/server/src/routes/fs/routes/git-stage.ts @@ -0,0 +1,54 @@ +/** + * POST /git-stage endpoint - Stage or unstage files + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { isGitRepo } from '@automaker/git-utils'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createGitStageHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { repoPath, filePath, action } = req.body as { + repoPath: string; + filePath: string; + action: 'stage' | 'unstage'; + }; + + if (!repoPath) { + res.status(400).json({ success: false, error: 'repoPath is required' }); + return; + } + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + if (!action || !['stage', 'unstage'].includes(action)) { + res.status(400).json({ success: false, error: 'action must be "stage" or "unstage"' }); + return; + } + + const isRepo = await isGitRepo(repoPath); + if (!isRepo) { + res.status(400).json({ success: false, error: 'Not a git repository' }); + return; + } + + const cmd = + action === 'stage' ? `git add -- "${filePath}"` : `git reset HEAD -- "${filePath}"`; + + await execAsync(cmd, { cwd: repoPath }); + + res.json({ success: true }); + } catch (error) { + logError(error, `Git ${req.body?.action || 'stage'} failed`); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/git-status.ts b/apps/server/src/routes/fs/routes/git-status.ts new file mode 100644 index 000000000..549167431 --- /dev/null +++ b/apps/server/src/routes/fs/routes/git-status.ts @@ -0,0 +1,41 @@ +/** + * POST /git-status endpoint - Get git status for a repository + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { isGitRepo, parseGitStatus } from '@automaker/git-utils'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createGitStatusHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { repoPath } = req.body as { repoPath: string }; + + if (!repoPath) { + res.status(400).json({ success: false, error: 'repoPath is required' }); + return; + } + + const isRepo = await isGitRepo(repoPath); + if (!isRepo) { + res.json({ success: true, isGitRepo: false, files: [] }); + return; + } + + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: repoPath, + }); + + const files = parseGitStatus(status); + + res.json({ success: true, isGitRepo: true, files }); + } catch (error) { + logError(error, 'Git status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/rename.ts b/apps/server/src/routes/fs/routes/rename.ts new file mode 100644 index 000000000..f397ae922 --- /dev/null +++ b/apps/server/src/routes/fs/routes/rename.ts @@ -0,0 +1,42 @@ +/** + * POST /rename endpoint - Rename/move file or directory + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createRenameHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { oldPath, newPath } = req.body as { + oldPath: string; + newPath: string; + }; + + if (!oldPath) { + res.status(400).json({ success: false, error: 'oldPath is required' }); + return; + } + + if (!newPath) { + res.status(400).json({ success: false, error: 'newPath is required' }); + return; + } + + await secureFs.rename(oldPath, newPath); + + res.json({ success: true }); + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Rename failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/search-content.ts b/apps/server/src/routes/fs/routes/search-content.ts new file mode 100644 index 000000000..b876be06f --- /dev/null +++ b/apps/server/src/routes/fs/routes/search-content.ts @@ -0,0 +1,244 @@ +/** + * POST /search-content endpoint - Search file contents (grep-like) + */ + +import type { Request, Response } from 'express'; +import { promises as fs } from 'fs'; +import { join, relative } from 'path'; +import { getErrorMessage, logError } from '../common.js'; + +/** Directories to skip during search */ +const IGNORED_DIRS = new Set([ + 'node_modules', + '.git', + '.DS_Store', + '.automaker', + 'dist', + 'build', + '.next', + '.cache', + '.turbo', + '__pycache__', + '.vscode', + '.idea', + '.svn', + '.hg', + 'coverage', + '.nyc_output', + '.parcel-cache', +]); + +/** Binary file extensions to skip */ +const BINARY_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'bmp', + 'ico', + 'webp', + 'svg', + 'mp3', + 'mp4', + 'avi', + 'mov', + 'mkv', + 'wav', + 'flac', + 'zip', + 'tar', + 'gz', + 'bz2', + 'xz', + '7z', + 'rar', + 'pdf', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx', + 'woff', + 'woff2', + 'ttf', + 'otf', + 'eot', + 'exe', + 'dll', + 'so', + 'dylib', + 'bin', + 'lock', +]); + +/** Maximum results */ +const MAX_RESULTS = 50; +/** Maximum depth */ +const MAX_DEPTH = 20; +/** Maximum file size to search (1MB) */ +const MAX_FILE_SIZE = 1024 * 1024; +/** Context lines before/after match */ +const CONTEXT_LINES = 1; + +interface ContentMatch { + path: string; + relativePath: string; + name: string; + matches: Array<{ + line: number; + content: string; + preview: string; // surrounding context + }>; +} + +/** + * Search file content for a pattern + */ +async function searchFileContent( + filePath: string, + pattern: RegExp, + maxMatchesPerFile: number +): Promise> { + let content: string; + try { + // Check size first + const stat = await fs.stat(filePath); + if (stat.size > MAX_FILE_SIZE) return []; + content = await fs.readFile(filePath, 'utf-8'); + } catch { + return []; + } + + const lines = content.split('\n'); + const matches: Array<{ line: number; content: string; preview: string }> = []; + + for (let i = 0; i < lines.length && matches.length < maxMatchesPerFile; i++) { + if (pattern.test(lines[i])) { + // Build preview with context + const start = Math.max(0, i - CONTEXT_LINES); + const end = Math.min(lines.length - 1, i + CONTEXT_LINES); + const previewLines = lines.slice(start, end + 1); + + matches.push({ + line: i + 1, // 1-indexed + content: lines[i].trim(), + preview: previewLines.join('\n'), + }); + } + } + + return matches; +} + +/** + * Recursively walk and search file contents + */ +async function walkAndSearch( + rootPath: string, + dirPath: string, + pattern: RegExp, + results: ContentMatch[], + depth: number, + fileTypes?: string[], + maxMatchesPerFile: number = 5 +): Promise { + if (depth > MAX_DEPTH || results.length >= MAX_RESULTS) return; + + let entries; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (results.length >= MAX_RESULTS) return; + + if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + + const fullPath = join(dirPath, entry.name); + + if (entry.isDirectory()) { + await walkAndSearch( + rootPath, + fullPath, + pattern, + results, + depth + 1, + fileTypes, + maxMatchesPerFile + ); + } else { + // Skip binary files + const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() || '' : ''; + if (BINARY_EXTENSIONS.has(ext)) continue; + + // Check file type filter + if (fileTypes && fileTypes.length > 0 && ext && !fileTypes.includes(ext)) continue; + + const matches = await searchFileContent(fullPath, pattern, maxMatchesPerFile); + if (matches.length > 0) { + results.push({ + path: fullPath, + relativePath: relative(rootPath, fullPath), + name: entry.name, + matches, + }); + } + } + } +} + +export function createSearchContentHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { rootPath, query, fileTypes, caseSensitive, useRegex, limit } = req.body as { + rootPath: string; + query: string; + fileTypes?: string[]; + caseSensitive?: boolean; + useRegex?: boolean; + limit?: number; + }; + + if (!rootPath) { + res.status(400).json({ success: false, error: 'rootPath is required' }); + return; + } + + if (!query || query.trim().length === 0) { + res.json({ success: true, results: [] }); + return; + } + + // Build regex pattern + let pattern: RegExp; + try { + const flags = caseSensitive ? 'g' : 'gi'; + if (useRegex) { + pattern = new RegExp(query, flags); + } else { + // Escape regex special chars for literal search + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + pattern = new RegExp(escaped, flags); + } + } catch { + res.status(400).json({ success: false, error: 'Invalid search pattern' }); + return; + } + + const results: ContentMatch[] = []; + await walkAndSearch(rootPath, rootPath, pattern, results, 0, fileTypes); + + // Apply limit + const maxResults = Math.min(limit || MAX_RESULTS, MAX_RESULTS); + const trimmed = results.slice(0, maxResults); + + res.json({ success: true, results: trimmed }); + } catch (error) { + logError(error, 'Content search failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/search-files.ts b/apps/server/src/routes/fs/routes/search-files.ts new file mode 100644 index 000000000..046c29635 --- /dev/null +++ b/apps/server/src/routes/fs/routes/search-files.ts @@ -0,0 +1,207 @@ +/** + * POST /search-files endpoint - Recursively search for files by name with fuzzy matching + */ + +import type { Request, Response } from 'express'; +import { promises as fs } from 'fs'; +import { join, relative } from 'path'; +import { getErrorMessage, logError } from '../common.js'; + +/** Directories to skip during recursive search */ +const IGNORED_DIRS = new Set([ + 'node_modules', + '.git', + '.DS_Store', + '.automaker', + 'dist', + 'build', + '.next', + '.cache', + '.turbo', + '__pycache__', + '.vscode', + '.idea', + '.svn', + '.hg', + 'coverage', + '.nyc_output', + '.parcel-cache', +]); + +/** Maximum number of results to return */ +const MAX_RESULTS = 100; + +/** Maximum depth to traverse */ +const MAX_DEPTH = 20; + +interface FileSearchResult { + path: string; // Absolute path + relativePath: string; // Relative to rootPath + name: string; // File name + isDirectory: boolean; + score: number; // Fuzzy match score (lower = better) +} + +/** + * Simple fuzzy match scoring. + * Returns a score (lower is better) or -1 if no match. + * Prefers: + * - Exact matches + * - Prefix matches + * - Consecutive character matches + * - Shorter file names + */ +function fuzzyScore(query: string, target: string): number { + const lowerQuery = query.toLowerCase(); + const lowerTarget = target.toLowerCase(); + + // Exact match + if (lowerTarget === lowerQuery) return 0; + + // Exact substring match + const substringIndex = lowerTarget.indexOf(lowerQuery); + if (substringIndex !== -1) { + // Prefix match is best (score 1), otherwise position-based + return substringIndex === 0 ? 1 : 2 + substringIndex; + } + + // Fuzzy character matching + let queryIdx = 0; + let score = 0; + let lastMatchIdx = -1; + let consecutiveBonus = 0; + + for (let i = 0; i < lowerTarget.length && queryIdx < lowerQuery.length; i++) { + if (lowerTarget[i] === lowerQuery[queryIdx]) { + // Consecutive match bonus + if (lastMatchIdx === i - 1) { + consecutiveBonus += 1; + } + // Word boundary bonus (after /, -, _, .) + const prevChar = i > 0 ? lowerTarget[i - 1] : '/'; + const isBoundary = + prevChar === '/' || prevChar === '-' || prevChar === '_' || prevChar === '.'; + score += isBoundary ? 0 : i - (lastMatchIdx === -1 ? 0 : lastMatchIdx); + lastMatchIdx = i; + queryIdx++; + } + } + + // All query chars matched? + if (queryIdx < lowerQuery.length) return -1; + + // Final score: gap penalty minus consecutive bonus, plus length penalty + return Math.max( + 10, + score - consecutiveBonus * 2 + (lowerTarget.length - lowerQuery.length) * 0.5 + ); +} + +/** + * Recursively walk a directory collecting matching files. + */ +async function walkAndMatch( + rootPath: string, + dirPath: string, + query: string, + results: FileSearchResult[], + depth: number, + fileTypeFilter?: string[], + showModifiedOnly?: boolean +): Promise { + if (depth > MAX_DEPTH || results.length >= MAX_RESULTS) return; + + let entries; + try { + entries = await fs.readdir(dirPath, { withFileTypes: true }); + } catch { + return; // Permission denied or gone + } + + for (const entry of entries) { + if (results.length >= MAX_RESULTS) return; + + if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + + const fullPath = join(dirPath, entry.name); + const relPath = relative(rootPath, fullPath); + + if (entry.isDirectory()) { + await walkAndMatch( + rootPath, + fullPath, + query, + results, + depth + 1, + fileTypeFilter, + showModifiedOnly + ); + } else { + // Check file type filter + if (fileTypeFilter && fileTypeFilter.length > 0) { + const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : ''; + if (ext && !fileTypeFilter.includes(ext)) continue; + } + + // Score against both file name and relative path + const nameScore = fuzzyScore(query, entry.name); + const pathScore = fuzzyScore(query, relPath); + const bestScore = + nameScore === -1 && pathScore === -1 + ? -1 + : nameScore === -1 + ? pathScore + : pathScore === -1 + ? nameScore + : Math.min(nameScore, pathScore); + + if (bestScore >= 0) { + results.push({ + path: fullPath, + relativePath: relPath, + name: entry.name, + isDirectory: false, + score: bestScore, + }); + } + } + } +} + +export function createSearchFilesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { rootPath, query, fileTypes, limit } = req.body as { + rootPath: string; + query: string; + fileTypes?: string[]; + limit?: number; + }; + + if (!rootPath) { + res.status(400).json({ success: false, error: 'rootPath is required' }); + return; + } + + if (!query || query.trim().length === 0) { + res.json({ success: true, results: [] }); + return; + } + + const results: FileSearchResult[] = []; + await walkAndMatch(rootPath, rootPath, query.trim(), results, 0, fileTypes); + + // Sort by score (lower = better match) + results.sort((a, b) => a.score - b.score); + + // Apply limit + const maxResults = Math.min(limit || MAX_RESULTS, MAX_RESULTS); + const trimmed = results.slice(0, maxResults); + + res.json({ success: true, results: trimmed }); + } catch (error) { + logError(error, 'File search failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index d81e539c3..119aa2baa 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -12,6 +12,7 @@ import * as secureFs from '../lib/secure-fs.js'; import path from 'path'; import net from 'net'; import { createLogger } from '@automaker/utils'; +import { isAutomakerPort } from '@automaker/types'; import type { EventEmitter } from '../lib/events.js'; const logger = createLogger('DevServerService'); @@ -205,9 +206,14 @@ class DevServerService { } /** - * Kill any process running on the given port + * Kill any process running on the given port. + * Refuses to kill processes on Automaker's reserved ports. */ private killProcessOnPort(port: number): void { + if (this.isReservedPort(port)) { + logger.debug(`Skipping kill on reserved Automaker port ${port}`); + return; + } try { if (process.platform === 'win32') { // Windows: find and kill process on port @@ -253,7 +259,14 @@ class DevServerService { } /** - * Find the next available port, killing any process on it first + * Check if a port is reserved by Automaker and should never be killed + */ + private isReservedPort(port: number): boolean { + return isAutomakerPort(port); + } + + /** + * Find the next available port, killing any non-reserved process on it first */ private async findAvailablePort(): Promise { let port = BASE_PORT; @@ -265,6 +278,12 @@ class DevServerService { continue; } + // Never kill processes on Automaker's reserved ports (UI/server) + if (this.isReservedPort(port)) { + port++; + continue; + } + // Force kill any process on this port before checking availability // This ensures we can claim the port even if something stale is holding it this.killProcessOnPort(port); diff --git a/apps/ui/package.json b/apps/ui/package.json index 2a9b71b23..30d7c4c44 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -42,7 +42,18 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/spec-parser": "1.0.0", "@automaker/types": "1.0.0", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "6.1.0", + "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.1", "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "6.1.3", @@ -80,6 +91,8 @@ "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.2.8", + "@replit/codemirror-emacs": "^6.1.0", + "@replit/codemirror-vim": "^6.3.0", "@tanstack/react-query": "^5.90.17", "@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-router": "1.141.6", diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 90d59db9d..44c447d8e 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -6,6 +6,7 @@ import { Bot, BookOpen, Terminal, + FolderOpen, CircleDot, GitPullRequest, Lightbulb, @@ -34,6 +35,7 @@ interface UseNavigationProps { graph: string; agent: string; terminal: string; + files: string; settings: string; projectSettings: string; ideation: string; @@ -174,6 +176,14 @@ export function useNavigation({ }); } + // Add Files to Project section + projectItems.push({ + id: 'files', + label: 'Files', + icon: FolderOpen, + shortcut: shortcuts.files, + }); + const sections: NavSection[] = [ // Dashboard - standalone at top (links to projects overview) { diff --git a/apps/ui/src/components/ui/code-editor.tsx b/apps/ui/src/components/ui/code-editor.tsx new file mode 100644 index 000000000..71d9476be --- /dev/null +++ b/apps/ui/src/components/ui/code-editor.tsx @@ -0,0 +1,633 @@ +import { useMemo, useCallback, useRef } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { EditorView, gutter, GutterMarker } from '@codemirror/view'; +import { Extension, EditorState } from '@codemirror/state'; +import { indentUnit } from '@codemirror/language'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; +import { javascript } from '@codemirror/lang-javascript'; +import { html } from '@codemirror/lang-html'; +import { css } from '@codemirror/lang-css'; +import { json } from '@codemirror/lang-json'; +import { markdown } from '@codemirror/lang-markdown'; +import { xml } from '@codemirror/lang-xml'; +import { python } from '@codemirror/lang-python'; +import { rust } from '@codemirror/lang-rust'; +import { cpp } from '@codemirror/lang-cpp'; +import { java } from '@codemirror/lang-java'; +import { sql } from '@codemirror/lang-sql'; +import { yaml } from '@codemirror/lang-yaml'; +import { StreamLanguage } from '@codemirror/language'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { go } from '@codemirror/legacy-modes/mode/go'; +import { ruby } from '@codemirror/legacy-modes/mode/ruby'; +import { toml } from '@codemirror/legacy-modes/mode/toml'; +import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'; +import { vim } from '@replit/codemirror-vim'; +import { emacs } from '@replit/codemirror-emacs'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import type { DiffHunk } from '@/lib/electron'; +import type { EditorKeybindings } from '@/store/types/ui-types'; + +const DEFAULT_MONO_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'; + +interface CodeEditorProps { + value: string; + onChange?: (value: string) => void; + onCursorChange?: (line: number, column: number) => void; + language?: string; + readOnly?: boolean; + mobile?: boolean; + diffHunks?: DiffHunk[]; + className?: string; + 'data-testid'?: string; +} + +// Syntax highlighting using CSS variables for theme compatibility +const syntaxColors = HighlightStyle.define([ + // Keywords (if, else, return, function, class, etc.) + { tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + // Definitions (class names, function names when defined) + { tag: t.definition(t.variableName), color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, + { + tag: t.definition(t.function(t.variableName)), + color: 'var(--chart-2, oklch(0.6 0.118 184.704))', + }, + // Type names + { tag: t.typeName, color: 'var(--chart-3, oklch(0.7 0.15 150))' }, + // Function/method calls + { tag: t.function(t.variableName), color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, + // Variable names + { tag: t.variableName, color: 'var(--foreground)' }, + // Property names + { tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, + // Strings + { tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, + // Numbers + { tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' }, + // Booleans and null + { tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + { tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' }, + // Comments + { tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' }, + { tag: t.lineComment, color: 'var(--muted-foreground)', fontStyle: 'italic' }, + { tag: t.blockComment, color: 'var(--muted-foreground)', fontStyle: 'italic' }, + // Operators + { tag: t.operator, color: 'var(--chart-5, oklch(0.65 0.2 30))' }, + // Brackets and punctuation + { tag: t.bracket, color: 'var(--muted-foreground)' }, + { tag: t.punctuation, color: 'var(--muted-foreground)' }, + // Tags (HTML/XML) + { tag: t.tagName, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, + { tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' }, + { tag: t.attributeValue, color: 'var(--chart-3, oklch(0.7 0.15 150))' }, + // Heading (for Markdown) + { tag: t.heading, color: 'var(--chart-4, oklch(0.7 0.15 280))', fontWeight: 'bold' }, + // Meta / decorators + { tag: t.meta, color: 'var(--chart-5, oklch(0.65 0.2 30))' }, + // Regex + { tag: t.regexp, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' }, + // Default text + { tag: t.content, color: 'var(--foreground)' }, +]); + +/** Build a dynamic EditorView theme based on user preferences */ +function createEditorTheme(opts: { + fontSize: string; + fontFamily: string; + lineHeight: string; + ligatures: boolean; + mobile?: boolean; +}): Extension { + const fontFeatureSettings = opts.ligatures ? '"liga" 1, "calt" 1' : '"liga" 0, "calt" 0'; + + if (opts.mobile) { + return EditorView.theme({ + '&': { + height: '100%', + fontSize: '16px', // Prevents iOS zoom on focus + fontFamily: opts.fontFamily, + fontFeatureSettings, + backgroundColor: 'transparent', + color: 'var(--foreground)', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: opts.fontFamily, + lineHeight: opts.lineHeight, + '-webkit-overflow-scrolling': 'touch', + }, + '.cm-content': { + padding: '0.5rem 0', + minHeight: '100%', + caretColor: 'var(--primary)', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--primary)', + borderLeftWidth: '2px', + }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--accent)', + opacity: '0.5', + }, + '.cm-line': { + padding: '2px 1rem 2px 0.25rem', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--muted-foreground)', + border: 'none', + borderRight: '1px solid var(--border)', + paddingRight: '0.25rem', + }, + '.cm-lineNumbers .cm-gutterElement': { + minWidth: '2.5rem', + textAlign: 'right', + paddingRight: '0.375rem', + fontSize: '0.8125rem', + }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--accent)', + opacity: '0.5', + }, + '.cm-panels': { + backgroundColor: 'var(--muted)', + color: 'var(--foreground)', + borderBottom: '1px solid var(--border)', + }, + '.cm-panels.cm-panels-top': { + borderBottom: '1px solid var(--border)', + }, + '.cm-searchMatch': { + backgroundColor: 'oklch(0.7 0.15 80 / 0.4)', + }, + '.cm-searchMatch.cm-searchMatch-selected': { + backgroundColor: 'oklch(0.7 0.15 80 / 0.7)', + }, + '.cm-foldPlaceholder': { + backgroundColor: 'var(--muted)', + border: '1px solid var(--border)', + color: 'var(--muted-foreground)', + borderRadius: '0.25rem', + padding: '0 0.25rem', + margin: '0 0.25rem', + }, + '&.cm-focused .cm-matchingBracket': { + backgroundColor: 'oklch(0.55 0.25 265 / 0.2)', + outline: '1px solid oklch(0.55 0.25 265 / 0.5)', + }, + '.cm-tooltip': { + backgroundColor: 'var(--popover)', + color: 'var(--popover-foreground)', + border: '1px solid var(--border)', + borderRadius: '0.375rem', + }, + '.cm-diff-gutter': { width: '3px', marginRight: '2px' }, + '.cm-diff-gutter .cm-gutterElement': { padding: '0', minWidth: '3px' }, + '.cm-diff-added': { backgroundColor: 'oklch(0.65 0.2 145)' }, + '.cm-diff-modified': { backgroundColor: 'oklch(0.7 0.15 80)' }, + '.cm-diff-deleted': { backgroundColor: 'oklch(0.6 0.2 25)' }, + }); + } + + return EditorView.theme({ + '&': { + height: '100%', + fontSize: opts.fontSize, + fontFamily: opts.fontFamily, + fontFeatureSettings, + backgroundColor: 'transparent', + color: 'var(--foreground)', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: opts.fontFamily, + lineHeight: opts.lineHeight, + }, + '.cm-content': { + padding: '0.5rem 0', + minHeight: '100%', + caretColor: 'var(--primary)', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--primary)', + }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: 'oklch(0.55 0.25 265 / 0.3)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--accent)', + opacity: '0.5', + }, + '.cm-line': { + padding: '0 1rem 0 0.25rem', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + color: 'var(--muted-foreground)', + border: 'none', + borderRight: '1px solid var(--border)', + paddingRight: '0.25rem', + }, + '.cm-lineNumbers .cm-gutterElement': { + minWidth: '3rem', + textAlign: 'right', + paddingRight: '0.5rem', + fontSize: '0.75rem', + }, + '.cm-foldGutter .cm-gutterElement': { + padding: '0 0.25rem', + }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--accent)', + opacity: '0.5', + }, + '.cm-panels': { + backgroundColor: 'var(--muted)', + color: 'var(--foreground)', + borderBottom: '1px solid var(--border)', + }, + '.cm-panels.cm-panels-top': { + borderBottom: '1px solid var(--border)', + }, + '.cm-searchMatch': { + backgroundColor: 'oklch(0.7 0.15 80 / 0.4)', + }, + '.cm-searchMatch.cm-searchMatch-selected': { + backgroundColor: 'oklch(0.7 0.15 80 / 0.7)', + }, + '.cm-foldPlaceholder': { + backgroundColor: 'var(--muted)', + border: '1px solid var(--border)', + color: 'var(--muted-foreground)', + borderRadius: '0.25rem', + padding: '0 0.25rem', + margin: '0 0.25rem', + }, + '&.cm-focused .cm-matchingBracket': { + backgroundColor: 'oklch(0.55 0.25 265 / 0.2)', + outline: '1px solid oklch(0.55 0.25 265 / 0.5)', + }, + '.cm-tooltip': { + backgroundColor: 'var(--popover)', + color: 'var(--popover-foreground)', + border: '1px solid var(--border)', + borderRadius: '0.375rem', + }, + '.cm-diff-gutter': { width: '3px', marginRight: '2px' }, + '.cm-diff-gutter .cm-gutterElement': { padding: '0', minWidth: '3px' }, + '.cm-diff-added': { backgroundColor: 'oklch(0.65 0.2 145)' }, + '.cm-diff-modified': { backgroundColor: 'oklch(0.7 0.15 80)' }, + '.cm-diff-deleted': { backgroundColor: 'oklch(0.6 0.2 25)' }, + }); +} + +/** Git diff gutter marker for added lines */ +class DiffAddedMarker extends GutterMarker { + toDOM() { + const el = document.createElement('div'); + el.className = 'cm-diff-added'; + el.style.width = '3px'; + el.style.height = '100%'; + return el; + } +} + +/** Git diff gutter marker for modified lines */ +class DiffModifiedMarker extends GutterMarker { + toDOM() { + const el = document.createElement('div'); + el.className = 'cm-diff-modified'; + el.style.width = '3px'; + el.style.height = '100%'; + return el; + } +} + +/** Git diff gutter marker for deleted lines (shown as a thin line) */ +class DiffDeletedMarker extends GutterMarker { + toDOM() { + const el = document.createElement('div'); + el.className = 'cm-diff-deleted'; + el.style.width = '3px'; + el.style.height = '2px'; + el.style.marginTop = '-1px'; + return el; + } +} + +const addedMarker = new DiffAddedMarker(); +const modifiedMarker = new DiffModifiedMarker(); +const deletedMarker = new DiffDeletedMarker(); + +/** + * Create a CodeMirror gutter extension that shows diff indicators + * for added, modified, and deleted lines based on diff hunks + */ +function createDiffGutter(hunks: DiffHunk[]): Extension { + // Pre-compute which lines have which status + const lineStatuses = new Map(); + + for (const hunk of hunks) { + let hasDeletes = false; + let hasAdds = false; + + for (const change of hunk.changes) { + if (change.type === 'add') { + hasAdds = true; + lineStatuses.set(change.line, 'add'); + } else if (change.type === 'delete') { + hasDeletes = true; + } + } + + // If a hunk has both adds and deletes, mark the added lines as modified + if (hasDeletes && hasAdds) { + for (const change of hunk.changes) { + if (change.type === 'add') { + lineStatuses.set(change.line, 'modify'); + } + } + } + + // If there are only deletes, mark the line where content was deleted + if (hasDeletes && !hasAdds) { + lineStatuses.set(hunk.newStart, 'delete'); + } + } + + return gutter({ + class: 'cm-diff-gutter', + lineMarker(view, line) { + const lineNum = view.state.doc.lineAt(line.from).number; + const status = lineStatuses.get(lineNum); + if (status === 'add') return addedMarker; + if (status === 'modify') return modifiedMarker; + if (status === 'delete') return deletedMarker; + return null; + }, + }); +} + +/** Map file extension to a CodeMirror language extension */ +function getLanguageExtension(lang: string): Extension | null { + switch (lang) { + case 'typescript': + case 'tsx': + return javascript({ typescript: true, jsx: lang === 'tsx' }); + case 'javascript': + return javascript(); + case 'jsx': + return javascript({ jsx: true }); + case 'html': + return html(); + case 'css': + case 'scss': + case 'less': + return css(); + case 'json': + case 'jsonc': + return json(); + case 'markdown': + return markdown(); + case 'xml': + case 'svg': + return xml(); + case 'python': + return python(); + case 'rust': + return rust(); + case 'cpp': + case 'c': + case 'h': + case 'hpp': + return cpp(); + case 'java': + return java(); + case 'sql': + return sql(); + case 'yaml': + case 'yml': + return yaml(); + case 'shell': + case 'bash': + case 'sh': + case 'zsh': + return StreamLanguage.define(shell); + case 'go': + return StreamLanguage.define(go); + case 'ruby': + case 'rb': + return StreamLanguage.define(ruby); + case 'toml': + return StreamLanguage.define(toml); + case 'dockerfile': + return StreamLanguage.define(dockerFile); + default: + return null; + } +} + +/** Return the keybinding extension for the given mode */ +function getKeybindingExtension(mode: EditorKeybindings): Extension | null { + switch (mode) { + case 'vim': + return vim(); + case 'emacs': + return emacs(); + default: + return null; + } +} + +/** Map a file name/path to a language identifier */ +export function detectLanguage(filePath: string): string { + const name = filePath.split('/').pop() || filePath; + const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : ''; + + // Check exact file names first + const fileNameMap: Record = { + Dockerfile: 'dockerfile', + Makefile: 'shell', + Rakefile: 'ruby', + Gemfile: 'ruby', + '.gitignore': 'shell', + '.env': 'shell', + '.bashrc': 'bash', + '.zshrc': 'zsh', + }; + if (fileNameMap[name]) return fileNameMap[name]; + + // Then check extensions + const extMap: Record = { + ts: 'typescript', + tsx: 'tsx', + js: 'javascript', + jsx: 'jsx', + mjs: 'javascript', + cjs: 'javascript', + mts: 'typescript', + cts: 'typescript', + html: 'html', + htm: 'html', + css: 'css', + scss: 'scss', + less: 'less', + json: 'json', + jsonc: 'jsonc', + md: 'markdown', + mdx: 'markdown', + xml: 'xml', + svg: 'svg', + py: 'python', + rs: 'rust', + cpp: 'cpp', + c: 'c', + h: 'h', + hpp: 'hpp', + java: 'java', + sql: 'sql', + yaml: 'yaml', + yml: 'yml', + sh: 'sh', + bash: 'bash', + zsh: 'zsh', + go: 'go', + rb: 'ruby', + toml: 'toml', + }; + + return ext ? extMap[ext] || '' : ''; +} + +export function CodeEditor({ + value, + onChange, + onCursorChange, + language = '', + readOnly = false, + mobile = false, + diffHunks, + className, + 'data-testid': testId, +}: CodeEditorProps) { + const onCursorChangeRef = useRef(onCursorChange); + onCursorChangeRef.current = onCursorChange; + + // Read editor preferences from store + const editorSettings = useAppStore((s) => s.fileEditorSettings); + + const resolvedFontFamily = editorSettings.fontFamily || DEFAULT_MONO_FONT; + const resolvedFontSize = `${editorSettings.fontSize}px`; + const resolvedLineHeight = `${editorSettings.lineHeight}`; + + // Serialize diffHunks to a stable key for useMemo + const diffHunksKey = useMemo( + () => (diffHunks && diffHunks.length > 0 ? JSON.stringify(diffHunks) : ''), + [diffHunks] + ); + + const extensions = useMemo(() => { + const theme = createEditorTheme({ + fontSize: resolvedFontSize, + fontFamily: resolvedFontFamily, + lineHeight: resolvedLineHeight, + ligatures: editorSettings.ligatures, + mobile, + }); + const exts: Extension[] = [syntaxHighlighting(syntaxColors), theme]; + + // Tab size and indent style + exts.push( + indentUnit.of(editorSettings.indentWithTabs ? '\t' : ' '.repeat(editorSettings.tabSize)) + ); + exts.push(EditorState.tabSize.of(editorSettings.tabSize)); + + // Word wrap + if (editorSettings.wordWrap) { + exts.push(EditorView.lineWrapping); + } + + // Language support + const langExt = getLanguageExtension(language); + if (langExt) exts.push(langExt); + + // Add diff gutter if hunks are available + if (diffHunks && diffHunks.length > 0) { + exts.push(createDiffGutter(diffHunks)); + } + + // Keybinding mode + const kbExt = getKeybindingExtension(editorSettings.keybindings); + if (kbExt) exts.push(kbExt); + + // Cursor position tracking extension + exts.push( + EditorView.updateListener.of((update) => { + if (update.selectionSet || update.docChanged) { + const pos = update.state.selection.main.head; + const line = update.state.doc.lineAt(pos); + onCursorChangeRef.current?.(line.number, pos - line.from + 1); + } + }) + ); + + return exts; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + language, + mobile, + diffHunksKey, + resolvedFontSize, + resolvedFontFamily, + resolvedLineHeight, + editorSettings.ligatures, + editorSettings.tabSize, + editorSettings.indentWithTabs, + editorSettings.wordWrap, + editorSettings.keybindings, + ]); + + const handleChange = useCallback( + (val: string) => { + onChange?.(val); + }, + [onChange] + ); + + return ( +
+ +
+ ); +} diff --git a/apps/ui/src/components/ui/file-search-dialog.tsx b/apps/ui/src/components/ui/file-search-dialog.tsx new file mode 100644 index 000000000..a0952a73c --- /dev/null +++ b/apps/ui/src/components/ui/file-search-dialog.tsx @@ -0,0 +1,469 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { + Search, + FileText, + FileCode, + FileJson, + FileType, + Image, + Cog, + Package, + FileTerminal, + File, + Loader2, + FileSearch, + Text, +} from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import type { FileSearchResultItem, ContentSearchResultItem } from '@/lib/electron'; + +type SearchMode = 'files' | 'content'; + +interface FileSearchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rootPath: string; + onFileSelect: (filePath: string) => void; +} + +function getFileIcon(name: string) { + const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : ''; + const baseName = name.toLowerCase(); + + if ( + baseName.startsWith('.') || + baseName === 'tsconfig.json' || + baseName.endsWith('.config.ts') || + baseName.endsWith('.config.js') + ) + return Cog; + if (baseName === 'package.json' || baseName === 'package-lock.json') return Package; + + switch (ext) { + case 'ts': + case 'tsx': + case 'js': + case 'jsx': + case 'mjs': + case 'cjs': + case 'py': + case 'rs': + case 'go': + case 'rb': + case 'java': + case 'cpp': + case 'c': + case 'h': + case 'hpp': + case 'cs': + case 'php': + case 'swift': + case 'html': + case 'htm': + case 'xml': + return FileCode; + case 'md': + case 'mdx': + case 'txt': + return FileText; + case 'json': + case 'jsonc': + return FileJson; + case 'css': + case 'scss': + case 'less': + return FileType; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'svg': + case 'webp': + return Image; + case 'sh': + case 'bash': + case 'zsh': + return FileTerminal; + case 'yaml': + case 'yml': + case 'toml': + case 'ini': + case 'env': + return Cog; + default: + return File; + } +} + +function getFileIconColor(name: string): string { + const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : ''; + switch (ext) { + case 'ts': + case 'tsx': + return 'text-blue-400'; + case 'js': + case 'jsx': + case 'mjs': + case 'cjs': + return 'text-yellow-400'; + case 'json': + case 'jsonc': + return 'text-yellow-500'; + case 'css': + case 'scss': + case 'less': + return 'text-purple-400'; + case 'html': + case 'htm': + return 'text-orange-400'; + case 'md': + case 'mdx': + return 'text-sky-400'; + case 'py': + return 'text-green-400'; + case 'rs': + return 'text-orange-500'; + case 'go': + return 'text-cyan-400'; + default: + return 'text-muted-foreground'; + } +} + +function highlightMatch(text: string, query: string): React.ReactNode { + if (!query) return text; + const lower = text.toLowerCase(); + const qLower = query.toLowerCase(); + const idx = lower.indexOf(qLower); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + query.length)} + {text.slice(idx + query.length)} + + ); +} + +export function FileSearchDialog({ + open, + onOpenChange, + rootPath, + onFileSelect, +}: FileSearchDialogProps) { + const [query, setQuery] = useState(''); + const [mode, setMode] = useState('files'); + const [fileResults, setFileResults] = useState([]); + const [contentResults, setContentResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const debounceRef = useRef | null>(null); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setQuery(''); + setFileResults([]); + setContentResults([]); + setSelectedIndex(0); + setMode('files'); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + // Perform search with debounce + const performSearch = useCallback( + async (searchQuery: string, searchMode: SearchMode) => { + if (!searchQuery.trim() || !rootPath) { + setFileResults([]); + setContentResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + try { + const api = getElectronAPI(); + if (searchMode === 'files') { + const result = await api.searchFiles(rootPath, searchQuery.trim(), undefined, 50); + if (result.success) { + setFileResults(result.results); + } + } else { + const result = await api.searchContent(rootPath, searchQuery.trim(), { limit: 30 }); + if (result.success) { + setContentResults(result.results); + } + } + } catch { + // Silently handle errors + } finally { + setIsSearching(false); + } + }, + [rootPath] + ); + + // Debounced search on query change + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (!query.trim()) { + setFileResults([]); + setContentResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + debounceRef.current = setTimeout( + () => { + performSearch(query, mode); + }, + mode === 'files' ? 150 : 300 + ); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, mode, performSearch]); + + // Current results for keyboard navigation + const totalResults = mode === 'files' ? fileResults.length : contentResults.length; + + // Scroll selected item into view + useEffect(() => { + if (!listRef.current) return; + const selectedEl = listRef.current.querySelector(`[data-index="${selectedIndex}"]`); + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex]); + + const handleSelect = useCallback( + (filePath: string) => { + onFileSelect(filePath); + onOpenChange(false); + }, + [onFileSelect, onOpenChange] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, totalResults - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (mode === 'files' && fileResults[selectedIndex]) { + handleSelect(fileResults[selectedIndex].path); + } else if (mode === 'content' && contentResults[selectedIndex]) { + handleSelect(contentResults[selectedIndex].path); + } + } else if (e.key === 'Tab') { + e.preventDefault(); + setMode((prev) => (prev === 'files' ? 'content' : 'files')); + setSelectedIndex(0); + } + }, + [totalResults, selectedIndex, fileResults, contentResults, mode, handleSelect] + ); + + return ( + + + File Search + Search for files and content in the project + + + {/* Mode Tabs */} +
+ + +
Tab to switch
+
+ + {/* Search Input */} +
+ + { + setQuery(e.target.value); + setSelectedIndex(0); + }} + onKeyDown={handleKeyDown} + placeholder={mode === 'files' ? 'Search files by name...' : 'Search file content...'} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + data-testid="file-search-input" + /> + {isSearching && } +
+ + {/* Results */} +
+ {!query.trim() ? ( +
+ {mode === 'files' ? 'Type to search for files...' : 'Type to search file content...'} +
+ ) : isSearching && totalResults === 0 ? ( +
+ +
+ ) : totalResults === 0 ? ( +
No results found
+ ) : mode === 'files' ? ( +
+ {fileResults.map((result, idx) => { + const Icon = getFileIcon(result.name); + const iconColor = getFileIconColor(result.name); + return ( + + ); + })} +
+ ) : ( +
+ {contentResults.map((result, idx) => { + const Icon = getFileIcon(result.name); + const iconColor = getFileIconColor(result.name); + return ( + + ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ + ↑↓ Navigate + + + Enter Open + + + Tab Switch mode + + + Esc Close + +
+ {totalResults > 0 && ( + + {totalResults} result{totalResults !== 1 ? 's' : ''} + + )} +
+
+
+ ); +} diff --git a/apps/ui/src/components/ui/file-tree.tsx b/apps/ui/src/components/ui/file-tree.tsx new file mode 100644 index 000000000..e1100cdcd --- /dev/null +++ b/apps/ui/src/components/ui/file-tree.tsx @@ -0,0 +1,1379 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { + ChevronRight, + ChevronDown, + File, + Folder, + FolderOpen, + Loader2, + Search, + X, + FileText, + FileCode, + FileJson, + FileType, + Image, + Cog, + Package, + FileTerminal, + Copy, + Trash2, + Pencil, + FilePlus, + FolderPlus, + ExternalLink, +} from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import type { FileEntry, GitFileStatus } from '@/lib/electron'; + +interface FileTreeProps { + rootPath: string; + selectedFile?: string; + onFileSelect: (filePath: string) => void; + onRename?: (filePath: string) => void; + onDelete?: (filePath: string) => void; + onCreateFile?: (parentDirPath: string) => void; + onCreateFolder?: (parentDirPath: string) => void; + onCopyPath?: (filePath: string) => void; + onCopyRelativePath?: (filePath: string) => void; + onRevealInFileManager?: (filePath: string) => void; + renamingPath?: string | null; + onRenameSubmit?: (oldPath: string, newName: string) => void; + onRenameCancel?: () => void; + creatingIn?: { parentPath: string; type: 'file' | 'folder' } | null; + onCreateSubmit?: (parentPath: string, name: string, type: 'file' | 'folder') => void; + onCreateCancel?: () => void; + touchMode?: boolean; + gitStatusMap?: Map; + className?: string; + 'data-testid'?: string; +} + +interface TreeNode { + name: string; + path: string; + isDirectory: boolean; + children?: TreeNode[]; + isLoaded?: boolean; + isLoading?: boolean; + size?: number; + mtime?: string; +} + +// Directories/files to hide from the tree +const HIDDEN_ENTRIES = new Set([ + 'node_modules', + '.git', + '.DS_Store', + 'Thumbs.db', + '.automaker', + 'dist', + 'build', + '.next', + '.cache', + '.turbo', + '__pycache__', + '.vscode', + '.idea', +]); + +function sortEntries(entries: FileEntry[]): FileEntry[] { + return [...entries].sort((a, b) => { + // Directories first + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + // Then alphabetical (case-insensitive) + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); +} + +/** Get an appropriate icon component for a file based on its extension */ +function getFileIcon(name: string) { + const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : ''; + const baseName = name.toLowerCase(); + + // Config / dotfiles + if ( + baseName.startsWith('.') || + baseName === 'tsconfig.json' || + baseName === 'vite.config.ts' || + baseName === 'tailwind.config.ts' || + baseName === 'postcss.config.js' || + baseName === 'eslint.config.js' || + baseName === 'vitest.config.ts' + ) { + return Cog; + } + + // Package files + if (baseName === 'package.json' || baseName === 'package-lock.json') { + return Package; + } + + switch (ext) { + // Code files + case 'ts': + case 'tsx': + case 'js': + case 'jsx': + case 'mjs': + case 'cjs': + case 'py': + case 'rs': + case 'go': + case 'rb': + case 'java': + case 'cpp': + case 'c': + case 'h': + case 'hpp': + case 'cs': + case 'php': + case 'swift': + case 'kt': + case 'scala': + case 'vue': + case 'svelte': + return FileCode; + + // Markup / text + case 'md': + case 'mdx': + case 'txt': + case 'rtf': + case 'rst': + return FileText; + + // Data / config + case 'json': + case 'jsonc': + return FileJson; + + // Style + case 'css': + case 'scss': + case 'less': + case 'sass': + return FileType; + + // Images + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'svg': + case 'webp': + case 'ico': + case 'bmp': + return Image; + + // Shell + case 'sh': + case 'bash': + case 'zsh': + case 'fish': + return FileTerminal; + + // HTML / XML + case 'html': + case 'htm': + case 'xml': + case 'xhtml': + return FileCode; + + // Config + case 'yaml': + case 'yml': + case 'toml': + case 'ini': + case 'env': + case 'conf': + return Cog; + + default: + return File; + } +} + +/** Get a color class for a file icon based on its extension */ +function getFileIconColor(name: string): string { + const ext = name.includes('.') ? name.split('.').pop()?.toLowerCase() : ''; + + switch (ext) { + case 'ts': + case 'tsx': + return 'text-blue-400'; + case 'js': + case 'jsx': + case 'mjs': + case 'cjs': + return 'text-yellow-400'; + case 'json': + case 'jsonc': + return 'text-yellow-500'; + case 'css': + case 'scss': + case 'less': + return 'text-purple-400'; + case 'html': + case 'htm': + return 'text-orange-400'; + case 'md': + case 'mdx': + return 'text-sky-400'; + case 'py': + return 'text-green-400'; + case 'rs': + return 'text-orange-500'; + case 'go': + return 'text-cyan-400'; + case 'svg': + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'webp': + return 'text-emerald-400'; + case 'yaml': + case 'yml': + case 'toml': + return 'text-red-300'; + case 'sh': + case 'bash': + case 'zsh': + return 'text-green-300'; + default: + return 'text-muted-foreground'; + } +} + +/** Get a color class for a file name based on its git status */ +function getGitStatusColor(status: string): string { + switch (status) { + case 'M': + return 'text-yellow-400'; // Modified + case 'A': + return 'text-green-400'; // Added + case 'D': + return 'text-red-400'; // Deleted + case '?': + return 'text-green-500/70'; // Untracked + case 'R': + return 'text-blue-400'; // Renamed + case 'C': + return 'text-blue-300'; // Copied + case 'U': + return 'text-orange-400'; // Unmerged + default: + return ''; + } +} + +/** Get a short status label for git status */ +function getGitStatusLabel(status: string): string { + switch (status) { + case 'M': + return 'M'; + case 'A': + return 'A'; + case 'D': + return 'D'; + case '?': + return 'U'; + case 'R': + return 'R'; + case 'C': + return 'C'; + case 'U': + return '!'; + default: + return ''; + } +} + +/** Format file size to human-readable */ +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** Check if a node or any descendant matches the filter */ +function nodeMatchesFilter(node: TreeNode, filter: string): boolean { + const lowerFilter = filter.toLowerCase(); + if (node.name.toLowerCase().includes(lowerFilter)) return true; + if (node.children) { + return node.children.some((child) => nodeMatchesFilter(child, lowerFilter)); + } + return false; +} + +/** Collect all matching file paths from the tree for search results */ +function collectMatchingPaths(nodes: TreeNode[], filter: string): string[] { + const lowerFilter = filter.toLowerCase(); + const results: string[] = []; + + function traverse(node: TreeNode) { + if (!node.isDirectory && node.name.toLowerCase().includes(lowerFilter)) { + results.push(node.path); + } + if (node.children) { + node.children.forEach(traverse); + } + } + + nodes.forEach(traverse); + return results; +} + +/** Context menu for file operations */ +function FileContextMenu({ + x, + y, + filePath, + isDirectory, + rootPath, + onClose, + onRename, + onDelete, + onCreateFile, + onCreateFolder, + onCopyPath, + onCopyRelativePath, + onRevealInFileManager, +}: { + x: number; + y: number; + filePath: string; + isDirectory: boolean; + rootPath: string; + onClose: () => void; + onRename?: (path: string) => void; + onDelete?: (path: string) => void; + onCreateFile?: (parentPath: string) => void; + onCreateFolder?: (parentPath: string) => void; + onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onRevealInFileManager?: (path: string) => void; +}) { + const menuRef = useRef(null); + + useEffect(() => { + const handleOutside = (e: TouchEvent | MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('touchstart', handleOutside); + document.addEventListener('mousedown', handleOutside); + return () => { + document.removeEventListener('touchstart', handleOutside); + document.removeEventListener('mousedown', handleOutside); + }; + }, [onClose]); + + useEffect(() => { + // Focus first button when menu opens + if (menuRef.current) { + const firstButton = menuRef.current.querySelector('button'); + firstButton?.focus(); + } + }, []); + + const handleCopyPath = () => { + if (onCopyPath) { + onCopyPath(filePath); + } else { + navigator.clipboard.writeText(filePath).catch(() => {}); + } + onClose(); + }; + + const handleCopyRelativePath = () => { + const relativePath = filePath.startsWith(rootPath + '/') + ? filePath.slice(rootPath.length + 1) + : filePath; + if (onCopyRelativePath) { + onCopyRelativePath(relativePath); + } else { + navigator.clipboard.writeText(relativePath).catch(() => {}); + } + onClose(); + }; + + const style: React.CSSProperties = { + position: 'fixed', + left: Math.min(x, window.innerWidth - 220), + top: Math.min(y, window.innerHeight - 300), + zIndex: 50, + }; + + return ( +
+ {/* Create operations (directories only) */} + {isDirectory && (onCreateFile || onCreateFolder) && ( + <> + {onCreateFile && ( + + )} + {onCreateFolder && ( + + )} +
+ + )} + + {/* Edit operations */} + {onRename && ( + + )} + {onDelete && ( + <> + +
+ + )} + + {/* Utility operations */} + + + {onRevealInFileManager && ( + + )} +
+ ); +} + +/** Inline input for creating new files/folders */ +function InlineCreateInput({ + type, + onSubmit, + onCancel, + depth, + touchMode, +}: { + type: 'file' | 'folder'; + onSubmit: (name: string) => void; + onCancel: () => void; + depth: number; + touchMode?: boolean; +}) { + const [value, setValue] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && value.trim()) { + onSubmit(value.trim()); + } else if (e.key === 'Escape') { + onCancel(); + } + }; + + const IconComponent = type === 'folder' ? Folder : File; + const iconColor = type === 'folder' ? 'text-blue-400' : 'text-muted-foreground'; + + return ( +
+ + + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={onCancel} + placeholder={type === 'folder' ? 'Folder name' : 'File name'} + className={cn( + 'flex-1 bg-transparent text-foreground placeholder:text-muted-foreground outline-none', + touchMode ? 'text-[0.9375rem]' : 'text-sm' + )} + data-testid="inline-create-input-field" + /> +
+ ); +} + +/** Inline input for renaming files/folders */ +function InlineRenameInput({ + currentName, + isDirectory, + onSubmit, + onCancel, + depth, + touchMode, + gitStatusMap, + node, +}: { + currentName: string; + isDirectory: boolean; + onSubmit: (newName: string) => void; + onCancel: () => void; + depth: number; + touchMode?: boolean; + gitStatusMap?: Map; + node: TreeNode; +}) { + const [value, setValue] = useState(() => { + // For files, pre-fill without extension + if (!isDirectory && currentName.includes('.')) { + const parts = currentName.split('.'); + parts.pop(); + return parts.join('.'); + } + return currentName; + }); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && value.trim()) { + // For files, re-add extension + let finalName = value.trim(); + if (!isDirectory && currentName.includes('.')) { + const ext = currentName.split('.').pop(); + finalName = `${finalName}.${ext}`; + } + onSubmit(finalName); + } else if (e.key === 'Escape') { + onCancel(); + } + }; + + const IconComponent = isDirectory ? Folder : getFileIcon(currentName); + const iconColor = isDirectory ? 'text-blue-400' : getFileIconColor(currentName); + + // Git status + const gitStatus = gitStatusMap?.get(node.path); + const gitColor = gitStatus ? getGitStatusColor(gitStatus.status) : ''; + const gitLabel = gitStatus ? getGitStatusLabel(gitStatus.status) : ''; + + return ( +
+ + + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={onCancel} + className={cn( + 'flex-1 bg-transparent text-foreground outline-none', + touchMode ? 'text-[0.9375rem]' : 'text-sm', + gitColor + )} + data-testid="inline-rename-input-field" + /> + {gitLabel && ( + + {gitLabel} + + )} +
+ ); +} + +function TreeItem({ + node, + depth, + selectedFile, + onFileSelect, + onToggle, + onRename, + onDelete, + onCreateFile, + onCreateFolder, + onCopyPath, + onCopyRelativePath, + onRevealInFileManager, + renamingPath, + onRenameSubmit, + onRenameCancel, + creatingIn, + onCreateSubmit, + onCreateCancel, + filter, + showMetadata, + touchMode, + gitStatusMap, + rootPath, +}: { + node: TreeNode; + depth: number; + selectedFile?: string; + onFileSelect: (path: string) => void; + onToggle: (node: TreeNode) => void; + onRename?: (path: string) => void; + onDelete?: (path: string) => void; + onCreateFile?: (parentPath: string) => void; + onCreateFolder?: (parentPath: string) => void; + onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onRevealInFileManager?: (path: string) => void; + renamingPath?: string | null; + onRenameSubmit?: (oldPath: string, newName: string) => void; + onRenameCancel?: () => void; + creatingIn?: { parentPath: string; type: 'file' | 'folder' } | null; + onCreateSubmit?: (parentPath: string, name: string, type: 'file' | 'folder') => void; + onCreateCancel?: () => void; + filter: string; + showMetadata: boolean; + touchMode?: boolean; + gitStatusMap?: Map; + rootPath: string; +}) { + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const longPressTimer = useRef | null>(null); + const touchMoved = useRef(false); + + // If filtering, hide nodes that don't match + if (filter && !nodeMatchesFilter(node, filter)) { + return null; + } + + const isSelected = selectedFile === node.path; + const isExpanded = node.isDirectory && node.isLoaded && node.children !== undefined; + const isRenaming = renamingPath === node.path; + + // Look up git status for this node + const gitStatus = gitStatusMap?.get(node.path); + const gitColor = gitStatus ? getGitStatusColor(gitStatus.status) : ''; + const gitLabel = gitStatus ? getGitStatusLabel(gitStatus.status) : ''; + + // Check if this directory contains any changed files + const dirHasChanges = + node.isDirectory && gitStatusMap + ? Array.from(gitStatusMap.keys()).some((p) => p.startsWith(node.path + '/')) + : false; + + const handleClick = useCallback(() => { + if (node.isDirectory) { + onToggle(node); + } else { + onFileSelect(node.path); + } + }, [node, onFileSelect, onToggle]); + + // Long-press for touch context menu + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchMoved.current = false; + const touch = e.touches[0]; + longPressTimer.current = setTimeout(() => { + if (!touchMoved.current) { + setContextMenu({ x: touch.clientX, y: touch.clientY }); + } + }, 500); + }, []); + + const handleTouchMove = useCallback(() => { + touchMoved.current = true; + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }, []); + + const handleTouchEnd = useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }, []); + + // Desktop right-click context menu + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + + const IconComponent = node.isDirectory + ? isExpanded + ? FolderOpen + : Folder + : getFileIcon(node.name); + + const iconColor = node.isDirectory ? 'text-blue-400' : getFileIconColor(node.name); + + // Build tooltip with metadata + const tooltipParts = node.isDirectory + ? [node.path] + : [ + node.path, + node.size !== undefined ? `Size: ${formatFileSize(node.size)}` : '', + node.mtime ? `Modified: ${new Date(node.mtime).toLocaleString()}` : '', + ]; + if (gitStatus) { + tooltipParts.push(`Git: ${gitStatus.statusText}`); + } + const tooltip = tooltipParts.filter(Boolean).join('\n'); + + return ( + <> + {/* Show inline create input if creating in this directory */} + {creatingIn && creatingIn.parentPath === node.path && onCreateSubmit && onCreateCancel && ( + onCreateSubmit(node.path, name, creatingIn.type)} + onCancel={onCreateCancel} + depth={depth + 1} + touchMode={touchMode} + /> + )} + + {/* Show inline rename input if renaming this node */} + {isRenaming && onRenameSubmit && onRenameCancel ? ( + onRenameSubmit(node.path, newName)} + onCancel={onRenameCancel} + depth={depth} + touchMode={touchMode} + gitStatusMap={gitStatusMap} + node={node} + /> + ) : ( + + )} + + {/* Context menu (long-press on touch, right-click on desktop) */} + {contextMenu && ( + setContextMenu(null)} + onRename={onRename} + onDelete={onDelete} + onCreateFile={onCreateFile} + onCreateFolder={onCreateFolder} + onCopyPath={onCopyPath} + onCopyRelativePath={onCopyRelativePath} + onRevealInFileManager={onRevealInFileManager} + /> + )} + {isExpanded && + node.children?.map((child) => ( + + ))} + + ); +} + +export function FileTree({ + rootPath, + selectedFile, + onFileSelect, + onRename, + onDelete, + onCreateFile, + onCreateFolder, + onCopyPath, + onCopyRelativePath, + onRevealInFileManager, + renamingPath, + onRenameSubmit, + onRenameCancel, + creatingIn, + onCreateSubmit, + onCreateCancel, + touchMode, + gitStatusMap, + className, + 'data-testid': testId, +}: FileTreeProps) { + const [roots, setRoots] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(''); + const [showSearch, setShowSearch] = useState(false); + const searchInputRef = useRef(null); + + // Focus search input when shown + useEffect(() => { + if (showSearch && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [showSearch]); + + // Toggle search with Cmd/Ctrl+F when tree is focused + const handleTreeKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + setShowSearch((prev) => !prev); + if (showSearch) { + setFilter(''); + } + } + if (e.key === 'Escape' && showSearch) { + setShowSearch(false); + setFilter(''); + } + }, + [showSearch] + ); + + // Load children for a directory, with optional metadata fetch + const loadChildren = useCallback(async (dirPath: string): Promise => { + const api = getElectronAPI(); + const result = await api.readdir(dirPath); + if (!result.success || !result.entries) { + return []; + } + const filtered = result.entries.filter( + (e) => !HIDDEN_ENTRIES.has(e.name) && !e.name.startsWith('.') + ); + const sorted = sortEntries(filtered); + + const nodes: TreeNode[] = sorted.map((entry) => ({ + name: entry.name, + path: `${dirPath}/${entry.name}`, + isDirectory: entry.isDirectory, + isLoaded: false, + })); + + // Fetch metadata for files (size and mtime) in the background + const fileNodes = nodes.filter((n) => !n.isDirectory); + if (fileNodes.length > 0) { + // Fetch stats in parallel, but don't block tree rendering + Promise.allSettled( + fileNodes.map(async (node) => { + try { + const statResult = await api.stat(node.path); + if (statResult.success && statResult.stats) { + return { + path: node.path, + size: statResult.stats.size, + mtime: statResult.stats.mtime, + }; + } + } catch { + // Ignore stat errors + } + return null; + }) + ).then((results) => { + const updates: Record = {}; + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + updates[result.value.path] = { + size: result.value.size, + mtime: + result.value.mtime instanceof Date + ? result.value.mtime.toISOString() + : String(result.value.mtime), + }; + } + } + if (Object.keys(updates).length > 0) { + setRoots((prev) => applyMetadataUpdates(prev, updates)); + } + }); + } + + return nodes; + }, []); + + // Initial load + useEffect(() => { + let cancelled = false; + setIsLoading(true); + setError(null); + loadChildren(rootPath) + .then((children) => { + if (!cancelled) { + setRoots(children); + setIsLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err?.message || 'Failed to load directory'); + setIsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [rootPath, loadChildren]); + + // Toggle directory expand/collapse + const handleToggle = useCallback( + async (node: TreeNode) => { + if (!node.isDirectory) return; + + // If already loaded, just toggle + if (node.isLoaded) { + setRoots((prev) => toggleNode(prev, node.path)); + return; + } + + // Mark as loading + setRoots((prev) => updateNode(prev, node.path, { isLoading: true })); + + try { + const children = await loadChildren(node.path); + setRoots((prev) => + updateNode(prev, node.path, { + children, + isLoaded: true, + isLoading: false, + }) + ); + } catch { + setRoots((prev) => updateNode(prev, node.path, { isLoading: false })); + } + }, + [loadChildren] + ); + + // Search match count + const matchCount = useMemo(() => { + if (!filter) return 0; + return collectMatchingPaths(roots, filter).length; + }, [roots, filter]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (roots.length === 0) { + return ( +
+ Empty directory +
+ ); + } + + return ( +
+ {/* Search/Filter Bar */} + {showSearch && ( +
+ + setFilter(e.target.value)} + placeholder="Filter files..." + className={cn( + 'flex-1 bg-transparent text-foreground placeholder:text-muted-foreground outline-none', + touchMode ? 'text-base py-1' : 'text-xs' + )} + data-testid="file-tree-search" + /> + {filter && ( + + {matchCount} {matchCount === 1 ? 'match' : 'matches'} + + )} + +
+ )} + + {/* Toolbar (visible when search is hidden) */} + {!showSearch && ( +
+ + {onCreateFile && ( + + )} + {onCreateFolder && ( + + )} +
+ )} + + {/* Tree Content */} + +
+ {/* Show inline create input at root level if creating at root */} + {creatingIn && creatingIn.parentPath === rootPath && onCreateSubmit && onCreateCancel && ( + onCreateSubmit(rootPath, name, creatingIn.type)} + onCancel={onCreateCancel} + depth={0} + touchMode={touchMode} + /> + )} + + {filter && matchCount === 0 ? ( +
+ No files matching “{filter}” +
+ ) : ( + roots.map((node) => ( + + )) + )} +
+
+
+ ); +} + +// Helpers for immutable tree updates + +function updateNode(nodes: TreeNode[], targetPath: string, updates: Partial): TreeNode[] { + return nodes.map((node) => { + if (node.path === targetPath) { + return { ...node, ...updates }; + } + if (node.children && targetPath.startsWith(node.path + '/')) { + return { + ...node, + children: updateNode(node.children, targetPath, updates), + }; + } + return node; + }); +} + +function toggleNode(nodes: TreeNode[], targetPath: string): TreeNode[] { + return nodes.map((node) => { + if (node.path === targetPath) { + // Collapse by removing children reference (keep isLoaded so re-expand is instant) + if (node.children) { + return { ...node, children: undefined }; + } + return node; + } + if (node.children && targetPath.startsWith(node.path + '/')) { + return { + ...node, + children: toggleNode(node.children, targetPath), + }; + } + return node; + }); +} + +/** Apply metadata updates (size, mtime) to tree nodes by path */ +function applyMetadataUpdates( + nodes: TreeNode[], + updates: Record +): TreeNode[] { + return nodes.map((node) => { + const update = updates[node.path]; + if (update) { + return { ...node, ...update }; + } + if (node.children) { + const updatedChildren = applyMetadataUpdates(node.children, updates); + if (updatedChildren !== node.children) { + return { ...node, children: updatedChildren }; + } + } + return node; + }); +} diff --git a/apps/ui/src/components/ui/keyboard-map.tsx b/apps/ui/src/components/ui/keyboard-map.tsx index cc5c76bdc..9e5201de8 100644 --- a/apps/ui/src/components/ui/keyboard-map.tsx +++ b/apps/ui/src/components/ui/keyboard-map.tsx @@ -92,6 +92,7 @@ const SHORTCUT_LABELS: Record = { settings: 'Settings', projectSettings: 'Project Settings', terminal: 'Terminal', + files: 'Files', ideation: 'Ideation', notifications: 'Notifications', githubIssues: 'GitHub Issues', @@ -122,6 +123,7 @@ const SHORTCUT_CATEGORIES: Record s.fileEditorTabs); + const activeTabPath = useAppStore((s) => s.fileEditorActiveTabPath); + const saveStatus = useAppStore((s) => s.fileEditorSaveStatus); + const settings = useAppStore((s) => s.fileEditorSettings); + const openFileTab = useAppStore((s) => s.openFileTab); + const closeFileTab = useAppStore((s) => s.closeFileTab); + const setActiveFileTab = useAppStore((s) => s.setActiveFileTab); + const updateFileContent = useAppStore((s) => s.updateFileContent); + const markFileSaved = useAppStore((s) => s.markFileSaved); + const setFileCursorPosition = useAppStore((s) => s.setFileCursorPosition); + const setSaveStatus = useAppStore((s) => s.setFileEditorSaveStatus); + const setFileEditorWorktree = useAppStore((s) => s.setFileEditorWorktree); + const getFileEditorWorktree = useAppStore((s) => s.getFileEditorWorktree); + + const isMobile = useIsMobile(); + + // Worktree integration + const { data: worktreeData } = useWorktrees(currentProject?.path); + const worktrees = worktreeData?.worktrees ?? []; + const selectedWorktree = currentProject ? getFileEditorWorktree(currentProject.path) : null; + const [worktreeDropdownOpen, setWorktreeDropdownOpen] = useState(false); + const worktreeDropdownRef = useRef(null); + + // The effective root path for the file tree: use selected worktree path, or project path + const effectiveRootPath = selectedWorktree?.path || currentProject?.path || ''; + // The effective git project path (for git operations) + const effectiveGitPath = selectedWorktree?.path || currentProject?.path || ''; + + // Close worktree dropdown on outside click + useEffect(() => { + if (!worktreeDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (worktreeDropdownRef.current && !worktreeDropdownRef.current.contains(e.target as Node)) { + setWorktreeDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [worktreeDropdownOpen]); + + // Local state for file stats (not needed in global store) + const [fileStats, setFileStats] = useState>({}); + const [loadingPaths, setLoadingPaths] = useState>(new Set()); + const [isTreeCollapsed, setIsTreeCollapsed] = useState(false); + const [mobileShowTree, setMobileShowTree] = useState(true); + const autoSaveTimerRef = useRef | null>(null); + const saveStatusTimerRef = useRef | null>(null); + const treePanelRef = useRef(null); + + // Mobile fullscreen editor state + const [isFullscreen, setIsFullscreen] = useState(false); + const [isVirtualKeyboardOpen, setIsVirtualKeyboardOpen] = useState(false); + + // File search dialog state + const [isSearchOpen, setIsSearchOpen] = useState(false); + + // Git integration state + const [gitFiles, setGitFiles] = useState([]); + const [isGitRepo, setIsGitRepo] = useState(false); + const [activeDiffHunks, setActiveDiffHunks] = useState([]); + const [showDiffView, setShowDiffView] = useState(false); + const [activeDiffContent, setActiveDiffContent] = useState(''); + const gitStatusTimerRef = useRef | null>(null); + + // File operations state + const [renamingPath, setRenamingPath] = useState(null); + const [creatingIn, setCreatingIn] = useState<{ + parentPath: string; + type: 'file' | 'folder'; + } | null>(null); + const [deleteTarget, setDeleteTarget] = useState<{ path: string; isDirectory: boolean } | null>( + null + ); + // Key to force re-mount the FileTree when files change (create/rename/delete) + const [treeRefreshKey, setTreeRefreshKey] = useState(0); + + const activeTab = tabs.find((t) => t.path === activeTabPath) || null; + + // Virtual keyboard detection for mobile + useEffect(() => { + if (!isMobile) return; + const handleResize = () => { + // Virtual keyboard is open when the viewport height is notably less + // than the window.screen.height (keyboard takes ~40% of screen) + if (window.visualViewport) { + const isKeyboard = window.visualViewport.height < window.screen.height * 0.75; + setIsVirtualKeyboardOpen(isKeyboard); + } + }; + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', handleResize); + return () => window.visualViewport?.removeEventListener('resize', handleResize); + } + }, [isMobile]); + + // Prevent zoom on input focus for iOS + useEffect(() => { + if (!isMobile) return; + const meta = document.querySelector('meta[name="viewport"]'); + const originalContent = meta?.getAttribute('content') || ''; + if (meta && !originalContent.includes('maximum-scale')) { + meta.setAttribute( + 'content', + 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' + ); + } + return () => { + if (meta) { + meta.setAttribute('content', originalContent); + } + }; + }, [isMobile]); + + // On mobile, when a file is selected, switch to editor view + const switchToEditorOnMobile = useCallback(() => { + if (isMobile) { + setMobileShowTree(false); + } + }, [isMobile]); + + // Build a map of absolute path -> git status for the file tree + const gitStatusMap = useMemo(() => { + if (!currentProject || !isGitRepo || gitFiles.length === 0) return undefined; + const map = new Map(); + const rootForGit = effectiveRootPath || currentProject.path; + for (const file of gitFiles) { + const absPath = `${rootForGit}/${file.path}`; + map.set(absPath, file); + } + return map; + }, [currentProject, isGitRepo, gitFiles, effectiveRootPath]); + + // Get git status for the active file + const activeFileGitStatus = useMemo(() => { + if (!activeTabPath || !gitStatusMap) return null; + return gitStatusMap.get(activeTabPath) || null; + }, [activeTabPath, gitStatusMap]); + + // Fetch git status for the project (or selected worktree) + const fetchGitStatus = useCallback(async () => { + if (!currentProject) return; + try { + const api = getElectronAPI(); + const result = await api.gitStatus(effectiveGitPath); + if (result.success) { + setIsGitRepo(result.isGitRepo ?? false); + setGitFiles(result.files ?? []); + } + } catch { + // Silently fail - git status is non-critical + } + }, [currentProject, effectiveGitPath]); + + // Fetch git diff for the active file + const fetchActiveDiff = useCallback(async () => { + if (!currentProject || !activeTabPath) { + setActiveDiffHunks([]); + return; + } + try { + const api = getElectronAPI(); + // Determine the root path for this file (its worktree or the project) + const activeTab = useAppStore.getState().fileEditorTabs.find((t) => t.path === activeTabPath); + const fileRoot = activeTab?.worktreePath || effectiveGitPath; + // Get the relative path for git diff + const relativePath = activeTabPath.startsWith(fileRoot + '/') + ? activeTabPath.slice(fileRoot.length + 1) + : activeTabPath; + const result = await api.gitDiff(fileRoot, relativePath); + if (result.success) { + setActiveDiffHunks(result.hunks ?? []); + setActiveDiffContent(result.diff ?? ''); + } + } catch { + setActiveDiffHunks([]); + } + }, [currentProject, activeTabPath, effectiveGitPath]); + + // Initial git status fetch and periodic refresh + useEffect(() => { + fetchGitStatus(); + // Refresh git status every 10 seconds + gitStatusTimerRef.current = setInterval(fetchGitStatus, 10000); + return () => { + if (gitStatusTimerRef.current) { + clearInterval(gitStatusTimerRef.current); + gitStatusTimerRef.current = null; + } + }; + }, [fetchGitStatus]); + + // Fetch diff when active tab changes + useEffect(() => { + if (isGitRepo && activeTabPath) { + fetchActiveDiff(); + } else { + setActiveDiffHunks([]); + } + }, [isGitRepo, activeTabPath, fetchActiveDiff]); + + // Toggle file tree panel (desktop: collapse/expand, mobile: switch views) + const toggleTreePanel = useCallback(() => { + if (isMobile) { + setMobileShowTree((prev) => !prev); + } else { + const panel = treePanelRef.current; + if (panel) { + if (isTreeCollapsed) { + panel.expand(); + } else { + panel.collapse(); + } + } + } + }, [isMobile, isTreeCollapsed]); + + const showSaveStatus = useCallback( + (message: string, duration = 2000) => { + setSaveStatus(message); + if (saveStatusTimerRef.current) clearTimeout(saveStatusTimerRef.current); + saveStatusTimerRef.current = setTimeout(() => setSaveStatus(null), duration); + }, + [setSaveStatus] + ); + + // Stage/unstage file handler + const handleGitStage = useCallback( + async (action: 'stage' | 'unstage') => { + if (!currentProject || !activeTabPath) return; + try { + const api = getElectronAPI(); + const activeTab = useAppStore + .getState() + .fileEditorTabs.find((t) => t.path === activeTabPath); + const fileRoot = activeTab?.worktreePath || effectiveGitPath; + const relativePath = activeTabPath.startsWith(fileRoot + '/') + ? activeTabPath.slice(fileRoot.length + 1) + : activeTabPath; + const result = await api.gitStage(fileRoot, relativePath, action); + if (result.success) { + showSaveStatus(action === 'stage' ? 'Staged' : 'Unstaged'); + // Refresh git status + fetchGitStatus(); + } + } catch { + showSaveStatus(`${action} failed`, 3000); + } + }, + [currentProject, activeTabPath, showSaveStatus, fetchGitStatus, effectiveGitPath] + ); + + const saveFile = useCallback( + async (filePath: string) => { + const tab = useAppStore.getState().fileEditorTabs.find((t) => t.path === filePath); + if (!tab || !tab.isDirty) return; + try { + const api = getElectronAPI(); + const result = await api.writeFile(tab.path, tab.content); + if (result.success) { + markFileSaved(filePath); + // Update stats after save + const statResult = await api.stat(filePath).catch(() => null); + if (statResult?.success && statResult.stats) { + setFileStats((prev) => ({ ...prev, [filePath]: statResult.stats! })); + } + showSaveStatus('Saved'); + // Refresh git status after save + fetchGitStatus(); + } else { + showSaveStatus(`Save failed: ${result.error}`, 3000); + } + } catch (err) { + showSaveStatus( + `Save failed: ${err instanceof Error ? err.message : 'Unknown error'}`, + 3000 + ); + } + }, + [markFileSaved, showSaveStatus, fetchGitStatus] + ); + + const handleSaveActive = useCallback(async () => { + if (!activeTabPath) return; + await saveFile(activeTabPath); + }, [activeTabPath, saveFile]); + + // Auto-save: save all dirty tabs at the configured interval + useEffect(() => { + if (autoSaveTimerRef.current) { + clearInterval(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + + if (settings.autoSaveEnabled) { + autoSaveTimerRef.current = setInterval(() => { + const dirtyTabs = useAppStore.getState().getDirtyFileTabs(); + for (const tab of dirtyTabs) { + saveFile(tab.path); + } + }, settings.autoSaveIntervalMs); + } + + return () => { + if (autoSaveTimerRef.current) { + clearInterval(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; + } + }; + }, [settings.autoSaveEnabled, settings.autoSaveIntervalMs, saveFile]); + + // Cleanup timers on unmount + useEffect(() => { + return () => { + if (saveStatusTimerRef.current) clearTimeout(saveStatusTimerRef.current); + }; + }, []); + + const handleFileSelect = useCallback( + async (filePath: string) => { + // If already open, just switch to it + const existing = tabs.find((t) => t.path === filePath); + if (existing) { + setActiveFileTab(filePath); + switchToEditorOnMobile(); + return; + } + + const name = filePath.split('/').pop() || filePath; + const language = detectLanguage(filePath); + + // Track loading state locally + setLoadingPaths((prev) => new Set(prev).add(filePath)); + + // Load file content and stats in parallel + try { + const api = getElectronAPI(); + const [fileResult, statResult] = await Promise.all([ + api.readFile(filePath), + api.stat(filePath).catch(() => null), + ]); + + if (statResult?.success && statResult.stats) { + setFileStats((prev) => ({ ...prev, [filePath]: statResult.stats! })); + } + + if (fileResult.success && fileResult.content !== undefined) { + openFileTab( + filePath, + name, + fileResult.content, + language, + selectedWorktree?.path || currentProject?.path, + selectedWorktree?.branch || 'main' + ); + } else { + openFileTab( + filePath, + name, + `Error: ${fileResult.error || 'Failed to read file'}`, + language, + selectedWorktree?.path || currentProject?.path, + selectedWorktree?.branch || 'main' + ); + } + switchToEditorOnMobile(); + } catch (err) { + openFileTab( + filePath, + name, + `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + language, + selectedWorktree?.path || currentProject?.path, + selectedWorktree?.branch || 'main' + ); + } finally { + setLoadingPaths((prev) => { + const next = new Set(prev); + next.delete(filePath); + return next; + }); + } + }, + [tabs, openFileTab, setActiveFileTab, switchToEditorOnMobile, selectedWorktree, currentProject] + ); + + const handleCloseTab = useCallback( + (path: string, e?: React.MouseEvent) => { + e?.stopPropagation(); + closeFileTab(path); + // Cleanup local stats + setFileStats((prev) => { + const next = { ...prev }; + delete next[path]; + return next; + }); + }, + [closeFileTab] + ); + + // --- File Operation Handlers --- + + const refreshTree = useCallback(() => { + setTreeRefreshKey((k) => k + 1); + }, []); + + const handleCreateFile = useCallback((parentPath: string) => { + setCreatingIn({ parentPath, type: 'file' }); + }, []); + + const handleCreateFolder = useCallback((parentPath: string) => { + setCreatingIn({ parentPath, type: 'folder' }); + }, []); + + const handleCreateSubmit = useCallback( + async (parentPath: string, name: string, type: 'file' | 'folder') => { + setCreatingIn(null); + const api = getElectronAPI(); + const newPath = `${parentPath}/${name}`; + try { + if (type === 'folder') { + const result = await api.mkdir(newPath); + if (result.success) { + toast.success(`Folder "${name}" created`); + refreshTree(); + } else { + toast.error(`Failed to create folder: ${result.error}`); + } + } else { + const result = await api.writeFile(newPath, ''); + if (result.success) { + toast.success(`File "${name}" created`); + refreshTree(); + // Open the newly created file + handleFileSelect(newPath); + } else { + toast.error(`Failed to create file: ${result.error}`); + } + } + } catch (err) { + toast.error( + `Failed to create ${type}: ${err instanceof Error ? err.message : 'Unknown error'}` + ); + } + }, + [refreshTree, handleFileSelect] + ); + + const handleCreateCancel = useCallback(() => { + setCreatingIn(null); + }, []); + + const handleRenameStart = useCallback((filePath: string) => { + setRenamingPath(filePath); + }, []); + + const handleRenameSubmit = useCallback( + async (oldPath: string, newName: string) => { + setRenamingPath(null); + const parentDir = oldPath.substring(0, oldPath.lastIndexOf('/')); + const newPath = `${parentDir}/${newName}`; + if (newPath === oldPath) return; + try { + const api = getElectronAPI(); + const result = await api.rename(oldPath, newPath); + if (result.success) { + toast.success(`Renamed to "${newName}"`); + refreshTree(); + // If the renamed file was open in a tab, close the old tab and open the new one + const openTab = tabs.find((t) => t.path === oldPath); + if (openTab) { + closeFileTab(oldPath); + handleFileSelect(newPath); + } + fetchGitStatus(); + } else { + toast.error(`Failed to rename: ${result.error}`); + } + } catch (err) { + toast.error(`Failed to rename: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + }, + [refreshTree, tabs, closeFileTab, handleFileSelect, fetchGitStatus] + ); + + const handleRenameCancel = useCallback(() => { + setRenamingPath(null); + }, []); + + const handleDeleteRequest = useCallback((filePath: string) => { + const name = filePath.split('/').pop() || filePath; + // Check if it's a directory by looking at the tree context (simple heuristic: no extension) + // We'll use the fs stat to determine this accurately, but for the dialog we can infer + const api = getElectronAPI(); + api + .stat(filePath) + .then((result) => { + const isDir = result.success && result.stats?.isDirectory; + setDeleteTarget({ path: filePath, isDirectory: !!isDir }); + }) + .catch(() => { + // Fallback: assume file + setDeleteTarget({ path: filePath, isDirectory: false }); + }); + }, []); + + const handleDeleteConfirm = useCallback(async () => { + if (!deleteTarget) return; + const { path: targetPath } = deleteTarget; + const name = targetPath.split('/').pop() || targetPath; + try { + const api = getElectronAPI(); + // Try trash first, then fall back to delete + const result = api.trashItem + ? await api.trashItem(targetPath) + : await api.deleteFile(targetPath); + if (result.success) { + toast.success(`"${name}" deleted`); + // Close the tab if the deleted file was open + const openTab = tabs.find((t) => t.path === targetPath); + if (openTab) { + closeFileTab(targetPath); + } + refreshTree(); + fetchGitStatus(); + } else { + toast.error(`Failed to delete: ${result.error}`); + } + } catch (err) { + toast.error(`Failed to delete: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + setDeleteTarget(null); + }, [deleteTarget, tabs, closeFileTab, refreshTree, fetchGitStatus]); + + const handleCopyPath = useCallback((filePath: string) => { + navigator.clipboard + .writeText(filePath) + .then(() => { + toast.success('Path copied to clipboard'); + }) + .catch(() => { + toast.error('Failed to copy path'); + }); + }, []); + + const handleCopyRelativePath = useCallback((relativePath: string) => { + navigator.clipboard + .writeText(relativePath) + .then(() => { + toast.success('Relative path copied to clipboard'); + }) + .catch(() => { + toast.error('Failed to copy path'); + }); + }, []); + + const handleContentChange = useCallback( + (value: string) => { + if (!activeTabPath) return; + updateFileContent(activeTabPath, value); + }, + [activeTabPath, updateFileContent] + ); + + const handleCursorChange = useCallback( + (line: number, column: number) => { + if (!activeTabPath) return; + setFileCursorPosition(activeTabPath, { line, column }); + }, + [activeTabPath, setFileCursorPosition] + ); + + // Keyboard shortcuts: Cmd/Ctrl+S to save, Cmd/Ctrl+B to toggle file tree, F2 rename, Delete/Backspace delete + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + handleSaveActive(); + } + if ((e.metaKey || e.ctrlKey) && e.key === 'b') { + e.preventDefault(); + toggleTreePanel(); + } + // F2 to rename selected file + if (e.key === 'F2' && activeTabPath && !renamingPath && !creatingIn) { + e.preventDefault(); + handleRenameStart(activeTabPath); + } + // Delete/Backspace (with Cmd/Ctrl) to delete selected file + if ( + (e.key === 'Delete' || (e.key === 'Backspace' && (e.metaKey || e.ctrlKey))) && + activeTabPath && + !renamingPath && + !creatingIn + ) { + e.preventDefault(); + handleDeleteRequest(activeTabPath); + } + // Cmd/Ctrl+N to create new file at root + if ( + (e.metaKey || e.ctrlKey) && + e.key === 'n' && + !e.shiftKey && + !renamingPath && + !creatingIn + ) { + e.preventDefault(); + handleCreateFile(effectiveRootPath); + } + // Cmd/Ctrl+Shift+N to create new folder at root + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N' && !renamingPath && !creatingIn) { + e.preventDefault(); + handleCreateFolder(effectiveRootPath); + } + // Cmd/Ctrl+P to open file search + if ((e.metaKey || e.ctrlKey) && e.key === 'p') { + e.preventDefault(); + setIsSearchOpen(true); + } + }, + [ + handleSaveActive, + toggleTreePanel, + activeTabPath, + renamingPath, + creatingIn, + handleRenameStart, + handleDeleteRequest, + handleCreateFile, + handleCreateFolder, + effectiveRootPath, + ] + ); + + // No project selected + if (!currentProject) { + return ( +
+

No project selected

+
+ ); + } + + // Check if tabs span multiple worktrees (to show branch badges) + const hasMultipleWorktrees = useMemo(() => { + const branches = new Set(tabs.map((t) => t.worktreeBranch || '')); + return branches.size > 1; + }, [tabs]); + + // --- Editor area content (shared between mobile and desktop) --- + const editorContent = ( +
+ {/* Tabs Bar - hidden when virtual keyboard is open on mobile fullscreen */} + {!(isMobile && isFullscreen && isVirtualKeyboardOpen) && ( +
+ {/* Mobile: back to tree button */} + {isMobile && !isFullscreen && ( + + )} + {/* Mobile fullscreen: exit fullscreen button */} + {isMobile && isFullscreen && ( + + )} + {/* Desktop: toggle tree button (shown when tree is collapsed) */} + {!isMobile && isTreeCollapsed && ( + + )} + {tabs.length > 0 ? ( + <> + {tabs.map((tab) => ( + + ))} + {/* Mobile: fullscreen toggle and save button */} + {isMobile && activeTab?.isDirty && ( + + )} + {isMobile && !isFullscreen && activeTab && ( + + )} + {/* Save status indicator */} + {saveStatus && !isMobile && ( +
+ + {saveStatus} +
+ )} + {/* Auto-save indicator */} + {settings.autoSaveEnabled && !saveStatus && !isMobile && ( +
+ + Auto +
+ )} + + ) : ( + No files open + )} +
+ )} + + {/* Editor Content */} + {activeTab ? ( + loadingPaths.has(activeTab.path) ? ( +
+ +
+ ) : showDiffView && activeDiffContent ? ( +
+
+ {activeDiffContent.split('\n').map((line, i) => { + let bgClass = ''; + let textClass = 'text-muted-foreground'; + if (line.startsWith('+') && !line.startsWith('+++')) { + bgClass = 'bg-green-500/10'; + textClass = 'text-green-400'; + } else if (line.startsWith('-') && !line.startsWith('---')) { + bgClass = 'bg-red-500/10'; + textClass = 'text-red-400'; + } else if (line.startsWith('@@')) { + textClass = 'text-blue-400'; + } else if (line.startsWith('diff') || line.startsWith('index')) { + textClass = 'text-muted-foreground font-bold'; + } + return ( +
+ {line || ' '} +
+ ); + })} +
+
+ ) : ( +
+ +
+ ) + ) : ( +
+
+ +

Select a file to open

+

+ {isMobile + ? 'Tap the back arrow to browse files' + : 'Browse files in the tree on the left'} +

+
+
+ )} + + {/* Status Bar - simplified on mobile, hidden during virtual keyboard */} + {activeTab && !loadingPaths.has(activeTab.path) && !(isMobile && isVirtualKeyboardOpen) && ( +
+ {isMobile ? ( + /* Mobile: simplified status bar */ + <> +
+ {activeTab.cursorPosition && ( + + Ln {activeTab.cursorPosition.line} + + )} + {activeTab.language && ( + {activeTab.language} + )} + {activeTab.isDirty && ( + + Modified + + )} +
+
+ {saveStatus && ( + + + {saveStatus} + + )} + {activeFileGitStatus && ( + + + {activeFileGitStatus.statusText} + + )} +
+ + ) : ( + /* Desktop: full status bar */ + <> + + {activeTab.path} + +
+ {activeTab.cursorPosition && ( + + Ln {activeTab.cursorPosition.line}, Col {activeTab.cursorPosition.column} + + )} + {activeTab.language && {activeTab.language}} + {activeTab.content.split('\n').length} lines + {fileStats[activeTab.path]?.size !== undefined && ( + + + {formatFileSize(fileStats[activeTab.path].size)} + + )} + {fileStats[activeTab.path]?.mtime && ( + + + {formatRelativeDate(new Date(fileStats[activeTab.path].mtime))} + + )} + {activeFileGitStatus && ( + + + {activeFileGitStatus.statusText} + + )} + {activeFileGitStatus && isGitRepo && ( + + + + + + )} + {activeTab.isDirty && ( + + Modified + + )} +
+ + )} +
+ )} +
+ ); + + // Handle worktree selection + const handleWorktreeSelect = useCallback( + (worktree: { path: string; branch: string; isMain: boolean } | null) => { + if (!currentProject) return; + if (worktree === null || worktree.isMain) { + // Reset to main project + setFileEditorWorktree(currentProject.path, null); + } else { + setFileEditorWorktree(currentProject.path, { + path: worktree.path, + branch: worktree.branch, + }); + } + setWorktreeDropdownOpen(false); + // Refresh git status when switching worktrees + fetchGitStatus(); + }, + [currentProject, setFileEditorWorktree, fetchGitStatus] + ); + + // Current worktree display label + const currentWorktreeLabel = selectedWorktree + ? selectedWorktree.branch + : currentProject?.path.split('/').pop() || 'Project'; + + // --- File tree content (shared between mobile and desktop) --- + const treeContent = ( +
+ {/* Tree Header */} +
+ + + {effectiveRootPath.split('/').pop() || currentProject.path} + + {/* Search button */} + + {/* Desktop: collapse tree button */} + {!isMobile && ( + + )} +
+ {/* Worktree Selector */} + {worktrees.length > 1 && ( +
+ + {worktreeDropdownOpen && ( +
+ {worktrees.map((wt) => { + const isSelected = wt.isMain + ? !selectedWorktree + : selectedWorktree?.path === wt.path; + return ( + + ); + })} +
+ )} +
+ )} + {/* Tree Content */} + + + {/* Delete Confirmation Dialog */} + { + if (!open) setDeleteTarget(null); + }} + onConfirm={handleDeleteConfirm} + title={`Delete ${deleteTarget?.isDirectory ? 'folder' : 'file'}?`} + description={`Are you sure you want to delete "${deleteTarget?.path.split('/').pop() || ''}"? This action cannot be undone.`} + confirmText={`Delete ${deleteTarget?.isDirectory ? 'Folder' : 'File'}`} + testId="file-delete-confirm-dialog" + confirmTestId="confirm-file-delete-button" + /> + + {/* File Search Dialog (CMD/CTRL+P) */} + +
+ ); + + // --- Mobile layout: full-screen switching between tree and editor --- + if (isMobile) { + return ( +
+ {mobileShowTree ? ( +
+ {treeContent} +
+ ) : ( +
+ {editorContent} +
+ )} +
+ ); + } + + // --- Desktop layout: resizable split panels --- + return ( +
+ + {/* File Tree Sidebar */} + setIsTreeCollapsed(true)} + onExpand={() => setIsTreeCollapsed(false)} + data-testid="tree-panel" + > + {treeContent} + + + + + {/* Editor Area */} + + {editorContent} + + +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index ff1b6a8c8..56334fe63 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -11,6 +11,7 @@ import { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; import { ModelDefaultsSection } from './settings-view/model-defaults'; import { AppearanceSection } from './settings-view/appearance/appearance-section'; import { TerminalSection } from './settings-view/terminal/terminal-section'; +import { EditorSection } from './settings-view/editor/editor-section'; import { AudioSection } from './settings-view/audio/audio-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; @@ -150,6 +151,8 @@ export function SettingsView() { ); case 'terminal': return ; + case 'editor': + return ; case 'keyboard': return ( setShowKeyboardMapDialog(true)} /> diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index e7647379c..5e7baf28e 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -15,6 +15,7 @@ import { Shield, GitBranch, Code2, + FileCode2, Webhook, } from 'lucide-react'; import { @@ -70,6 +71,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [ items: [ { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'terminal', label: 'Terminal', icon: SquareTerminal }, + { id: 'editor', label: 'Editor', icon: FileCode2 }, { id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 }, { id: 'audio', label: 'Audio', icon: Volume2 }, { id: 'event-hooks', label: 'Event Hooks', icon: Webhook }, diff --git a/apps/ui/src/components/views/settings-view/editor/editor-section.tsx b/apps/ui/src/components/views/settings-view/editor/editor-section.tsx new file mode 100644 index 000000000..24f41f2e5 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/editor/editor-section.tsx @@ -0,0 +1,405 @@ +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Slider } from '@/components/ui/slider'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { FileCode2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { UI_MONO_FONT_OPTIONS, DEFAULT_FONT_VALUE } from '@/config/ui-font-options'; +import { + DEFAULT_EDITOR_FONT_SIZE, + DEFAULT_EDITOR_TAB_SIZE, + DEFAULT_EDITOR_LINE_HEIGHT, +} from '@/store/defaults'; +import type { EditorKeybindings } from '@/store/types/ui-types'; + +export function EditorSection() { + const { fileEditorSettings, setFileEditorSettings } = useAppStore(); + + const { + fontSize, + fontFamily, + tabSize, + indentWithTabs, + wordWrap, + ligatures, + lineHeight, + showLineNumbers, + showFoldGutter, + highlightActiveLine, + bracketMatching, + closeBrackets, + autoSaveEnabled, + autoSaveIntervalMs, + keybindings, + } = fileEditorSettings; + + return ( +
+ {/* Main Editor Card */} +
+
+
+
+ +
+

Editor

+
+

+ Configure the code editor appearance, behavior, and keybinding preferences. +

+
+ +
+ {/* Font Family */} +
+ +

Monospace font used in the code editor

+ +
+ + {/* Font Size */} +
+
+ + {fontSize}px +
+ setFileEditorSettings({ fontSize: value })} + className="flex-1" + data-testid="editor-font-size" + /> +
+ + {/* Line Height */} +
+
+ + {lineHeight.toFixed(1)} +
+ setFileEditorSettings({ lineHeight: value })} + className="flex-1" + data-testid="editor-line-height" + /> +
+ + {/* Font Ligatures */} +
+
+ +

+ Enable ligatures for fonts that support them (e.g. Fira Code, JetBrains Mono) +

+
+ { + setFileEditorSettings({ ligatures: checked }); + toast.success(checked ? 'Ligatures enabled' : 'Ligatures disabled'); + }} + data-testid="editor-ligatures" + /> +
+ + {/* Tab Size */} +
+
+ + {tabSize} spaces +
+ setFileEditorSettings({ tabSize: value })} + className="flex-1" + data-testid="editor-tab-size" + /> +
+ + {/* Indent with Tabs */} +
+
+ +

+ Use tab characters instead of spaces for indentation +

+
+ { + setFileEditorSettings({ indentWithTabs: checked }); + toast.success( + checked ? 'Using tabs for indentation' : 'Using spaces for indentation' + ); + }} + data-testid="editor-indent-tabs" + /> +
+ + {/* Word Wrap */} +
+
+ +

+ Wrap long lines instead of horizontal scrolling +

+
+ { + setFileEditorSettings({ wordWrap: checked }); + toast.success(checked ? 'Word wrap enabled' : 'Word wrap disabled'); + }} + data-testid="editor-word-wrap" + /> +
+ + {/* Line Numbers */} +
+
+ +

Show line numbers in the gutter

+
+ { + setFileEditorSettings({ showLineNumbers: checked }); + }} + data-testid="editor-line-numbers" + /> +
+ + {/* Fold Gutter */} +
+
+ +

+ Show fold/collapse controls in the gutter +

+
+ { + setFileEditorSettings({ showFoldGutter: checked }); + }} + data-testid="editor-fold-gutter" + /> +
+ + {/* Highlight Active Line */} +
+
+ +

+ Highlight the line where the cursor is located +

+
+ { + setFileEditorSettings({ highlightActiveLine: checked }); + }} + data-testid="editor-highlight-active-line" + /> +
+ + {/* Bracket Matching */} +
+
+ +

+ Highlight matching brackets when the cursor is near one +

+
+ { + setFileEditorSettings({ bracketMatching: checked }); + }} + data-testid="editor-bracket-matching" + /> +
+ + {/* Auto-close Brackets */} +
+
+ +

+ Automatically insert closing brackets, quotes, and tags +

+
+ { + setFileEditorSettings({ closeBrackets: checked }); + }} + data-testid="editor-close-brackets" + /> +
+ + {/* Keybindings */} +
+ +

+ Keyboard shortcut style for the code editor +

+ +
+
+
+ + {/* Auto-save Card */} +
+
+

Auto Save

+

+ Automatically save files at a regular interval. +

+
+
+ {/* Auto-save Toggle */} +
+
+ +

Periodically save open files to disk

+
+ { + setFileEditorSettings({ autoSaveEnabled: checked }); + toast.success(checked ? 'Auto save enabled' : 'Auto save disabled'); + }} + data-testid="editor-auto-save" + /> +
+ + {/* Auto-save Interval */} + {autoSaveEnabled && ( +
+
+ + + {(autoSaveIntervalMs / 1000).toFixed(0)}s + +
+ setFileEditorSettings({ autoSaveIntervalMs: value })} + className="flex-1" + data-testid="editor-auto-save-interval" + /> +
+ )} +
+
+ + {/* Reset Button */} +
+ +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index e466da5d5..5db740ce9 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -15,6 +15,7 @@ export type SettingsViewId = | 'model-defaults' | 'appearance' | 'terminal' + | 'editor' | 'keyboard' | 'audio' | 'event-hooks' diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 5470b45ac..df4b2cea3 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -99,6 +99,8 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'worktreePanelCollapsed', 'lastProjectDir', 'recentFolders', + // File Editor Settings + 'fileEditorSettings', ] as const; // Fields from setup store to sync @@ -736,6 +738,19 @@ export async function refreshSettingsFromServer(): Promise { recentFolders: serverSettings.recentFolders ?? [], // Event hooks eventHooks: serverSettings.eventHooks ?? [], + // File Editor Settings (not yet in GlobalSettings type, access via cast) + ...(() => { + const raw = serverSettings as unknown as Record; + if (raw.fileEditorSettings && typeof raw.fileEditorSettings === 'object') { + return { + fileEditorSettings: { + ...currentAppState.fileEditorSettings, + ...(raw.fileEditorSettings as Record), + }, + }; + } + return {}; + })(), // Terminal settings (nested in terminalState) ...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && { terminalState: { diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 88e4236c8..cf31f483f 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -194,6 +194,75 @@ export interface StatResult { error?: string; } +// Git status types for file tree integration +export interface GitFileStatus { + status: string; + path: string; + statusText: string; +} + +export interface GitStatusResult { + success: boolean; + isGitRepo?: boolean; + files?: GitFileStatus[]; + error?: string; +} + +export interface DiffChange { + type: 'add' | 'delete' | 'context'; + line: number; + content: string; +} + +export interface DiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + changes: DiffChange[]; +} + +export interface GitDiffResult { + success: boolean; + diff?: string; + hunks?: DiffHunk[]; + error?: string; +} + +// File search result types +export interface FileSearchResultItem { + path: string; + relativePath: string; + name: string; + isDirectory: boolean; + score: number; +} + +export interface FileSearchResult { + success: boolean; + results: FileSearchResultItem[]; + error?: string; +} + +export interface ContentMatchItem { + line: number; + content: string; + preview: string; +} + +export interface ContentSearchResultItem { + path: string; + relativePath: string; + name: string; + matches: ContentMatchItem[]; +} + +export interface ContentSearchResult { + success: boolean; + results: ContentSearchResultItem[]; + error?: string; +} + // Options for creating a pull request export interface CreatePROptions { projectPath?: string; @@ -644,6 +713,25 @@ export interface ElectronAPI { stat: (filePath: string) => Promise; deleteFile: (filePath: string) => Promise; trashItem?: (filePath: string) => Promise; + rename: (oldPath: string, newPath: string) => Promise; + gitStatus: (repoPath: string) => Promise; + gitDiff: (repoPath: string, filePath?: string) => Promise; + gitStage: ( + repoPath: string, + filePath: string, + action: 'stage' | 'unstage' + ) => Promise; + searchFiles: ( + rootPath: string, + query: string, + fileTypes?: string[], + limit?: number + ) => Promise; + searchContent: ( + rootPath: string, + query: string, + options?: { fileTypes?: string[]; caseSensitive?: boolean; useRegex?: boolean; limit?: number } + ) => Promise; getPath: (name: string) => Promise; openInEditor?: ( filePath: string, @@ -1292,6 +1380,38 @@ const _getMockElectronAPI = (): ElectronAPI => { return { success: true }; }, + rename: async () => { + return { success: true }; + }, + + gitStatus: async () => { + return { + success: true, + isGitRepo: true, + files: [ + { status: 'M', path: 'src/index.ts', statusText: 'Modified' }, + { status: 'A', path: 'src/utils.ts', statusText: 'Added' }, + { status: '?', path: 'src/components/Header.tsx', statusText: 'Untracked' }, + ], + }; + }, + + gitDiff: async () => { + return { success: true, diff: '', hunks: [] }; + }, + + gitStage: async () => { + return { success: true }; + }, + + searchFiles: async () => { + return { success: true, results: [] }; + }, + + searchContent: async () => { + return { success: true, results: [] }; + }, + getPath: async (name: string) => { if (name === 'userData') { return '/mock/userData'; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 0031278ef..801988b6c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -32,6 +32,10 @@ import type { NotificationsAPI, EventHistoryAPI, CreatePROptions, + GitStatusResult, + GitDiffResult, + FileSearchResult, + ContentSearchResult, } from './electron'; import type { IdeationContextSources, @@ -41,11 +45,7 @@ import type { Notification, } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; -import type { - ClaudeUsageResponse, - CodexUsageResponse, - GeminiUsage, -} from '@/store/app-store'; +import type { ClaudeUsageResponse, CodexUsageResponse, GeminiUsage } from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; @@ -1188,6 +1188,43 @@ export class HttpApiClient implements ElectronAPI { return this.deleteFile(filePath); } + async rename(oldPath: string, newPath: string): Promise { + return this.post('/api/fs/rename', { oldPath, newPath }); + } + + async gitStatus(repoPath: string): Promise { + return this.post('/api/fs/git-status', { repoPath }); + } + + async gitDiff(repoPath: string, filePath?: string): Promise { + return this.post('/api/fs/git-diff', { repoPath, filePath }); + } + + async gitStage( + repoPath: string, + filePath: string, + action: 'stage' | 'unstage' + ): Promise { + return this.post('/api/fs/git-stage', { repoPath, filePath, action }); + } + + async searchFiles( + rootPath: string, + query: string, + fileTypes?: string[], + limit?: number + ): Promise { + return this.post('/api/fs/search-files', { rootPath, query, fileTypes, limit }); + } + + async searchContent( + rootPath: string, + query: string, + options?: { fileTypes?: string[]; caseSensitive?: boolean; useRegex?: boolean; limit?: number } + ): Promise { + return this.post('/api/fs/search-content', { rootPath, query, ...options }); + } + async getPath(name: string): Promise { // Server provides data directory if (name === 'userData') { diff --git a/apps/ui/src/routes/files.tsx b/apps/ui/src/routes/files.tsx new file mode 100644 index 000000000..692dba837 --- /dev/null +++ b/apps/ui/src/routes/files.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { FilesView } from '@/components/views/files-view'; + +export const Route = createFileRoute('/files')({ + component: FilesView, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 224004288..e4a266b61 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -87,6 +87,11 @@ import { type AutoModeActivity, type AppState, type AppActions, + // File Editor types + type OpenTab, + type CursorPosition, + type FileHistoryEntry, + type FileEditorSettings, // Usage types type ClaudeUsage, type ClaudeUsageResponse, @@ -117,7 +122,16 @@ import { } from './utils'; // Import default values from modular defaults files -import { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults'; +import { + defaultBackgroundSettings, + defaultTerminalState, + MAX_INIT_OUTPUT_LINES, + MAX_FILE_HISTORY_ENTRIES, + DEFAULT_AUTO_SAVE_INTERVAL_MS, + DEFAULT_EDITOR_FONT_SIZE, + DEFAULT_EDITOR_TAB_SIZE, + DEFAULT_EDITOR_LINE_HEIGHT, +} from './defaults'; // Import internal theme utils (not re-exported publicly) import { @@ -175,6 +189,10 @@ export type { AutoModeActivity, AppState, AppActions, + OpenTab, + CursorPosition, + FileHistoryEntry, + FileEditorSettings, ClaudeUsage, ClaudeUsageResponse, CodexPlanType, @@ -207,7 +225,13 @@ export { }; // Re-export defaults from ./defaults for backward compatibility -export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults'; +export { + defaultBackgroundSettings, + defaultTerminalState, + MAX_INIT_OUTPUT_LINES, + MAX_FILE_HISTORY_ENTRIES, + DEFAULT_AUTO_SAVE_INTERVAL_MS, +} from './defaults'; // NOTE: Type definitions moved to ./types/ directory, utilities moved to ./utils/ directory // The following inline types have been replaced with imports above: @@ -351,6 +375,29 @@ const initialState: AppState = { lastProjectDir: '', recentFolders: [], initScriptState: {}, + fileEditorTabs: [], + fileEditorActiveTabPath: null, + fileEditorHistory: [], + fileEditorSettings: { + autoSaveEnabled: false, + autoSaveIntervalMs: DEFAULT_AUTO_SAVE_INTERVAL_MS, + fontSize: DEFAULT_EDITOR_FONT_SIZE, + fontFamily: null, + tabSize: DEFAULT_EDITOR_TAB_SIZE, + indentWithTabs: false, + wordWrap: false, + showMinimap: false, + ligatures: true, + lineHeight: DEFAULT_EDITOR_LINE_HEIGHT, + showLineNumbers: true, + showFoldGutter: true, + highlightActiveLine: true, + bracketMatching: true, + closeBrackets: true, + keybindings: 'default', + }, + fileEditorSaveStatus: null, + fileEditorWorktreeByProject: {}, }; export const useAppStore = create()((set, get) => ({ @@ -2645,6 +2692,158 @@ export const useAppStore = create()((set, get) => ({ .map(([key, state]) => ({ key, state })); }, + // File Editor actions + openFileTab: (path, name, content, language, worktreePath?, worktreeBranch?) => { + set((state) => { + const existing = state.fileEditorTabs.find((t) => t.path === path); + if (existing) { + return { fileEditorActiveTabPath: path }; + } + const newTab: OpenTab = { + path, + name, + content, + originalContent: content, + language, + isDirty: false, + isLoading: false, + cursorPosition: { line: 1, column: 1 }, + lastModified: undefined, + worktreePath, + worktreeBranch, + }; + const historyEntry: FileHistoryEntry = { path, openedAt: Date.now() }; + const newHistory = [ + historyEntry, + ...state.fileEditorHistory.filter((h) => h.path !== path), + ].slice(0, MAX_FILE_HISTORY_ENTRIES); + return { + fileEditorTabs: [...state.fileEditorTabs, newTab], + fileEditorActiveTabPath: path, + fileEditorHistory: newHistory, + }; + }); + }, + + closeFileTab: (path) => { + set((state) => { + const tabIndex = state.fileEditorTabs.findIndex((t) => t.path === path); + const remaining = state.fileEditorTabs.filter((t) => t.path !== path); + let newActiveTabPath = state.fileEditorActiveTabPath; + if (newActiveTabPath === path) { + if (remaining.length === 0) { + newActiveTabPath = null; + } else { + const newIndex = Math.min(tabIndex, remaining.length - 1); + newActiveTabPath = remaining[newIndex]?.path || null; + } + } + const newHistory = state.fileEditorHistory.map((h) => + h.path === path && !h.closedAt ? { ...h, closedAt: Date.now() } : h + ); + return { + fileEditorTabs: remaining, + fileEditorActiveTabPath: newActiveTabPath, + fileEditorHistory: newHistory, + }; + }); + }, + + setActiveFileTab: (path) => set({ fileEditorActiveTabPath: path }), + + updateFileContent: (path, content) => { + set((state) => ({ + fileEditorTabs: state.fileEditorTabs.map((t) => + t.path === path + ? { + ...t, + content, + isDirty: content !== t.originalContent, + lastModified: Date.now(), + } + : t + ), + })); + }, + + markFileSaved: (path) => { + set((state) => ({ + fileEditorTabs: state.fileEditorTabs.map((t) => + t.path === path ? { ...t, isDirty: false, originalContent: t.content } : t + ), + })); + }, + + setFileCursorPosition: (path, position) => { + set((state) => ({ + fileEditorTabs: state.fileEditorTabs.map((t) => + t.path === path ? { ...t, cursorPosition: position } : t + ), + })); + }, + + setFileEditorSaveStatus: (status) => set({ fileEditorSaveStatus: status }), + + setFileEditorAutoSave: (enabled) => { + set((state) => ({ + fileEditorSettings: { ...state.fileEditorSettings, autoSaveEnabled: enabled }, + })); + }, + + setFileEditorAutoSaveInterval: (intervalMs) => { + set((state) => ({ + fileEditorSettings: { ...state.fileEditorSettings, autoSaveIntervalMs: intervalMs }, + })); + }, + + setFileEditorSettings: (settings) => { + set((state) => ({ + fileEditorSettings: { ...state.fileEditorSettings, ...settings }, + })); + }, + + clearAllFileTabs: () => { + set({ + fileEditorTabs: [], + fileEditorActiveTabPath: null, + fileEditorSaveStatus: null, + }); + }, + + getActiveFileTab: () => { + const state = get(); + return state.fileEditorTabs.find((t) => t.path === state.fileEditorActiveTabPath) || null; + }, + + getDirtyFileTabs: () => { + return get().fileEditorTabs.filter((t) => t.isDirty); + }, + + reorderFileTabs: (fromPath, toPath) => { + set((state) => { + const tabs = [...state.fileEditorTabs]; + const fromIndex = tabs.findIndex((t) => t.path === fromPath); + const toIndex = tabs.findIndex((t) => t.path === toPath); + if (fromIndex === -1 || toIndex === -1) return state; + const [removed] = tabs.splice(fromIndex, 1); + tabs.splice(toIndex, 0, removed); + return { fileEditorTabs: tabs }; + }); + }, + + setFileEditorWorktree: (projectPath, worktree) => { + set((state) => ({ + fileEditorWorktreeByProject: { + ...state.fileEditorWorktreeByProject, + [projectPath]: worktree, + }, + })); + }, + + getFileEditorWorktree: (projectPath) => { + return get().fileEditorWorktreeByProject[projectPath] ?? null; + }, + // Reset reset: () => set(initialState), })); diff --git a/apps/ui/src/store/defaults/constants.ts b/apps/ui/src/store/defaults/constants.ts index b69969e09..165b893a6 100644 --- a/apps/ui/src/store/defaults/constants.ts +++ b/apps/ui/src/store/defaults/constants.ts @@ -1,2 +1,13 @@ // Maximum number of output lines to keep in init script state (prevents unbounded memory growth) export const MAX_INIT_OUTPUT_LINES = 500; + +// Maximum number of file history entries to keep +export const MAX_FILE_HISTORY_ENTRIES = 50; + +// Default auto-save interval in milliseconds (30 seconds) +export const DEFAULT_AUTO_SAVE_INTERVAL_MS = 30_000; + +// Editor preference defaults +export const DEFAULT_EDITOR_FONT_SIZE = 13; +export const DEFAULT_EDITOR_TAB_SIZE = 2; +export const DEFAULT_EDITOR_LINE_HEIGHT = 1.5; diff --git a/apps/ui/src/store/defaults/index.ts b/apps/ui/src/store/defaults/index.ts index 82a42f407..56629c88d 100644 --- a/apps/ui/src/store/defaults/index.ts +++ b/apps/ui/src/store/defaults/index.ts @@ -1,3 +1,10 @@ export { defaultBackgroundSettings } from './background-settings'; export { defaultTerminalState } from './terminal-defaults'; -export { MAX_INIT_OUTPUT_LINES } from './constants'; +export { + MAX_INIT_OUTPUT_LINES, + MAX_FILE_HISTORY_ENTRIES, + DEFAULT_AUTO_SAVE_INTERVAL_MS, + DEFAULT_EDITOR_FONT_SIZE, + DEFAULT_EDITOR_TAB_SIZE, + DEFAULT_EDITOR_LINE_HEIGHT, +} from './constants'; diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 7bf019687..2a5c5b3b2 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -31,6 +31,11 @@ import type { BoardViewMode, KeyboardShortcuts, BackgroundSettings, + OpenTab, + CursorPosition, + FileHistoryEntry, + FileEditorSettings, + EditorKeybindings, } from './ui-types'; import type { ApiKeys } from './settings-types'; import type { ChatMessage, ChatSession, FeatureImage } from './chat-types'; @@ -349,6 +354,15 @@ export interface AppState { // Init Script State (keyed by "projectPath::branch" to support concurrent scripts) initScriptState: Record; + + // File Editor State + fileEditorTabs: OpenTab[]; + fileEditorActiveTabPath: string | null; + fileEditorHistory: FileHistoryEntry[]; + fileEditorSettings: FileEditorSettings; + fileEditorSaveStatus: string | null; + // Per-project selected worktree for file browser (keyed by project path) + fileEditorWorktreeByProject: Record; } export interface AppActions { @@ -801,6 +815,34 @@ export interface AppActions { projectPath: string ) => Array<{ key: string; state: InitScriptState }>; + // File Editor actions + openFileTab: ( + path: string, + name: string, + content: string, + language: string, + worktreePath?: string, + worktreeBranch?: string + ) => void; + closeFileTab: (path: string) => void; + setActiveFileTab: (path: string | null) => void; + updateFileContent: (path: string, content: string) => void; + markFileSaved: (path: string) => void; + setFileCursorPosition: (path: string, position: CursorPosition) => void; + setFileEditorSaveStatus: (status: string | null) => void; + setFileEditorAutoSave: (enabled: boolean) => void; + setFileEditorAutoSaveInterval: (intervalMs: number) => void; + setFileEditorSettings: (settings: Partial) => void; + clearAllFileTabs: () => void; + getActiveFileTab: () => OpenTab | null; + getDirtyFileTabs: () => OpenTab[]; + reorderFileTabs: (fromPath: string, toPath: string) => void; + setFileEditorWorktree: ( + projectPath: string, + worktree: { path: string; branch: string } | null + ) => void; + getFileEditorWorktree: (projectPath: string) => { path: string; branch: string } | null; + // Reset reset: () => void; } diff --git a/apps/ui/src/store/types/ui-types.ts b/apps/ui/src/store/types/ui-types.ts index e586d0157..5e16c6328 100644 --- a/apps/ui/src/store/types/ui-types.ts +++ b/apps/ui/src/store/types/ui-types.ts @@ -81,6 +81,53 @@ export interface BackgroundSettings { hideScrollbar: boolean; } +// File Editor Types +export interface CursorPosition { + line: number; + column: number; +} + +export interface FileHistoryEntry { + path: string; + openedAt: number; // timestamp + closedAt?: number; // timestamp +} + +export interface OpenTab { + path: string; + name: string; + content: string; + originalContent: string; // Content at last save/load, used for dirty detection + language: string; + isDirty: boolean; + isLoading: boolean; + cursorPosition: CursorPosition; + lastModified?: number; // timestamp of last edit + worktreePath?: string; // Root path of the worktree this file belongs to (for multi-worktree tabs) + worktreeBranch?: string; // Branch name of the worktree (for display in tab) +} + +export type EditorKeybindings = 'default' | 'vim' | 'emacs'; + +export interface FileEditorSettings { + autoSaveEnabled: boolean; + autoSaveIntervalMs: number; // milliseconds between auto-saves (default 30000 = 30s) + fontSize: number; // Editor font size in pixels (default 13) + fontFamily: string | null; // null = use global mono font + tabSize: number; // Tab width in spaces (default 2) + indentWithTabs: boolean; // Use tabs instead of spaces (default false) + wordWrap: boolean; // Enable line wrapping (default false) + showMinimap: boolean; // Show minimap (not supported in CodeMirror, reserved) (default false) + ligatures: boolean; // Enable font ligatures (default true) + lineHeight: number; // Line height multiplier (default 1.5) + showLineNumbers: boolean; // Show line numbers gutter (default true) + showFoldGutter: boolean; // Show code fold gutter (default true) + highlightActiveLine: boolean; // Highlight the active line (default true) + bracketMatching: boolean; // Highlight matching brackets (default true) + closeBrackets: boolean; // Auto-close brackets (default true) + keybindings: EditorKeybindings; // Keybinding mode (default 'default') +} + // Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K" export interface KeyboardShortcuts { // Navigation shortcuts @@ -93,6 +140,7 @@ export interface KeyboardShortcuts { settings: string; projectSettings: string; terminal: string; + files: string; ideation: string; notifications: string; githubIssues: string; diff --git a/apps/ui/src/store/utils/shortcut-utils.ts b/apps/ui/src/store/utils/shortcut-utils.ts index 3c82b1c89..731d21fdc 100644 --- a/apps/ui/src/store/utils/shortcut-utils.ts +++ b/apps/ui/src/store/utils/shortcut-utils.ts @@ -88,6 +88,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { settings: 'S', projectSettings: 'Shift+S', terminal: 'T', + files: 'F', ideation: 'I', notifications: 'X', githubIssues: 'G', diff --git a/apps/ui/tests/files/git-integration.spec.ts b/apps/ui/tests/files/git-integration.spec.ts new file mode 100644 index 000000000..307188daa --- /dev/null +++ b/apps/ui/tests/files/git-integration.spec.ts @@ -0,0 +1,286 @@ +/** + * Git Integration E2E Test + * + * Verifies git status indicators in file tree, diff gutter in editor, + * and git quick actions (diff view, stage/unstage) in the files view. + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { + authenticateForTests, + handleLoginScreenIfPresent, + waitForNetworkIdle, + API_BASE_URL, +} from '../utils'; + +let tempDir: string; + +/** + * Set up a project in both server settings and localStorage + * so the server doesn't override the client's project choice. + */ +async function setupGitTestProject(page: import('@playwright/test').Page, projectPath: string) { + // First, authenticate so we can make API calls + await authenticateForTests(page); + + // Update server settings to use our temp project + const projectId = `git-test-${Date.now()}`; + const project = { + id: projectId, + name: 'Git Test Project', + path: projectPath, + lastOpened: new Date().toISOString(), + }; + + // Update server settings via API + await page.request.put(`${API_BASE_URL}/api/settings/global`, { + data: { + projects: [project], + currentProjectId: projectId, + }, + }); + + // Set up localStorage to match + await page.addInitScript( + ({ + proj, + projId, + }: { + proj: { id: string; name: string; path: string; lastOpened: string }; + projId: string; + }) => { + const appState = { + state: { + projects: [proj], + currentProject: proj, + currentView: 'files', + theme: 'dark', + sidebarOpen: true, + skipSandboxWarning: true, + apiKeys: { anthropic: '', google: '' }, + chatSessions: [], + chatHistoryOpen: false, + maxConcurrency: 3, + }, + version: 2, + }; + localStorage.setItem('automaker-storage', JSON.stringify(appState)); + + const setupState = { + state: { + isFirstRun: false, + setupComplete: true, + skipClaudeSetup: false, + }, + version: 1, + }; + localStorage.setItem('automaker-setup', JSON.stringify(setupState)); + sessionStorage.setItem('automaker-splash-shown', 'true'); + }, + { proj: project, projId: projectId } + ); +} + +test.describe('Files View - Git Integration', () => { + test.beforeAll(async () => { + // Create a temporary git repo with tracked and modified files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'automaker-git-test-')); + + // Create .automaker directory structure (required for project setup) + fs.mkdirSync(path.join(tempDir, '.automaker', 'features'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, '.automaker', 'context'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, '.automaker', 'app_spec.txt'), + 'Git Test Project' + ); + + // Initialize git repo and create initial commit + execSync('git init', { cwd: tempDir }); + execSync('git config user.email "test@test.com"', { cwd: tempDir }); + execSync('git config user.name "Test User"', { cwd: tempDir }); + + // Create and commit initial files + fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const hello = "world";\n'); + fs.writeFileSync( + path.join(tempDir, 'utils.ts'), + 'export function add(a: number, b: number) { return a + b; }\n' + ); + fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test Project\n'); + + execSync('git add .', { cwd: tempDir }); + execSync('git commit -m "Initial commit"', { cwd: tempDir }); + + // Now create modifications to produce git status indicators: + // 1. Modified file + fs.writeFileSync( + path.join(tempDir, 'index.ts'), + 'export const hello = "world";\nexport const foo = "bar";\n' + ); + // 2. New untracked file + fs.writeFileSync(path.join(tempDir, 'newfile.ts'), 'export const isNew = true;\n'); + }); + + test.afterAll(async () => { + // Clean up temp directory + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should show files view with git status indicators', async ({ page }) => { + await setupGitTestProject(page, tempDir); + + // Navigate to files view + await page.goto('/files'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + + // Wait for files view to render + await expect(page.locator('[data-testid="files-view"]')).toBeVisible({ timeout: 10000 }); + + // Wait for the file tree to load and show files + await expect(async () => { + const treeItems = page.locator('[data-testid^="file-tree-item-"]'); + const count = await treeItems.count(); + expect(count).toBeGreaterThan(0); + }).toPass({ timeout: 15000 }); + + // Verify git status badges exist on modified/untracked files + await expect(async () => { + const gitBadges = page.locator('[data-testid^="git-status-"]'); + const badgeCount = await gitBadges.count(); + expect(badgeCount).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + }); + + test('should show git status indicator and actions when file is selected', async ({ page }) => { + await setupGitTestProject(page, tempDir); + + await page.goto('/files'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="files-view"]')).toBeVisible({ timeout: 10000 }); + + // Wait for tree to load + await expect(async () => { + const treeItems = page.locator('[data-testid^="file-tree-item-"]'); + expect(await treeItems.count()).toBeGreaterThan(0); + }).toPass({ timeout: 15000 }); + + // Click on index.ts (modified file) to open it + const indexFile = page + .locator('[data-testid^="file-tree-item-"]') + .filter({ hasText: 'index.ts' }) + .first(); + await expect(indexFile).toBeVisible({ timeout: 5000 }); + await indexFile.click(); + + // Wait for the editor to load the file content + await expect(page.locator('[data-testid^="file-tab-"]')).toBeVisible({ timeout: 5000 }); + + // Wait for status bar to show git status indicator + await expect(page.locator('[data-testid="git-status-indicator"]')).toBeVisible({ + timeout: 10000, + }); + + // Verify git quick action buttons are visible + await expect(page.locator('[data-testid="toggle-diff-view"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('[data-testid="git-stage-button"]')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('[data-testid="git-unstage-button"]')).toBeVisible({ timeout: 5000 }); + }); + + test('should toggle diff view when clicking diff button', async ({ page }) => { + await setupGitTestProject(page, tempDir); + + await page.goto('/files'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="files-view"]')).toBeVisible({ timeout: 10000 }); + + // Wait for tree and click modified file + await expect(async () => { + const treeItems = page.locator('[data-testid^="file-tree-item-"]'); + expect(await treeItems.count()).toBeGreaterThan(0); + }).toPass({ timeout: 15000 }); + + const indexFile = page + .locator('[data-testid^="file-tree-item-"]') + .filter({ hasText: 'index.ts' }) + .first(); + await indexFile.click(); + + // Wait for file to load and status bar to appear + await expect(page.locator('[data-testid="git-status-indicator"]')).toBeVisible({ + timeout: 10000, + }); + + // Click diff view button + await page.locator('[data-testid="toggle-diff-view"]').click(); + + // Verify diff view appears + await expect(page.locator('[data-testid="diff-view"]')).toBeVisible({ timeout: 5000 }); + + // Click again to toggle back to editor + await page.locator('[data-testid="toggle-diff-view"]').click(); + await expect(page.locator('[data-testid="diff-view"]')).not.toBeVisible({ timeout: 5000 }); + }); + + test('should stage and unstage a file', async ({ page }) => { + await setupGitTestProject(page, tempDir); + + await page.goto('/files'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="files-view"]')).toBeVisible({ timeout: 10000 }); + + // Wait for tree and click modified file + await expect(async () => { + const treeItems = page.locator('[data-testid^="file-tree-item-"]'); + expect(await treeItems.count()).toBeGreaterThan(0); + }).toPass({ timeout: 15000 }); + + const indexFile = page + .locator('[data-testid^="file-tree-item-"]') + .filter({ hasText: 'index.ts' }) + .first(); + await indexFile.click(); + + // Wait for git status indicator + await expect(page.locator('[data-testid="git-status-indicator"]')).toBeVisible({ + timeout: 10000, + }); + + // Stage the file (use force to bypass TanStack DevTools overlay) + await page.locator('[data-testid="git-stage-button"]').click({ force: true }); + + // Verify the file was staged via git + await page.waitForTimeout(1000); + const stagedStatus = execSync('git diff --cached --name-only', { cwd: tempDir }) + .toString() + .trim(); + expect(stagedStatus).toContain('index.ts'); + + // Unstage the file - use dispatchEvent to bypass overlay + await page.locator('[data-testid="git-unstage-button"]').dispatchEvent('click'); + + // Wait for the unstage to complete + await page.waitForTimeout(2000); + + // Verify the file was unstaged + const unstagedStatus = execSync('git diff --cached --name-only', { cwd: tempDir }) + .toString() + .trim(); + expect(unstagedStatus).not.toContain('index.ts'); + }); +}); diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index bcbc6febd..b841397c4 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -253,7 +253,7 @@ This feature depends on: {{dependencies}} {{/if}} **CRITICAL - Port Protection:** -NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application. Killing these ports will crash Automaker and terminate this session. +NEVER kill or terminate processes running on ports ${STATIC_PORT} through ${SERVER_PORT + 10}. Automaker uses ports in this range (default ${STATIC_PORT} and ${SERVER_PORT}, but may use nearby ports if defaults are occupied). Killing these ports will crash Automaker and terminate this session. `; export const DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE = `## Follow-up on Feature Implementation @@ -346,7 +346,7 @@ You have access to several tools: 5. Guide users toward good software design principles **CRITICAL - Port Protection:** -NEVER kill or terminate processes running on ports ${STATIC_PORT} or ${SERVER_PORT}. These are reserved for the Automaker application itself. Killing these ports will crash Automaker and terminate your session. +NEVER kill or terminate processes running on ports ${STATIC_PORT} through ${SERVER_PORT + 10}. Automaker uses ports in this range (default ${STATIC_PORT} and ${SERVER_PORT}, but may use nearby ports if defaults are occupied). Killing these ports will crash Automaker and terminate your session. Remember: You're a collaborative partner in the development process. Be helpful, clear, and thorough.`; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index c938eacf3..59ede5d6b 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -300,7 +300,14 @@ export type { } from './pipeline.js'; // Port configuration -export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; +export { + STATIC_PORT, + SERVER_PORT, + RESERVED_PORTS, + registerRuntimePort, + isAutomakerPort, + getAutomakerPorts, +} from './ports.js'; // Editor types export type { EditorInfo } from './editor.js'; diff --git a/libs/types/src/ports.ts b/libs/types/src/ports.ts index 451ecdd75..716b73321 100644 --- a/libs/types/src/ports.ts +++ b/libs/types/src/ports.ts @@ -5,11 +5,33 @@ * killed or terminated by AI agents during feature implementation. */ -/** Port for the static/UI server (Vite dev server) */ +/** Default port for the static/UI server (Vite dev server) */ export const STATIC_PORT = 3007; -/** Port for the backend API server (Express + WebSocket) */ +/** Default port for the backend API server (Express + WebSocket) */ export const SERVER_PORT = 3008; -/** Array of all reserved Automaker ports */ +/** Array of default reserved Automaker ports */ export const RESERVED_PORTS = [STATIC_PORT, SERVER_PORT] as const; + +/** + * Runtime port registry for tracking actual ports in use by Automaker. + * When ports are dynamically assigned (due to conflicts), the actual ports + * are registered here so they can be protected from being killed. + */ +const runtimePorts = new Set([STATIC_PORT, SERVER_PORT]); + +/** Register a port as actively used by Automaker */ +export function registerRuntimePort(port: number): void { + runtimePorts.add(port); +} + +/** Check if a port is reserved or actively used by Automaker */ +export function isAutomakerPort(port: number): boolean { + return runtimePorts.has(port); +} + +/** Get all ports currently reserved/used by Automaker */ +export function getAutomakerPorts(): readonly number[] { + return [...runtimePorts]; +} diff --git a/package-lock.json b/package-lock.json index 7c187c224..7aed8228a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "automaker", "version": "0.13.0", "hasInstallScript": true, + "license": "MIT", "workspaces": [ "apps/*", "libs/*" @@ -105,7 +106,18 @@ "@automaker/dependency-resolver": "1.0.0", "@automaker/spec-parser": "1.0.0", "@automaker/types": "1.0.0", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-xml": "6.1.0", + "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.1", "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "6.1.3", @@ -143,6 +155,8 @@ "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.2.8", + "@replit/codemirror-emacs": "^6.1.0", + "@replit/codemirror-vim": "^6.3.0", "@tanstack/react-query": "^5.90.17", "@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-router": "1.141.6", @@ -1257,6 +1271,133 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@codemirror/lang-xml": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", @@ -1271,6 +1412,21 @@ "@lezer/xml": "^1.0.0" } }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.1", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", @@ -3706,6 +3862,28 @@ "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", "license": "MIT" }, + "node_modules/@lezer/cpp": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz", + "integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/highlight": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", @@ -3715,6 +3893,50 @@ "@lezer/common": "^1.3.0" } }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz", @@ -3724,6 +3946,38 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/xml": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", @@ -3735,6 +3989,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -5106,6 +5371,32 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@replit/codemirror-emacs": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz", + "integrity": "sha512-74DITnht6Cs6sHg02PQ169IKb1XgtyhI9sLD0JeOFco6Ds18PT+dkD8+DgXBDokne9UIFKsBbKPnpFRAz60/Lw==", + "license": "MIT", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.2", + "@codemirror/commands": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.1", + "@codemirror/view": "^6.3.0" + } + }, + "node_modules/@replit/codemirror-vim": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz", + "integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==", + "license": "MIT", + "peerDependencies": { + "@codemirror/commands": "6.x.x", + "@codemirror/language": "6.x.x", + "@codemirror/search": "6.x.x", + "@codemirror/state": "6.x.x", + "@codemirror/view": "6.x.x" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", From ee256d535c295a23407672bb5454f97fa333b002 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 14 Feb 2026 10:36:14 -0800 Subject: [PATCH 12/12] feat: Add markdown preview modes (editor, preview, split) for markdown files --- apps/ui/src/components/views/files-view.tsx | 93 ++++++++++++++++++++- apps/ui/src/store/app-store.ts | 2 + apps/ui/src/store/types/ui-types.ts | 3 + 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/components/views/files-view.tsx b/apps/ui/src/components/views/files-view.tsx index ff6a1bc5a..1c9eec6da 100644 --- a/apps/ui/src/components/views/files-view.tsx +++ b/apps/ui/src/components/views/files-view.tsx @@ -20,12 +20,16 @@ import { Diff, ChevronDown, Search, + Eye, + EyeOff, + SplitSquareHorizontal, } from 'lucide-react'; import { toast } from 'sonner'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import type { ImperativePanelHandle } from 'react-resizable-panels'; import { FileTree } from '@/components/ui/file-tree'; import { CodeEditor, detectLanguage } from '@/components/ui/code-editor'; +import { Markdown } from '@/components/ui/markdown'; import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; import { FileSearchDialog } from '@/components/ui/file-search-dialog'; import { getElectronAPI } from '@/lib/electron'; @@ -58,12 +62,47 @@ function formatRelativeDate(date: Date): string { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } +/** Check if a file is a markdown file */ +function isMarkdownFile(filePath: string): boolean { + const ext = filePath.split('.').pop()?.toLowerCase(); + return ext === 'md' || ext === 'mdx' || ext === 'markdown'; +} + export function FilesView() { const { currentProject } = useAppStore(); const tabs = useAppStore((s) => s.fileEditorTabs); const activeTabPath = useAppStore((s) => s.fileEditorActiveTabPath); const saveStatus = useAppStore((s) => s.fileEditorSaveStatus); const settings = useAppStore((s) => s.fileEditorSettings); + + // Markdown preview state (per-file: path -> mode) + const [markdownPreviewMode, setMarkdownPreviewMode] = useState< + Record + >({}); + const markdownPreviewPanelRef = useRef(null); + + const activeTab = tabs.find((t) => t.path === activeTabPath) || null; + + // Get markdown preview mode for active file (default to 'editor') + const activeMarkdownPreviewMode = useMemo(() => { + if (!activeTabPath) return 'editor'; + return markdownPreviewMode[activeTabPath] ?? settings.markdownPreviewMode; + }, [activeTabPath, markdownPreviewMode, settings.markdownPreviewMode]); + + // Check if active file is a markdown file + const activeFileIsMarkdown = useMemo(() => { + return activeTabPath ? isMarkdownFile(activeTabPath) : false; + }, [activeTabPath]); + + // Handler to toggle markdown preview mode + const handleToggleMarkdownPreview = useCallback(() => { + if (!activeTabPath) return; + const currentMode = markdownPreviewMode[activeTabPath] ?? settings.markdownPreviewMode; + const nextMode: 'editor' | 'preview' | 'split' = + currentMode === 'editor' ? 'preview' : currentMode === 'preview' ? 'split' : 'editor'; + setMarkdownPreviewMode((prev) => ({ ...prev, [activeTabPath]: nextMode })); + }, [activeTabPath, markdownPreviewMode, settings.markdownPreviewMode]); + const openFileTab = useAppStore((s) => s.openFileTab); const closeFileTab = useAppStore((s) => s.closeFileTab); const setActiveFileTab = useAppStore((s) => s.setActiveFileTab); @@ -135,10 +174,6 @@ export function FilesView() { ); // Key to force re-mount the FileTree when files change (create/rename/delete) const [treeRefreshKey, setTreeRefreshKey] = useState(0); - - const activeTab = tabs.find((t) => t.path === activeTabPath) || null; - - // Virtual keyboard detection for mobile useEffect(() => { if (!isMobile) return; const handleResize = () => { @@ -153,6 +188,7 @@ export function FilesView() { window.visualViewport.addEventListener('resize', handleResize); return () => window.visualViewport?.removeEventListener('resize', handleResize); } + const activeTab = tabs.find((t) => t.path === activeTabPath) || null; }, [isMobile]); // Prevent zoom on input focus for iOS @@ -850,6 +886,25 @@ export function FilesView() { Auto
)} + {/* Markdown preview toggle (only for .md files) */} + {activeFileIsMarkdown && ( +
+ +
+ )} ) : ( No files open @@ -888,6 +943,36 @@ export function FilesView() { })}
+ ) : activeFileIsMarkdown && activeMarkdownPreviewMode === 'preview' ? ( +
+
+ {activeTab.content} +
+
+ ) : activeFileIsMarkdown && activeMarkdownPreviewMode === 'split' ? ( + + +
+ +
+
+ + +
+
+ {activeTab.content} +
+
+
+
) : (