diff --git a/apps/desktop/src/main/claude-code-settings/reader.ts b/apps/desktop/src/main/claude-code-settings/reader.ts index c8141a8dbe..f10a328954 100644 --- a/apps/desktop/src/main/claude-code-settings/reader.ts +++ b/apps/desktop/src/main/claude-code-settings/reader.ts @@ -147,6 +147,36 @@ function isValidSettings(obj: unknown): obj is ClaudeCodeSettings { } } + // Validate and sanitize enabledPlugins field + if ('enabledPlugins' in obj) { + if (isPlainObject(obj.enabledPlugins)) { + const plugins: Record = {}; + let hasValidPlugins = false; + for (const [key, value] of Object.entries(obj.enabledPlugins as Record)) { + if (typeof value === 'boolean') { + plugins[key] = value; + hasValidPlugins = true; + } + } + if (hasValidPlugins) { + sanitized.enabledPlugins = plugins; + hasValidFields = true; + } + } else { + debugLog(`${LOG_PREFIX} Skipping invalid enabledPlugins field`); + } + } + + // Validate and sanitize mcpServers field (pass through as Record) + if ('mcpServers' in obj) { + if (isPlainObject(obj.mcpServers)) { + sanitized.mcpServers = obj.mcpServers as Record; + hasValidFields = true; + } else { + debugLog(`${LOG_PREFIX} Skipping invalid mcpServers field`); + } + } + // If we have at least one valid field, mutate the original object to contain only sanitized fields if (hasValidFields) { // Clear the original object and copy sanitized fields @@ -195,7 +225,7 @@ function readJsonFile(filePath: string): ClaudeCodeSettings | undefined { * 2. CLAUDE_CONFIG_DIR environment variable * 3. Default: ~/.claude */ -function getUserConfigDir(): string { +export function getUserConfigDir(): string { // Try to get configDir from the active Claude profile. // We use a lazy import to avoid circular dependencies and to handle // the case where ClaudeProfileManager hasn't been initialized yet. diff --git a/apps/desktop/src/main/claude-code-settings/types.ts b/apps/desktop/src/main/claude-code-settings/types.ts index 2f73b1e37e..44d0e895c3 100644 --- a/apps/desktop/src/main/claude-code-settings/types.ts +++ b/apps/desktop/src/main/claude-code-settings/types.ts @@ -34,6 +34,10 @@ export interface ClaudeCodeSettings { alwaysThinkingEnabled?: boolean; /** Environment variables to inject into agent processes */ env?: Record; + /** Enabled marketplace plugins keyed by pluginKey (e.g. "pluginId@marketplace") */ + enabledPlugins?: Record; + /** Inline MCP server configurations keyed by server ID */ + mcpServers?: Record; } /** diff --git a/apps/desktop/src/main/ipc-handlers/claude-agents-handlers.ts b/apps/desktop/src/main/ipc-handlers/claude-agents-handlers.ts new file mode 100644 index 0000000000..380e85e6b7 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/claude-agents-handlers.ts @@ -0,0 +1,125 @@ +/** + * Claude Agents Handlers + * + * IPC handlers for reading Claude Code custom agent definitions + * from ~/.claude/agents/ directory structure. + */ + +import { ipcMain } from 'electron'; +import { existsSync, readdirSync } from 'fs'; +import path from 'path'; +import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import type { IPCResult } from '../../shared/types'; +import type { ClaudeAgentsInfo, ClaudeAgentCategory, ClaudeCustomAgent } from '../../shared/types/integrations'; +import { getUserConfigDir } from '../claude-code-settings/reader'; +import { debugLog } from '../../shared/utils/debug-logger'; + +const LOG_PREFIX = '[ClaudeAgents]'; + +/** + * Convert a category directory name to a human-readable name. + * Removes the number prefix (e.g. "01-") and capitalizes words. + */ +function toCategoryName(dirName: string): string { + // Remove number prefix (e.g. "01-" from "01-core-development") + const withoutPrefix = dirName.replace(/^\d+-/, ''); + return withoutPrefix + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Convert an agent filename to a human-readable name. + * Removes the .md extension, capitalizes words, replaces hyphens with spaces. + */ +function toAgentName(fileName: string): string { + // Remove .md extension + const withoutExt = fileName.replace(/\.md$/, ''); + return withoutExt + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Get the agents directory path (~/.claude/agents/). + * Respects CLAUDE_CONFIG_DIR environment variable. + */ +function getAgentsDir(): string { + return path.join(getUserConfigDir(), 'agents'); +} + +/** + * Register Claude Agents IPC handlers. + */ +export function registerClaudeAgentsHandlers(): void { + ipcMain.handle(IPC_CHANNELS.CLAUDE_AGENTS_GET, async (): Promise> => { + try { + const agentsDir = getAgentsDir(); + + if (!existsSync(agentsDir)) { + debugLog(`${LOG_PREFIX} Agents directory not found:`, agentsDir); + return { success: true, data: { categories: [], totalAgents: 0 } }; + } + + const categories: ClaudeAgentCategory[] = []; + let totalAgents = 0; + + const entries = readdirSync(agentsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const entryPath = path.join(agentsDir, entry.name); + + const agents: ClaudeCustomAgent[] = []; + + try { + const files = readdirSync(entryPath); + for (const file of files) { + if (!file.endsWith('.md') || file.toLowerCase() === 'readme.md') continue; + + const agentId = file.replace(/\.md$/, ''); + + // Use relative path (categoryDir/file) instead of absolute filePath + // to avoid exposing full filesystem paths to the renderer process + const relativePath = path.join(entry.name, file); + + agents.push({ + agentId, + agentName: toAgentName(file), + categoryDir: entry.name, + categoryName: toCategoryName(entry.name), + filePath: relativePath, + }); + } + } catch { + debugLog(`${LOG_PREFIX} Failed to read category directory:`, entryPath); + continue; + } + + if (agents.length > 0) { + // Sort agents by name within category + agents.sort((a, b) => a.agentName.localeCompare(b.agentName)); + + categories.push({ + categoryDir: entry.name, + categoryName: toCategoryName(entry.name), + agents, + }); + totalAgents += agents.length; + } + } + + // Sort categories by directory name (already numbered) + categories.sort((a, b) => a.categoryDir.localeCompare(b.categoryDir)); + + debugLog(`${LOG_PREFIX} Found ${totalAgents} agent(s) in ${categories.length} categories`); + return { success: true, data: { categories, totalAgents } }; + } catch (error) { + debugLog(`${LOG_PREFIX} Error reading agents:`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read custom agents', + }; + } + }); +} diff --git a/apps/desktop/src/main/ipc-handlers/claude-mcp-handlers.ts b/apps/desktop/src/main/ipc-handlers/claude-mcp-handlers.ts new file mode 100644 index 0000000000..0b7a7e8bdb --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/claude-mcp-handlers.ts @@ -0,0 +1,365 @@ +/** + * Claude MCP Handlers + * + * IPC handlers for reading Claude Code's global MCP configuration. + * Resolves both inline mcpServers from settings.json and enabled plugins + * from the marketplace plugin cache. + */ + +import { ipcMain } from 'electron'; +import fs from 'fs/promises'; +import { homedir } from 'os'; +import path from 'path'; +import { IPC_CHANNELS } from '../../shared/constants/ipc'; +import type { IPCResult } from '../../shared/types'; +import type { GlobalMcpInfo, GlobalMcpServerEntry } from '../../shared/types/integrations'; +import { readUserGlobalSettings, getUserConfigDir } from '../claude-code-settings/reader'; +import { debugLog } from '../../shared/utils/debug-logger'; + +const LOG_PREFIX = '[ClaudeMCP]'; + +/** + * Convert a serverId (e.g. "context7", "github-mcp") to a human-readable name. + * Capitalizes words and replaces hyphens/underscores with spaces. + */ +function toServerName(serverId: string): string { + return serverId + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Find the most recently modified subdirectory within a directory. + * Plugin caches store configs in hash-named subdirectories; we want the latest one. + */ +async function findLatestSubdir(dirPath: string): Promise { + try { + await fs.access(dirPath); + } catch { + return undefined; + } + + try { + const entries = await fs.readdir(dirPath); + let latestDir: string | undefined; + let latestMtime = 0; + + for (const entry of entries) { + const entryPath = path.join(dirPath, entry); + try { + const stat = await fs.stat(entryPath); + if (stat.isDirectory() && stat.mtimeMs > latestMtime) { + latestMtime = stat.mtimeMs; + latestDir = entryPath; + } + } catch { + // Skip entries we can't stat + } + } + + return latestDir; + } catch { + return undefined; + } +} + +/** + * Resolve a single enabled plugin to its MCP server entries. + * Reads the .mcp.json from the plugin cache directory. + * + * @param pluginKey - Plugin key in format "pluginId@marketplace" + * @param claudeDir - Path to ~/.claude directory + * @returns Array of resolved server entries (a plugin .mcp.json can define multiple servers) + */ +async function resolvePluginServers(pluginKey: string, claudeDir: string): Promise { + const atIndex = pluginKey.lastIndexOf('@'); + if (atIndex <= 0) { + debugLog(`${LOG_PREFIX} Invalid plugin key format (missing @):`, pluginKey); + return []; + } + + const pluginId = pluginKey.substring(0, atIndex); + const marketplace = pluginKey.substring(atIndex + 1); + + // Plugin cache path: ~/.claude/plugins/cache/{marketplace}/{pluginId}/ + const pluginCacheDir = path.join(claudeDir, 'plugins', 'cache', marketplace, pluginId); + + try { + await fs.access(pluginCacheDir); + } catch { + debugLog(`${LOG_PREFIX} Plugin cache directory not found:`, pluginCacheDir); + return []; + } + + // Find the most recently modified hash subdirectory + const latestHashDir = await findLatestSubdir(pluginCacheDir); + if (!latestHashDir) { + debugLog(`${LOG_PREFIX} No hash subdirectory found in plugin cache:`, pluginCacheDir); + return []; + } + + // Read .mcp.json from the hash directory (read directly to avoid TOCTOU race) + const mcpJsonPath = path.join(latestHashDir, '.mcp.json'); + try { + const content = await fs.readFile(mcpJsonPath, 'utf-8'); + const parsed = JSON.parse(content); + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + debugLog(`${LOG_PREFIX} Invalid .mcp.json structure:`, mcpJsonPath); + return []; + } + + const entries: GlobalMcpServerEntry[] = []; + + // Each key in the .mcp.json is a server ID with its config + for (const [serverId, serverConfig] of Object.entries(parsed)) { + if (typeof serverConfig !== 'object' || serverConfig === null) { + debugLog(`${LOG_PREFIX} Skipping invalid server config in .mcp.json:`, { pluginKey, serverId }); + continue; + } + + const config = serverConfig as Record; + const entry: GlobalMcpServerEntry = { + pluginKey, + serverId, + serverName: toServerName(serverId), + config: { + ...(typeof config.type === 'string' && (config.type === 'http' || config.type === 'sse') + ? { type: config.type as 'http' | 'sse' } + : {}), + ...(typeof config.command === 'string' ? { command: config.command } : {}), + ...(Array.isArray(config.args) ? { args: config.args.filter((a): a is string => typeof a === 'string') } : {}), + ...(typeof config.url === 'string' ? { url: config.url } : {}), + ...(typeof config.headers === 'object' && config.headers !== null && !Array.isArray(config.headers) + ? { headers: Object.fromEntries( + Object.entries(config.headers as Record) + .filter(([, v]) => typeof v === 'string') + ) as Record } + : {}), + ...(typeof config.env === 'object' && config.env !== null && !Array.isArray(config.env) + ? { env: Object.fromEntries( + Object.entries(config.env as Record) + .filter(([, v]) => typeof v === 'string') + ) as Record } + : {}), + }, + source: 'plugin', + }; + + entries.push(entry); + } + + debugLog(`${LOG_PREFIX} Resolved ${entries.length} server(s) from plugin:`, pluginKey); + return entries; + } catch (error: unknown) { + // ENOENT means file doesn't exist — not an error worth logging at detail level + if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { + debugLog(`${LOG_PREFIX} .mcp.json not found in plugin cache:`, mcpJsonPath); + } else { + debugLog(`${LOG_PREFIX} Failed to parse .mcp.json:`, mcpJsonPath, error); + } + return []; + } +} + +/** + * Convert inline mcpServers config entries to GlobalMcpServerEntry array. + * Performs runtime type validation since the input may come from untrusted JSON. + * + * @param mcpServers - MCP server configurations keyed by server ID (runtime-validated) + * @param source - Where this config was sourced from ('settings' for settings.json, 'claude-json' for ~/.claude.json) + */ +function resolveInlineServers( + mcpServers: Record, + source: 'settings' | 'claude-json' = 'settings' +): GlobalMcpServerEntry[] { + const entries: GlobalMcpServerEntry[] = []; + + for (const [serverId, rawConfig] of Object.entries(mcpServers)) { + if (typeof rawConfig !== 'object' || rawConfig === null || Array.isArray(rawConfig)) { + debugLog(`${LOG_PREFIX} Skipping invalid mcpServers entry (not an object):`, serverId); + continue; + } + + const config = rawConfig as Record; + + // Skip disabled servers + if (config.disabled === true) { + debugLog(`${LOG_PREFIX} Skipping disabled mcpServers entry:`, serverId); + continue; + } + + const entry: GlobalMcpServerEntry = { + serverId, + serverName: toServerName(serverId), + config: { + ...(typeof config.type === 'string' && (config.type === 'http' || config.type === 'sse') + ? { type: config.type as 'http' | 'sse' } + : {}), + ...(typeof config.command === 'string' ? { command: config.command } : {}), + ...(Array.isArray(config.args) + ? { args: config.args.filter((a: unknown): a is string => typeof a === 'string') } + : {}), + ...(typeof config.url === 'string' ? { url: config.url } : {}), + ...(typeof config.headers === 'object' && config.headers !== null && !Array.isArray(config.headers) + ? { headers: Object.fromEntries( + Object.entries(config.headers as Record) + .filter(([, v]) => typeof v === 'string') + ) as Record } + : {}), + ...(typeof config.env === 'object' && config.env !== null && !Array.isArray(config.env) + ? { env: Object.fromEntries( + Object.entries(config.env as Record) + .filter(([, v]) => typeof v === 'string') + ) as Record } + : {}), + }, + source, + }; + + // Ensure the server has a usable transport (command-based or HTTP/SSE) + const hasCommandTransport = + typeof entry.config.command === 'string' && entry.config.command.trim().length > 0; + const hasHttpTransport = + (entry.config.type === 'http' || entry.config.type === 'sse') && + typeof entry.config.url === 'string' && + entry.config.url.trim().length > 0; + + if (!hasCommandTransport && !hasHttpTransport) { + debugLog(`${LOG_PREFIX} Skipping unusable mcpServers entry (no command or url transport):`, serverId); + continue; + } + + entries.push(entry); + } + + return entries; +} + +/** + * Get the Claude home directory (~/.claude). + * Delegates to getUserConfigDir() from the settings reader for consistency + * with profile-aware config resolution (active profile -> CLAUDE_CONFIG_DIR -> ~/.claude). + */ +function getClaudeHomeDir(): string { + return getUserConfigDir(); +} + +/** + * Read MCP servers from ~/.claude.json (the main Claude Code configuration file). + * This file contains a top-level `mcpServers` key with the same structure as + * ClaudeCodeMcpServerConfig entries. + * + * @returns Array of GlobalMcpServerEntry with source 'claude-json', or empty array on failure. + */ +async function readClaudeJsonMcpServers(): Promise { + // .claude.json lives in the home directory (or CLAUDE_CONFIG_DIR parent). + // Use getUserConfigDir() for profile-aware resolution, then look for + // .claude.json in the parent of that config dir. + const configDir = getUserConfigDir(); + const configParent = path.dirname(configDir); + const homeDir = homedir(); + + // Build candidate list: config dir parent first, then home as fallback + const candidates = [path.join(configParent, '.claude.json')]; + if (configParent !== homeDir) { + candidates.push(path.join(homeDir, '.claude.json')); + } + + let claudeJsonPath: string | undefined; + for (const candidate of candidates) { + try { + await fs.access(candidate); + claudeJsonPath = candidate; + break; + } catch { + // Not found, try next + } + } + if (!claudeJsonPath) { + debugLog(`${LOG_PREFIX} .claude.json not found in expected locations`); + return []; + } + + try { + const content = await fs.readFile(claudeJsonPath, 'utf-8'); + const parsed = JSON.parse(content); + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + debugLog(`${LOG_PREFIX} Invalid ~/.claude.json structure (expected object)`); + return []; + } + + const mcpServers = parsed.mcpServers; + if (!mcpServers || typeof mcpServers !== 'object' || Array.isArray(mcpServers)) { + debugLog(`${LOG_PREFIX} No valid mcpServers found in ~/.claude.json`); + return []; + } + + const entries = resolveInlineServers( + mcpServers as Record, + 'claude-json' + ); + + debugLog(`${LOG_PREFIX} Resolved ${entries.length} server(s) from ~/.claude.json`); + return entries; + } catch (error) { + debugLog(`${LOG_PREFIX} Failed to read/parse ~/.claude.json:`, claudeJsonPath, error); + return []; + } +} + +/** + * Register Claude MCP IPC handlers. + */ +export function registerClaudeMcpHandlers(): void { + ipcMain.handle(IPC_CHANNELS.CLAUDE_MCP_GET_GLOBAL, async (): Promise> => { + try { + debugLog(`${LOG_PREFIX} Reading global MCP configuration`); + + const settings = readUserGlobalSettings(); + const claudeDir = getClaudeHomeDir(); + + const result: GlobalMcpInfo = { + pluginServers: [], + inlineServers: [], + claudeJsonServers: [], + }; + + // Resolve enabled plugins + if (settings?.enabledPlugins) { + for (const [pluginKey, enabled] of Object.entries(settings.enabledPlugins)) { + if (!enabled) { + continue; + } + + const servers = await resolvePluginServers(pluginKey, claudeDir); + result.pluginServers.push(...servers); + } + } + + // Resolve inline mcpServers from settings.json + if (settings?.mcpServers) { + result.inlineServers = resolveInlineServers(settings.mcpServers); + } + + // Read ~/.claude.json mcpServers + result.claudeJsonServers = await readClaudeJsonMcpServers(); + + debugLog( + `${LOG_PREFIX} Resolved global MCPs:`, + `${result.pluginServers.length} plugin server(s),`, + `${result.inlineServers.length} inline server(s),`, + `${result.claudeJsonServers.length} claude.json server(s)` + ); + + return { success: true, data: result }; + } catch (error) { + debugLog(`${LOG_PREFIX} Error reading global MCP configuration:`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read global MCP configuration', + }; + } + }); +} diff --git a/apps/desktop/src/main/ipc-handlers/github/customer-github-handlers.ts b/apps/desktop/src/main/ipc-handlers/github/customer-github-handlers.ts new file mode 100644 index 0000000000..93529b5742 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers/github/customer-github-handlers.ts @@ -0,0 +1,432 @@ +/** + * Multi-repo GitHub Issues handlers for Customer projects. + * + * A Customer project aggregates issues from multiple child repositories. + * The GitHub token comes from the customer's own .env while each child + * repository supplies its own GITHUB_REPO value. + */ + +import { ipcMain } from 'electron'; +import { existsSync, readFileSync } from 'fs'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { IPC_CHANNELS } from '../../../shared/constants'; +import type { IPCResult, GitHubIssue, MultiRepoGitHubStatus, MultiRepoIssuesResult, MultiRepoPRsResult } from '../../../shared/types'; +import { projectStore } from '../../project-store'; +import { getGitHubConfig, githubFetch, normalizeRepoReference } from './utils'; +import { getToolPath } from '../../cli-tool-manager'; +import type { GitHubAPIIssue } from './types'; +import { transformIssue } from './issue-handlers'; +import { parseEnvFile } from '../utils'; +import { debugLog } from '../../../shared/utils/debug-logger'; + +const execFileAsync = promisify(execFile); + +/** Cross-platform child path check using path.relative */ +function isChildPath(parentPath: string, candidatePath: string): boolean { + const rel = path.relative(parentPath, candidatePath); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Shared helper +// ──────────────────────────────────────────────────────────────────────────── + +interface CustomerRepo { + projectId: string; + repoFullName: string; +} + +interface CustomerGitHubConfig { + token: string; + repos: CustomerRepo[]; +} + +/** + * Resolve the GitHub token and child-repo list for a Customer project. + * + * Token resolution order: + * 1. GITHUB_TOKEN from the customer's .env + * 2. Fallback to `getGitHubConfig(customer)?.token` (which also tries `gh` CLI) + * + * Each child repo's GITHUB_REPO is read from its own .env via `getGitHubConfig`. + */ +async function getCustomerGitHubConfig(customerId: string): Promise { + const customer = projectStore.getProject(customerId); + if (!customer) { + debugLog('[Customer GitHub] Customer project not found:', customerId); + return null; + } + + if (customer.type !== 'customer') { + debugLog('[Customer GitHub] Project is not a customer:', customerId); + return null; + } + + // 1. Resolve token from customer's .env + let token: string | undefined; + + if (customer.autoBuildPath) { + const envPath = path.join(customer.path, customer.autoBuildPath, '.env'); + if (existsSync(envPath)) { + try { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + token = vars['GITHUB_TOKEN']; + } catch { + // ignore read errors, fall through to fallback + } + } + } + + // Fallback: try getGitHubConfig which also checks gh CLI + if (!token) { + const fallbackConfig = getGitHubConfig(customer); + token = fallbackConfig?.token; + } + + if (!token) { + debugLog('[Customer GitHub] No GitHub token found for customer:', customerId); + return null; + } + + // 2. Discover child repos + const allProjects = projectStore.getProjects(); + const childProjects = allProjects.filter( + (p) => p.id !== customer.id && isChildPath(customer.path, p.path) + ); + + const repos: CustomerRepo[] = []; + + for (const child of childProjects) { + // Try .env first (if child has autoBuildPath and GITHUB_REPO configured) + const childConfig = getGitHubConfig(child); + if (childConfig?.repo) { + const normalized = normalizeRepoReference(childConfig.repo); + if (normalized) { + repos.push({ projectId: child.id, repoFullName: normalized }); + continue; + } + } + + // Fallback: detect from git remote origin (cloned repos have this) + try { + const { stdout } = await execFileAsync(getToolPath('git'), ['remote', 'get-url', 'origin'], { + encoding: 'utf-8', + cwd: child.path, + timeout: 5000, + }); + const remoteUrl = stdout.trim(); + + const match = remoteUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/); + if (match) { + const repoFullName = match[1]; + debugLog('[Customer GitHub] Detected repo from git remote:', repoFullName, 'for', child.path); + repos.push({ projectId: child.id, repoFullName }); + } + } catch { + debugLog('[Customer GitHub] Could not detect git remote for child:', child.path); + } + } + + debugLog('[Customer GitHub] Resolved config:', { + customerId, + hasToken: !!token, + repoCount: repos.length, + }); + + return { token, repos }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Handler 1: Check multi-repo connection +// ──────────────────────────────────────────────────────────────────────────── + +function registerCheckMultiRepoConnection(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_CHECK_MULTI_REPO_CONNECTION, + async (_, customerId: string): Promise> => { + debugLog('[Customer GitHub] checkMultiRepoConnection called', { customerId }); + + const config = await getCustomerGitHubConfig(customerId); + if (!config) { + return { + success: true, + data: { + connected: false, + repos: [], + error: 'No GitHub token configured for this customer', + }, + }; + } + + return { + success: true, + data: { + connected: true, + repos: config.repos, + }, + }; + } + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Handler 2: Get issues across all child repos +// ──────────────────────────────────────────────────────────────────────────── + +function registerGetMultiRepoIssues(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUES, + async ( + _, + customerId: string, + state: 'open' | 'closed' | 'all' = 'open', + page: number = 1 + ): Promise> => { + // Validate IPC query parameters + const validStates = ['open', 'closed', 'all'] as const; + if (!validStates.includes(state)) { + return { success: false, error: `Invalid state parameter: ${String(state)}. Must be one of: ${validStates.join(', ')}` }; + } + if (typeof page !== 'number' || !Number.isFinite(page) || page < 1) { + page = 1; + } + + debugLog('[Customer GitHub] getMultiRepoIssues called', { customerId, state, page }); + + const config = await getCustomerGitHubConfig(customerId); + if (!config) { + return { success: false, error: 'No GitHub configuration found for this customer' }; + } + + if (config.repos.length === 0) { + return { + success: true, + data: { issues: [], repos: [], hasMore: false }, + }; + } + + try { + const allRepoNames = config.repos.map((r) => r.repoFullName); + + // Fetch issues from all repos in parallel + const settledResults = await Promise.allSettled( + config.repos.map(async (repo) => { + const endpoint = `/repos/${repo.repoFullName}/issues?state=${state}&per_page=50&sort=updated&page=${page}`; + const data = await githubFetch(config.token, endpoint); + return { repoFullName: repo.repoFullName, data }; + }) + ); + + const allIssues: GitHubIssue[] = []; + const perPage = 50; + let anyRepoHasMore = false; + + for (const result of settledResults) { + if (result.status === 'fulfilled') { + const { repoFullName, data } = result.value; + if (Array.isArray(data)) { + if (data.length === perPage) { + anyRepoHasMore = true; + } + const issuesOnly = (data as GitHubAPIIssue[]).filter( + (item) => !item.pull_request + ); + const transformed = issuesOnly.map((issue) => + transformIssue(issue, repoFullName) + ); + allIssues.push(...transformed); + } + } else { + debugLog('[Customer GitHub] Failed to fetch from repo:', result.reason); + } + } + + // Sort by updatedAt descending + allIssues.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + debugLog('[Customer GitHub] Returning', allIssues.length, 'issues from', allRepoNames.length, 'repos'); + + return { + success: true, + data: { + issues: allIssues, + repos: allRepoNames, + hasMore: anyRepoHasMore, + }, + }; + } catch (error) { + debugLog('[Customer GitHub] Error fetching multi-repo issues:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch multi-repo issues', + }; + } + } + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Handler 3: Get single issue detail from a specific repo +// ──────────────────────────────────────────────────────────────────────────── + +function registerGetMultiRepoIssueDetail(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUE_DETAIL, + async ( + _, + customerId: string, + repoFullName: string, + issueNumber: number + ): Promise> => { + // Validate issueNumber + if (typeof issueNumber !== 'number' || !Number.isFinite(issueNumber) || issueNumber < 1) { + return { success: false, error: `Invalid issue number: ${String(issueNumber)}` }; + } + + debugLog('[Customer GitHub] getMultiRepoIssueDetail called', { + customerId, + repoFullName, + issueNumber, + }); + + const config = await getCustomerGitHubConfig(customerId); + if (!config) { + return { success: false, error: 'No GitHub configuration found for this customer' }; + } + + // Validate that the requested repo belongs to this customer's configured repos + const isValidRepo = config.repos.some(r => r.repoFullName === repoFullName); + if (!isValidRepo) { + return { success: false, error: `Repository ${repoFullName} is not configured for this customer` }; + } + + try { + const issue = (await githubFetch( + config.token, + `/repos/${repoFullName}/issues/${issueNumber}` + )) as GitHubAPIIssue; + + const result = transformIssue(issue, repoFullName); + + return { success: true, data: result }; + } catch (error) { + debugLog('[Customer GitHub] Error fetching issue detail:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issue detail', + }; + } + } + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Handler 4: Get PRs across all child repos +// ──────────────────────────────────────────────────────────────────────────── + +function registerGetMultiRepoPRs(): void { + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_MULTI_REPO_PRS, + async (_, customerId: string): Promise> => { + debugLog('[Customer GitHub] getMultiRepoPRs called', { customerId }); + + const config = await getCustomerGitHubConfig(customerId); + if (!config) { + return { success: false, error: 'No GitHub configuration found for this customer' }; + } + + if (config.repos.length === 0) { + return { + success: true, + data: { prs: [], repos: [] }, + }; + } + + try { + const allRepoNames = config.repos.map((r) => r.repoFullName); + + // Fetch open PRs from all repos in parallel + const settledResults = await Promise.allSettled( + config.repos.map(async (repo) => { + const endpoint = `/repos/${repo.repoFullName}/pulls?state=open&sort=updated&direction=desc&per_page=50`; + const data = await githubFetch(config.token, endpoint); + return { repoFullName: repo.repoFullName, data }; + }) + ); + + const allPRs: MultiRepoPRsResult['prs'] = []; + + for (const result of settledResults) { + if (result.status === 'fulfilled') { + const { repoFullName, data } = result.value; + if (Array.isArray(data)) { + // TODO: Add a typed interface (e.g. GitHubAPIPullRequest) for the GitHub PR API response shape + // biome-ignore lint/suspicious/noExplicitAny: GitHub REST API response shape + const transformed = (data as any[]).map((pr) => ({ + number: pr.number, + title: pr.title, + body: pr.body || '', + state: pr.state.toLowerCase(), + author: { login: pr.user.login }, + headRefName: pr.head.ref, + baseRefName: pr.base.ref, + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changed_files ?? 0, + // biome-ignore lint/suspicious/noExplicitAny: GitHub REST API assignee shape + assignees: (pr.assignees || []).map((a: any) => ({ login: a.login })), + createdAt: pr.created_at, + updatedAt: pr.updated_at, + htmlUrl: pr.html_url, + repoFullName, + })); + allPRs.push(...transformed); + } + } else { + debugLog('[Customer GitHub] Failed to fetch PRs from repo:', result.reason); + } + } + + // Sort by updatedAt descending + allPRs.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + debugLog('[Customer GitHub] Returning', allPRs.length, 'PRs from', allRepoNames.length, 'repos'); + + return { + success: true, + data: { + prs: allPRs, + repos: allRepoNames, + }, + }; + } catch (error) { + debugLog('[Customer GitHub] Error fetching multi-repo PRs:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch multi-repo PRs', + }; + } + } + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Public registration +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Register all Customer multi-repo GitHub IPC handlers + */ +export function registerCustomerGitHubHandlers(): void { + registerCheckMultiRepoConnection(); + registerGetMultiRepoIssues(); + registerGetMultiRepoIssueDetail(); + registerGetMultiRepoPRs(); +} diff --git a/apps/desktop/src/main/ipc-handlers/github/issue-handlers.ts b/apps/desktop/src/main/ipc-handlers/github/issue-handlers.ts index a3be6a1fb3..6b414321a3 100644 --- a/apps/desktop/src/main/ipc-handlers/github/issue-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/github/issue-handlers.ts @@ -19,7 +19,7 @@ const MAX_PAGES_FETCH_ALL = 30; // Max API pages to fetch in fetchAll mode /** * Transform GitHub API issue to application format */ -function transformIssue(issue: GitHubAPIIssue, repoFullName: string): GitHubIssue { +export function transformIssue(issue: GitHubAPIIssue, repoFullName: string): GitHubIssue { return { id: issue.id, number: issue.number, diff --git a/apps/desktop/src/main/ipc-handlers/index.ts b/apps/desktop/src/main/ipc-handlers/index.ts index 98c06890c5..a8be447c24 100644 --- a/apps/desktop/src/main/ipc-handlers/index.ts +++ b/apps/desktop/src/main/ipc-handlers/index.ts @@ -30,6 +30,8 @@ import { registerAppUpdateHandlers } from './app-update-handlers'; import { registerDebugHandlers } from './debug-handlers'; import { registerClaudeCodeHandlers } from './claude-code-handlers'; import { registerMcpHandlers } from './mcp-handlers'; +import { registerClaudeMcpHandlers } from './claude-mcp-handlers'; +import { registerClaudeAgentsHandlers } from './claude-agents-handlers'; import { registerProfileHandlers } from './profile-handlers'; import { registerScreenshotHandlers } from './screenshot-handlers'; import { registerTerminalWorktreeIpcHandlers } from './terminal'; @@ -118,6 +120,12 @@ export function setupIpcHandlers( // MCP server health check handlers registerMcpHandlers(); + // Claude Code global MCP configuration handlers + registerClaudeMcpHandlers(); + + // Claude Code custom agents handlers + registerClaudeAgentsHandlers(); + // API Profile handlers (custom Anthropic-compatible endpoints) registerProfileHandlers(); @@ -153,6 +161,8 @@ export { registerDebugHandlers, registerClaudeCodeHandlers, registerMcpHandlers, + registerClaudeMcpHandlers, + registerClaudeAgentsHandlers, registerProfileHandlers, registerScreenshotHandlers, registerCodexAuthHandlers diff --git a/apps/desktop/src/main/ipc-handlers/mcp-handlers.ts b/apps/desktop/src/main/ipc-handlers/mcp-handlers.ts index 2a9d82420e..b5d64dcb14 100644 --- a/apps/desktop/src/main/ipc-handlers/mcp-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/mcp-handlers.ts @@ -254,6 +254,77 @@ async function checkCommandHealth(server: CustomMcpServer, startTime: number): P }); } +/** + * Health check for global MCPs from Claude Code config. + * These servers come from a trusted source (~/.claude.json, ~/.claude/settings.json) + * so we skip the command allowlist validation. For command-based servers, we check + * if the command exists in PATH. For HTTP servers, we probe the URL. + */ +async function checkGlobalMcpHealth(server: CustomMcpServer): Promise { + const startTime = Date.now(); + + if (server.type === 'http') { + // HTTP servers: reuse existing check (no allowlist involved) + return checkHttpHealth(server, startTime); + } + + // Command-based servers: just verify the command exists (no allowlist filter) + if (!server.command) { + return { + serverId: server.id, + status: 'unhealthy', + message: 'No command configured', + checkedAt: new Date().toISOString(), + }; + } + + return new Promise((resolve) => { + const whichCmd = isWindows() ? getWhereExePath() : 'which'; + const proc = spawn(whichCmd, [server.command!], { + timeout: 5000, + windowsHide: true, + }); + + let found = false; + + proc.stdout.on('data', () => { + found = true; + }); + + proc.on('close', (code) => { + const responseTime = Date.now() - startTime; + if (code === 0 || found) { + resolve({ + serverId: server.id, + status: 'healthy', + message: `Available — '${server.command}' found (starts on demand)`, + responseTime, + checkedAt: new Date().toISOString(), + }); + } else { + resolve({ + serverId: server.id, + status: 'unhealthy', + message: `Command '${server.command}' not found in PATH`, + responseTime, + checkedAt: new Date().toISOString(), + }); + } + }); + + proc.on('error', (error: Error) => { + const responseTime = Date.now() - startTime; + resolve({ + serverId: server.id, + status: 'unhealthy', + message: `Failed to check: ${error.message}`, + responseTime, + checkedAt: new Date().toISOString(), + }); + }); + }); +} + /** * Full MCP connection test - actually connects to the server and tries to list tools. * This is more thorough but slower than the health check. @@ -566,6 +637,20 @@ export function registerMcpHandlers(): void { } }); + // Health check for global MCPs (from Claude Code config — trusted source, skip allowlist) + ipcMain.handle(IPC_CHANNELS.MCP_CHECK_GLOBAL_HEALTH, async (_event, server: CustomMcpServer) => { + try { + const result = await checkGlobalMcpHealth(server); + return { success: true, data: result }; + } catch (error) { + appLog.error('Global MCP health check error:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Health check failed', + }; + } + }); + // Full connection test ipcMain.handle(IPC_CHANNELS.MCP_TEST_CONNECTION, async (_event, server: CustomMcpServer) => { try { diff --git a/apps/desktop/src/main/ipc-handlers/memory-handlers.ts b/apps/desktop/src/main/ipc-handlers/memory-handlers.ts index ec74869987..3420800ec0 100644 --- a/apps/desktop/src/main/ipc-handlers/memory-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/memory-handlers.ts @@ -15,6 +15,84 @@ import type { } from '../../shared/types'; import { openTerminalWithCommand } from './claude-code-handlers'; +/** + * Known Ollama embedding model dimensions. + * Single source of truth for the frontend — mirrors the backend's + * KNOWN_EMBEDDING_MODELS in ollama_model_detector.py and + * KNOWN_OLLAMA_EMBEDDING_MODELS in ollama_embedder.py. + */ +const KNOWN_OLLAMA_EMBEDDING_DIMS: Record = { + 'embeddinggemma': 768, + 'embeddinggemma:300m': 768, + 'qwen3-embedding': 1024, + 'qwen3-embedding:0.6b': 1024, + 'qwen3-embedding:4b': 2560, + 'qwen3-embedding:8b': 4096, + 'nomic-embed-text': 768, + 'nomic-embed-text:latest': 768, + 'mxbai-embed-large': 1024, + 'mxbai-embed-large:latest': 1024, + 'bge-large': 1024, + 'bge-large:latest': 1024, + 'bge-large-en': 1024, + 'bge-base-en': 768, + 'bge-small-en': 384, + 'bge-m3': 1024, + 'bge-m3:latest': 1024, + 'all-minilm': 384, + 'all-minilm:latest': 384, + 'snowflake-arctic-embed': 1024, + 'jina-embeddings-v2-base-en': 768, + 'e5-small': 384, + 'e5-base': 768, + 'e5-large': 1024, + 'paraphrase-multilingual': 768, +}; + +/** + * Look up the embedding dimension for an Ollama model. + * Tries exact match, base-name match, prefix match, then heuristic fallback. + */ +function lookupEmbeddingDim(modelName: string): { dim: number; source: 'known' | 'fallback' } | null { + const nameLower = modelName.toLowerCase(); + + // Exact match + if (nameLower in KNOWN_OLLAMA_EMBEDDING_DIMS) { + return { dim: KNOWN_OLLAMA_EMBEDDING_DIMS[nameLower], source: 'known' }; + } + + // Base name match (strip :tag) + const baseName = nameLower.split(':')[0]; + if (baseName in KNOWN_OLLAMA_EMBEDDING_DIMS) { + return { dim: KNOWN_OLLAMA_EMBEDDING_DIMS[baseName], source: 'known' }; + } + + // Prefix match + for (const [key, dim] of Object.entries(KNOWN_OLLAMA_EMBEDDING_DIMS)) { + if (nameLower.startsWith(key)) { + return { dim, source: 'known' }; + } + } + + // Heuristic fallback based on name patterns. + // WARNING: These are guesses and may be incorrect for unknown models. + // The 'fallback' source flag allows callers to surface this uncertainty. + if (nameLower.includes('large')) { + console.warn(`[OllamaEmbedding] Using heuristic dimension guess (1024) for unknown model: ${modelName}`); + return { dim: 1024, source: 'fallback' }; + } + if (nameLower.includes('base')) { + console.warn(`[OllamaEmbedding] Using heuristic dimension guess (768) for unknown model: ${modelName}`); + return { dim: 768, source: 'fallback' }; + } + if (nameLower.includes('small') || nameLower.includes('mini')) { + console.warn(`[OllamaEmbedding] Using heuristic dimension guess (384) for unknown model: ${modelName}`); + return { dim: 384, source: 'fallback' }; + } + + return null; +} + /** * Ollama Service Status * Contains information about Ollama service availability and configuration @@ -592,4 +670,45 @@ export function registerMemoryHandlers(): void { } }, ); + + // ============================================ + // Ollama Embedding Dimension Lookup + // ============================================ + + /** + * Get the embedding dimension for an Ollama model. + * Single source of truth — the renderer calls this instead of + * maintaining its own hardcoded dimension map. + */ + ipcMain.handle( + IPC_CHANNELS.OLLAMA_GET_EMBEDDING_DIM, + async ( + _, + modelName: string + ): Promise> => { + try { + if (!modelName || typeof modelName !== 'string') { + return { success: false, error: 'Model name is required' }; + } + + const result = lookupEmbeddingDim(modelName); + if (result) { + return { + success: true, + data: { model: modelName, dim: result.dim, source: result.source }, + }; + } + + return { + success: false, + error: `Unknown embedding model: ${modelName}. Dimension could not be determined.`, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get embedding dimension', + }; + } + } + ); } diff --git a/apps/desktop/src/main/ipc-handlers/project-handlers.ts b/apps/desktop/src/main/ipc-handlers/project-handlers.ts index e5567c1792..703eb2bb52 100644 --- a/apps/desktop/src/main/ipc-handlers/project-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/project-handlers.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron'; -import { existsSync } from 'fs'; +import { existsSync, mkdirSync } from 'fs'; +import path from 'path'; import { execFileSync } from 'child_process'; import { IPC_CHANNELS } from '../../shared/constants'; import type { @@ -21,6 +22,7 @@ import { } from '../project-initializer'; import { getToolPath } from '../cli-tool-manager'; import type { BrowserWindow } from 'electron'; +import { debugLog } from '../../shared/utils/debug-logger'; // ============================================ // Git Helper Functions @@ -310,7 +312,7 @@ export function registerProjectHandlers( IPC_CHANNELS.TAB_STATE_GET, async (): Promise> => { const tabState = projectStore.getTabState(); - console.log('[IPC] TAB_STATE_GET returning:', tabState); + debugLog('[IPC] TAB_STATE_GET returning:', tabState); return { success: true, data: tabState }; } ); @@ -321,7 +323,7 @@ export function registerProjectHandlers( _, tabState: { openProjectIds: string[]; activeProjectId: string | null; tabOrder: string[] } ): Promise => { - console.log('[IPC] TAB_STATE_SAVE called with:', tabState); + debugLog('[IPC] TAB_STATE_SAVE called with:', tabState); projectStore.saveTabState(tabState); return { success: true }; } @@ -395,6 +397,39 @@ export function registerProjectHandlers( } ); + // Initialize customer project — creates .auto-claude/ without requiring git + ipcMain.handle( + IPC_CHANNELS.PROJECT_INIT_CUSTOMER, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Validate that the project root directory still exists before creating subdirectory. + // This prevents silently recreating deleted/moved project directories. + if (!existsSync(project.path)) { + return { success: false, error: `Project directory does not exist: ${project.path}` }; + } + + const dotAutoClaude = path.join(project.path, '.auto-claude'); + + if (!existsSync(dotAutoClaude)) { + mkdirSync(dotAutoClaude, { recursive: true }); + } + + projectStore.updateAutoBuildPath(projectId, '.auto-claude'); + return { success: true, data: { success: true } }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + // PROJECT_CHECK_VERSION now just checks if project is initialized // Version tracking for .auto-claude is removed since it only contains data ipcMain.handle( diff --git a/apps/desktop/src/preload/api/modules/github-api.ts b/apps/desktop/src/preload/api/modules/github-api.ts index c2115eb110..8de2c8f9e2 100644 --- a/apps/desktop/src/preload/api/modules/github-api.ts +++ b/apps/desktop/src/preload/api/modules/github-api.ts @@ -10,7 +10,10 @@ import type { VersionSuggestion, PaginatedIssuesResult, PRStatusUpdate, - PollingMetadata + PollingMetadata, + MultiRepoGitHubStatus, + MultiRepoIssuesResult, + MultiRepoPRsResult } from '../../../shared/types'; import { createIpcListener, invokeIpc, sendIpc, IpcListenerCleanup } from './ipc-utils'; @@ -196,6 +199,20 @@ export interface GitHubAPI { callback: (data: { oldUsername: string | null; newUsername: string }) => void ) => IpcListenerCleanup; + // Multi-repo operations (Customer projects) + checkMultiRepoConnection: (customerId: string) => Promise>; + getMultiRepoIssues: ( + customerId: string, + state?: 'open' | 'closed' | 'all', + page?: number + ) => Promise>; + getMultiRepoIssueDetail: ( + customerId: string, + repoFullName: string, + issueNumber: number + ) => Promise>; + getMultiRepoPRs: (customerId: string) => Promise>; + // Repository detection and management detectGitHubRepo: (projectPath: string) => Promise>; getGitHubBranches: (repo: string, token: string) => Promise>; @@ -602,6 +619,27 @@ export const createGitHubAPI = (): GitHubAPI => ({ ): IpcListenerCleanup => createIpcListener(IPC_CHANNELS.GITHUB_AUTH_CHANGED, callback), + // Multi-repo operations (Customer projects) + checkMultiRepoConnection: (customerId: string): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_CHECK_MULTI_REPO_CONNECTION, customerId), + + getMultiRepoIssues: ( + customerId: string, + state?: 'open' | 'closed' | 'all', + page?: number + ): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUES, customerId, state, page), + + getMultiRepoIssueDetail: ( + customerId: string, + repoFullName: string, + issueNumber: number + ): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_MULTI_REPO_ISSUE_DETAIL, customerId, repoFullName, issueNumber), + + getMultiRepoPRs: (customerId: string): Promise> => + invokeIpc(IPC_CHANNELS.GITHUB_GET_MULTI_REPO_PRS, customerId), + // Repository detection and management detectGitHubRepo: (projectPath: string): Promise> => invokeIpc(IPC_CHANNELS.GITHUB_DETECT_REPO, projectPath), diff --git a/apps/desktop/src/renderer/components/AddCustomerModal.tsx b/apps/desktop/src/renderer/components/AddCustomerModal.tsx new file mode 100644 index 0000000000..1dc2aa34a5 --- /dev/null +++ b/apps/desktop/src/renderer/components/AddCustomerModal.tsx @@ -0,0 +1,287 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FolderOpen, FolderPlus, ChevronRight, ArrowLeft, RefreshCw } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from './ui/dialog'; +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; +import { useProjectStore } from '../stores/project-store'; +import type { Project } from '../../shared/types'; + +interface AddCustomerModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCustomerAdded?: (project: Project) => void; +} + +type Step = 'choose' | 'create'; + +export function AddCustomerModal({ open, onOpenChange, onCustomerAdded }: AddCustomerModalProps) { + const { t } = useTranslation('dialogs'); + const [error, setError] = useState(null); + const [step, setStep] = useState('choose'); + const [customerName, setCustomerName] = useState(''); + const [location, setLocation] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [isPicking, setIsPicking] = useState(false); + + useEffect(() => { + if (open) { + setError(null); + setStep('choose'); + setCustomerName(''); + setLocation(''); + setIsCreating(false); + setIsPicking(false); + } + }, [open]); + + const registerAndInitCustomer = async (path: string) => { + // Pass type: 'customer' through IPC so it's persisted to disk (projects.json) + const result = await window.electronAPI.addProject(path, 'customer'); + if (!result.success || !result.data) { + setError(result.error || t('addCustomer.failedToOpen')); + return; + } + + const store = useProjectStore.getState(); + const project = result.data; + + // Add with type already set, then select — Sidebar will see type: 'customer' and skip git check + store.addProject(project); + store.selectProject(project.id); + store.openProjectTab(project.id); + + // Create .auto-claude/ and persist autoBuildPath via dedicated customer IPC. + // We can't use initializeProject because it requires git (customers don't have git). + if (!project.autoBuildPath) { + try { + const initResult = await window.electronAPI.initializeCustomerProject(project.id); + if (initResult.success) { + store.updateProject(project.id, { autoBuildPath: '.auto-claude' }); + } + } catch (e) { + // Non-fatal — user can configure later + console.debug('[AddCustomerModal] Failed to initialize customer project:', e); + } + } + + // Read updated project from fresh store state (avoids stale Zustand snapshot) + const updatedProject = useProjectStore.getState().projects.find(p => p.id === project.id) || project; + onCustomerAdded?.(updatedProject); + onOpenChange(false); + }; + + const handleOpenExisting = async () => { + setIsPicking(true); + try { + const path = await window.electronAPI.selectDirectory(); + if (path) { + await registerAndInitCustomer(path); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('addCustomer.failedToOpen')); + } finally { + setIsPicking(false); + } + }; + + const handleBrowseLocation = async () => { + setIsPicking(true); + try { + const path = await window.electronAPI.selectDirectory(); + if (path) { + setLocation(path); + } + } catch { + // User cancelled + } finally { + setIsPicking(false); + } + }; + + const handleCreateFolder = async () => { + if (!customerName.trim()) { + setError(t('addCustomer.nameRequired')); + return; + } + if (!location) { + setError(t('addCustomer.locationRequired')); + return; + } + + setIsCreating(true); + setError(null); + + try { + const result = await window.electronAPI.createProjectFolder( + location, + customerName.trim(), + false // No git init for customer folders + ); + if (!result.success || !result.data) { + setError(result.error || t('addCustomer.failedToCreate')); + return; + } + await registerAndInitCustomer(result.data.path); + } catch (err) { + setError(err instanceof Error ? err.message : t('addCustomer.failedToCreate')); + } finally { + setIsCreating(false); + } + }; + + const sep = window.navigator.platform.startsWith('Win') ? '\\' : '/'; + const folderPreview = customerName.trim() && location + ? `${location}${sep}${customerName.trim()}` + : null; + + return ( + + + + {t('addCustomer.title')} + + {step === 'choose' + ? t('addCustomer.description') + : t('addCustomer.createNewSubtitle')} + + + + {step === 'choose' && ( +
+ {/* Create New Folder */} + + + {/* Open Existing Folder */} + +
+ )} + + {step === 'create' && ( +
+ {/* Customer Name */} +
+ + { + setCustomerName(e.target.value); + setError(null); + }} + placeholder={t('addCustomer.customerNamePlaceholder')} + className={cn( + 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + )} + autoFocus + /> +
+ + {/* Location */} +
+ + {t('addCustomer.location')} + +
+
+ {location || t('addCustomer.locationPlaceholder')} +
+ +
+
+ + {/* Folder preview */} + {folderPreview && ( +
+ {t('addCustomer.willCreate')} {folderPreview} +
+ )} + + {/* Actions */} +
+ + +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/AgentAnalysisBanner.tsx b/apps/desktop/src/renderer/components/AgentAnalysisBanner.tsx new file mode 100644 index 0000000000..860611a347 --- /dev/null +++ b/apps/desktop/src/renderer/components/AgentAnalysisBanner.tsx @@ -0,0 +1,224 @@ +import { motion } from 'motion/react'; +import { memo, useState, useEffect, useRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { cn } from '../lib/utils'; +import type { ExecutionPhase, ExecutionProgress, Subtask } from '../../shared/types'; + +interface AgentAnalysisBannerProps { + currentSubtask?: string; + subtasks: Subtask[]; + phase?: ExecutionPhase; + executionProgress?: ExecutionProgress; + startedAt?: Date | string; + className?: string; +} + +// Phase display colors — mirrors PhaseProgressIndicator +const PHASE_COLORS: Record = { + idle: { dot: 'bg-muted-foreground', bg: 'bg-muted' }, + planning: { dot: 'bg-amber-500', bg: 'bg-amber-500/10' }, + coding: { dot: 'bg-info', bg: 'bg-info/10' }, + rate_limit_paused: { dot: 'bg-orange-500', bg: 'bg-orange-500/10' }, + auth_failure_paused: { dot: 'bg-red-500', bg: 'bg-red-500/10' }, + qa_review: { dot: 'bg-purple-500', bg: 'bg-purple-500/10' }, + qa_fixing: { dot: 'bg-orange-500', bg: 'bg-orange-500/10' }, + complete: { dot: 'bg-success', bg: 'bg-success/10' }, + failed: { dot: 'bg-destructive', bg: 'bg-destructive/10' }, +}; + +// Phases that indicate active execution +const ACTIVE_PHASES = new Set([ + 'planning', + 'coding', + 'rate_limit_paused', + 'auth_failure_paused', + 'qa_review', + 'qa_fixing', +]); + +// i18n key constants under the 'tasks' namespace +const I18N = { + analyzing: 'analysisBanner.analyzing', + next: 'analysisBanner.next', + lastItem: 'analysisBanner.lastItem', + elapsed: 'analysisBanner.elapsed', + last30min: 'analysisBanner.last30min', + completed: 'analysisBanner.completed', + inProgress: 'analysisBanner.inProgress', + pending: 'analysisBanner.pending', + noActivity: 'analysisBanner.noActivity', +} as const; + +/** + * Format elapsed time as MM:SS + */ +function formatElapsed(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; +} + +/** + * Compact status banner displayed while an agent is actively running on a task. + * Shows the current subtask being analyzed, the next pending subtask, and elapsed time. + */ +export const AgentAnalysisBanner = memo(function AgentAnalysisBanner({ + currentSubtask, + subtasks, + phase: rawPhase, + executionProgress, + startedAt, + className, +}: AgentAnalysisBannerProps) { + const { t } = useTranslation('tasks'); + const phase = rawPhase || 'idle'; + const [elapsed, setElapsed] = useState(0); + const intervalRef = useRef | null>(null); + + // Elapsed timer — ticks every second while the banner is active + useEffect(() => { + if (!startedAt) { + setElapsed(0); + return; + } + + const origin = typeof startedAt === 'string' ? new Date(startedAt) : startedAt; + + const tick = () => { + const diff = Math.max(0, Math.floor((Date.now() - origin.getTime()) / 1000)); + setElapsed(diff); + }; + + tick(); + intervalRef.current = setInterval(tick, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [startedAt]); + + // Resolve current subtask object + const currentSubtaskObj = useMemo(() => { + if (!currentSubtask) return undefined; + return subtasks.find( + (s) => s.id === currentSubtask || s.title === currentSubtask, + ); + }, [currentSubtask, subtasks]); + + // Determine next pending subtask after current + const nextSubtask = useMemo(() => { + if (!currentSubtaskObj) { + return subtasks.find((s) => s.status === 'pending'); + } + const currentIndex = subtasks.indexOf(currentSubtaskObj); + if (currentIndex === -1) { + return subtasks.find((s) => s.status === 'pending'); + } + return subtasks.slice(currentIndex + 1).find((s) => s.status === 'pending'); + }, [currentSubtaskObj, subtasks]); + + // Activity summary for the last 30 minutes + const activitySummary = useMemo(() => { + const completedCount = subtasks.filter((s) => s.status === 'completed').length; + const inProgressCount = subtasks.filter((s) => s.status === 'in_progress').length; + const pendingCount = subtasks.filter((s) => s.status === 'pending').length; + return { completedCount, inProgressCount, pendingCount }; + }, [subtasks]); + + // Do not render when execution is not active + if (!ACTIVE_PHASES.has(phase)) { + return null; + } + + const colors = PHASE_COLORS[phase] || PHASE_COLORS.idle; + const currentTitle = + currentSubtaskObj?.title || + executionProgress?.currentSubtask || + currentSubtask; + + const hasActivity = + activitySummary.completedCount > 0 || + activitySummary.inProgressCount > 0; + + return ( + + {/* Primary line */} +
+ {/* Left: phase dot + current subtask */} +
+ + + + {t(I18N.analyzing)}: + {' '} + + {currentTitle || '...'} + + +
+ + {/* Center: next subtask */} +
+ {t(I18N.next)}:{' '} + + {nextSubtask ? nextSubtask.title : t(I18N.lastItem)} + +
+ + {/* Right: elapsed timer */} +
+ {t(I18N.elapsed)} + {formatElapsed(elapsed)} +
+
+ + {/* Secondary line */} +
+ + {t(I18N.last30min)}:{' '} + {hasActivity ? ( + <> + {activitySummary.completedCount > 0 && ( + + {activitySummary.completedCount} {t(I18N.completed)} + + )} + {activitySummary.completedCount > 0 && + activitySummary.inProgressCount > 0 && ', '} + {activitySummary.inProgressCount > 0 && ( + + {activitySummary.inProgressCount} {t(I18N.inProgress)} + + )} + + ) : ( + t(I18N.noActivity) + )} + + {executionProgress?.message && ( + <> + | + {executionProgress.message} + + )} +
+
+ ); +}); diff --git a/apps/desktop/src/renderer/components/AgentTools.tsx b/apps/desktop/src/renderer/components/AgentTools.tsx index 4142a4ca79..2d2ed8ac29 100644 --- a/apps/desktop/src/renderer/components/AgentTools.tsx +++ b/apps/desktop/src/renderer/components/AgentTools.tsx @@ -32,7 +32,9 @@ import { Terminal, Loader2, RefreshCw, - Lock + Lock, + ExternalLink, + Activity } from 'lucide-react'; import { useState, useMemo, useEffect, useCallback } from 'react'; import { ScrollArea } from './ui/scroll-area'; @@ -45,9 +47,11 @@ import { DialogHeader, DialogTitle } from './ui/dialog'; -import { useSettingsStore } from '../stores/settings-store'; +import { useSettingsStore, saveSettings } from '../stores/settings-store'; +import { cn } from '../lib/utils'; import { useProjectStore } from '../stores/project-store'; -import type { ProjectEnvConfig, AgentMcpOverride, CustomMcpServer, McpHealthCheckResult, } from '../../shared/types'; +import type { ProjectEnvConfig, AgentMcpOverride, CustomMcpServer, McpHealthCheckResult } from '../../shared/types'; +import type { GlobalMcpInfo, GlobalMcpServerEntry } from '../../shared/types/integrations'; import { CustomMcpDialog } from './CustomMcpDialog'; import { useTranslation } from 'react-i18next'; import { @@ -60,7 +64,7 @@ import { type AgentSettingsSource, } from '../hooks'; import { useActiveProvider } from '../hooks/useActiveProvider'; -import type { ThinkingLevel } from '../../shared/types/settings'; +import type { ThinkingLevel, GlobalMcpPhaseConfig } from '../../shared/types/settings'; // Agent configuration data - mirrors AGENT_CONFIGS from backend // Model and thinking are now dynamically read from user settings @@ -664,6 +668,12 @@ export function AgentTools() { const [serverHealthStatus, setServerHealthStatus] = useState>({}); const [testingServers, setTestingServers] = useState>(new Set()); + // Global Claude Code MCP state + const [globalMcps, setGlobalMcps] = useState(null); + const [isLoadingGlobalMcps, setIsLoadingGlobalMcps] = useState(false); + const [globalMcpHealth, setGlobalMcpHealth] = useState>({}); + const [isCheckingGlobalHealth, setIsCheckingGlobalHealth] = useState(false); + // Load project env config when project changes useEffect(() => { if (selectedProjectId && selectedProject?.autoBuildPath) { @@ -687,6 +697,102 @@ export function AgentTools() { } }, [selectedProjectId, selectedProject?.autoBuildPath]); + // Load global Claude Code MCPs on mount + const loadGlobalMcps = useCallback(async () => { + setIsLoadingGlobalMcps(true); + try { + const result = await window.electronAPI.getGlobalMcps(); + if (result.success && result.data) { + setGlobalMcps(result.data); + } + } catch { + // Non-critical -- global MCPs are informational only + } finally { + setIsLoadingGlobalMcps(false); + } + }, []); + + useEffect(() => { + loadGlobalMcps(); + }, [loadGlobalMcps]); + + // Combine all global MCP servers for display + const allGlobalServers = useMemo((): GlobalMcpServerEntry[] => { + if (!globalMcps) return []; + return [...globalMcps.claudeJsonServers, ...globalMcps.pluginServers, ...globalMcps.inlineServers]; + }, [globalMcps]); + + // Check health of all global MCP servers + const checkGlobalMcpHealth = useCallback(async () => { + if (!allGlobalServers.length) return; + setIsCheckingGlobalHealth(true); + + const results: Record = {}; + + await Promise.all( + allGlobalServers.map(async (server) => { + try { + const customServer: CustomMcpServer = { + id: server.serverId, + name: server.serverName, + type: server.config.command ? 'command' : 'http', + command: server.config.command, + args: server.config.args, + url: server.config.url, + headers: server.config.headers, + env: server.config.env, + }; + const result = await window.electronAPI.checkGlobalMcpHealth(customServer); + if (result.success && result.data) { + results[server.serverId] = result.data; + } + } catch { + results[server.serverId] = { + serverId: server.serverId, + status: 'unknown', + message: t('mcp.globalMcps.healthCheckFailed'), + checkedAt: new Date().toISOString(), + }; + } + }) + ); + + setGlobalMcpHealth(results); + setIsCheckingGlobalHealth(false); + }, [allGlobalServers, t]); + + // Auto-check health when global MCPs are loaded + useEffect(() => { + if (allGlobalServers.length > 0) { + checkGlobalMcpHealth(); + } + }, [checkGlobalMcpHealth]); + + // Settings access for global MCP phase assignments + const globalMcpPhases = settings.globalMcpPhases || {}; + + // Toggle a global MCP server's assignment to a pipeline phase + const handleToggleGlobalMcpPhase = async (serverId: string, phase: keyof GlobalMcpPhaseConfig) => { + const current = { ...globalMcpPhases }; + const phaseServers = current[phase] || []; + + if (phaseServers.includes(serverId)) { + current[phase] = phaseServers.filter(id => id !== serverId); + } else { + current[phase] = [...phaseServers, serverId]; + } + + // Clean up empty arrays + for (const key of Object.keys(current) as Array) { + if (current[key]?.length === 0) { + delete current[key]; + } + } + + const hasAny = Object.values(current).some(v => v && v.length > 0); + await saveSettings({ globalMcpPhases: hasAny ? current : undefined }); + }; + // Update MCP server toggle const updateMcpServer = useCallback(async ( key: keyof NonNullable, @@ -1318,6 +1424,159 @@ export function AgentTools() { )} + {/* Claude Code Global MCPs Section */} + {( +
+
+
+ +

+ {t('settings:mcp.globalMcps.title')} +

+ + {t('settings:mcp.globalMcps.badge')} + +
+
+ + +
+
+

+ {t('settings:mcp.globalMcps.description')} +

+
+ {allGlobalServers.length === 0 && ( +

+ {t('settings:mcp.globalMcps.noServers', 'No global MCP servers found.')} +

+ )} + {allGlobalServers.map((server) => { + const serverType = server.config.command + ? t('settings:mcp.globalMcps.serverType.command') + : server.config.type === 'sse' + ? t('settings:mcp.globalMcps.serverType.sse') + : t('settings:mcp.globalMcps.serverType.http'); + const ServerIcon = server.config.command ? Terminal : Globe; + const detail = server.config.command + ? `${server.config.command} ${server.config.args?.join(' ') || ''}` + : server.config.url || ''; + const health = globalMcpHealth[server.serverId]; + const statusColor = health?.status === 'healthy' + ? 'bg-green-500' + : health?.status === 'unhealthy' + ? 'bg-red-500' + : health?.status === 'needs_auth' + ? 'bg-yellow-500' + : 'bg-gray-400'; + + const PIPELINE_PHASES: Array<{ key: keyof GlobalMcpPhaseConfig; label: string }> = [ + { key: 'spec', label: t('settings:mcp.globalMcps.phases.spec') }, + { key: 'build', label: t('settings:mcp.globalMcps.phases.build') }, + { key: 'qa', label: t('settings:mcp.globalMcps.phases.qa') }, + { key: 'utility', label: t('settings:mcp.globalMcps.phases.utility') }, + { key: 'ideation', label: t('settings:mcp.globalMcps.phases.ideation') }, + ]; + + return ( +
+
+
+
+ + +
+
+
+ {server.serverName} + + {serverType} + + + {server.source === 'plugin' + ? t('settings:mcp.globalMcps.source.plugin') + : server.source === 'claude-json' + ? t('settings:mcp.globalMcps.source.claudeJson') + : t('settings:mcp.globalMcps.source.settings')} + + {health?.responseTime && ( + + {health.responseTime}ms + + )} +
+

+ {detail} +

+
+
+
+ {/* Phase assignment chips */} +
+ + {t('settings:mcp.globalMcps.useIn')} + + {PIPELINE_PHASES.map(({ key, label }) => { + const isActive = (globalMcpPhases[key] || []).includes(server.serverId); + return ( + + ); + })} +
+
+ ); + })} +
+
+ )} + {/* Agent Categories */} {Object.entries(CATEGORIES).map(([categoryId, category]) => { const agents = agentsByCategory[categoryId] || []; diff --git a/apps/desktop/src/renderer/components/CustomerReposModal.tsx b/apps/desktop/src/renderer/components/CustomerReposModal.tsx new file mode 100644 index 0000000000..37bf6afd39 --- /dev/null +++ b/apps/desktop/src/renderer/components/CustomerReposModal.tsx @@ -0,0 +1,252 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Github, Download, CheckCircle2, Loader2, Lock, Globe, Search, X, FolderGit2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from './ui/dialog'; +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; +import { useProjectStore } from '../stores/project-store'; +import type { Project } from '../../shared/types'; + +interface CustomerReposModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + customer: Project; +} + +interface RepoItem { + fullName: string; + description: string | null; + isPrivate: boolean; +} + +type CloneStatus = 'idle' | 'cloning' | 'done' | 'error'; + +export function CustomerReposModal({ open, onOpenChange, customer }: CustomerReposModalProps) { + const { t } = useTranslation('dialogs'); + const [repos, setRepos] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [cloneStatuses, setCloneStatuses] = useState>({}); + const [cloneErrors, setCloneErrors] = useState>({}); + + const loadRepos = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const result = await window.electronAPI.listGitHubUserRepos(); + if (result.success && result.data) { + setRepos(result.data.repos); + } else { + setError(result.error || t('customerRepos.failedToLoad')); + } + } catch (err) { + setError(err instanceof Error ? err.message : t('customerRepos.failedToLoad')); + } finally { + setIsLoading(false); + } + }, [t]); + + useEffect(() => { + if (open) { + setSearch(''); + setCloneStatuses({}); + setCloneErrors({}); + loadRepos(); + } + }, [open, loadRepos]); + + const handleClone = async (repo: RepoItem) => { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'cloning' })); + setCloneErrors(prev => { + const next = { ...prev }; + delete next[repo.fullName]; + return next; + }); + + try { + const result = await window.electronAPI.cloneGitHubRepo(repo.fullName, customer.path); + if (!result.success || !result.data) { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ ...prev, [repo.fullName]: result.error || t('customerRepos.cloneFailed') })); + return; + } + + // Register the cloned repo as a project + const addResult = await window.electronAPI.addProject(result.data.path); + if (addResult.success && addResult.data) { + const store = useProjectStore.getState(); + store.addProject(addResult.data); + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'done' })); + } else { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ + ...prev, + [repo.fullName]: addResult.error || t('customerRepos.cloneFailed') + })); + } + } catch (err) { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ + ...prev, + [repo.fullName]: err instanceof Error ? err.message : t('customerRepos.cloneFailed') + })); + } + }; + + const filteredRepos = repos.filter(repo => + repo.fullName.toLowerCase().includes(search.toLowerCase()) || + (repo.description && repo.description.toLowerCase().includes(search.toLowerCase())) + ); + + const clonedCount = Object.values(cloneStatuses).filter(s => s === 'done').length; + + return ( + + + + + + {t('customerRepos.title')} + + + {t('customerRepos.description', { name: customer.name })} + + + + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder={t('customerRepos.searchPlaceholder')} + className={cn( + 'w-full rounded-md border border-input bg-background pl-9 pr-9 py-2 text-sm', + 'placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring' + )} + /> + {search && ( + + )} +
+ + {/* Repo list */} +
+ {isLoading && ( +
+ + {t('customerRepos.loading')} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!isLoading && !error && filteredRepos.length === 0 && ( +
+ {search ? t('customerRepos.noResults') : t('customerRepos.noRepos')} +
+ )} + + {filteredRepos.map((repo) => { + const status = cloneStatuses[repo.fullName] || 'idle'; + const cloneError = cloneErrors[repo.fullName]; + + return ( +
+ +
+
+ {repo.fullName} + {repo.isPrivate ? ( + + ) : ( + + )} +
+ {repo.description && ( +

+ {repo.description} +

+ )} + {cloneError && ( +

{cloneError}

+ )} +
+ +
+ {status === 'idle' && ( + + )} + {status === 'cloning' && ( + + )} + {status === 'done' && ( + + + {t('customerRepos.cloned')} + + )} + {status === 'error' && ( + + )} +
+
+ ); + })} +
+ + {/* Footer */} +
+ + {clonedCount > 0 && t('customerRepos.clonedCount', { count: clonedCount })} + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/GitHubSetupModal.tsx b/apps/desktop/src/renderer/components/GitHubSetupModal.tsx index a0a6ae9667..013396c3f0 100644 --- a/apps/desktop/src/renderer/components/GitHubSetupModal.tsx +++ b/apps/desktop/src/renderer/components/GitHubSetupModal.tsx @@ -127,7 +127,15 @@ export function GitHubSetupModal({ const hasAIAuth = accounts.length > 0; // Determine starting step based on existing auth - if (hasGitHubAuth && hasAIAuth) { + if (hasGitHubAuth && (project as Project & { type?: string }).type === 'customer') { + // Customer with existing GitHub auth -- just complete with token + onComplete({ + githubToken: ghTokenResult.data!.token, + githubRepo: '', + mainBranch: '', + githubAuthMethod: 'oauth' + }); + } else if (hasGitHubAuth && hasAIAuth) { // Both authenticated, go directly to repo detection setGithubToken(ghTokenResult.data!.token); setStep('repo'); // Temporary, detectRepository will update @@ -241,6 +249,17 @@ export function GitHubSetupModal({ const handleGitHubAuthSuccess = async (token: string) => { setGithubToken(token); + // For customers, we only need the GitHub token -- skip repo/branch/claude steps + if ((project as Project & { type?: string }).type === 'customer') { + onComplete({ + githubToken: token, + githubRepo: '', + mainBranch: '', + githubAuthMethod: 'oauth' + }); + return; + } + // Check if user already has AI provider accounts configured try { await loadProviderAccounts(); diff --git a/apps/desktop/src/renderer/components/Sidebar.tsx b/apps/desktop/src/renderer/components/Sidebar.tsx index c156d8697d..4db376efaf 100644 --- a/apps/desktop/src/renderer/components/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/Sidebar.tsx @@ -27,6 +27,13 @@ import { import { Button } from './ui/button'; import { ScrollArea } from './ui/scroll-area'; import { Separator } from './ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from './ui/select'; import { Tooltip, TooltipContent, @@ -45,7 +52,7 @@ import { cn } from '../lib/utils'; import { useProjectStore, removeProject, - initializeProject + initializeProject, } from '../stores/project-store'; import { useSettingsStore, saveSettings } from '../stores/settings-store'; import { @@ -67,6 +74,7 @@ interface SidebarProps { onNewTaskClick: () => void; activeView?: SidebarView; onViewChange?: (view: SidebarView) => void; + onCustomerAdded?: (project: Project) => void; } interface NavItem { @@ -105,11 +113,13 @@ export function Sidebar({ onSettingsClick, onNewTaskClick, activeView = 'kanban', - onViewChange + onViewChange, + onCustomerAdded }: SidebarProps) { const { t } = useTranslation(['navigation', 'dialogs', 'common']); const projects = useProjectStore((state) => state.projects); const selectedProjectId = useProjectStore((state) => state.selectedProjectId); + const selectProject = useProjectStore((state) => state.selectProject); const settings = useSettingsStore((state) => state.settings); const [showAddProjectModal, setShowAddProjectModal] = useState(false); @@ -121,6 +131,29 @@ export function Sidebar({ const selectedProject = projects.find((p) => p.id === selectedProjectId); + // Normalize path separators for cross-platform customer/child comparisons. + const normalizePath = (value: string) => value.replace(/\\/g, '/').replace(/\/+$/, ''); + + // Determine customer context: the parent customer for the selected project + const customerContext = useMemo(() => { + if (!selectedProject) return null; + if ((selectedProject as Project & { type?: string }).type === 'customer') return selectedProject; + const normalizedSelected = normalizePath(selectedProject.path); + const parentCustomer = projects.find( + p => (p as Project & { type?: string }).type === 'customer' && normalizedSelected.startsWith(normalizePath(p.path) + '/') + ); + return parentCustomer ?? null; + }, [selectedProject, projects]); + + // Child repos belonging to the current customer context + const customerChildRepos = useMemo(() => { + if (!customerContext) return []; + const normalizedCustomer = normalizePath(customerContext.path); + return projects.filter( + p => p.id !== customerContext.id && normalizePath(p.path).startsWith(normalizedCustomer + '/') + ); + }, [customerContext, projects]); + // Sidebar collapsed state from settings const isCollapsed = settings.sidebarCollapsed ?? false; @@ -135,20 +168,22 @@ export function Sidebar({ // Track the last loaded project ID to avoid redundant loads const lastLoadedProjectIdRef = useRef(null); - // Compute visible nav items based on GitHub/GitLab enabled state from store + // When inside a Customer context, always show GitHub nav + const inCustomerContext = !!customerContext; + + // Compute visible nav items -- show GitHub OR GitLab based on what's configured const visibleNavItems = useMemo(() => { const items = [...baseNavItems]; - - if (githubEnabled) { + const effectiveGithubEnabled = githubEnabled || inCustomerContext; + if (effectiveGithubEnabled && !gitlabEnabled) { items.push(...githubNavItems); - } - - if (gitlabEnabled) { + } else if (gitlabEnabled && !effectiveGithubEnabled) { items.push(...gitlabNavItems); + } else if (effectiveGithubEnabled && gitlabEnabled) { + items.push(...githubNavItems, ...gitlabNavItems); } - return items; - }, [githubEnabled, gitlabEnabled]); + }, [githubEnabled, gitlabEnabled, inCustomerContext]); // Load envConfig when project changes to ensure store is populated useEffect(() => { @@ -212,16 +247,25 @@ export function Sidebar({ return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedProjectId, onViewChange, visibleNavItems]); - // Check git status when project changes + // Track which project IDs had git modal dismissed to avoid re-showing + const gitModalDismissedRef = useRef>(new Set()); + + // Check git status when project changes (skip for customer-type projects) useEffect(() => { const checkGit = async () => { if (selectedProject) { + // Customer folders don't require git + if ((selectedProject as Project & { type?: string }).type === 'customer') { + setGitStatus(null); + return; + } try { const result = await window.electronAPI.checkGitStatus(selectedProject.path); if (result.success && result.data) { setGitStatus(result.data); // Show git setup modal if project is not a git repo or has no commits - if (!result.data.isGitRepo || !result.data.hasCommits) { + // but only if user hasn't already dismissed it for this project + if ((!result.data.isGitRepo || !result.data.hasCommits) && !gitModalDismissedRef.current.has(selectedProject.id)) { setShowGitSetupModal(true); } } @@ -233,7 +277,7 @@ export function Sidebar({ } }; checkGit(); - }, [selectedProject]); + }, [selectedProjectId]); const handleProjectAdded = (project: Project, needsInit: boolean) => { if (needsInit) { @@ -399,6 +443,30 @@ export function Sidebar({ {t('sections.project')} )} + + {/* Repo Selector Dropdown -- only visible in customer context */} + {customerContext && customerChildRepos.length > 0 && !isCollapsed && ( +
+ +
+ )} + @@ -569,7 +637,13 @@ export function Sidebar({ {/* Git Setup Modal */} { + setShowGitSetupModal(open); + // When user closes the modal, remember not to show it again for this project + if (!open && selectedProjectId) { + gitModalDismissedRef.current.add(selectedProjectId); + } + }} project={selectedProject || null} gitStatus={gitStatus} onGitInitialized={handleGitInitialized} diff --git a/apps/desktop/src/renderer/components/TaskCard.tsx b/apps/desktop/src/renderer/components/TaskCard.tsx index 837b7df58f..053411bf8f 100644 --- a/apps/desktop/src/renderer/components/TaskCard.tsx +++ b/apps/desktop/src/renderer/components/TaskCard.tsx @@ -15,6 +15,7 @@ import { } from './ui/dropdown-menu'; import { cn, formatRelativeTime, sanitizeMarkdownForDisplay } from '../lib/utils'; import { PhaseProgressIndicator } from './PhaseProgressIndicator'; +import { AgentAnalysisBanner } from './AgentAnalysisBanner'; import { TASK_CATEGORY_LABELS, TASK_CATEGORY_COLORS, @@ -506,6 +507,19 @@ export const TaskCard = memo(function TaskCard({ )} + {/* Agent analysis banner — shows current/next subtask and elapsed time */} + {isRunning && hasActiveExecution && ( +
+ +
+ )} + {/* Progress section - Phase-aware with animations */} {(task.subtasks.length > 0 || hasActiveExecution || isRunning || isStuck) && (
diff --git a/apps/desktop/src/renderer/components/github-issues/components/RepoFilterDropdown.tsx b/apps/desktop/src/renderer/components/github-issues/components/RepoFilterDropdown.tsx new file mode 100644 index 0000000000..3d0fd782e7 --- /dev/null +++ b/apps/desktop/src/renderer/components/github-issues/components/RepoFilterDropdown.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; +import { GitBranch } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '../../ui/select'; + +interface RepoFilterDropdownProps { + repos: string[]; + selectedRepo: string; + onRepoChange: (repo: string) => void; +} + +export function RepoFilterDropdown({ repos, selectedRepo, onRepoChange }: RepoFilterDropdownProps) { + const { t } = useTranslation('navigation'); + + if (repos.length === 0) return null; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/github-issues/hooks/useMultiRepoGitHubIssues.ts b/apps/desktop/src/renderer/components/github-issues/hooks/useMultiRepoGitHubIssues.ts new file mode 100644 index 0000000000..f6513b5e8a --- /dev/null +++ b/apps/desktop/src/renderer/components/github-issues/hooks/useMultiRepoGitHubIssues.ts @@ -0,0 +1,249 @@ +import { useEffect, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { GitHubIssue, MultiRepoGitHubStatus } from '@shared/types'; +import type { FilterState } from '@/components/github-issues/types'; + +/** + * Creates a composite issue ID from repo name and issue number. + * Format: `repoFullName#number` (e.g., `org/repo#123`) + * Falls back to `#number` when repoFullName is empty (single-repo compat). + */ +export function makeIssueId(repoFullName: string | undefined, number: number): string { + return `${repoFullName || ''}#${number}`; +} + +/** + * Parses a composite issue ID back into its parts. + * Handles both `repoFullName#number` and `#number` formats. + */ +export function parseIssueId(id: string): { repo: string; number: number } { + const hashIndex = id.lastIndexOf('#'); + if (hashIndex === -1) { + return { repo: '', number: Number.parseInt(id, 10) || 0 }; + } + return { + repo: id.slice(0, hashIndex), + number: Number.parseInt(id.slice(hashIndex + 1), 10) || 0, + }; +} + +interface MultiRepoState { + issues: GitHubIssue[]; + repos: string[]; + selectedRepo: string; // 'all' or repoFullName + isLoading: boolean; + error: string | null; + syncStatus: MultiRepoGitHubStatus | null; + selectedIssueId: string | null; + filterState: FilterState; +} + +export function useMultiRepoGitHubIssues(customerId: string | undefined) { + const { t } = useTranslation('common'); + const [state, setState] = useState({ + issues: [], + repos: [], + selectedRepo: 'all', + isLoading: false, + error: null, + syncStatus: null, + selectedIssueId: null, + filterState: 'open', + }); + + // Check multi-repo connection on mount/customerId change + useEffect(() => { + if (!customerId) return; + let cancelled = false; + + const checkConnection = async () => { + try { + const result = await window.electronAPI.github.checkMultiRepoConnection(customerId); + if (cancelled) return; + if (result.success && result.data) { + const data = result.data; + setState(prev => ({ + ...prev, + syncStatus: data, + repos: data.repos.map((r: { projectId: string; repoFullName: string }) => r.repoFullName), + })); + } else { + setState(prev => ({ + ...prev, + syncStatus: { connected: false, repos: [], error: result.error }, + error: result.error || t('issues.multiRepo.failedToCheckConnection'), + })); + } + } catch (error) { + if (cancelled) return; + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : t('issues.multiRepo.unknownError'), + })); + } + }; + + checkConnection(); + return () => { cancelled = true; }; + }, [customerId, t]); + + // Load issues when connected or filter changes + useEffect(() => { + if (!customerId || !state.syncStatus?.connected) return; + let cancelled = false; + + const loadIssues = async () => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const result = await window.electronAPI.github.getMultiRepoIssues( + customerId, + state.filterState + ); + + if (cancelled) return; + if (result.success && result.data) { + const data = result.data; + setState(prev => ({ + ...prev, + issues: data.issues, + repos: data.repos.length > 0 ? data.repos : prev.repos, + isLoading: false, + })); + } else { + setState(prev => ({ + ...prev, + error: result.error || t('issues.multiRepo.failedToLoadIssues'), + isLoading: false, + })); + } + } catch (error) { + if (cancelled) return; + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : t('issues.multiRepo.unknownError'), + isLoading: false, + })); + } + }; + + loadIssues(); + return () => { cancelled = true; }; + }, [customerId, state.syncStatus?.connected, state.filterState, t]); + + const selectIssue = useCallback((issueId: string | null) => { + setState(prev => ({ ...prev, selectedIssueId: issueId })); + }, []); + + const setSelectedRepo = useCallback((repo: string) => { + setState(prev => ({ ...prev, selectedRepo: repo, selectedIssueId: null })); + }, []); + + const handleFilterChange = useCallback((filterState: FilterState) => { + setState(prev => ({ ...prev, filterState, selectedIssueId: null })); + }, []); + + const handleRefresh = useCallback(() => { + if (!customerId) return; + + const refresh = async () => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const connResult = await window.electronAPI.github.checkMultiRepoConnection(customerId); + if (connResult.success && connResult.data) { + const connData = connResult.data; + setState(prev => ({ + ...prev, + syncStatus: connData, + repos: connData.repos.map(r => r.repoFullName), + })); + } + + const result = await window.electronAPI.github.getMultiRepoIssues( + customerId, + state.filterState + ); + + if (result.success && result.data) { + const data = result.data; + setState(prev => ({ + ...prev, + issues: data.issues, + repos: data.repos.length > 0 ? data.repos : prev.repos, + isLoading: false, + })); + } else { + setState(prev => ({ + ...prev, + error: result.error || t('issues.multiRepo.failedToRefreshIssues'), + isLoading: false, + })); + } + } catch (error) { + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : t('issues.multiRepo.unknownError'), + isLoading: false, + })); + } + }; + + refresh(); + }, [customerId, state.filterState, t]); + + // Get filtered issues based on selected repo + // Note: state filtering is already done by the API via the `state` parameter + const getFilteredIssues = useCallback((): GitHubIssue[] => { + const { issues, selectedRepo } = state; + + // Filter by repo + if (selectedRepo !== 'all') { + return issues.filter(issue => issue.repoFullName === selectedRepo); + } + + return issues; + }, [state]); + + const getOpenIssuesCount = useCallback((): number => { + const { issues, selectedRepo } = state; + let filtered = issues.filter(issue => issue.state === 'open'); + if (selectedRepo !== 'all') { + filtered = filtered.filter(issue => issue.repoFullName === selectedRepo); + } + return filtered.length; + }, [state]); + + const selectedIssue = useMemo(() => { + if (!state.selectedIssueId) return null; + const { repo, number } = parseIssueId(state.selectedIssueId); + return state.issues.find(i => + i.number === number && (repo === '' || i.repoFullName === repo) + ) || null; + }, [state.issues, state.selectedIssueId]); + + return { + issues: state.issues, + syncStatus: state.syncStatus, + isLoading: state.isLoading, + isLoadingMore: false, + error: state.error, + selectedIssueId: state.selectedIssueId, + selectedIssue, + filterState: state.filterState, + hasMore: false, + selectIssue, + getFilteredIssues, + getOpenIssuesCount, + handleRefresh, + handleFilterChange, + handleLoadMore: undefined, + handleSearchStart: () => { /* no-op: multi-repo fetches all issues at once */ }, + handleSearchClear: () => { /* no-op: multi-repo fetches all issues at once */ }, + // Multi-repo specific + repos: state.repos, + selectedRepo: state.selectedRepo, + setSelectedRepo, + isMultiRepo: true, + }; +} diff --git a/apps/desktop/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/desktop/src/renderer/components/github-prs/GitHubPRs.tsx index a31286c8ce..ca86cf0143 100644 --- a/apps/desktop/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/desktop/src/renderer/components/github-prs/GitHubPRs.tsx @@ -19,7 +19,7 @@ function NotConnectedState({ }: { error: string | null; onOpenSettings?: () => void; - t: (key: string) => string; + t: (key: string, options?: Record) => string; }) { return (
diff --git a/apps/desktop/src/renderer/components/github-prs/hooks/useMultiRepoGitHubPRs.ts b/apps/desktop/src/renderer/components/github-prs/hooks/useMultiRepoGitHubPRs.ts new file mode 100644 index 0000000000..a81f1fed86 --- /dev/null +++ b/apps/desktop/src/renderer/components/github-prs/hooks/useMultiRepoGitHubPRs.ts @@ -0,0 +1,217 @@ +import { useEffect, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { MultiRepoGitHubStatus, MultiRepoPRData } from '@shared/types'; + +/** + * Creates a composite PR ID from repo name and PR number. + * Format: `repoFullName#number` (e.g., `org/repo#123`) + * Falls back to `#number` when repoFullName is empty (single-repo compat). + */ +export function makePRId(repoFullName: string | undefined, number: number): string { + return `${repoFullName || ''}#${number}`; +} + +/** + * Parses a composite PR ID back into its parts. + * Handles both `repoFullName#number` and `#number` formats. + */ +export function parsePRId(id: string): { repo: string; number: number } { + const hashIndex = id.lastIndexOf('#'); + if (hashIndex === -1) { + return { repo: '', number: Number.parseInt(id, 10) || 0 }; + } + return { + repo: id.slice(0, hashIndex), + number: Number.parseInt(id.slice(hashIndex + 1), 10) || 0, + }; +} + +interface MultiRepoPRState { + prs: MultiRepoPRData[]; + repos: string[]; + selectedRepo: string; // 'all' or repoFullName + isLoading: boolean; + error: string | null; + syncStatus: MultiRepoGitHubStatus | null; + selectedPRId: string | null; +} + +export function useMultiRepoGitHubPRs(customerId: string | undefined) { + const { t } = useTranslation('common'); + const [state, setState] = useState({ + prs: [], + repos: [], + selectedRepo: 'all', + isLoading: false, + error: null, + syncStatus: null, + selectedPRId: null, + }); + + // Check multi-repo connection on mount/customerId change + useEffect(() => { + if (!customerId) return; + let cancelled = false; + + // Reset state on customer change to prevent stale data + setState(prev => ({ ...prev, prs: [], repos: [], syncStatus: null, error: null, selectedPRId: null })); + + const checkConnection = async () => { + try { + const result = await window.electronAPI.github.checkMultiRepoConnection(customerId); + if (cancelled) return; + if (result.success && result.data) { + const data = result.data; + setState(prev => ({ + ...prev, + syncStatus: data, + repos: data.repos.map((r: { projectId: string; repoFullName: string }) => r.repoFullName), + })); + } else { + setState(prev => ({ + ...prev, + syncStatus: { connected: false, repos: [], error: result.error }, + error: result.error || t('prReview.multiRepo.failedToCheckConnection'), + })); + } + } catch (error) { + if (cancelled) return; + setState(prev => ({ + ...prev, + syncStatus: { connected: false, repos: [], error: error instanceof Error ? error.message : t('prReview.multiRepo.unknownError') }, + error: error instanceof Error ? error.message : t('prReview.multiRepo.unknownError'), + })); + } + }; + + checkConnection(); + return () => { cancelled = true; }; + }, [customerId, t]); + + // Load PRs when connected + useEffect(() => { + if (!customerId || !state.syncStatus?.connected) return; + let cancelled = false; + + const loadPRs = async () => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const result = await window.electronAPI.github.getMultiRepoPRs(customerId); + + if (cancelled) return; + if (result.success && result.data) { + const data = result.data; + setState(prev => ({ + ...prev, + prs: data.prs, + repos: data.repos.length > 0 ? data.repos : prev.repos, + isLoading: false, + })); + } else { + setState(prev => ({ + ...prev, + error: result.error || t('prReview.multiRepo.failedToLoadPRs'), + isLoading: false, + })); + } + } catch (error) { + if (cancelled) return; + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : t('prReview.multiRepo.unknownError'), + isLoading: false, + })); + } + }; + + loadPRs(); + return () => { cancelled = true; }; + }, [customerId, state.syncStatus?.connected, t]); + + const selectPR = useCallback((prId: string | null) => { + setState(prev => ({ ...prev, selectedPRId: prId })); + }, []); + + const setSelectedRepo = useCallback((repo: string) => { + setState(prev => ({ ...prev, selectedRepo: repo, selectedPRId: null })); + }, []); + + const handleRefresh = useCallback(() => { + if (!customerId) return; + + const refresh = async () => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const connResult = await window.electronAPI.github.checkMultiRepoConnection(customerId); + if (connResult.success && connResult.data) { + const connData = connResult.data; + setState(prev => ({ + ...prev, + syncStatus: connData, + repos: connData.repos.map(r => r.repoFullName), + })); + } + + const result = await window.electronAPI.github.getMultiRepoPRs(customerId); + + if (result.success && result.data) { + const data = result.data; + setState(prev => ({ + ...prev, + prs: data.prs, + repos: data.repos.length > 0 ? data.repos : prev.repos, + isLoading: false, + })); + } else { + setState(prev => ({ + ...prev, + error: result.error || t('prReview.multiRepo.failedToRefreshPRs'), + isLoading: false, + })); + } + } catch (error) { + setState(prev => ({ + ...prev, + error: error instanceof Error ? error.message : t('prReview.multiRepo.unknownError'), + isLoading: false, + })); + } + }; + + refresh(); + }, [customerId, t]); + + // Get filtered PRs based on selected repo + const filteredPRs = useMemo((): MultiRepoPRData[] => { + const { prs, selectedRepo } = state; + if (selectedRepo === 'all') return prs; + return prs.filter(pr => pr.repoFullName === selectedRepo); + }, [state.prs, state.selectedRepo]); + + const selectedPR = useMemo(() => { + if (!state.selectedPRId) return null; + const { repo, number } = parsePRId(state.selectedPRId); + return state.prs.find(pr => + pr.number === number && (repo === '' || pr.repoFullName === repo) + ) || null; + }, [state.prs, state.selectedPRId]); + + return { + prs: filteredPRs, + syncStatus: state.syncStatus, + isLoading: state.isLoading, + error: state.error, + selectedPRId: state.selectedPRId, + selectedPR, + isConnected: state.syncStatus?.connected ?? false, + selectPR, + refresh: handleRefresh, + // Multi-repo specific + repos: state.repos, + selectedRepo: state.selectedRepo, + setSelectedRepo, + isMultiRepo: true, + }; +} diff --git a/apps/desktop/src/renderer/components/ideation/Ideation.tsx b/apps/desktop/src/renderer/components/ideation/Ideation.tsx index e684fb3e06..e089bc530d 100644 --- a/apps/desktop/src/renderer/components/ideation/Ideation.tsx +++ b/apps/desktop/src/renderer/components/ideation/Ideation.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { TabsContent } from '../ui/tabs'; import { IDEATION_TYPE_DESCRIPTIONS } from '../../../shared/constants'; import { IdeationEmptyState } from './IdeationEmptyState'; @@ -17,6 +18,7 @@ interface IdeationProps { } export function Ideation({ projectId, onGoToTask }: IdeationProps) { + const { t } = useTranslation('common'); // Get showArchived from shared context for cross-page sync const { showArchived } = useViewState(); @@ -157,7 +159,7 @@ export function Ideation({ projectId, onGoToTask }: IdeationProps) { ))} {activeIdeas.length === 0 && (
- No ideas to display + {t('ideation.noIdeasToDisplay')}
)}
diff --git a/apps/desktop/src/renderer/components/settings/AgentProfileSettings.tsx b/apps/desktop/src/renderer/components/settings/AgentProfileSettings.tsx index f1c9fbcda3..035c366d1f 100644 --- a/apps/desktop/src/renderer/components/settings/AgentProfileSettings.tsx +++ b/apps/desktop/src/renderer/components/settings/AgentProfileSettings.tsx @@ -1,8 +1,8 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useActiveProvider } from '../../hooks/useActiveProvider'; import { getProviderModelLabel } from '../../../shared/utils/model-display'; -import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'; +import { Brain, Scale, Zap, Check, Sparkles, ChevronDown, ChevronUp, ChevronRight, RotateCcw, Bot } from 'lucide-react'; import { cn } from '../../lib/utils'; import { DEFAULT_AGENT_PROFILES, @@ -20,6 +20,7 @@ import { Label } from '../ui/label'; import { Button } from '../ui/button'; import type { AgentProfile, PhaseModelConfig, PhaseThinkingConfig, ThinkingLevel } from '../../../shared/types/settings'; import type { BuiltinProvider } from '../../../shared/types/provider-account'; +import type { ClaudeAgentsInfo } from '../../../shared/types/integrations'; /** * Icon mapping for agent profile icons @@ -48,6 +49,25 @@ export function AgentProfileSettings({ provider }: AgentProfileSettingsProps) { const providerConfig = provider ? settings.providerAgentConfig?.[provider] : undefined; const selectedProfileId = providerConfig?.selectedAgentProfile ?? settings.selectedAgentProfile ?? 'auto'; const [showPhaseConfig, setShowPhaseConfig] = useState(true); + const [showAgentsCatalog, setShowAgentsCatalog] = useState(false); + const [expandedAgentCategories, setExpandedAgentCategories] = useState>(new Set()); + const [agentsInfo, setAgentsInfo] = useState(null); + + // Load custom agents from ~/.claude/agents/ + const loadAgents = useCallback(async () => { + try { + const result = await window.electronAPI.getClaudeAgents(); + if (result.success && result.data) { + setAgentsInfo(result.data); + } + } catch { + // Silently fail - custom agents are optional + } + }, []); + + useEffect(() => { + loadAgents(); + }, [loadAgents]); // Find the selected profile const selectedProfile = useMemo(() => @@ -351,6 +371,91 @@ export function AgentProfileSettings({ provider }: AgentProfileSettingsProps) { )}
+ {/* Available Specialist Agents */} + {agentsInfo && agentsInfo.totalAgents > 0 && ( +
+ + + {showAgentsCatalog && ( +
+

+ {t('agentProfile.availableAgents.info')} +

+ {agentsInfo.categories.map((category) => { + const isExpanded = expandedAgentCategories.has(category.categoryDir); + return ( +
+ + {isExpanded && ( +
+ {category.agents.map((agent) => ( +
+ + {agent.agentName} +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ )} + ); } diff --git a/apps/desktop/src/renderer/components/settings/integrations/GitHubIntegration.tsx b/apps/desktop/src/renderer/components/settings/integrations/GitHubIntegration.tsx index fcfa2f8c3f..7d00e04e4f 100644 --- a/apps/desktop/src/renderer/components/settings/integrations/GitHubIntegration.tsx +++ b/apps/desktop/src/renderer/components/settings/integrations/GitHubIntegration.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch } from 'lucide-react'; +import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch, Download, FolderGit2 } from 'lucide-react'; +import { useProjectStore } from '../../../stores/project-store'; import { Input } from '../../ui/input'; import { Label } from '../../ui/label'; import { Switch } from '../../ui/switch'; @@ -10,6 +11,7 @@ import { Combobox } from '../../ui/combobox'; import { GitHubOAuthFlow } from '../../project-settings/GitHubOAuthFlow'; import { PasswordInput } from '../../project-settings/PasswordInput'; import { buildBranchOptions } from '../../../lib/branch-utils'; +import { cn } from '../../../lib/utils'; import type { ProjectEnvConfig, GitHubSyncStatus, ProjectSettings, GitBranchDetail } from '../../../../shared/types'; // Debug logging @@ -38,6 +40,9 @@ interface GitHubIntegrationProps { gitHubConnectionStatus: GitHubSyncStatus | null; isCheckingGitHub: boolean; projectPath?: string; // Project path for fetching git branches + projectType?: 'project' | 'customer'; // Project type for customer-specific UI + projectName?: string; // Project name for display + projectId?: string; // Project ID for store lookups // Project settings for mainBranch (used by kanban tasks and terminal worktrees) settings?: ProjectSettings; setSettings?: React.Dispatch>; @@ -55,6 +60,9 @@ export function GitHubIntegration({ gitHubConnectionStatus, isCheckingGitHub, projectPath, + projectType, + projectName, + projectId, settings, setSettings }: GitHubIntegrationProps) { @@ -70,6 +78,23 @@ export function GitHubIntegration({ const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [branchesError, setBranchesError] = useState(null); + // Customer clone repos state + const [customerRepos, setCustomerRepos] = useState([]); + const [isLoadingCustomerRepos, setIsLoadingCustomerRepos] = useState(false); + const [customerReposError, setCustomerReposError] = useState(null); + const [cloneStatuses, setCloneStatuses] = useState>({}); + const [cloneErrors, setCloneErrors] = useState>({}); + const [customerRepoSearch, setCustomerRepoSearch] = useState(''); + + // Get child projects (repos cloned into customer folder) + const allProjects = useProjectStore((state) => state.projects); + const customerChildProjects = useMemo(() => { + if (projectType !== 'customer' || !projectPath) return []; + const normalize = (p: string) => p.replace(/\\/g, '/'); + const normalizedCustomerPath = normalize(projectPath); + return allProjects.filter(p => p.id !== projectId && normalize(p.path).startsWith(normalizedCustomerPath + '/')); + }, [projectType, projectPath, projectId, allProjects]); + debugLog('Render - authMode:', authMode); debugLog('Render - projectPath:', projectPath); debugLog('Render - envConfig:', envConfig ? { githubEnabled: envConfig.githubEnabled, hasToken: !!envConfig.githubToken, defaultBranch: envConfig.defaultBranch } : null); @@ -221,6 +246,66 @@ export function GitHubIntegration({ updateEnvConfig({ githubRepo: repoFullName }); }; + // Customer-specific: load repos for cloning + const loadCustomerRepos = async () => { + setIsLoadingCustomerRepos(true); + setCustomerReposError(null); + try { + const result = await window.electronAPI.listGitHubUserRepos(); + if (result.success && result.data) { + setCustomerRepos(result.data.repos); + } else { + setCustomerReposError(result.error || 'Failed to load repositories'); + } + } catch (err) { + setCustomerReposError(err instanceof Error ? err.message : 'Failed to load repositories'); + } finally { + setIsLoadingCustomerRepos(false); + } + }; + + // Customer-specific: clone a repo into the customer folder + const handleCloneRepo = async (repo: GitHubRepo) => { + if (!projectPath) return; + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'cloning' })); + setCloneErrors(prev => { + const next = { ...prev }; + delete next[repo.fullName]; + return next; + }); + + try { + const result = await window.electronAPI.cloneGitHubRepo(repo.fullName, projectPath); + if (!result.success || !result.data) { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ ...prev, [repo.fullName]: result.error || 'Clone failed' })); + return; + } + + // Register cloned repo as a project + const addResult = await window.electronAPI.addProject(result.data.path); + if (addResult?.success && addResult?.data) { + const store = useProjectStore.getState(); + store.addProject(addResult.data); + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'done' })); + } else { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ ...prev, [repo.fullName]: addResult?.error || 'Failed to register project' })); + } + } catch (err) { + setCloneStatuses(prev => ({ ...prev, [repo.fullName]: 'error' })); + setCloneErrors(prev => ({ + ...prev, + [repo.fullName]: err instanceof Error ? err.message : 'Clone failed' + })); + } + }; + + const filteredCustomerRepos = customerRepos.filter(repo => + repo.fullName.toLowerCase().includes(customerRepoSearch.toLowerCase()) || + (repo.description?.toLowerCase().includes(customerRepoSearch.toLowerCase())) + ); + // Selected branch for Combobox value const selectedBranch = settings?.mainBranch || envConfig?.defaultBranch || ''; const pushNewBranches = settings?.pushNewBranches !== false; @@ -344,98 +429,240 @@ export function GitHubIntegration({ )} - {envConfig.githubToken && envConfig.githubRepo && ( - - )} - - {gitHubConnectionStatus?.connected && } - - + {/* Customer-specific: Clone Repositories */} + {projectType === 'customer' && envConfig.githubToken && ( + <> + - {/* Default Branch Selector */} - {projectPath && ( -
-
-
-
- - + {/* Already cloned repos */} + {customerChildProjects.length > 0 && ( +
+ +
+ {customerChildProjects.map((child) => ( +
+ + {child.name} + {child.path} +
+ ))}
-

- {t('settings:projectSections.github.defaultBranch.description')} -

- -
+ )} - {branchesError && ( -
- - {branchesError} + {/* Clone new repos */} +
+
+ +
- )} -
- + {customerReposError && ( +
+ + {customerReposError} +
+ )} + + {customerRepos.length > 0 && ( + <> + {/* Search */} + setCustomerRepoSearch(e.target.value)} + placeholder="Search repositories..." + className="h-8 text-xs" + /> + + {/* Repo list */} +
+ {filteredCustomerRepos.map((repo) => { + const status = cloneStatuses[repo.fullName] || 'idle'; + const alreadyCloned = customerChildProjects.some( + p => p.name === repo.fullName.split('/').pop() + ); + const cloneError = cloneErrors[repo.fullName]; + + return ( +
+ +
+
+ {repo.fullName} + {repo.isPrivate ? ( + + ) : ( + + )} +
+ {repo.description && ( +

{repo.description}

+ )} + {cloneError && ( +

{cloneError}

+ )} +
+ +
+ {alreadyCloned || status === 'done' ? ( + + + Cloned + + ) : status === 'cloning' ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ + )}
- - {selectedBranch && ( -

- {t('settings:projectSections.github.defaultBranch.selectedBranchHelp', { branch: selectedBranch })} -

- )} -
+ )} - {setSettings && ( + {/* Regular project: repo connection + branch + auto-sync */} + {projectType !== 'customer' && ( <> + {envConfig.githubToken && envConfig.githubRepo && ( + + )} + + {gitHubConnectionStatus?.connected && } + -
-
- -

- {t('settings:projectSections.github.pushNewBranches.description')} -

+ {/* Default Branch Selector */} + {projectPath && ( +
+
+
+
+ + +
+

+ {t('settings:projectSections.github.defaultBranch.description')} +

+
+ +
+ + {branchesError && ( +
+ + {branchesError} +
+ )} + +
+ +
+ + {selectedBranch && ( +

+ {t('settings:projectSections.github.defaultBranch.selectedBranchHelp', { branch: selectedBranch })} +

+ )}
- setSettings(prev => ({ ...prev, pushNewBranches: checked }))} - /> -
- - )} + )} + + {setSettings && ( + <> + + +
+
+ +

+ {t('settings:projectSections.github.pushNewBranches.description')} +

+
+ setSettings(prev => ({ ...prev, pushNewBranches: checked }))} + /> +
+ + )} - + - updateEnvConfig({ githubAutoSync: checked })} - /> + updateEnvConfig({ githubAutoSync: checked })} + /> + + )} )}
diff --git a/apps/desktop/src/renderer/lib/browser-mock.ts b/apps/desktop/src/renderer/lib/browser-mock.ts index 5259afd86c..0a66a6c320 100644 --- a/apps/desktop/src/renderer/lib/browser-mock.ts +++ b/apps/desktop/src/renderer/lib/browser-mock.ts @@ -104,6 +104,21 @@ const browserMockAPI: ElectronAPI = { onRoadmapComplete: () => () => {}, onRoadmapError: () => () => {}, onRoadmapStopped: () => () => {}, + // Customer Project initialization + initializeCustomerProject: async () => ({ + success: true, + data: { success: true, version: '1.0.0', wasUpdate: false } + }), + + // Context index progress listener + onIndexProgress: () => () => {}, + + // Clone GitHub repo + cloneGitHubRepo: async () => ({ + success: false, + error: 'Not available in browser mode' + }), + // Context Operations ...contextMock, @@ -119,6 +134,35 @@ const browserMockAPI: ElectronAPI = { // Infrastructure & Docker Operations ...infrastructureMock, + // Ollama embedding dimension + getOllamaEmbeddingDim: async () => ({ + success: true, + data: { model: 'nomic-embed-text', dim: 768, source: 'fallback' as const } + }), + + // Global MCP health check + checkGlobalMcpHealth: async (server: any) => ({ + success: true, + data: { + serverId: server.id || 'unknown', + status: 'unknown' as const, + message: 'Health check not available in browser mode', + checkedAt: new Date().toISOString() + } + }), + + // Claude Code global MCP configuration + getGlobalMcps: async () => ({ + success: true, + data: { pluginServers: [], inlineServers: [], claudeJsonServers: [] } + }), + + // Claude Code custom agents + getClaudeAgents: async () => ({ + success: true, + data: { categories: [], totalAgents: 0 } + }), + // API Profile Management (custom Anthropic-compatible endpoints) getAPIProfiles: async () => ({ success: true, @@ -265,6 +309,11 @@ const browserMockAPI: ElectronAPI = { listGitHubOrgs: async () => ({ success: true, data: { orgs: [] } }), onGitHubAuthDeviceCode: () => () => {}, onGitHubAuthChanged: () => () => {}, + // Multi-repo operations (Customer projects) + checkMultiRepoConnection: async () => ({ success: true, data: { connected: false, repos: [] } }), + getMultiRepoIssues: async () => ({ success: true, data: { issues: [], repos: [], hasMore: false } }), + getMultiRepoIssueDetail: async () => ({ success: false, error: 'Not available in browser mode' }), + getMultiRepoPRs: async () => ({ success: true, data: { prs: [], repos: [] } }), onGitHubInvestigationProgress: () => () => {}, onGitHubInvestigationComplete: () => () => {}, onGitHubInvestigationError: () => () => {}, diff --git a/apps/desktop/src/renderer/stores/context-store.ts b/apps/desktop/src/renderer/stores/context-store.ts index f18ae2d21a..db36c56f1e 100644 --- a/apps/desktop/src/renderer/stores/context-store.ts +++ b/apps/desktop/src/renderer/stores/context-store.ts @@ -12,6 +12,9 @@ interface ContextState { projectIndex: ProjectIndex | null; indexLoading: boolean; indexError: string | null; + indexProgress: string | null; + indexProgressCurrent: number | null; + indexProgressTotal: number | null; // Memory Status memoryStatus: MemorySystemStatus | null; @@ -32,6 +35,7 @@ interface ContextState { setProjectIndex: (index: ProjectIndex | null) => void; setIndexLoading: (loading: boolean) => void; setIndexError: (error: string | null) => void; + setIndexProgress: (message: string | null, current?: number | null, total?: number | null) => void; setMemoryStatus: (status: MemorySystemStatus | null) => void; setMemoryState: (state: MemorySystemState | null) => void; setMemoryLoading: (loading: boolean) => void; @@ -49,6 +53,9 @@ export const useContextStore = create((set) => ({ projectIndex: null, indexLoading: false, indexError: null, + indexProgress: null, + indexProgressCurrent: null, + indexProgressTotal: null, // Memory Status memoryStatus: null, @@ -69,6 +76,11 @@ export const useContextStore = create((set) => ({ setProjectIndex: (index) => set({ projectIndex: index }), setIndexLoading: (loading) => set({ indexLoading: loading }), setIndexError: (error) => set({ indexError: error }), + setIndexProgress: (message, current, total) => set({ + indexProgress: message, + indexProgressCurrent: current ?? null, + indexProgressTotal: total ?? null + }), setMemoryStatus: (status) => set({ memoryStatus: status }), setMemoryState: (state) => set({ memoryState: state }), setMemoryLoading: (loading) => set({ memoryLoading: loading }), @@ -83,6 +95,9 @@ export const useContextStore = create((set) => ({ projectIndex: null, indexLoading: false, indexError: null, + indexProgress: null, + indexProgressCurrent: null, + indexProgressTotal: null, memoryStatus: null, memoryState: null, memoryLoading: false, @@ -125,14 +140,16 @@ export async function loadProjectContext(projectId: string): Promise { /** * Refresh project index by re-running analyzer + * @param force - If true, re-runs analyzer even if index already exists (for customer child repos) */ -export async function refreshProjectIndex(projectId: string): Promise { +export async function refreshProjectIndex(projectId: string, force?: boolean): Promise { const store = useContextStore.getState(); store.setIndexLoading(true); store.setIndexError(null); + store.setIndexProgress(null); try { - const result = await window.electronAPI.refreshProjectIndex(projectId); + const result = await window.electronAPI.refreshProjectIndex(projectId, force); if (result.success && result.data) { store.setProjectIndex(result.data); } else { @@ -142,6 +159,7 @@ export async function refreshProjectIndex(projectId: string): Promise { store.setIndexError(error instanceof Error ? error.message : 'Unknown error'); } finally { store.setIndexLoading(false); + store.setIndexProgress(null); } } diff --git a/apps/desktop/src/shared/constants/ipc.ts b/apps/desktop/src/shared/constants/ipc.ts index 80e500b0e5..8ca8ff1de6 100644 --- a/apps/desktop/src/shared/constants/ipc.ts +++ b/apps/desktop/src/shared/constants/ipc.ts @@ -10,6 +10,7 @@ export const IPC_CHANNELS = { PROJECT_LIST: 'project:list', PROJECT_UPDATE_SETTINGS: 'project:updateSettings', PROJECT_INITIALIZE: 'project:initialize', + PROJECT_INIT_CUSTOMER: 'project:initCustomer', PROJECT_CHECK_VERSION: 'project:checkVersion', // Tab state operations (persisted in main process) @@ -213,6 +214,7 @@ export const IPC_CHANNELS = { // Context operations CONTEXT_GET: 'context:get', CONTEXT_REFRESH_INDEX: 'context:refreshIndex', + CONTEXT_INDEX_PROGRESS: 'context:indexProgress', CONTEXT_MEMORY_STATUS: 'context:memoryStatus', CONTEXT_SEARCH_MEMORIES: 'context:searchMemories', CONTEXT_GET_MEMORIES: 'context:getMemories', @@ -264,6 +266,12 @@ export const IPC_CHANNELS = { GITHUB_IMPORT_ISSUES: 'github:importIssues', GITHUB_CREATE_RELEASE: 'github:createRelease', + // Customer multi-repo GitHub operations + GITHUB_CHECK_MULTI_REPO_CONNECTION: 'github:checkMultiRepoConnection', + GITHUB_GET_MULTI_REPO_ISSUES: 'github:getMultiRepoIssues', + GITHUB_GET_MULTI_REPO_ISSUE_DETAIL: 'github:getMultiRepoIssueDetail', + GITHUB_GET_MULTI_REPO_PRS: 'github:getMultiRepoPRs', + // GitHub OAuth (gh CLI authentication) GITHUB_CHECK_CLI: 'github:checkCli', GITHUB_CHECK_AUTH: 'github:checkAuth', @@ -276,6 +284,7 @@ export const IPC_CHANNELS = { GITHUB_CREATE_REPO: 'github:createRepo', GITHUB_ADD_REMOTE: 'github:addRemote', GITHUB_LIST_ORGS: 'github:listOrgs', + GITHUB_CLONE_REPO: 'github:cloneRepo', // GitHub OAuth events (main -> renderer) - for streaming device code during auth GITHUB_AUTH_DEVICE_CODE: 'github:authDeviceCode', @@ -468,6 +477,7 @@ export const IPC_CHANNELS = { OLLAMA_LIST_EMBEDDING_MODELS: 'ollama:listEmbeddingModels', OLLAMA_PULL_MODEL: 'ollama:pullModel', OLLAMA_PULL_PROGRESS: 'ollama:pullProgress', + OLLAMA_GET_EMBEDDING_DIM: 'ollama:getEmbeddingDim', // Changelog operations CHANGELOG_GET_DONE_TASKS: 'changelog:getDoneTasks', @@ -567,8 +577,13 @@ export const IPC_CHANNELS = { // MCP Server health checks MCP_CHECK_HEALTH: 'mcp:checkHealth', // Quick connectivity check + MCP_CHECK_GLOBAL_HEALTH: 'mcp:checkGlobalHealth', // Health check for global MCPs (trusted source, no allowlist) MCP_TEST_CONNECTION: 'mcp:testConnection', // Full MCP protocol test + // Claude Code global MCP configuration + CLAUDE_MCP_GET_GLOBAL: 'claude-mcp:getGlobalMcps', + CLAUDE_AGENTS_GET: 'claude-agents:getAgents', + // Sentry error reporting SENTRY_STATE_CHANGED: 'sentry:state-changed', // Notify main process when setting changes GET_SENTRY_DSN: 'sentry:get-dsn', // Get DSN from main process (env var) diff --git a/apps/desktop/src/shared/i18n/index.ts b/apps/desktop/src/shared/i18n/index.ts index 095b0b1188..cc5c556678 100644 --- a/apps/desktop/src/shared/i18n/index.ts +++ b/apps/desktop/src/shared/i18n/index.ts @@ -13,6 +13,7 @@ import enGitlab from './locales/en/gitlab.json'; import enTaskReview from './locales/en/taskReview.json'; import enTerminal from './locales/en/terminal.json'; import enErrors from './locales/en/errors.json'; +import enContext from './locales/en/context.json'; // Import French translation resources import frCommon from './locales/fr/common.json'; @@ -26,6 +27,7 @@ import frGitlab from './locales/fr/gitlab.json'; import frTaskReview from './locales/fr/taskReview.json'; import frTerminal from './locales/fr/terminal.json'; import frErrors from './locales/fr/errors.json'; +import frContext from './locales/fr/context.json'; export const defaultNS = 'common'; @@ -41,7 +43,8 @@ export const resources = { gitlab: enGitlab, taskReview: enTaskReview, terminal: enTerminal, - errors: enErrors + errors: enErrors, + context: enContext }, fr: { common: frCommon, @@ -54,7 +57,8 @@ export const resources = { gitlab: frGitlab, taskReview: frTaskReview, terminal: frTerminal, - errors: frErrors + errors: frErrors, + context: frContext } } as const; @@ -65,7 +69,7 @@ i18n lng: 'en', // Default language (will be overridden by settings) fallbackLng: 'en', defaultNS, - ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview', 'terminal', 'errors'], + ns: ['common', 'navigation', 'settings', 'tasks', 'welcome', 'onboarding', 'dialogs', 'gitlab', 'taskReview', 'terminal', 'errors', 'context'], interpolation: { escapeValue: false // React already escapes values }, diff --git a/apps/desktop/src/shared/i18n/locales/en/common.json b/apps/desktop/src/shared/i18n/locales/en/common.json index 7c44cedbc0..937a4696f0 100644 --- a/apps/desktop/src/shared/i18n/locales/en/common.json +++ b/apps/desktop/src/shared/i18n/locales/en/common.json @@ -25,7 +25,8 @@ "hideArchivedTasks": "Hide archived tasks", "closeTab": "Close tab", "closeTabAriaLabel": "Close tab (removes project from app)", - "addProjectAriaLabel": "Add project" + "addProjectAriaLabel": "Add project", + "dragHandle": "Drag to reorder {{name}}" }, "accessibility": { "deleteFeatureAriaLabel": "Delete feature", @@ -142,7 +143,9 @@ "justNow": "Just now", "minutesAgo": "{{count}}m ago", "hoursAgo": "{{count}}h ago", - "daysAgo": "{{count}}d ago" + "daysAgo": "{{count}}d ago", + "yesterday": "yesterday", + "weeksAgo": "{{count}}w ago" }, "errors": { "generic": "An error occurred", @@ -431,6 +434,13 @@ "agentActivity": "Agent Activity", "showMore": "Show {{count}} more", "hideMore": "Hide {{count}} more" + }, + "reposCount": "{{count}} repos", + "multiRepo": { + "failedToCheckConnection": "Failed to check multi-repo connection", + "failedToLoadPRs": "Failed to load PRs", + "failedToRefreshPRs": "Failed to refresh PRs", + "unknownError": "Unknown error" } }, "downloads": { @@ -447,6 +457,7 @@ "starting": "Starting..." }, "insights": { + "placeholder": "Ask about your codebase...", "suggestedTask": "Suggested Task", "creating": "Creating...", "taskCreated": "Task Created", @@ -492,6 +503,7 @@ } }, "ideation": { + "noIdeasToDisplay": "No ideas to display", "converting": "Converting...", "convertToTask": "Convert to Auto-Build Task", "dismissIdea": "Dismiss Idea", @@ -504,9 +516,18 @@ "conversionErrorDescription": "An error occurred while converting the idea" }, "issues": { + "noIssuesFound": "No issues found", + "selectIssueToView": "Select an issue to view details", "loadingMore": "Loading more...", "scrollForMore": "Scroll for more", - "allLoaded": "All issues loaded" + "allLoaded": "All issues loaded", + "reposCount": "{{count}} repos", + "multiRepo": { + "failedToCheckConnection": "Failed to check multi-repo connection", + "failedToLoadIssues": "Failed to load issues", + "failedToRefreshIssues": "Failed to refresh issues", + "unknownError": "Unknown error" + } }, "usage": { "dataUnavailable": "Usage data unavailable", @@ -739,6 +760,8 @@ } }, "auth": { + "claudeAuthRequired": "Claude Authentication Required", + "claudeAuthRequiredDescription": "A Claude Code OAuth token is required to generate AI-powered feature ideas.", "failure": { "title": "Authentication Required", "profileLabel": "Profile", @@ -897,6 +920,7 @@ "empty": "No memories yet. Memories are automatically created as agents work on tasks.", "emptyFilter": "No memories match the selected filter.", "showAll": "Show all memories", + "searchPlaceholder": "Search for patterns, insights, gotchas...", "expand": "Expand", "collapse": "Collapse", "sections": { diff --git a/apps/desktop/src/shared/i18n/locales/en/context.json b/apps/desktop/src/shared/i18n/locales/en/context.json new file mode 100644 index 0000000000..a865e893a8 --- /dev/null +++ b/apps/desktop/src/shared/i18n/locales/en/context.json @@ -0,0 +1,36 @@ +{ + "projectIndex": { + "title": "Project Structure", + "subtitle": "AI-discovered knowledge about your codebase", + "reanalyze": "Re-analyze", + "reanalyzeTooltip": "Force re-analyze all project structures from scratch", + "refresh": "Refresh", + "analyzeTooltip": "Analyze project structure", + "errorTitle": "Failed to load project index", + "analyzing": "Analyzing project structure...", + "repoProgress": "Repository {{current}} of {{total}}", + "noIndexTitle": "No Project Index Found", + "noIndexDescription": "Click the button below to analyze your project structure and create an index.", + "analyzeButton": "Analyze Project", + "overview": "Overview", + "serviceCount": "{{count}} service", + "serviceCount_other": "{{count}} services", + "repoCount": "{{count}} repository", + "repoCount_other": "{{count}} repositories", + "repositories": "Repositories", + "services": "Services", + "infrastructure": "Infrastructure", + "dockerCompose": "Docker Compose", + "ciCd": "CI/CD", + "deployment": "Deployment", + "dockerServices": "Docker Services", + "conventions": "Conventions", + "pythonLinting": "Python Linting", + "jsLinting": "JS Linting", + "formatting": "Formatting", + "gitHooks": "Git Hooks", + "typescript": "TypeScript", + "enabled": "Enabled", + "svcCount": "{{count}} svc" + } +} diff --git a/apps/desktop/src/shared/i18n/locales/en/dialogs.json b/apps/desktop/src/shared/i18n/locales/en/dialogs.json index 35feafb800..3646dc8506 100644 --- a/apps/desktop/src/shared/i18n/locales/en/dialogs.json +++ b/apps/desktop/src/shared/i18n/locales/en/dialogs.json @@ -139,6 +139,49 @@ "openExistingAriaLabel": "Open existing project folder", "createNewAriaLabel": "Create new project" }, + "addCustomer": { + "title": "Add Customer", + "description": "Create a new customer folder or select an existing one", + "createNew": "Create New Folder", + "createNewDescription": "Start fresh with a new customer folder", + "createNewSubtitle": "Set up a new customer folder", + "createNewAriaLabel": "Create new customer folder", + "openExisting": "Open Existing Folder", + "openExistingDescription": "Browse to an existing customer folder on your computer", + "openExistingAriaLabel": "Open existing customer folder", + "customerName": "Customer Name", + "customerNamePlaceholder": "e.g., Acme Corp", + "location": "Location", + "locationPlaceholder": "Select a folder...", + "browse": "Browse", + "willCreate": "Will create:", + "back": "Back", + "creating": "Creating...", + "createCustomer": "Create Customer", + "nameRequired": "Please enter a customer name", + "locationRequired": "Please select a location", + "failedToOpen": "Failed to open customer folder", + "failedToCreate": "Failed to create customer folder" + }, + "customerRepos": { + "title": "Clone Repositories", + "description": "Select repositories to clone into {{name}}'s folder", + "searchPlaceholder": "Search repositories...", + "loading": "Loading repositories...", + "failedToLoad": "Failed to load repositories", + "noResults": "No repositories match your search", + "noRepos": "No repositories found", + "clone": "Clone", + "cloning": "Cloning...", + "cloned": "Cloned", + "cloneFailed": "Failed to clone repository", + "retry": "Retry", + "clonedCount": "{{count}} repository cloned", + "clonedCount_other": "{{count}} repositories cloned", + "done": "Done", + "private": "Private repository", + "public": "Public repository" + }, "customModel": { "title": "Custom Model Configuration", "description": "Configure the model and thinking level for this chat session.", @@ -225,6 +268,12 @@ "skipDescription": "Generate roadmap without any competitor insights.", "cancel": "Cancel" }, + "envConfig": { + "profileNotFound": "Profile not found", + "failedToSaveToken": "Failed to save token", + "invalidProfileCredentials": "Selected profile does not have valid credentials. Please re-authenticate.", + "failedToUseProfile": "Failed to use profile" + }, "versionWarning": { "title": "Action Required", "subtitle": "Version 2.7.5 Update", diff --git a/apps/desktop/src/shared/i18n/locales/en/navigation.json b/apps/desktop/src/shared/i18n/locales/en/navigation.json index d19cabee01..b197cf9435 100644 --- a/apps/desktop/src/shared/i18n/locales/en/navigation.json +++ b/apps/desktop/src/shared/i18n/locales/en/navigation.json @@ -18,6 +18,18 @@ "worktrees": "Worktrees", "agentTools": "MCP Overview" }, + "projectSelector": { + "placeholder": "Select a project", + "selectRepo": "Select a repository", + "addProject": "Add Project", + "addCustomer": "Add Customer" + }, + "multiRepo": { + "allRepos": "All Repos", + "filterByRepo": "Filter by repository", + "repoCount_one": "{{count}} repo", + "repoCount_other": "{{count}} repos" + }, "actions": { "settings": "Settings", "help": "Help & Feedback", diff --git a/apps/desktop/src/shared/i18n/locales/en/settings.json b/apps/desktop/src/shared/i18n/locales/en/settings.json index 68cfa87c67..98068a3c99 100644 --- a/apps/desktop/src/shared/i18n/locales/en/settings.json +++ b/apps/desktop/src/shared/i18n/locales/en/settings.json @@ -188,7 +188,12 @@ "fineTuneDescription": "Adjust from 75% to 200% in 5% increments", "default": "Default", "comfortable": "Comfortable", - "large": "Large" + "large": "Large", + "resetToDefault": "Reset to default (100%)", + "decreaseScale": "Decrease scale by {{step}}%", + "increaseScale": "Increase scale by {{step}}%", + "applyChanges": "Apply scale changes", + "apply": "Apply" }, "logOrder": { "label": "Log Order", @@ -259,7 +264,9 @@ "dark": "Dark", "system": "System", "colorTheme": "Color Theme", - "colorThemeDescription": "Choose your preferred color palette" + "colorThemeDescription": "Choose your preferred color palette", + "backgroundColorTitle": "Background color", + "accentColorTitle": "Accent color" }, "devtools": { "title": "Developer Tools", @@ -353,7 +360,7 @@ "projectSections": { "general": { "title": "General", - "description": "Auto-Build and agent config", + "description": "Configure Auto-Build, agent model, and notifications for {{name}}", "useClaudeMd": "Use CLAUDE.md", "useClaudeMdDescription": "Include CLAUDE.md instructions in agent context" }, @@ -424,6 +431,11 @@ "customized": "Customized", "ollamaNotConfigured": "Select models below", "phaseConfigNote": "These settings will be used as defaults when creating new tasks with this profile. You can override them per-task in the task creation wizard.", + "availableAgents": { + "title": "Available Specialist Agents", + "description": "All agents are available for automatic use during builds", + "info": "These agents from ~/.claude/agents/ are automatically available during task execution. The system selects the most relevant specialist based on the task context — no manual assignment needed." + }, "adaptiveThinking": { "badge": "Adaptive", "tooltip": "Opus uses adaptive thinking — it dynamically decides how much to think within the budget cap set by the thinking level." @@ -971,6 +983,40 @@ "createAnthropicKey": "Create Anthropic API Key", "openai": "This looks like an OpenAI API. You'll need an API key.", "createOpenaiKey": "Create OpenAI API Key" + }, + "globalMcps": { + "title": "Claude Code MCPs (Global)", + "description": "MCP servers configured in your Claude Code (~/.claude.json and ~/.claude/settings.json)", + "noGlobalMcps": "No global MCP servers configured in Claude Code", + "source": { + "plugin": "Plugin", + "settings": "Settings", + "claudeJson": "Claude Config" + }, + "badge": "Global", + "serverType": { + "command": "Command", + "http": "HTTP", + "sse": "SSE" + }, + "refreshTooltip": "Refresh global MCP list", + "readOnly": "Read-only — configure in Claude Code CLI settings", + "checkHealth": "Check Health", + "healthCheckFailed": "Health check failed", + "statusUnknown": "Status unknown — click Check Health", + "useIn": "Use in:", + "phases": { + "spec": "Spec", + "build": "Build", + "qa": "QA", + "utility": "Utility", + "ideation": "Ideation" + } + }, + "customAgents": { + "title": "Custom Agents (Global)", + "description": "Custom agent definitions from ~/.claude/agents/", + "refreshTooltip": "Refresh agent list" } }, "terminalFonts": { @@ -1114,6 +1160,75 @@ "description": "AI-fills GitHub PR templates from code changes" } }, + "projectSelector": { + "placeholder": "Select a project...", + "noProjects": "No projects yet", + "addProject": "Add Project...", + "addCustomer": "Add Customer..." + }, + "linear": { + "enableSync": "Enable Linear Sync", + "enableSyncDescription": "Create and update Linear issues automatically", + "apiKey": "API Key", + "apiKeyDescription": "Get your API key from", + "linearSettings": "Linear Settings", + "connectionStatus": "Connection Status", + "checking": "Checking...", + "connected": "Connected to {{team}}", + "connectedNoTeam": "Connected", + "notConnected": "Not connected", + "tasksAvailable": "{{count}}+ tasks available to import", + "importTitle": "Import Existing Tasks", + "importDescription": "Select which Linear issues to import into AutoBuild as tasks.", + "importButton": "Import Tasks from Linear", + "realtimeSync": "Real-time Sync", + "realtimeSyncDescription": "Automatically import new tasks created in Linear", + "realtimeSyncWarning": "When enabled, new Linear issues will be automatically imported into AutoBuild. Make sure to configure your team/project filters below to control which issues are imported.", + "teamId": "Team ID (Optional)", + "projectId": "Project ID (Optional)" + }, + "github": { + "enableIssues": "Enable GitHub Issues", + "enableIssuesDescription": "Sync issues from GitHub and create tasks automatically", + "connectedViaCLI": "Connected via GitHub CLI", + "authenticatedAs": "Authenticated as {{username}}", + "useDifferentToken": "Use Different Token", + "authentication": "GitHub Authentication", + "useManualToken": "Use Manual Token", + "personalAccessToken": "Personal Access Token", + "useOAuthInstead": "Use OAuth Instead", + "tokenInstructions": "Create a token with", + "tokenScopeFrom": "scope from", + "githubSettings": "GitHub Settings", + "clonedRepositories": "Cloned Repositories", + "cloneRepositories": "Clone Repositories", + "cloned": "Cloned", + "cloning": "Cloning", + "clone": "Clone", + "refresh": "Refresh", + "loadRepos": "Load Repos", + "searchRepos": "Search repositories...", + "repository": "Repository", + "repositoryFormat": "Format: owner/repo (e.g., facebook/react)", + "selectRepository": "Select a repository...", + "enterManually": "Enter Manually", + "loadingRepositories": "Loading repositories...", + "noMatchingRepositories": "No matching repositories", + "noRepositoriesFound": "No repositories found", + "selected": "Selected", + "connectionStatus": "Connection Status", + "checking": "Checking...", + "connectedTo": "Connected to {{repo}}", + "notConnected": "Not connected", + "issuesAvailable": "Issues Available", + "issuesAvailableDescription": "Access GitHub Issues from the sidebar to view, investigate, and create tasks from issues.", + "autoSyncOnLoad": "Auto-Sync on Load", + "autoSyncDescription": "Automatically fetch issues when the project loads", + "failedToLoadBranches": "Failed to load branches", + "failedToLoadRepositories": "Failed to load repositories", + "cloneFailed": "Clone failed", + "failedToRegisterProject": "Failed to register cloned project" + }, "provider": { "title": "AI Provider", "description": "Configure your AI provider and model preferences", diff --git a/apps/desktop/src/shared/i18n/locales/en/tasks.json b/apps/desktop/src/shared/i18n/locales/en/tasks.json index a407a01886..0f9de25ba3 100644 --- a/apps/desktop/src/shared/i18n/locales/en/tasks.json +++ b/apps/desktop/src/shared/i18n/locales/en/tasks.json @@ -350,6 +350,28 @@ "deletePermanently": "Delete Permanently", "deleting": "Deleting..." }, + "progress": { + "title": "Progress", + "currentSubtask": "Subtask: {{subtask}}", + "subtasksCompleted": "{{completed}}/{{total}} subtasks completed", + "noSubtasksYet": "No subtasks yet", + "phasePlanning": "Planning (0-20%)", + "phaseCoding": "Coding (20-80%)", + "phaseAIReview": "AI Review (80-95%)", + "phaseComplete": "Complete (95-100%)" + }, + "analysisBanner": { + "analyzing": "Analyzing", + "next": "Next", + "lastItem": "Last item", + "elapsed": "Elapsed", + "last30min": "Last 30min", + "completed": "completed", + "inProgress": "in progress", + "pending": "pending", + "noActivity": "No activity yet", + "phase": "Phase" + }, "referenceImages": { "title": "Reference Images (optional)", "description": "Add visual references like screenshots or designs to help the AI understand your requirements." diff --git a/apps/desktop/src/shared/i18n/locales/fr/common.json b/apps/desktop/src/shared/i18n/locales/fr/common.json index e404930657..07f5d5ed89 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/common.json +++ b/apps/desktop/src/shared/i18n/locales/fr/common.json @@ -25,7 +25,8 @@ "hideArchivedTasks": "Masquer les tâches archivées", "closeTab": "Fermer l'onglet", "closeTabAriaLabel": "Fermer l'onglet (retire le projet de l'application)", - "addProjectAriaLabel": "Ajouter un projet" + "addProjectAriaLabel": "Ajouter un projet", + "dragHandle": "Glisser pour réorganiser {{name}}" }, "accessibility": { "deleteFeatureAriaLabel": "Supprimer la fonctionnalité", @@ -142,7 +143,9 @@ "justNow": "À l'instant", "minutesAgo": "Il y a {{count}} min", "hoursAgo": "Il y a {{count}}h", - "daysAgo": "Il y a {{count}}j" + "daysAgo": "Il y a {{count}}j", + "yesterday": "hier", + "weeksAgo": "Il y a {{count}} sem" }, "errors": { "generic": "Une erreur s'est produite", @@ -431,6 +434,13 @@ "agentActivity": "Activité des agents", "showMore": "Afficher {{count}} de plus", "hideMore": "Masquer {{count}}" + }, + "reposCount": "{{count}} dépôts", + "multiRepo": { + "failedToCheckConnection": "Échec de la vérification de la connexion multi-dépôts", + "failedToLoadPRs": "Échec du chargement des PRs", + "failedToRefreshPRs": "Échec de l'actualisation des PRs", + "unknownError": "Erreur inconnue" } }, "downloads": { @@ -447,6 +457,7 @@ "starting": "Démarrage..." }, "insights": { + "placeholder": "Posez une question sur votre codebase...", "suggestedTask": "Tâche suggérée", "creating": "Création...", "taskCreated": "Tâche créée", @@ -492,6 +503,7 @@ } }, "ideation": { + "noIdeasToDisplay": "Aucune idée à afficher", "converting": "Conversion...", "convertToTask": "Convertir en tâche Auto-Build", "dismissIdea": "Ignorer l'idée", @@ -504,9 +516,18 @@ "conversionErrorDescription": "Une erreur s'est produite lors de la conversion de l'idée" }, "issues": { + "noIssuesFound": "Aucune issue trouvée", + "selectIssueToView": "Sélectionnez une issue pour voir les détails", "loadingMore": "Chargement...", "scrollForMore": "Défiler pour plus", - "allLoaded": "Toutes les issues chargées" + "allLoaded": "Toutes les issues chargées", + "reposCount": "{{count}} dépôts", + "multiRepo": { + "failedToCheckConnection": "Échec de la vérification de la connexion multi-dépôts", + "failedToLoadIssues": "Échec du chargement des issues", + "failedToRefreshIssues": "Échec de l'actualisation des issues", + "unknownError": "Erreur inconnue" + } }, "usage": { "dataUnavailable": "Données d'utilisation non disponibles", @@ -739,6 +760,8 @@ } }, "auth": { + "claudeAuthRequired": "Authentification Claude requise", + "claudeAuthRequiredDescription": "Un token OAuth Claude Code est requis pour générer des idées de fonctionnalités.", "failure": { "title": "Authentification requise", "profileLabel": "Profil", @@ -897,6 +920,7 @@ "empty": "Aucune mémoire pour l'instant. Les mémoires sont créées automatiquement lorsque les agents travaillent sur des tâches.", "emptyFilter": "Aucune mémoire ne correspond au filtre sélectionné.", "showAll": "Afficher toutes les mémoires", + "searchPlaceholder": "Rechercher des modèles, informations, pièges...", "expand": "Développer", "collapse": "Réduire", "sections": { diff --git a/apps/desktop/src/shared/i18n/locales/fr/context.json b/apps/desktop/src/shared/i18n/locales/fr/context.json new file mode 100644 index 0000000000..7073893c96 --- /dev/null +++ b/apps/desktop/src/shared/i18n/locales/fr/context.json @@ -0,0 +1,36 @@ +{ + "projectIndex": { + "title": "Structure du projet", + "subtitle": "Connaissances sur votre code découvertes par l'IA", + "reanalyze": "Ré-analyser", + "reanalyzeTooltip": "Forcer la ré-analyse de toutes les structures du projet", + "refresh": "Actualiser", + "analyzeTooltip": "Analyser la structure du projet", + "errorTitle": "Échec du chargement de l'index du projet", + "analyzing": "Analyse de la structure du projet...", + "repoProgress": "Dépôt {{current}} sur {{total}}", + "noIndexTitle": "Aucun index de projet trouvé", + "noIndexDescription": "Cliquez sur le bouton ci-dessous pour analyser la structure de votre projet et créer un index.", + "analyzeButton": "Analyser le projet", + "overview": "Aperçu", + "serviceCount": "{{count}} service", + "serviceCount_other": "{{count}} services", + "repoCount": "{{count}} dépôt", + "repoCount_other": "{{count}} dépôts", + "repositories": "Dépôts", + "services": "Services", + "infrastructure": "Infrastructure", + "dockerCompose": "Docker Compose", + "ciCd": "CI/CD", + "deployment": "Déploiement", + "dockerServices": "Services Docker", + "conventions": "Conventions", + "pythonLinting": "Linting Python", + "jsLinting": "Linting JS", + "formatting": "Formatage", + "gitHooks": "Hooks Git", + "typescript": "TypeScript", + "enabled": "Activé", + "svcCount": "{{count}} svc" + } +} diff --git a/apps/desktop/src/shared/i18n/locales/fr/dialogs.json b/apps/desktop/src/shared/i18n/locales/fr/dialogs.json index 93cd263406..2ef76f14b5 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/dialogs.json +++ b/apps/desktop/src/shared/i18n/locales/fr/dialogs.json @@ -139,6 +139,49 @@ "openExistingAriaLabel": "Ouvrir un dossier de projet existant", "createNewAriaLabel": "Créer un nouveau projet" }, + "addCustomer": { + "title": "Ajouter un client", + "description": "Créer un nouveau dossier client ou sélectionner un existant", + "createNew": "Créer un nouveau dossier", + "createNewDescription": "Commencer avec un nouveau dossier client", + "createNewSubtitle": "Configurer un nouveau dossier client", + "createNewAriaLabel": "Créer un nouveau dossier client", + "openExisting": "Ouvrir un dossier existant", + "openExistingDescription": "Parcourir vers un dossier client existant sur votre ordinateur", + "openExistingAriaLabel": "Ouvrir un dossier client existant", + "customerName": "Nom du client", + "customerNamePlaceholder": "ex. Acme Corp", + "location": "Emplacement", + "locationPlaceholder": "Sélectionner un dossier...", + "browse": "Parcourir", + "willCreate": "Va créer :", + "back": "Retour", + "creating": "Création en cours...", + "createCustomer": "Créer le client", + "nameRequired": "Veuillez entrer un nom de client", + "locationRequired": "Veuillez sélectionner un emplacement", + "failedToOpen": "Échec de l'ouverture du dossier client", + "failedToCreate": "Échec de la création du dossier client" + }, + "customerRepos": { + "title": "Cloner des dépôts", + "description": "Sélectionnez les dépôts à cloner dans le dossier de {{name}}", + "searchPlaceholder": "Rechercher des dépôts...", + "loading": "Chargement des dépôts...", + "failedToLoad": "Échec du chargement des dépôts", + "noResults": "Aucun dépôt ne correspond à votre recherche", + "noRepos": "Aucun dépôt trouvé", + "clone": "Cloner", + "cloning": "Clonage...", + "cloned": "Cloné", + "cloneFailed": "Échec du clonage du dépôt", + "retry": "Réessayer", + "clonedCount": "{{count}} dépôt cloné", + "clonedCount_other": "{{count}} dépôts clonés", + "done": "Terminé", + "private": "Dépôt privé", + "public": "Dépôt public" + }, "customModel": { "title": "Configuration du modèle personnalisé", "description": "Configurez le modèle et le niveau de réflexion pour cette session de chat.", @@ -225,6 +268,12 @@ "skipDescription": "Générer la feuille de route sans informations concurrentielles.", "cancel": "Annuler" }, + "envConfig": { + "profileNotFound": "Profil introuvable", + "failedToSaveToken": "Échec de la sauvegarde du jeton", + "invalidProfileCredentials": "Le profil sélectionné n'a pas d'identifiants valides. Veuillez vous ré-authentifier.", + "failedToUseProfile": "Échec de l'utilisation du profil" + }, "versionWarning": { "title": "Action requise", "subtitle": "Mise à jour version 2.7.5", diff --git a/apps/desktop/src/shared/i18n/locales/fr/navigation.json b/apps/desktop/src/shared/i18n/locales/fr/navigation.json index 06ac517360..5ceb576515 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/navigation.json +++ b/apps/desktop/src/shared/i18n/locales/fr/navigation.json @@ -18,6 +18,18 @@ "worktrees": "Worktrees", "agentTools": "Aperçu MCP" }, + "projectSelector": { + "placeholder": "Sélectionner un projet", + "selectRepo": "Sélectionner un dépôt", + "addProject": "Ajouter un projet", + "addCustomer": "Ajouter un client" + }, + "multiRepo": { + "allRepos": "Tous les dépôts", + "filterByRepo": "Filtrer par dépôt", + "repoCount_one": "{{count}} dépôt", + "repoCount_other": "{{count}} dépôts" + }, "actions": { "settings": "Paramètres", "help": "Aide & Feedback", diff --git a/apps/desktop/src/shared/i18n/locales/fr/settings.json b/apps/desktop/src/shared/i18n/locales/fr/settings.json index 5970f01a76..1addeed4a5 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/settings.json +++ b/apps/desktop/src/shared/i18n/locales/fr/settings.json @@ -188,7 +188,12 @@ "fineTuneDescription": "Ajustez de 75% à 200% par incréments de 5%", "default": "Par défaut", "comfortable": "Confortable", - "large": "Grand" + "large": "Grand", + "resetToDefault": "Réinitialiser par défaut (100%)", + "decreaseScale": "Diminuer l'échelle de {{step}}%", + "increaseScale": "Augmenter l'échelle de {{step}}%", + "applyChanges": "Appliquer les changements d'échelle", + "apply": "Appliquer" }, "logOrder": { "label": "Ordre des journaux", @@ -259,7 +264,9 @@ "dark": "Sombre", "system": "Système", "colorTheme": "Thème de couleur", - "colorThemeDescription": "Choisissez votre palette de couleurs préférée" + "colorThemeDescription": "Choisissez votre palette de couleurs préférée", + "backgroundColorTitle": "Couleur d'arrière-plan", + "accentColorTitle": "Couleur d'accentuation" }, "devtools": { "title": "Outils de développement", @@ -353,7 +360,7 @@ "projectSections": { "general": { "title": "Général", - "description": "Auto-Build et configuration de l'agent", + "description": "Configurer Auto-Build, modèle d'agent et notifications pour {{name}}", "useClaudeMd": "Utiliser CLAUDE.md", "useClaudeMdDescription": "Inclure les instructions CLAUDE.md dans le contexte de l'agent" }, @@ -424,6 +431,11 @@ "customized": "Personnalisé", "ollamaNotConfigured": "Sélectionnez les modèles ci-dessous", "phaseConfigNote": "Ces paramètres seront utilisés par défaut lors de la création de nouvelles tâches avec ce profil. Vous pouvez les modifier par tâche dans l'assistant de création.", + "availableAgents": { + "title": "Agents Spécialistes Disponibles", + "description": "Tous les agents sont disponibles pour utilisation automatique", + "info": "Ces agents de ~/.claude/agents/ sont automatiquement disponibles pendant l'exécution des tâches. Le système sélectionne le spécialiste le plus pertinent en fonction du contexte — aucune assignation manuelle nécessaire." + }, "adaptiveThinking": { "badge": "Adaptatif", "tooltip": "Opus utilise la réflexion adaptative — il décide dynamiquement de la profondeur de réflexion dans la limite du budget défini par le niveau de réflexion." @@ -971,6 +983,40 @@ "createAnthropicKey": "Créer une clé API Anthropic", "openai": "Ceci ressemble à une API OpenAI. Vous aurez besoin d'une clé API.", "createOpenaiKey": "Créer une clé API OpenAI" + }, + "globalMcps": { + "title": "MCPs Claude Code (Global)", + "description": "Serveurs MCP configurés dans Claude Code (~/.claude.json et ~/.claude/settings.json)", + "noGlobalMcps": "Aucun serveur MCP global configuré dans Claude Code", + "source": { + "plugin": "Plugin", + "settings": "Paramètres", + "claudeJson": "Config Claude" + }, + "badge": "Global", + "serverType": { + "command": "Commande", + "http": "HTTP", + "sse": "SSE" + }, + "refreshTooltip": "Actualiser la liste des MCP globaux", + "readOnly": "Lecture seule — configurez dans les paramètres Claude Code CLI", + "checkHealth": "Vérifier", + "healthCheckFailed": "Échec de la vérification de santé", + "statusUnknown": "Statut inconnu — cliquez Vérifier", + "useIn": "Utiliser dans :", + "phases": { + "spec": "Spec", + "build": "Build", + "qa": "QA", + "utility": "Utilitaire", + "ideation": "Idéation" + } + }, + "customAgents": { + "title": "Agents Personnalisés (Global)", + "description": "Définitions d'agents personnalisés depuis ~/.claude/agents/", + "refreshTooltip": "Actualiser la liste des agents" } }, "terminalFonts": { @@ -1114,6 +1160,75 @@ "description": "Remplit intelligemment les modèles de PR GitHub à partir des changements de code" } }, + "projectSelector": { + "placeholder": "Sélectionner un projet...", + "noProjects": "Aucun projet", + "addProject": "Ajouter un projet...", + "addCustomer": "Ajouter un client..." + }, + "linear": { + "enableSync": "Activer la synchronisation Linear", + "enableSyncDescription": "Créer et mettre à jour les issues Linear automatiquement", + "apiKey": "Clé API", + "apiKeyDescription": "Obtenez votre clé API depuis", + "linearSettings": "Paramètres Linear", + "connectionStatus": "État de la connexion", + "checking": "Vérification...", + "connected": "Connecté à {{team}}", + "connectedNoTeam": "Connecté", + "notConnected": "Non connecté", + "tasksAvailable": "{{count}}+ tâches disponibles à importer", + "importTitle": "Importer les tâches existantes", + "importDescription": "Sélectionnez les issues Linear à importer dans AutoBuild.", + "importButton": "Importer depuis Linear", + "realtimeSync": "Synchronisation en temps réel", + "realtimeSyncDescription": "Importer automatiquement les nouvelles tâches créées dans Linear", + "realtimeSyncWarning": "Lorsqu'activé, les nouvelles issues Linear seront automatiquement importées dans AutoBuild. Assurez-vous de configurer les filtres équipe/projet ci-dessous.", + "teamId": "ID d'équipe (Optionnel)", + "projectId": "ID de projet (Optionnel)" + }, + "github": { + "enableIssues": "Activer les issues GitHub", + "enableIssuesDescription": "Synchroniser les issues depuis GitHub et créer des tâches automatiquement", + "connectedViaCLI": "Connecté via GitHub CLI", + "authenticatedAs": "Authentifié en tant que {{username}}", + "useDifferentToken": "Utiliser un autre token", + "authentication": "Authentification GitHub", + "useManualToken": "Utiliser un token manuel", + "personalAccessToken": "Token d'accès personnel", + "useOAuthInstead": "Utiliser OAuth à la place", + "tokenInstructions": "Créez un token avec", + "tokenScopeFrom": "scope depuis", + "githubSettings": "Paramètres GitHub", + "clonedRepositories": "Dépôts clonés", + "cloneRepositories": "Cloner des dépôts", + "cloned": "Cloné", + "cloning": "Clonage", + "clone": "Cloner", + "refresh": "Actualiser", + "loadRepos": "Charger les dépôts", + "searchRepos": "Rechercher des dépôts...", + "repository": "Dépôt", + "repositoryFormat": "Format : owner/repo (ex. : facebook/react)", + "selectRepository": "Sélectionner un dépôt...", + "enterManually": "Saisir manuellement", + "loadingRepositories": "Chargement des dépôts...", + "noMatchingRepositories": "Aucun dépôt correspondant", + "noRepositoriesFound": "Aucun dépôt trouvé", + "selected": "Sélectionné", + "connectionStatus": "État de la connexion", + "checking": "Vérification...", + "connectedTo": "Connecté à {{repo}}", + "notConnected": "Non connecté", + "issuesAvailable": "Issues disponibles", + "issuesAvailableDescription": "Accédez aux issues GitHub depuis la barre latérale pour les consulter, les analyser et créer des tâches.", + "autoSyncOnLoad": "Synchronisation automatique", + "autoSyncDescription": "Synchroniser automatiquement les issues au chargement du projet", + "failedToLoadBranches": "Échec du chargement des branches", + "failedToLoadRepositories": "Échec du chargement des dépôts", + "cloneFailed": "Échec du clonage", + "failedToRegisterProject": "Échec de l'enregistrement du projet cloné" + }, "provider": { "title": "Fournisseur IA", "description": "Configurez votre fournisseur IA et vos préférences de modèle", diff --git a/apps/desktop/src/shared/i18n/locales/fr/tasks.json b/apps/desktop/src/shared/i18n/locales/fr/tasks.json index afcdf1c6f1..951c1abb46 100644 --- a/apps/desktop/src/shared/i18n/locales/fr/tasks.json +++ b/apps/desktop/src/shared/i18n/locales/fr/tasks.json @@ -350,6 +350,28 @@ "deletePermanently": "Supprimer d\u00e9finitivement", "deleting": "Suppression..." }, + "progress": { + "title": "Progression", + "currentSubtask": "Sous-tâche : {{subtask}}", + "subtasksCompleted": "{{completed}}/{{total}} sous-tâches terminées", + "noSubtasksYet": "Aucune sous-tâche pour le moment", + "phasePlanning": "Planification (0-20%)", + "phaseCoding": "Développement (20-80%)", + "phaseAIReview": "Revue IA (80-95%)", + "phaseComplete": "Terminé (95-100%)" + }, + "analysisBanner": { + "analyzing": "Analyse en cours", + "next": "Suivant", + "lastItem": "Dernier élément", + "elapsed": "Écoulé", + "last30min": "30 dernières min", + "completed": "terminé(s)", + "inProgress": "en cours", + "pending": "en attente", + "noActivity": "Aucune activité", + "phase": "Phase" + }, "referenceImages": { "title": "Images de référence (facultatif)", "description": "Ajoutez des références visuelles comme des captures d'écran ou des conceptions pour aider l'IA à comprendre vos exigences." diff --git a/apps/desktop/src/shared/types/integrations.ts b/apps/desktop/src/shared/types/integrations.ts index 741e388f33..bff5e22135 100644 --- a/apps/desktop/src/shared/types/integrations.ts +++ b/apps/desktop/src/shared/types/integrations.ts @@ -122,6 +122,47 @@ export interface GitHubSyncStatus { error?: string; } +/** + * Multi-repo GitHub connection status for Customer projects + */ +export interface MultiRepoGitHubStatus { + connected: boolean; + repos: { projectId: string; repoFullName: string }[]; + error?: string; +} + +/** + * Result type for multi-repo issue fetching + */ +export interface MultiRepoIssuesResult { + issues: GitHubIssue[]; + repos: string[]; + hasMore: boolean; +} + +export interface MultiRepoPRData { + number: number; + title: string; + body: string; + state: string; + author: { login: string }; + headRefName: string; + baseRefName: string; + additions: number; + deletions: number; + changedFiles: number; + assignees: Array<{ login: string }>; + createdAt: string; + updatedAt: string; + htmlUrl: string; + repoFullName: string; +} + +export interface MultiRepoPRsResult { + prs: MultiRepoPRData[]; + repos: string[]; +} + export interface GitHubImportResult { success: boolean; imported: number; @@ -478,3 +519,84 @@ export interface RoadmapProviderConfig { * Canny-specific status values */ export type CannyStatus = 'open' | 'under review' | 'planned' | 'in progress' | 'complete' | 'closed'; + +// ============================================ +// Claude Code Global MCP Types +// ============================================ + +/** + * A single MCP server entry resolved from Claude Code's global settings. + * Can originate from an enabled plugin (marketplace), an inline mcpServers definition + * in settings.json, or the top-level mcpServers in ~/.claude.json. + */ +export interface GlobalMcpServerEntry { + /** Plugin key (only for plugin-sourced servers), e.g. "context7@claude-plugins-official" */ + pluginKey?: string; + /** Server identifier from the MCP config, e.g. "context7" */ + serverId: string; + /** Human-readable name derived from serverId */ + serverName: string; + /** MCP server configuration */ + config: { + type?: 'http' | 'sse'; + command?: string; + args?: string[]; + url?: string; + headers?: Record; + env?: Record; + }; + /** Where this server config was sourced from */ + source: 'plugin' | 'settings' | 'claude-json'; +} + +/** + * Combined result of all global MCP servers from Claude Code settings. + */ +export interface GlobalMcpInfo { + /** MCP servers resolved from enabledPlugins (via plugin cache .mcp.json files) */ + pluginServers: GlobalMcpServerEntry[]; + /** MCP servers defined inline in the mcpServers field of settings.json */ + inlineServers: GlobalMcpServerEntry[]; + /** MCP servers from ~/.claude.json (main Claude Code config) */ + claudeJsonServers: GlobalMcpServerEntry[]; +} + +// ============================================ +// Claude Code Custom Agent Types +// ============================================ + +/** + * A custom agent definition from ~/.claude/agents/ + */ +export interface ClaudeCustomAgent { + /** Agent ID derived from filename (e.g. "frontend-developer") */ + agentId: string; + /** Human-readable name (e.g. "Frontend Developer") */ + agentName: string; + /** Category directory name (e.g. "01-core-development") */ + categoryDir: string; + /** Human-readable category name (e.g. "Core Development") */ + categoryName: string; + /** Full file path to the .md file */ + filePath: string; +} + +/** + * A category of custom agents + */ +export interface ClaudeAgentCategory { + /** Category directory name (e.g. "01-core-development") */ + categoryDir: string; + /** Human-readable name (e.g. "Core Development") */ + categoryName: string; + /** Agents in this category */ + agents: ClaudeCustomAgent[]; +} + +/** + * Combined result of all custom agents from ~/.claude/agents/ + */ +export interface ClaudeAgentsInfo { + categories: ClaudeAgentCategory[]; + totalAgents: number; +} diff --git a/apps/desktop/src/shared/types/ipc.ts b/apps/desktop/src/shared/types/ipc.ts index eb99c71553..acfde07020 100644 --- a/apps/desktop/src/shared/types/ipc.ts +++ b/apps/desktop/src/shared/types/ipc.ts @@ -119,6 +119,8 @@ import type { LinearIssue, LinearImportResult, LinearSyncStatus, + GlobalMcpInfo, + ClaudeAgentsInfo, GitHubRepository, GitHubIssue, GitHubSyncStatus, @@ -179,11 +181,12 @@ export interface TabState { export interface ElectronAPI { // Project operations - addProject: (projectPath: string) => Promise>; + addProject: (projectPath: string, type?: 'project' | 'customer') => Promise>; removeProject: (projectId: string) => Promise; getProjects: () => Promise>; updateProjectSettings: (projectId: string, settings: Partial) => Promise; initializeProject: (projectId: string) => Promise>; + initializeCustomerProject: (projectId: string) => Promise>; checkProjectVersion: (projectId: string) => Promise>; // Tab State (persisted in main process for reliability) @@ -464,7 +467,8 @@ export interface ElectronAPI { // Context operations getProjectContext: (projectId: string) => Promise>; - refreshProjectIndex: (projectId: string) => Promise>; + refreshProjectIndex: (projectId: string, force?: boolean) => Promise>; + onIndexProgress: (callback: (data: { message: string; current?: number; total?: number; projectId?: string }) => void) => () => void; getMemoryStatus: (projectId: string) => Promise>; searchMemories: (projectId: string, query: string) => Promise>; getRecentMemories: (projectId: string, limit?: number) => Promise>; @@ -525,6 +529,7 @@ export interface ElectronAPI { getGitHubToken: () => Promise>; getGitHubUser: () => Promise>; listGitHubUserRepos: () => Promise }>>; + cloneGitHubRepo: (repoFullName: string, targetDir: string) => Promise>; detectGitHubRepo: (projectPath: string) => Promise>; getGitHubBranches: (repo: string, token: string) => Promise>; createGitHubRepo: ( @@ -882,6 +887,12 @@ export interface ElectronAPI { status: 'completed' | 'failed'; output: string[]; }>>; + /** Get the embedding dimension for an Ollama model (single source of truth from backend) */ + getOllamaEmbeddingDim: (modelName: string) => Promise>; // Ollama download progress listener onDownloadProgress: ( @@ -924,8 +935,15 @@ export interface ElectronAPI { // MCP Server health check operations checkMcpHealth: (server: CustomMcpServer) => Promise>; + checkGlobalMcpHealth: (server: CustomMcpServer) => Promise>; testMcpConnection: (server: CustomMcpServer) => Promise>; + // Claude Code global MCP configuration + getGlobalMcps: () => Promise>; + + // Claude Code custom agents + getClaudeAgents: () => Promise>; + // Screenshot capture operations getSources: () => Promise & { devMode?: boolean }>; capture: (options: { sourceId: string }) => Promise>; diff --git a/apps/desktop/src/shared/types/project.ts b/apps/desktop/src/shared/types/project.ts index f8afb6339a..99c561aa9a 100644 --- a/apps/desktop/src/shared/types/project.ts +++ b/apps/desktop/src/shared/types/project.ts @@ -10,6 +10,7 @@ export interface Project { settings: ProjectSettings; createdAt: Date; updatedAt: Date; + type?: 'project' | 'customer'; } export interface ProjectSettings { @@ -41,10 +42,12 @@ export interface NotificationSettings { export interface ProjectIndex { project_root: string; - project_type: 'single' | 'monorepo'; + project_type: 'single' | 'monorepo' | 'customer'; services: Record; infrastructure: InfrastructureInfo; conventions: ConventionsInfo; + /** For customer projects: indexes of each child repo keyed by repo name */ + child_repos?: Record; } export interface ServiceInfo { @@ -52,7 +55,7 @@ export interface ServiceInfo { path: string; language?: string; framework?: string; - type?: 'backend' | 'frontend' | 'worker' | 'scraper' | 'library' | 'proxy' | 'mobile' | 'desktop' | 'unknown'; + type?: 'backend' | 'frontend' | 'worker' | 'scraper' | 'library' | 'proxy' | 'mobile' | 'desktop' | 'documentation' | 'unknown'; package_manager?: string; default_port?: number; entry_point?: string; @@ -404,6 +407,8 @@ export interface CustomMcpServer { url?: string; /** HTTP headers (for type: 'http'). e.g., { "Authorization": "Bearer ..." } */ headers?: Record; + /** Environment variables to pass to the MCP server process */ + env?: Record; /** Optional description shown in UI */ description?: string; } diff --git a/apps/desktop/src/shared/types/settings.ts b/apps/desktop/src/shared/types/settings.ts index 0245f84e7d..bb3a95a45e 100644 --- a/apps/desktop/src/shared/types/settings.ts +++ b/apps/desktop/src/shared/types/settings.ts @@ -177,6 +177,27 @@ export type ModelTypeShort = 'haiku' | 'sonnet' | 'opus' | 'opus-1m' | 'opus-4.5 /** Widened model type: Claude shorthands + any arbitrary model ID */ export type ModelSelection = ModelTypeShort | (string & {}); +// Phase-based custom agent configuration +// Each phase can optionally use a custom agent from ~/.claude/agents/ +export interface PhaseCustomAgentsConfig { + spec?: string; // Custom agent ID for spec creation + planning?: string; // Custom agent ID for planning + coding?: string; // Custom agent ID for coding + qa?: string; // Custom agent ID for QA +} + +/** + * Configuration for assigning global MCP servers to pipeline phases. + * Each phase has a list of global MCP server IDs that should be available during that phase. + */ +export interface GlobalMcpPhaseConfig { + spec?: string[]; + build?: string[]; + qa?: string[]; + utility?: string[]; + ideation?: string[]; +} + // Phase-based model configuration for Auto profile // Each phase can use a different model optimized for that task type // Values can be Claude shorthands ('opus', 'sonnet') or concrete model IDs ('gpt-5.3-codex', 'gemini-2.5-pro') @@ -314,6 +335,10 @@ export interface AppSettings { // Custom phase configuration for Auto profile (overrides defaults) customPhaseModels?: PhaseModelConfig; customPhaseThinking?: PhaseThinkingConfig; + // Custom agent per phase (from ~/.claude/agents/) + phaseCustomAgents?: PhaseCustomAgentsConfig; + // Global MCP servers assigned to pipeline phases + globalMcpPhases?: GlobalMcpPhaseConfig; // Feature-specific configuration (insights, ideation, roadmap) featureModels?: FeatureModelConfig; featureThinking?: FeatureThinkingConfig; diff --git a/apps/desktop/src/shared/types/task.ts b/apps/desktop/src/shared/types/task.ts index 0b2f06d953..5695b54d42 100644 --- a/apps/desktop/src/shared/types/task.ts +++ b/apps/desktop/src/shared/types/task.ts @@ -236,6 +236,7 @@ export interface TaskMetadata { isAutoProfile?: boolean; // True when using Auto (Optimized) profile phaseModels?: PhaseModelConfig; // Per-phase model configuration phaseThinking?: PhaseThinkingConfig; // Per-phase thinking configuration + phaseCustomAgents?: import('./settings').PhaseCustomAgentsConfig; // Per-phase custom agent IDs phaseProviders?: Record; // Per-phase provider preference (cross-provider mode) fastMode?: boolean; // Fast Mode — faster Opus 4.6 output, higher cost per token