diff --git a/apps/server/src/lib/exec-utils.ts b/apps/server/src/lib/exec-utils.ts new file mode 100644 index 00000000..0073f695 --- /dev/null +++ b/apps/server/src/lib/exec-utils.ts @@ -0,0 +1,37 @@ +/** + * Shared execution utilities + * + * Common helpers for spawning child processes with the correct environment. + * Used by both route handlers and service layers. + */ + +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ExecUtils'); + +// Extended PATH to include common tool installation locations +export const extendedPath = [ + process.env.PATH, + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${process.env.HOME}/.local/bin`, +] + .filter(Boolean) + .join(':'); + +export const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export function logError(error: unknown, context: string): void { + logger.error(`${context}:`, error); +} diff --git a/apps/server/src/routes/fs/routes/write.ts b/apps/server/src/routes/fs/routes/write.ts index ad70cc9e..f5cdce56 100644 --- a/apps/server/src/routes/fs/routes/write.ts +++ b/apps/server/src/routes/fs/routes/write.ts @@ -24,7 +24,9 @@ export function createWriteHandler() { // Ensure parent directory exists (symlink-safe) await mkdirSafe(path.dirname(path.resolve(filePath))); - await secureFs.writeFile(filePath, content, 'utf-8'); + // Default content to empty string if undefined/null to prevent writing + // "undefined" as literal text (e.g. when content field is missing from request) + await secureFs.writeFile(filePath, content ?? '', 'utf-8'); res.json({ success: true }); } catch (error) { diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts index dddae96e..1f315c90 100644 --- a/apps/server/src/routes/github/index.ts +++ b/apps/server/src/routes/github/index.ts @@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js' import { createListIssuesHandler } from './routes/list-issues.js'; import { createListPRsHandler } from './routes/list-prs.js'; import { createListCommentsHandler } from './routes/list-comments.js'; +import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js'; +import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js'; import { createValidateIssueHandler } from './routes/validate-issue.js'; import { createValidationStatusHandler, @@ -29,6 +31,16 @@ export function createGitHubRoutes( router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler()); + router.post( + '/pr-review-comments', + validatePathParams('projectPath'), + createListPRReviewCommentsHandler() + ); + router.post( + '/resolve-pr-comment', + validatePathParams('projectPath'), + createResolvePRCommentHandler() + ); router.post( '/validate-issue', validatePathParams('projectPath'), diff --git a/apps/server/src/routes/github/routes/common.ts b/apps/server/src/routes/github/routes/common.ts index 211be715..52ce2e3d 100644 --- a/apps/server/src/routes/github/routes/common.ts +++ b/apps/server/src/routes/github/routes/common.ts @@ -1,38 +1,14 @@ /** * Common utilities for GitHub routes + * + * Re-exports shared utilities from lib/exec-utils so route consumers + * can continue importing from this module unchanged. */ import { exec } from 'child_process'; import { promisify } from 'util'; -import { createLogger } from '@automaker/utils'; - -const logger = createLogger('GitHub'); export const execAsync = promisify(exec); -// Extended PATH to include common tool installation locations -export const extendedPath = [ - process.env.PATH, - '/opt/homebrew/bin', - '/usr/local/bin', - '/home/linuxbrew/.linuxbrew/bin', - `${process.env.HOME}/.local/bin`, -] - .filter(Boolean) - .join(':'); - -export const execEnv = { - ...process.env, - PATH: extendedPath, -}; - -export function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -export function logError(error: unknown, context: string): void { - logger.error(`${context}:`, error); -} +// Re-export shared utilities from the canonical location +export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js'; diff --git a/apps/server/src/routes/github/routes/list-pr-review-comments.ts b/apps/server/src/routes/github/routes/list-pr-review-comments.ts new file mode 100644 index 00000000..ff16f6e9 --- /dev/null +++ b/apps/server/src/routes/github/routes/list-pr-review-comments.ts @@ -0,0 +1,72 @@ +/** + * POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR + * + * Fetches both regular PR comments and inline code review comments + * for a specific pull request, providing file path and line context. + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; +import { + fetchPRReviewComments, + fetchReviewThreadResolvedStatus, + type PRReviewComment, + type ListPRReviewCommentsResult, +} from '../../../services/pr-review-comments.service.js'; + +// Re-export types so existing callers continue to work +export type { PRReviewComment, ListPRReviewCommentsResult }; +// Re-export service functions so existing callers continue to work +export { fetchPRReviewComments, fetchReviewThreadResolvedStatus }; + +interface ListPRReviewCommentsRequest { + projectPath: string; + prNumber: number; +} + +export function createListPRReviewCommentsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!prNumber || typeof prNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'prNumber is required and must be a number' }); + return; + } + + // Check if this is a GitHub repo and get owner/repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + const comments = await fetchPRReviewComments( + projectPath, + remoteStatus.owner, + remoteStatus.repo, + prNumber + ); + + res.json({ + success: true, + comments, + totalCount: comments.length, + }); + } catch (error) { + logError(error, 'Fetch PR review comments failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/resolve-pr-comment.ts b/apps/server/src/routes/github/routes/resolve-pr-comment.ts new file mode 100644 index 00000000..39855c04 --- /dev/null +++ b/apps/server/src/routes/github/routes/resolve-pr-comment.ts @@ -0,0 +1,66 @@ +/** + * POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread + * + * Uses the GitHub GraphQL API to resolve or unresolve a review thread + * identified by its GraphQL node ID (threadId). + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; +import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js'; + +export interface ResolvePRCommentResult { + success: boolean; + isResolved?: boolean; + error?: string; +} + +interface ResolvePRCommentRequest { + projectPath: string; + threadId: string; + resolve: boolean; +} + +export function createResolvePRCommentHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!threadId) { + res.status(400).json({ success: false, error: 'threadId is required' }); + return; + } + + if (typeof resolve !== 'boolean') { + res.status(400).json({ success: false, error: 'resolve must be a boolean' }); + return; + } + + // Check if this is a GitHub repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + const result = await executeReviewThreadMutation(projectPath, threadId, resolve); + + res.json({ + success: true, + isResolved: result.isResolved, + }); + } catch (error) { + logError(error, 'Resolve PR comment failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts index d0444ad0..ab3a3aca 100644 --- a/apps/server/src/routes/worktree/routes/generate-commit-message.ts +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -6,7 +6,7 @@ */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { join } from 'path'; @@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js'; import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; const logger = createLogger('GenerateCommitMessage'); -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); /** Timeout for AI provider calls in milliseconds (30 seconds) */ const AI_TIMEOUT_MS = 30_000; @@ -33,20 +33,39 @@ async function* withTimeout( generator: AsyncIterable, timeoutMs: number ): AsyncGenerator { + let timerId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs); + timerId = setTimeout( + () => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), + timeoutMs + ); }); const iterator = generator[Symbol.asyncIterator](); let done = false; - while (!done) { - const result = await Promise.race([iterator.next(), timeoutPromise]); - if (result.done) { - done = true; - } else { - yield result.value; + try { + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => { + // Capture the original error, then attempt to close the iterator. + // If iterator.return() throws, log it but rethrow the original error + // so the timeout error (not the teardown error) is preserved. + try { + await iterator.return?.(); + } catch (teardownErr) { + logger.warn('Error during iterator cleanup after timeout:', teardownErr); + } + throw err; + }); + if (result.done) { + done = true; + } else { + yield result.value; + } } + } finally { + clearTimeout(timerId); } } @@ -117,14 +136,14 @@ export function createGenerateCommitMessageHandler( let diff = ''; try { // First try to get staged changes - const { stdout: stagedDiff } = await execAsync('git diff --cached', { + const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], { cwd: worktreePath, maxBuffer: 1024 * 1024 * 5, // 5MB buffer }); // If no staged changes, get unstaged changes if (!stagedDiff.trim()) { - const { stdout: unstagedDiff } = await execAsync('git diff', { + const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], { cwd: worktreePath, maxBuffer: 1024 * 1024 * 5, // 5MB buffer }); @@ -213,14 +232,16 @@ export function createGenerateCommitMessageHandler( } } } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - // Use result if available (some providers return final text here) - responseText = msg.result; + // Use result text if longer than accumulated text (consistent with simpleQuery pattern) + if (msg.result.length > responseText.length) { + responseText = msg.result; + } } } const message = responseText.trim(); - if (!message || message.trim().length === 0) { + if (!message) { logger.warn('Received empty response from model'); const response: GenerateCommitMessageErrorResponse = { success: false, diff --git a/apps/server/src/routes/worktree/routes/generate-pr-description.ts b/apps/server/src/routes/worktree/routes/generate-pr-description.ts index 0f272e71..4f42b47e 100644 --- a/apps/server/src/routes/worktree/routes/generate-pr-description.ts +++ b/apps/server/src/routes/worktree/routes/generate-pr-description.ts @@ -30,6 +30,8 @@ const MAX_DIFF_SIZE = 15_000; const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided. +IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else. + Output your response in EXACTLY this format (including the markers): ---TITLE--- @@ -41,6 +43,7 @@ Output your response in EXACTLY this format (including the markers): Rules: +- Your ENTIRE response must start with ---TITLE--- and contain nothing before it - The title should be concise and descriptive (50-72 characters) - Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle") - The description should explain WHAT changed and WHY @@ -397,7 +400,10 @@ export function createGeneratePRDescriptionHandler( } } } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { - responseText = msg.result; + // Use result text if longer than accumulated text (consistent with simpleQuery pattern) + if (msg.result.length > responseText.length) { + responseText = msg.result; + } } } @@ -413,7 +419,9 @@ export function createGeneratePRDescriptionHandler( return; } - // Parse the response to extract title and body + // Parse the response to extract title and body. + // The model may include conversational preamble before the structured markers, + // so we search for the markers anywhere in the response, not just at the start. let title = ''; let body = ''; @@ -424,14 +432,46 @@ export function createGeneratePRDescriptionHandler( title = titleMatch[1].trim(); body = bodyMatch[1].trim(); } else { - // Fallback: treat first line as title, rest as body - const lines = fullResponse.split('\n'); - title = lines[0].trim(); - body = lines.slice(1).join('\n').trim(); + // Fallback: try to extract meaningful content, skipping any conversational preamble. + // Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc. + const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0); + + // Skip lines that look like conversational preamble + let startIndex = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // Check if this line looks like conversational AI preamble + if ( + /^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test( + line + ) || + /^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test( + line + ) + ) { + startIndex = i + 1; + continue; + } + break; + } + + // Use remaining lines after skipping preamble + const contentLines = lines.slice(startIndex); + if (contentLines.length > 0) { + title = contentLines[0].trim(); + body = contentLines.slice(1).join('\n').trim(); + } else { + // If all lines were filtered as preamble, use the original first non-empty line + title = lines[0]?.trim() || ''; + body = lines.slice(1).join('\n').trim(); + } } - // Clean up title - remove any markdown or quotes - title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, ''); + // Clean up title - remove any markdown headings, quotes, or marker artifacts + title = title + .replace(/^#+\s*/, '') + .replace(/^["']|["']$/g, '') + .replace(/^---\w+---\s*/, ''); logger.info(`Generated PR title: ${title.substring(0, 100)}...`); diff --git a/apps/server/src/services/github-pr-comment.service.ts b/apps/server/src/services/github-pr-comment.service.ts new file mode 100644 index 00000000..b49d417a --- /dev/null +++ b/apps/server/src/services/github-pr-comment.service.ts @@ -0,0 +1,103 @@ +/** + * GitHub PR Comment Service + * + * Domain logic for resolving/unresolving PR review threads via the + * GitHub GraphQL API. Extracted from the route handler so the route + * only deals with request/response plumbing. + */ + +import { spawn } from 'child_process'; +import { execEnv } from '../lib/exec-utils.js'; + +/** Timeout for GitHub GraphQL API requests in milliseconds */ +const GITHUB_API_TIMEOUT_MS = 30000; + +interface GraphQLMutationResponse { + data?: { + resolveReviewThread?: { + thread?: { isResolved: boolean; id: string } | null; + } | null; + unresolveReviewThread?: { + thread?: { isResolved: boolean; id: string } | null; + } | null; + }; + errors?: Array<{ message: string }>; +} + +/** + * Execute a GraphQL mutation to resolve or unresolve a review thread. + */ +export async function executeReviewThreadMutation( + projectPath: string, + threadId: string, + resolve: boolean +): Promise<{ isResolved: boolean }> { + const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'; + + const mutation = ` + mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) { + ${mutationName}(input: { threadId: $threadId }) { + thread { + id + isResolved + } + } + }`; + + const variables = { threadId }; + const requestBody = JSON.stringify({ query: mutation, variables }); + + // Declare timeoutId before registering the error handler to avoid TDZ confusion + let timeoutId: NodeJS.Timeout | undefined; + + const response = await new Promise((res, rej) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + gh.on('error', (err) => { + clearTimeout(timeoutId); + rej(err); + }); + + timeoutId = setTimeout(() => { + gh.kill(); + rej(new Error('GitHub GraphQL API request timed out')); + }, GITHUB_API_TIMEOUT_MS); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== 0) { + return rej(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + res(JSON.parse(stdout)); + } catch (e) { + rej(e); + } + }); + + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message); + } + + const threadData = resolve + ? response.data?.resolveReviewThread?.thread + : response.data?.unresolveReviewThread?.thread; + + if (!threadData) { + throw new Error('No thread data returned from GitHub API'); + } + + return { isResolved: threadData.isResolved }; +} diff --git a/apps/server/src/services/pr-review-comments.service.ts b/apps/server/src/services/pr-review-comments.service.ts new file mode 100644 index 00000000..68d801dc --- /dev/null +++ b/apps/server/src/services/pr-review-comments.service.ts @@ -0,0 +1,338 @@ +/** + * PR Review Comments Service + * + * Domain logic for fetching PR review comments, enriching them with + * resolved-thread status, and sorting. Extracted from the route handler + * so the route only deals with request/response plumbing. + */ + +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; +import { createLogger } from '@automaker/utils'; +import { execEnv, logError } from '../lib/exec-utils.js'; + +const execFileAsync = promisify(execFile); + +// ── Public types (re-exported for callers) ── + +export interface PRReviewComment { + id: string; + author: string; + avatarUrl?: string; + body: string; + path?: string; + line?: number; + createdAt: string; + updatedAt?: string; + isReviewComment: boolean; + /** Whether this is an outdated review comment (code has changed since) */ + isOutdated?: boolean; + /** Whether the review thread containing this comment has been resolved */ + isResolved?: boolean; + /** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */ + threadId?: string; + /** The diff hunk context for the comment */ + diffHunk?: string; + /** The side of the diff (LEFT or RIGHT) */ + side?: string; + /** The commit ID the comment was made on */ + commitId?: string; +} + +export interface ListPRReviewCommentsResult { + success: boolean; + comments?: PRReviewComment[]; + totalCount?: number; + error?: string; +} + +// ── Internal types ── + +/** Timeout for GitHub GraphQL API requests in milliseconds */ +const GITHUB_API_TIMEOUT_MS = 30000; + +interface GraphQLReviewThreadComment { + databaseId: number; +} + +interface GraphQLReviewThread { + id: string; + isResolved: boolean; + comments: { + pageInfo?: { + hasNextPage: boolean; + }; + nodes: GraphQLReviewThreadComment[]; + }; +} + +interface GraphQLResponse { + data?: { + repository?: { + pullRequest?: { + reviewThreads?: { + nodes: GraphQLReviewThread[]; + pageInfo?: { + hasNextPage: boolean; + }; + }; + } | null; + }; + }; + errors?: Array<{ message: string }>; +} + +interface ReviewThreadInfo { + isResolved: boolean; + threadId: string; +} + +// ── Logger ── + +const logger = createLogger('PRReviewCommentsService'); + +// ── Service functions ── + +/** + * Fetch review thread resolved status and thread IDs using GitHub GraphQL API. + * Returns a map of comment ID (string) -> { isResolved, threadId }. + */ +export async function fetchReviewThreadResolvedStatus( + projectPath: string, + owner: string, + repo: string, + prNumber: number +): Promise> { + const resolvedMap = new Map(); + + const query = ` + query GetPRReviewThreads( + $owner: String! + $repo: String! + $prNumber: Int! + ) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100) { + pageInfo { + hasNextPage + } + nodes { + id + isResolved + comments(first: 100) { + pageInfo { + hasNextPage + } + nodes { + databaseId + } + } + } + } + } + } + }`; + + const variables = { owner, repo, prNumber }; + const requestBody = JSON.stringify({ query, variables }); + + try { + let timeoutId: NodeJS.Timeout | undefined; + + const response = await new Promise((resolve, reject) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + gh.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + + timeoutId = setTimeout(() => { + gh.kill(); + reject(new Error('GitHub GraphQL API request timed out')); + }, GITHUB_API_TIMEOUT_MS); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== 0) { + return reject(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + resolve(JSON.parse(stdout)); + } catch (e) { + reject(e); + } + }); + + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message); + } + + // Check if reviewThreads data was truncated (more than 100 threads) + const pageInfo = response.data?.repository?.pullRequest?.reviewThreads?.pageInfo; + if (pageInfo?.hasNextPage) { + logger.warn( + `PR #${prNumber} in ${owner}/${repo} has more than 100 review threads — ` + + 'results are truncated. Some comments may be missing resolved status.' + ); + // TODO: Implement cursor-based pagination by iterating with + // reviewThreads.nodes pageInfo.endCursor across spawn calls. + } + + const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []; + for (const thread of threads) { + if (thread.comments.pageInfo?.hasNextPage) { + logger.warn( + `Review thread ${thread.id} in PR #${prNumber} has more than 100 comments — ` + + 'comment list is truncated. Some comments may be missing resolved status.' + ); + } + const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id }; + for (const comment of thread.comments.nodes) { + resolvedMap.set(String(comment.databaseId), info); + } + } + } catch (error) { + // Log but don't fail — resolved status is best-effort + logError(error, 'Failed to fetch PR review thread resolved status'); + } + + return resolvedMap; +} + +/** + * Fetch all comments for a PR (both regular and inline review comments) + */ +export async function fetchPRReviewComments( + projectPath: string, + owner: string, + repo: string, + prNumber: number +): Promise { + const allComments: PRReviewComment[] = []; + + // Fetch review thread resolved status in parallel with comment fetching + const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber); + + // 1. Fetch regular PR comments (issue-level comments) + try { + const { stdout: commentsOutput } = await execFileAsync( + 'gh', + ['pr', 'view', String(prNumber), '-R', `${owner}/${repo}`, '--json', 'comments'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const commentsData = JSON.parse(commentsOutput); + const regularComments = (commentsData.comments || []).map( + (c: { + id: string; + author: { login: string; avatarUrl?: string }; + body: string; + createdAt: string; + updatedAt?: string; + }) => ({ + id: String(c.id), + author: c.author?.login || 'unknown', + avatarUrl: c.author?.avatarUrl, + body: c.body, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + isReviewComment: false, + isOutdated: false, + // Regular PR comments are not part of review threads, so not resolvable + isResolved: false, + }) + ); + + allComments.push(...regularComments); + } catch (error) { + logError(error, 'Failed to fetch regular PR comments'); + } + + // 2. Fetch inline review comments (code-level comments with file/line info) + try { + const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`; + const { stdout: reviewsOutput } = await execFileAsync( + 'gh', + ['api', reviewsEndpoint, '--paginate', '--slurp', '--jq', 'add // []'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const reviewsData = JSON.parse(reviewsOutput); + const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map( + (c: { + id: number; + user: { login: string; avatar_url?: string }; + body: string; + path: string; + line?: number; + original_line?: number; + created_at: string; + updated_at?: string; + diff_hunk?: string; + side?: string; + commit_id?: string; + position?: number | null; + }) => ({ + id: String(c.id), + author: c.user?.login || 'unknown', + avatarUrl: c.user?.avatar_url, + body: c.body, + path: c.path, + line: c.line ?? c.original_line, + createdAt: c.created_at, + updatedAt: c.updated_at, + isReviewComment: true, + // A review comment is "outdated" if position is null (code has changed) + isOutdated: c.position === null, + // isResolved will be filled in below from GraphQL data + isResolved: false, + diffHunk: c.diff_hunk, + side: c.side, + commitId: c.commit_id, + }) + ); + + allComments.push(...reviewComments); + } catch (error) { + logError(error, 'Failed to fetch inline review comments'); + } + + // Wait for resolved status and apply to inline review comments + const resolvedMap = await resolvedStatusPromise; + for (const comment of allComments) { + if (comment.isReviewComment && resolvedMap.has(comment.id)) { + const info = resolvedMap.get(comment.id)!; + comment.isResolved = info.isResolved; + comment.threadId = info.threadId; + } + } + + // Sort by createdAt descending (newest first) + allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return allComments; +} diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index f7611145..0bd5a9b2 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -117,6 +117,8 @@ const eslintConfig = defineConfig([ Electron: 'readonly', // Console console: 'readonly', + // Structured clone (modern browser/Node API) + structuredClone: 'readonly', // Vite defines __APP_VERSION__: 'readonly', __APP_BUILD_HASH__: 'readonly', diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index dd2597f5..1c09dffa 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -5,4 +5,6 @@ export { FileBrowserDialog } from './file-browser-dialog'; export { NewProjectModal } from './new-project-modal'; export { SandboxRejectionScreen } from './sandbox-rejection-screen'; export { SandboxRiskDialog } from './sandbox-risk-dialog'; +export { PRCommentResolutionDialog } from './pr-comment-resolution-dialog'; +export type { PRCommentResolutionPRInfo } from './pr-comment-resolution-dialog'; export { WorkspacePickerModal } from './workspace-picker-modal'; diff --git a/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx new file mode 100644 index 00000000..356332b1 --- /dev/null +++ b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx @@ -0,0 +1,1118 @@ +/** + * PR Comment Resolution Dialog + * + * A dialog that displays PR review comments with multi-selection support, + * allowing users to create feature tasks to address comments individually + * or as a group. + */ + +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { + MessageSquare, + FileCode, + User, + AlertCircle, + CheckCircle2, + Loader2, + ChevronDown, + ArrowUpDown, + EyeOff, + Eye, + Maximize2, + Check, + Undo2, +} from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { Markdown } from '@/components/ui/markdown'; +import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { useGitHubPRReviewComments } from '@/hooks/queries'; +import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations'; +import { toast } from 'sonner'; +import type { PRReviewComment } from '@/lib/electron'; +import type { Feature } from '@/store/app-store'; +import type { PhaseModelEntry } from '@automaker/types'; +import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types'; +import { resolveModelString } from '@automaker/model-resolver'; +import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults'; + +// ============================================ +// Types +// ============================================ + +type AddressMode = 'together' | 'individually'; +type SortOrder = 'newest' | 'oldest'; + +/** Minimal PR info needed by the dialog - works with both GitHubPR and WorktreePRInfo */ +export interface PRCommentResolutionPRInfo { + number: number; + title: string; + /** The branch name (headRefName) associated with this PR, used to assign features to the correct worktree */ + headRefName?: string; +} + +interface PRCommentResolutionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + pr: PRCommentResolutionPRInfo; +} + +// ============================================ +// Utility Functions +// ============================================ + +/** Generate a feature ID */ +function generateFeatureId(): string { + return generateUUID(); +} + +/** Format a date string for display */ +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +/** Format a time string for display */ +function formatTime(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +/** Format the file location string */ +function formatFileLocation(comment: PRReviewComment): string | null { + if (!comment.path) return null; + if (comment.line) return `${comment.path}:${comment.line}`; + return comment.path; +} + +// ============================================ +// Prompt Generation +// ============================================ + +/** Generate a feature title for a single comment */ +function generateSingleCommentTitle( + pr: PRCommentResolutionPRInfo, + comment: PRReviewComment +): string { + const location = comment.path + ? ` on ${comment.path}${comment.line ? `:${comment.line}` : ''}` + : ''; + return `Address PR #${pr.number} comment by @${comment.author}${location}`; +} + +/** Generate a feature title for multiple comments addressed together */ +function generateGroupTitle(pr: PRCommentResolutionPRInfo, comments: PRReviewComment[]): string { + return `Address ${comments.length} review comment${comments.length > 1 ? 's' : ''} on PR #${pr.number}`; +} + +/** Generate a feature description for a single comment */ +function generateSingleCommentDescription( + pr: PRCommentResolutionPRInfo, + comment: PRReviewComment +): string { + const fileContext = comment.path + ? `**File:** \`${comment.path}\`${comment.line ? ` (line ${comment.line})` : ''}\n` + : ''; + + return `## PR Review Comment Resolution + +**Pull Request:** #${pr.number} - ${pr.title} +**Comment Author:** @${comment.author} +${fileContext} +### Review Comment + +> ${comment.body.split('\n').join('\n> ')} + +### Instructions + +Please address the review comment above. The comment was left ${comment.isReviewComment ? 'as an inline code review' : 'as a general PR'} comment${comment.path ? ` on file \`${comment.path}\`` : ''}${comment.line ? ` at line ${comment.line}` : ''}. + +Review the code in context and make the necessary changes to resolve this feedback. Ensure the changes: +1. Directly address the reviewer's concern +2. Follow the existing code patterns and conventions +3. Do not introduce regressions +`; +} + +/** Generate a feature description for multiple comments addressed together */ +function generateGroupDescription( + pr: PRCommentResolutionPRInfo, + comments: PRReviewComment[] +): string { + const commentSections = comments + .map((comment, index) => { + const fileContext = comment.path + ? `**File:** \`${comment.path}\`${comment.line ? ` (line ${comment.line})` : ''}\n` + : ''; + + return `### Comment ${index + 1} - by @${comment.author} +${fileContext} +> ${comment.body.split('\n').join('\n> ')} +`; + }) + .join('\n---\n\n'); + + return `## PR Review Comments Resolution + +**Pull Request:** #${pr.number} - ${pr.title} +**Number of comments:** ${comments.length} + +Please address all of the following review comments from this pull request. + +--- + +${commentSections} + +### Instructions + +Please address all the review comments listed above. For each comment: +1. Review the code in context at the specified file and line +2. Make the necessary changes to resolve the reviewer's feedback +3. Ensure changes follow existing code patterns and conventions +4. Do not introduce regressions +`; +} + +// ============================================ +// Comment Row Component +// ============================================ + +interface CommentRowProps { + comment: PRReviewComment; + isSelected: boolean; + onToggle: () => void; + onExpandDetail: () => void; + onResolve?: (comment: PRReviewComment, resolve: boolean) => void; + isResolvingThread?: boolean; +} + +function CommentRow({ + comment, + isSelected, + onToggle, + onExpandDetail, + onResolve, + isResolvingThread, +}: CommentRowProps) { + const fileLocation = formatFileLocation(comment); + const [isExpanded, setIsExpanded] = useState(false); + + // Determine if the comment body is long enough to need expansion + const PREVIEW_CHAR_LIMIT = 200; + const needsExpansion = comment.body.length > PREVIEW_CHAR_LIMIT || comment.body.includes('\n'); + + const handleExpandToggle = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded((prev) => !prev); + }, []); + + const handleExpandDetail = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onExpandDetail(); + }, + [onExpandDetail] + ); + + const handleResolveClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (onResolve) { + onResolve(comment, !comment.isResolved); + } + }, + [comment, onResolve] + ); + + return ( +
+ onToggle()} + className="mt-0.5" + onClick={(e) => e.stopPropagation()} + /> + +
+ {/* Header: disclosure triangle + author + file location + tags */} +
+ {/* Disclosure triangle - always shown, toggles expand/collapse */} + {needsExpansion ? ( + + ) : ( + + )} + +
+
+ {comment.avatarUrl ? ( + {comment.author} + ) : ( +
+ +
+ )} + @{comment.author} +
+ + {fileLocation && ( +
+ + {fileLocation} +
+ )} + + {comment.isOutdated && ( + + Outdated + + )} + + {comment.isReviewComment && ( + + Review + + )} + + {comment.isResolved && ( + + Resolved + + )} + + {/* Resolve / Unresolve button - only for review comments with a threadId */} + {comment.isReviewComment && comment.threadId && onResolve && ( + + )} + + {/* Expand detail button */} + +
+
+ + {/* Comment body - collapsible, rendered as markdown */} + {isExpanded ? ( +
e.stopPropagation()}> + + {comment.body} + +
+ ) : ( +
+ + {comment.body} + +
+ )} + + {/* Date row */} +
+
+
{formatDate(comment.createdAt)}
+
{formatTime(comment.createdAt)}
+
+
+
+
+ ); +} + +// ============================================ +// Comment Detail Dialog Component +// ============================================ + +interface CommentDetailDialogProps { + comment: PRReviewComment | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialogProps) { + if (!comment) return null; + + const fileLocation = formatFileLocation(comment); + + return ( + + + + + + Comment Details + + + Full view of the review comment with rendered content. + + + +
+
+ {/* Author & metadata section */} +
+
+ {comment.avatarUrl ? ( + {comment.author} + ) : ( +
+ +
+ )} +
+ @{comment.author} +
+ {formatDate(comment.createdAt)} at {formatTime(comment.createdAt)} +
+
+
+ + {/* Badges */} +
+ {comment.isOutdated && ( + + Outdated + + )} + {comment.isReviewComment && ( + + Review + + )} + {comment.isResolved && ( + + Resolved + + )} +
+
+ + {/* File location */} + {fileLocation && ( +
+ + {fileLocation} +
+ )} + + {/* Diff hunk */} + {comment.diffHunk && ( +
+
+ Code Context +
+
+                  {comment.diffHunk}
+                
+
+ )} + + {/* Comment body - rendered as markdown */} +
+ {comment.body} +
+ + {/* Additional metadata */} + {(comment.updatedAt || comment.commitId || comment.side) && ( +
+ {comment.updatedAt && comment.updatedAt !== comment.createdAt && ( + Updated: {formatDate(comment.updatedAt)} + )} + {comment.commitId && ( + Commit: {comment.commitId.slice(0, 7)} + )} + {comment.side && Side: {comment.side}} +
+ )} +
+
+ + + + +
+
+ ); +} + +// ============================================ +// Error State Component +// ============================================ + +interface CreationErrorStateProps { + errors: Array<{ comment: PRReviewComment; error: string }>; + onDismiss: () => void; +} + +function CreationErrorState({ errors, onDismiss }: CreationErrorStateProps) { + return ( +
+
+ + + Failed to create {errors.length} feature{errors.length > 1 ? 's' : ''} + +
+
    + {errors.map((err, i) => ( +
  • + @{err.comment.author} + {err.comment.path && on {err.comment.path}}: {err.error} +
  • + ))} +
+ +
+ ); +} + +// ============================================ +// Main Dialog Component +// ============================================ + +export function PRCommentResolutionDialog({ + open, + onOpenChange, + pr, +}: PRCommentResolutionDialogProps) { + const { currentProject, defaultFeatureModel } = useAppStore(); + + // Use project-level default feature model if set, otherwise fall back to global + const effectiveDefaultFeatureModel = currentProject?.defaultFeatureModel ?? defaultFeatureModel; + + // State + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [addressMode, setAddressMode] = useState('together'); + const [sortOrder, setSortOrder] = useState('newest'); + const [showResolved, setShowResolved] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [creationErrors, setCreationErrors] = useState< + Array<{ comment: PRReviewComment; error: string }> + >([]); + const [detailComment, setDetailComment] = useState(null); + + // Per-thread resolving state - tracks which threads are currently being resolved/unresolved + const [resolvingThreads, setResolvingThreads] = useState>(new Set()); + + // Model selection state + const [modelEntry, setModelEntry] = useState({ model: 'claude-sonnet' }); + + // Track previous open state to detect when dialog opens + const wasOpenRef = useRef(false); + + // Sync model defaults only when dialog opens (transitions from closed to open) + useEffect(() => { + const justOpened = open && !wasOpenRef.current; + wasOpenRef.current = open; + + if (justOpened) { + setModelEntry(effectiveDefaultFeatureModel); + } + }, [open, effectiveDefaultFeatureModel]); + + const handleModelChange = useCallback((entry: PhaseModelEntry) => { + // Normalize thinking level when switching between adaptive and non-adaptive models + const isNewModelAdaptive = + typeof entry.model === 'string' && isAdaptiveThinkingModel(entry.model); + const currentLevel = entry.thinkingLevel || 'none'; + + if (isNewModelAdaptive && currentLevel !== 'none' && currentLevel !== 'adaptive') { + // Switching TO an adaptive model with a manual level -> auto-switch to 'adaptive' + setModelEntry({ ...entry, thinkingLevel: 'adaptive' }); + } else if (!isNewModelAdaptive && currentLevel === 'adaptive') { + // Switching FROM an adaptive model with adaptive -> auto-switch to 'high' + setModelEntry({ ...entry, thinkingLevel: 'high' }); + } else { + setModelEntry(entry); + } + }, []); + + // Fetch PR review comments + const { + data, + isLoading: loading, + error, + refetch, + } = useGitHubPRReviewComments(currentProject?.path, open ? pr.number : undefined); + + const allComments = useMemo(() => { + const raw = data?.comments ?? []; + // Sort based on current sort order + return [...raw].sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return sortOrder === 'newest' ? dateB - dateA : dateA - dateB; + }); + }, [data, sortOrder]); + + // Count resolved and unresolved comments for filter display + const resolvedCount = useMemo( + () => allComments.filter((c) => c.isResolved).length, + [allComments] + ); + const hasResolvedComments = resolvedCount > 0; + + const comments = useMemo(() => { + if (showResolved) return allComments; + return allComments.filter((c) => !c.isResolved); + }, [allComments, showResolved]); + + // Feature creation mutation + const createFeature = useCreateFeature(currentProject?.path ?? ''); + + // Resolve/unresolve thread mutation + const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number); + + // Derived state + const allSelected = comments.length > 0 && comments.every((c) => selectedIds.has(c.id)); + const someSelected = selectedIds.size > 0 && !allSelected; + const noneSelected = selectedIds.size === 0; + + // ============================================ + // Handlers + // ============================================ + + const handleToggleComment = useCallback((commentId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(commentId)) { + next.delete(commentId); + } else { + next.add(commentId); + } + return next; + }); + }, []); + + const handleResolveComment = useCallback( + (comment: PRReviewComment, resolve: boolean) => { + if (!comment.threadId) return; + const threadId = comment.threadId; + setResolvingThreads((prev) => { + const next = new Set(prev); + next.add(threadId); + return next; + }); + resolveThread.mutate( + { threadId, resolve }, + { + onSettled: () => { + setResolvingThreads((prev) => { + const next = new Set(prev); + next.delete(threadId); + return next; + }); + }, + } + ); + }, + [resolveThread] + ); + + const handleSelectAll = useCallback(() => { + if (allSelected) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(comments.map((c) => c.id))); + } + }, [allSelected, comments]); + + const handleModeChange = useCallback((checked: boolean) => { + setAddressMode(checked ? 'individually' : 'together'); + }, []); + + const handleSortToggle = useCallback(() => { + setSortOrder((prev) => (prev === 'newest' ? 'oldest' : 'newest')); + }, []); + + const handleShowResolvedToggle = useCallback(() => { + setShowResolved((prev) => { + const nextShowResolved = !prev; + // When hiding resolved comments, remove any selected resolved comment IDs + if (!nextShowResolved) { + setSelectedIds((prevIds) => { + const resolvedIds = new Set(allComments.filter((c) => c.isResolved).map((c) => c.id)); + const next = new Set(prevIds); + for (const id of resolvedIds) { + next.delete(id); + } + return next.size !== prevIds.size ? next : prevIds; + }); + } + return nextShowResolved; + }); + }, [allComments]); + + const handleSubmit = useCallback(async () => { + if (noneSelected || !currentProject?.path) return; + + const selectedComments = comments.filter((c) => selectedIds.has(c.id)); + + // Resolve model settings from the current model entry + const selectedModel = resolveModelString(modelEntry.model); + const normalizedThinking = modelSupportsThinking(selectedModel) + ? modelEntry.thinkingLevel || 'none' + : 'none'; + const normalizedReasoning = supportsReasoningEffort(selectedModel) + ? modelEntry.reasoningEffort || 'none' + : 'none'; + + setIsCreating(true); + setCreationErrors([]); + + try { + if (addressMode === 'together') { + // Create a single feature for all selected comments + const feature: Feature = { + id: generateFeatureId(), + title: generateGroupTitle(pr, selectedComments), + category: 'bug-fix', + description: generateGroupDescription(pr, selectedComments), + steps: [], + status: 'backlog', + model: selectedModel, + thinkingLevel: normalizedThinking, + reasoningEffort: normalizedReasoning, + // Associate feature with the PR's branch so it appears on the correct worktree + ...(pr.headRefName ? { branchName: pr.headRefName } : {}), + }; + + await createFeature.mutateAsync(feature); + toast.success('Feature created', { + description: `Created feature to address ${selectedComments.length} PR comment${selectedComments.length > 1 ? 's' : ''}`, + }); + onOpenChange(false); + } else { + // Create one feature per selected comment + const errors: Array<{ comment: PRReviewComment; error: string }> = []; + let successCount = 0; + + for (const comment of selectedComments) { + try { + const feature: Feature = { + id: generateFeatureId(), + title: generateSingleCommentTitle(pr, comment), + category: 'bug-fix', + description: generateSingleCommentDescription(pr, comment), + steps: [], + status: 'backlog', + model: selectedModel, + thinkingLevel: normalizedThinking, + reasoningEffort: normalizedReasoning, + // Associate feature with the PR's branch so it appears on the correct worktree + ...(pr.headRefName ? { branchName: pr.headRefName } : {}), + }; + + await createFeature.mutateAsync(feature); + successCount++; + } catch (err) { + errors.push({ + comment, + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + if (errors.length > 0) { + setCreationErrors(errors); + if (successCount > 0) { + toast.warning(`Created ${successCount} feature${successCount > 1 ? 's' : ''}`, { + description: `${errors.length} failed to create`, + }); + } + } else { + toast.success(`Created ${successCount} feature${successCount > 1 ? 's' : ''}`, { + description: `Each PR comment will be addressed individually`, + }); + onOpenChange(false); + } + } + } catch (err) { + toast.error('Failed to create feature', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setIsCreating(false); + } + }, [ + noneSelected, + currentProject?.path, + comments, + selectedIds, + addressMode, + pr, + createFeature, + onOpenChange, + modelEntry, + ]); + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!newOpen) { + // Reset state when closing + setSelectedIds(new Set()); + setAddressMode('together'); + setSortOrder('newest'); + setShowResolved(false); + setCreationErrors([]); + setDetailComment(null); + setResolvingThreads(new Set()); + setModelEntry(effectiveDefaultFeatureModel); + } + onOpenChange(newOpen); + }, + [onOpenChange, effectiveDefaultFeatureModel] + ); + + // ============================================ + // Render + // ============================================ + + return ( + + + + + + Manage PR Review Comments + + + Select comments from PR #{pr.number} to create feature tasks that address them. + + + + {/* Content Area */} +
+ {/* Loading State */} + {loading && ( +
+ +
+ )} + + {/* Error State */} + {error && !loading && ( +
+
+ +
+

Failed to Load Comments

+

+ {error instanceof Error ? error.message : 'Unknown error'} +

+ +
+ )} + + {/* Comments List (controls + items) - shown whenever there are any comments */} + {!loading && !error && allComments.length > 0 && ( + <> + {/* Controls Bar */} +
+ {/* Select All - only interactive when there are visible comments */} +
+ + +
+ +
+ {/* Show/Hide Resolved Filter Toggle - always visible */} + + + {/* Sort Toggle Button */} + + + {/* Mode Toggle */} +
+ + + +
+
+
+ + {/* Empty State - all comments filtered out (all resolved, filter hiding them) */} + {comments.length === 0 && ( +
+
+ +
+

All Comments Resolved

+

+ All {resolvedCount} comment{resolvedCount !== 1 ? 's' : ''} on this pull request{' '} + {resolvedCount !== 1 ? 'have' : 'has'} been resolved. +

+ +
+ )} + + {/* Selection Info */} + {!noneSelected && comments.length > 0 && ( +
+ {selectedIds.size} comment{selectedIds.size > 1 ? 's' : ''} selected + {addressMode === 'together' + ? ' - will create 1 feature' + : ` - will create ${selectedIds.size} feature${selectedIds.size > 1 ? 's' : ''}`} +
+ )} + + {/* Scrollable Comments */} + {comments.length > 0 && ( +
+ {comments.map((comment) => ( + handleToggleComment(comment.id)} + onExpandDetail={() => setDetailComment(comment)} + onResolve={handleResolveComment} + isResolvingThread={ + !!comment.threadId && resolvingThreads.has(comment.threadId) + } + /> + ))} +
+ )} + + {/* Creation Errors */} + {creationErrors.length > 0 && ( + setCreationErrors([])} + /> + )} + + )} + + {/* Empty State - no comments at all */} + {!loading && !error && allComments.length === 0 && ( +
+
+ +
+

No Open Comments

+

+ This pull request has no comments to address. +

+
+ )} +
+ + {/* Footer */} + +
+ {/* Cancel button - left side */} + + + {/* Model selector + Create button - right side */} +
+ {!loading && !error && allComments.length > 0 && ( + + )} + +
+
+
+
+ + {/* Comment Detail Dialog - opened when user clicks expand on a comment */} + { + if (!open) setDetailComment(null); + }} + /> +
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index a8aa521f..e3c14fc6 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -103,7 +103,15 @@ export function ProjectSwitcher() { }; const handleProjectClick = useCallback( - (project: Project) => { + async (project: Project) => { + try { + // Ensure .automaker directory structure exists before switching + await initializeProject(project.path); + } catch (error) { + console.error('Failed to initialize project during switch:', error); + // Continue with switch even if initialization fails - + // the project may already be initialized + } setCurrentProject(project); // Navigate to board view when switching projects navigate({ to: '/board' }); diff --git a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx index 28af95c3..34a98fa7 100644 --- a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx +++ b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { Folder, ChevronDown, @@ -15,6 +16,8 @@ import { } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store'; +import { initializeProject } from '@/lib/project-init'; +import type { Project } from '@/lib/electron'; import { DropdownMenu, DropdownMenuContent, @@ -87,6 +90,22 @@ export function ProjectSelectorWithOptions({ } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); + // Wrap setCurrentProject to ensure .automaker is initialized before switching + const setCurrentProjectWithInit = useCallback( + async (p: Project) => { + try { + // Ensure .automaker directory structure exists before switching + await initializeProject(p.path); + } catch (error) { + console.error('Failed to initialize project during switch:', error); + // Continue with switch even if initialization fails - + // the project may already be initialized + } + setCurrentProject(p); + }, + [setCurrentProject] + ); + const { projectSearchQuery, setProjectSearchQuery, @@ -99,7 +118,7 @@ export function ProjectSelectorWithOptions({ currentProject, isProjectPickerOpen, setIsProjectPickerOpen, - setCurrentProject, + setCurrentProject: setCurrentProjectWithInit, }); const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); @@ -107,6 +126,14 @@ export function ProjectSelectorWithOptions({ const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } = useProjectTheme(); + const handleSelectProject = useCallback( + async (p: Project) => { + await setCurrentProjectWithInit(p); + setIsProjectPickerOpen(false); + }, + [setCurrentProjectWithInit, setIsProjectPickerOpen] + ); + if (!sidebarOpen || projects.length === 0) { return null; } @@ -204,10 +231,7 @@ export function ProjectSelectorWithOptions({ project={project} currentProjectId={currentProject?.id} isHighlighted={index === selectedProjectIndex} - onSelect={(p) => { - setCurrentProject(p); - setIsProjectPickerOpen(false); - }} + onSelect={handleSelectProject} /> ))} diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts index d8666b72..f518a4ef 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-project-picker.ts @@ -6,7 +6,7 @@ interface UseProjectPickerProps { currentProject: Project | null; isProjectPickerOpen: boolean; setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void; - setCurrentProject: (project: Project) => void; + setCurrentProject: (project: Project) => void | Promise; } export function useProjectPicker({ @@ -92,9 +92,9 @@ export function useProjectPicker({ }, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]); // Handle selecting the currently highlighted project - const selectHighlightedProject = useCallback(() => { + const selectHighlightedProject = useCallback(async () => { if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) { - setCurrentProject(filteredProjects[selectedProjectIndex]); + await setCurrentProject(filteredProjects[selectedProjectIndex]); setIsProjectPickerOpen(false); } }, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]); @@ -108,7 +108,9 @@ export function useProjectPicker({ setIsProjectPickerOpen(false); } else if (event.key === 'Enter') { event.preventDefault(); - selectHighlightedProject(); + selectHighlightedProject().catch(() => { + /* Error already logged upstream */ + }); } else if (event.key === 'ArrowDown') { event.preventDefault(); setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev)); diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts index a7486f05..937e28fa 100644 --- a/apps/ui/src/components/layout/sidebar/types.ts +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -25,7 +25,7 @@ export interface SortableProjectItemProps { project: Project; currentProjectId: string | undefined; isHighlighted: boolean; - onSelect: (project: Project) => void; + onSelect: (project: Project) => void | Promise; } export interface ThemeMenuItemProps { diff --git a/apps/ui/src/components/ui/app-error-boundary.tsx b/apps/ui/src/components/ui/app-error-boundary.tsx new file mode 100644 index 00000000..523aeb63 --- /dev/null +++ b/apps/ui/src/components/ui/app-error-boundary.tsx @@ -0,0 +1,131 @@ +import { Component, type ReactNode, type ErrorInfo } from 'react'; +import { createLogger } from '@automaker/utils/logger'; + +const logger = createLogger('AppErrorBoundary'); + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Root-level error boundary for the entire application. + * + * Catches uncaught React errors that would otherwise show TanStack Router's + * default "Something went wrong!" screen with a raw error message. + * + * Provides a user-friendly error screen with a reload button to recover. + * This is especially important for transient errors during initial app load + * (e.g., race conditions during auth/hydration on fresh browser sessions). + */ +export class AppErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + logger.error('Uncaught application error:', { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + } + + handleReload = () => { + window.location.reload(); + }; + + render() { + if (this.state.hasError) { + return ( +
+ {/* Logo matching the app shell in index.html */} + + +
+

Something went wrong

+

+ The application encountered an unexpected error. This is usually temporary and can be + resolved by reloading the page. +

+
+ + + + {/* Collapsible technical details for debugging */} + {this.state.error && ( +
+ + Technical details + +
+                {this.state.error.stack || this.state.error.message}
+              
+
+ )} +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index dca23f00..622ecf78 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { @@ -33,7 +33,11 @@ import { getHttpApiClient } from '@/lib/http-api-client'; import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types'; import { pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; -import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal'; +import { + BoardBackgroundModal, + PRCommentResolutionDialog, + type PRCommentResolutionPRInfo, +} from '@/components/dialogs'; import { useShallow } from 'zustand/react/shallow'; import { useAutoMode } from '@/hooks/use-auto-mode'; import { resolveModelString } from '@automaker/model-resolver'; @@ -184,6 +188,9 @@ export function BoardView() { const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false); const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false); + const [showPRCommentDialog, setShowPRCommentDialog] = useState(false); + const [prCommentDialogPRInfo, setPRCommentDialogPRInfo] = + useState(null); const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState( null ); @@ -429,6 +436,29 @@ export function BoardView() { // This needs to be before useBoardActions so we can pass currentWorktreeBranch const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; const currentWorktreePath = currentWorktreeInfo?.path ?? null; + + // Track the previous worktree path to detect worktree switches + const prevWorktreePathRef = useRef(undefined); + + // When the active worktree changes, invalidate feature queries to ensure + // feature cards (especially their todo lists / planSpec tasks) render fresh data. + // Without this, cards that unmount when filtered out and remount when the user + // switches back may show stale or missing todo list data until the next polling cycle. + useEffect(() => { + // Skip the initial mount (prevWorktreePathRef starts as undefined) + if (prevWorktreePathRef.current === undefined) { + prevWorktreePathRef.current = currentWorktreePath; + return; + } + // Only invalidate when the worktree actually changed + if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); + } + prevWorktreePathRef.current = currentWorktreePath; + }, [currentWorktreePath, currentProject?.path, queryClient]); + const worktreesByProject = useAppStore((s) => s.worktreesByProject); const worktrees = useMemo( () => @@ -922,26 +952,39 @@ export function BoardView() { [handleAddFeature, handleStartImplementation] ); - // Handler for addressing PR comments - creates a feature and starts it automatically - const handleAddressPRComments = useCallback( + // Handler for managing PR comments - opens the PR Comment Resolution dialog + const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => { + setPRCommentDialogPRInfo({ + number: prInfo.number, + title: prInfo.title, + // Pass the worktree's branch so features are created on the correct worktree + headRefName: worktree.branch, + }); + setShowPRCommentDialog(true); + }, []); + + // Handler for auto-addressing PR comments - immediately creates and starts a feature task + const handleAutoAddressPRComments = useCallback( async (worktree: WorktreeInfo, prInfo: PRInfo) => { - // Use a simple prompt that instructs the agent to read and address PR feedback - // The agent will fetch the PR comments directly, which is more reliable and up-to-date - const prNumber = prInfo.number; - const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`; + if (!prInfo.number) { + toast.error('Cannot address PR comments', { + description: 'No PR number available for this worktree.', + }); + return; + } const featureData = { - title: `Address PR #${prNumber} Review Comments`, - category: 'PR Review', - description, + title: `Address PR #${prInfo.number} Review Comments`, + category: 'Maintenance', + description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`, images: [], imagePaths: [], skipTests: defaultSkipTests, - model: 'opus' as const, + model: resolveModelString('opus'), thinkingLevel: 'none' as const, branchName: worktree.branch, - workMode: 'custom' as const, // Use the worktree's branch - priority: 1, // High priority for PR feedback + workMode: 'custom' as const, + priority: 1, planningMode: 'skip' as const, requirePlanApproval: false, }; @@ -988,7 +1031,7 @@ export function BoardView() { images: [], imagePaths: [], skipTests: defaultSkipTests, - model: 'opus' as const, + model: resolveModelString('opus'), thinkingLevel: 'none' as const, branchName: conflictInfo.targetBranch, workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved @@ -1508,6 +1551,7 @@ export function BoardView() { setShowCreateBranchDialog(true); }} onAddressPRComments={handleAddressPRComments} + onAutoAddressPRComments={handleAutoAddressPRComments} onResolveConflicts={handleResolveConflicts} onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature} onBranchSwitchConflict={handleBranchSwitchConflict} @@ -1985,6 +2029,18 @@ export function BoardView() { }} /> + {/* PR Comment Resolution Dialog */} + {prCommentDialogPRInfo && ( + { + setShowPRCommentDialog(open); + if (!open) setPRCommentDialogPRInfo(null); + }} + pr={prCommentDialogPRInfo} + /> + )} + {/* Init Script Indicator - floating overlay for worktree init script status */} {getShowInitScriptIndicator(currentProject.path) && ( diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index e992c8f6..86cb6fa4 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useState, useMemo, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store'; import { getProviderFromModel } from '@/lib/utils'; import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser'; @@ -10,6 +11,7 @@ import { getElectronAPI } from '@/lib/electron'; import { SummaryDialog } from './summary-dialog'; import { getProviderIconForModel } from '@/components/ui/provider-icon'; import { useFeature, useAgentOutput } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; /** * Formats thinking level for compact display @@ -58,6 +60,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ summary, isActivelyRunning, }: AgentInfoPanelProps) { + const queryClient = useQueryClient(); const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false); const [isTodosExpanded, setIsTodosExpanded] = useState(false); // Track real-time task status updates from WebSocket events @@ -130,6 +133,25 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ pollingInterval, }); + // On mount, ensure feature and agent output queries are fresh. + // This handles the worktree switch scenario where cards unmount when filtered out + // and remount when the user switches back. Without this, the React Query cache + // may serve stale data (or no data) for the individual feature query, causing + // the todo list to appear empty until the next polling cycle. + useEffect(() => { + if (shouldFetchData && projectPath && feature.id && !contextContent) { + // Invalidate both the single feature and agent output queries to trigger immediate refetch + queryClient.invalidateQueries({ + queryKey: queryKeys.features.single(projectPath, feature.id), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.features.agentOutput(projectPath, feature.id), + }); + } + // Only run on mount (feature.id and projectPath identify this specific card instance) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [feature.id, projectPath]); + // Parse agent output into agentInfo const agentInfo = useMemo(() => { if (contextContent) { @@ -305,9 +327,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ // Agent Info Panel for non-backlog cards // Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode) + // OR if the feature has effective todos from any source (handles initial mount after worktree switch) + // OR if the feature is actively running (ensures panel stays visible during execution) // Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec // (The backlog case was already handled above and returned early) - if (agentInfo || hasPlanSpecTasks) { + if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) { return ( <>
diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index a816204f..3fdd93e6 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -123,6 +123,18 @@ interface AddFeatureDialogProps { * This is used when the "Default to worktree mode" setting is disabled. */ forceCurrentBranchMode?: boolean; + /** + * Pre-filled title for the feature (e.g., from a GitHub issue). + */ + prefilledTitle?: string; + /** + * Pre-filled description for the feature (e.g., from a GitHub issue). + */ + prefilledDescription?: string; + /** + * Pre-filled category for the feature (e.g., 'From GitHub'). + */ + prefilledCategory?: string; } /** @@ -149,6 +161,9 @@ export function AddFeatureDialog({ projectPath, selectedNonMainWorktreeBranch, forceCurrentBranchMode, + prefilledTitle, + prefilledDescription, + prefilledCategory, }: AddFeatureDialogProps) { const isSpawnMode = !!parentFeature; const navigate = useNavigate(); @@ -211,6 +226,11 @@ export function AddFeatureDialog({ wasOpenRef.current = open; if (justOpened) { + // Initialize with prefilled values if provided, otherwise use defaults + setTitle(prefilledTitle ?? ''); + setDescription(prefilledDescription ?? ''); + setCategory(prefilledCategory ?? ''); + setSkipTests(defaultSkipTests); // When a non-main worktree is selected, use its branch name for custom mode // Otherwise, use the default branch @@ -254,6 +274,9 @@ export function AddFeatureDialog({ forceCurrentBranchMode, parentFeature, allFeatures, + prefilledTitle, + prefilledDescription, + prefilledCategory, ]); // Clear requirePlanApproval when planning mode is skip or lite diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index acef3298..950fd2f3 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -105,43 +105,106 @@ export function CreatePRDialog({ const branchAheadCount = branchesData?.aheadCount ?? 0; const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges; - // Filter out current worktree branch from the list - // When a target remote is selected, only show branches from that remote - const branches = useMemo(() => { - if (!branchesData?.branches) return []; - const allBranches = branchesData.branches - .map((b) => b.name) - .filter((name) => name !== worktree?.branch); + // Determine the active remote to scope branches to. + // For multi-remote: use the selected target remote. + // For single remote: automatically scope to that remote. + const activeRemote = useMemo(() => { + if (remotes.length === 1) return remotes[0].name; + if (selectedTargetRemote) return selectedTargetRemote; + return ''; + }, [remotes, selectedTargetRemote]); - // If a target remote is selected and we have remote info with branches, - // only show that remote's branches (not branches from other remotes) - if (selectedTargetRemote) { - const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote); - if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) { - const targetBranchNames = new Set(targetRemoteInfo.branches); - // Filter to only include branches that exist on the target remote - // Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main") - return allBranches.filter((name) => { - // Check if the branch name matches a target remote branch directly - if (targetBranchNames.has(name)) return true; - // Check if it's a prefixed remote branch (e.g. "upstream/main") - const prefix = `${selectedTargetRemote}/`; - if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length))) - return true; - return false; + // Filter branches by the active remote and strip remote prefixes for display. + // Returns display names (e.g. "main") without remote prefix. + // Also builds a map from display name → full ref (e.g. "origin/main") for PR creation. + const { branches, branchFullRefMap } = useMemo(() => { + if (!branchesData?.branches) + return { branches: [], branchFullRefMap: new Map() }; + + const refMap = new Map(); + + // If we have an active remote with branch info from the remotes endpoint, use that as the source + const activeRemoteInfo = activeRemote + ? remotes.find((r) => r.name === activeRemote) + : undefined; + + if (activeRemoteInfo?.branches && activeRemoteInfo.branches.length > 0) { + // Use the remote's branch list — these are already short names (e.g. "main") + const filteredBranches = activeRemoteInfo.branches + .filter((branchName) => { + // Exclude the current worktree branch + return branchName !== worktree?.branch; + }) + .map((branchName) => { + // Map display name to full ref + const fullRef = `${activeRemote}/${branchName}`; + refMap.set(branchName, fullRef); + return branchName; }); + + return { branches: filteredBranches, branchFullRefMap: refMap }; + } + + // Fallback: if no remote info available, use the branches from the branches endpoint + // Filter and strip prefixes + const seen = new Set(); + const filteredBranches: string[] = []; + + for (const b of branchesData.branches) { + // Skip the current worktree branch + if (b.name === worktree?.branch) continue; + + if (b.isRemote) { + // Remote branch: check if it belongs to the active remote + const slashIndex = b.name.indexOf('/'); + if (slashIndex === -1) continue; + + const remoteName = b.name.substring(0, slashIndex); + const branchName = b.name.substring(slashIndex + 1); + + // If we have an active remote, only include branches from that remote + if (activeRemote && remoteName !== activeRemote) continue; + + // Strip the remote prefix for display + if (!seen.has(branchName)) { + seen.add(branchName); + filteredBranches.push(branchName); + refMap.set(branchName, b.name); + } + } else { + // Local branch — only include if it has a remote counterpart on the active remote + // or if no active remote is set (no remotes at all) + if (!activeRemote) { + if (!seen.has(b.name)) { + seen.add(b.name); + filteredBranches.push(b.name); + refMap.set(b.name, b.name); + } + } + // When active remote is set, skip local-only branches — the remote version + // will be included from the remote branches above } } - return allBranches; - }, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]); + return { branches: filteredBranches, branchFullRefMap: refMap }; + }, [branchesData?.branches, worktree?.branch, activeRemote, remotes]); // When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid useEffect(() => { if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) { // Current base branch is not in the filtered list — pick the best match - const mainBranch = branches.find((b) => b === 'main' || b === 'master'); - setBaseBranch(mainBranch || branches[0]); + // Strip any existing remote prefix from the current base branch for comparison + const strippedBaseBranch = baseBranch.includes('/') + ? baseBranch.substring(baseBranch.indexOf('/') + 1) + : baseBranch; + + // Check if the stripped version exists in the list + if (branches.includes(strippedBaseBranch)) { + setBaseBranch(strippedBaseBranch); + } else { + const mainBranch = branches.find((b) => b === 'main' || b === 'master'); + setBaseBranch(mainBranch || branches[0]); + } } }, [branches, baseBranch]); @@ -234,7 +297,16 @@ export function CreatePRDialog({ try { const api = getHttpApiClient(); - const result = await api.worktree.generatePRDescription(worktree.path, baseBranch); + // Resolve the display name to the actual branch name for the API + const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch; + // Only strip the remote prefix if the resolved ref differs from the original + // (indicating it was resolved from a full ref like "origin/main"). + // This preserves local branch names that contain slashes (e.g. "release/1.0"). + const branchNameForApi = + resolvedRef !== baseBranch && resolvedRef.includes('/') + ? resolvedRef.substring(resolvedRef.indexOf('/') + 1) + : resolvedRef; + const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi); if (result.success) { if (result.title) { @@ -270,12 +342,26 @@ export function CreatePRDialog({ setError('Worktree API not available'); return; } + // Resolve the display branch name to the full ref for the API call. + // The baseBranch state holds the display name (e.g. "main"), but the API + // may need the short name without the remote prefix. We pass the display name + // since the backend handles branch resolution. However, if the full ref is + // available, we can use it for more precise targeting. + const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch; + // Only strip the remote prefix if the resolved ref differs from the original + // (indicating it was resolved from a full ref like "origin/main"). + // This preserves local branch names that contain slashes (e.g. "release/1.0"). + const baseBranchForApi = + resolvedBaseBranch !== baseBranch && resolvedBaseBranch.includes('/') + ? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1) + : resolvedBaseBranch; + const result = await api.worktree.createPR(worktree.path, { projectPath: projectPath || undefined, commitMessage: commitMessage || undefined, prTitle: title || worktree.branch, prBody: body || `Changes from branch ${worktree.branch}`, - baseBranch, + baseBranch: baseBranchForApi, draft: isDraft, remote: selectedRemote || undefined, targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined, @@ -626,9 +712,13 @@ export function CreatePRDialog({ onChange={setBaseBranch} branches={branches} placeholder="Select base branch..." - disabled={isLoadingBranches} + disabled={isLoadingBranches || isLoadingRemotes} allowCreate={false} - emptyMessage="No matching branches found." + emptyMessage={ + activeRemote + ? `No branches found on remote "${activeRemote}".` + : 'No matching branches found.' + } data-testid="base-branch-autocomplete" />
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 04d5badd..164729f2 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -40,6 +41,7 @@ import { AlertTriangle, XCircle, CheckCircle, + Settings2, } from 'lucide-react'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -54,6 +56,7 @@ import { import { getEditorIcon } from '@/components/icons/editor-icons'; import { getTerminalIcon } from '@/components/icons/terminal-icons'; import { useAppStore } from '@/store/app-store'; +import type { TerminalScript } from '@/components/views/project-settings-view/terminal-scripts-constants'; interface WorktreeActionsDropdownProps { worktree: WorktreeInfo; @@ -102,6 +105,7 @@ interface WorktreeActionsDropdownProps { onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; + onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void; @@ -128,6 +132,12 @@ interface WorktreeActionsDropdownProps { /** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */ onContinueOperation?: (worktree: WorktreeInfo) => void; hasInitScript: boolean; + /** Terminal quick scripts configured for the project */ + terminalScripts?: TerminalScript[]; + /** Callback to run a terminal quick script in a new terminal session */ + onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; + /** Callback to open the script editor UI */ + onEditScripts?: () => void; } export function WorktreeActionsDropdown({ @@ -166,6 +176,7 @@ export function WorktreeActionsDropdown({ onCommit, onCreatePR, onAddressPRComments, + onAutoAddressPRComments, onResolveConflicts, onDeleteWorktree, onStartDevServer, @@ -184,6 +195,9 @@ export function WorktreeActionsDropdown({ onAbortOperation, onContinueOperation, hasInitScript, + terminalScripts, + onRunTerminalScript, + onEditScripts, }: WorktreeActionsDropdownProps) { // Get available editors for the "Open In" submenu const { editors } = useAvailableEditors(); @@ -238,6 +252,21 @@ export function WorktreeActionsDropdown({ // Determine if the destructive/bottom section has any visible items const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain; + // Pre-compute PR info for the PR submenu (avoids an IIFE in JSX) + const prInfo = useMemo(() => { + if (!showPRInfo || !worktree.pr) return null; + return { + number: worktree.pr.number, + title: worktree.pr.title, + url: worktree.pr.url, + state: worktree.pr.state, + author: '', + body: '', + comments: [], + reviewComments: [], + }; + }, [showPRInfo, worktree.pr]); + return ( @@ -358,19 +387,18 @@ export function WorktreeActionsDropdown({ ? 'Dev Server Starting...' : `Dev Server Running (:${devServerInfo?.port})`} - onOpenDevServerUrl(worktree)} - className="text-xs" - disabled={devServerInfo?.urlDetected === false} - aria-label={ - devServerInfo?.urlDetected === false - ? 'Open dev server in browser' - : `Open dev server on port ${devServerInfo?.port} in browser` - } - > - + {devServerInfo != null && + devServerInfo.port != null && + devServerInfo.urlDetected !== false && ( + onOpenDevServerUrl(worktree)} + className="text-xs" + aria-label={`Open dev server on port ${devServerInfo.port} in browser`} + > + + )} onViewDevServerLogs(worktree)} className="text-xs"> View Logs @@ -575,12 +603,57 @@ export function WorktreeActionsDropdown({ })} - {!worktree.isMain && hasInitScript && ( - onRunInitScript(worktree)} className="text-xs"> - - Re-run Init Script - - )} + {/* Scripts submenu - consolidates init script and terminal quick scripts */} + + + + Scripts + + + {/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured or no handler */} + {!worktree.isMain && ( + <> + onRunInitScript(worktree)} + className="text-xs" + disabled={!hasInitScript} + > + + Re-run Init Script + + + + )} + {/* Terminal quick scripts */} + {terminalScripts && terminalScripts.length > 0 ? ( + terminalScripts.map((script) => ( + onRunTerminalScript?.(worktree, script.command)} + className="text-xs" + disabled={!onRunTerminalScript} + > + + {script.name} + + )) + ) : ( + + No scripts configured + + )} + {/* Divider before Edit Commands & Scripts */} + + onEditScripts?.()} + className="text-xs" + disabled={!onEditScripts} + > + + Edit Commands & Scripts + + + {remotes && remotes.length > 1 && onPullWithRemote ? ( @@ -815,32 +888,67 @@ export function WorktreeActionsDropdown({ )} - - isGitOpsAvailable && onViewCommits(worktree)} - disabled={!isGitOpsAvailable} - className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')} - > - - View Commits - {!isGitOpsAvailable && ( - - )} - - - {/* Cherry-pick commits from another branch */} - {onCherryPick && ( + {/* View Commits - split button when Cherry Pick is available: + click main area to view commits directly, chevron opens sub-menu with Cherry Pick */} + {onCherryPick ? ( + + +
+ {/* Main clickable area - opens commit history directly */} + isGitOpsAvailable && onViewCommits(worktree)} + disabled={!isGitOpsAvailable} + className={cn( + 'text-xs flex-1 pr-0 rounded-r-none', + !isGitOpsAvailable && 'opacity-50 cursor-not-allowed' + )} + > + + View Commits + {!isGitOpsAvailable && ( + + )} + + {/* Chevron trigger for sub-menu containing Cherry Pick */} + +
+
+ + {/* Cherry-pick commits from another branch */} + isGitOpsAvailable && onCherryPick(worktree)} + disabled={!isGitOpsAvailable} + className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')} + > + + Cherry Pick + {!isGitOpsAvailable && ( + + )} + + +
+ ) : ( isGitOpsAvailable && onCherryPick(worktree)} + onClick={() => isGitOpsAvailable && onViewCommits(worktree)} disabled={!isGitOpsAvailable} className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')} > - - Cherry Pick + + View Commits {!isGitOpsAvailable && ( )} @@ -849,81 +957,67 @@ export function WorktreeActionsDropdown({ )} {(hasChangesSectionContent || hasDestructiveSectionContent) && } - {worktree.hasChanges && ( - onViewChanges(worktree)} className="text-xs"> - - View Changes - - )} - {/* Stash operations - combined submenu or simple item. + {/* View Changes split button - main action views changes directly, chevron reveals stash options. Only render when at least one action is meaningful: - - (worktree.hasChanges && onStashChanges): stashing changes is possible - - onViewStashes: viewing existing stashes is possible - Without this guard, the item would appear clickable but be a silent no-op - when hasChanges is false and onViewStashes is undefined. */} - {((worktree.hasChanges && onStashChanges) || onViewStashes) && ( - - {onViewStashes && worktree.hasChanges && onStashChanges ? ( - // Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu - -
- {/* Main clickable area - stash changes (primary action) */} + - worktree.hasChanges: View Changes action is available + - (worktree.hasChanges && onStashChanges): Create Stash action is possible + - onViewStashes: viewing existing stashes is possible */} + {/* View Changes split button - show submenu only when there are non-duplicate sub-actions */} + {worktree.hasChanges && (onStashChanges || onViewStashes) ? ( + +
+ {/* Main clickable area - view changes (primary action) */} + onViewChanges(worktree)} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + View Changes + + {/* Chevron trigger for submenu with stash options */} + +
+ + {onStashChanges && ( + { if (!isGitOpsAvailable) return; onStashChanges(worktree); }} disabled={!isGitOpsAvailable} - className={cn( - 'text-xs flex-1 pr-0 rounded-r-none', - !isGitOpsAvailable && 'opacity-50 cursor-not-allowed' - )} + className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')} > - Stash Changes + Create Stash {!isGitOpsAvailable && ( )} - {/* Chevron trigger for submenu with stash options */} - -
- - onViewStashes(worktree)} className="text-xs"> - - View Stashes - - -
- ) : ( - // Only one action is meaningful - render a simple menu item without submenu - { - if (!isGitOpsAvailable) return; - if (worktree.hasChanges && onStashChanges) { - onStashChanges(worktree); - } else if (onViewStashes) { - onViewStashes(worktree); - } - }} - disabled={!isGitOpsAvailable} - className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')} - > - - {worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'} - {!isGitOpsAvailable && ( - - )} - - )} -
- )} +
+ )} + {onViewStashes && ( + onViewStashes(worktree)} className="text-xs"> + + View Stashes + + )} + + + ) : worktree.hasChanges ? ( + onViewChanges(worktree)} className="text-xs"> + + View Changes + + ) : onViewStashes ? ( + onViewStashes(worktree)} className="text-xs"> + + View Stashes + + ) : null} {worktree.hasChanges && ( )} - {/* Show PR info and Address Comments button if PR exists */} - {showPRInfo && worktree.pr && ( - <> - { - window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer'); - }} - className="text-xs" - > - - PR #{worktree.pr.number} - - {worktree.pr.state} - - - { - // Convert stored PR info to the full PRInfo format for the handler - // The handler will fetch full comments from GitHub - const prInfo: PRInfo = { - number: worktree.pr!.number, - title: worktree.pr!.title, - url: worktree.pr!.url, - state: worktree.pr!.state, - author: '', // Will be fetched - body: '', // Will be fetched - comments: [], - reviewComments: [], - }; - onAddressPRComments(worktree, prInfo); - }} - className="text-xs text-blue-500 focus:text-blue-600" - > - - Address PR Comments - - + {/* Show PR info with Address Comments in sub-menu if PR exists */} + {prInfo && worktree.pr && ( + +
+ {/* Main clickable area - opens PR in browser */} + { + window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer'); + }} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + PR #{worktree.pr.number} + + {worktree.pr.state} + + + {/* Chevron trigger for submenu with PR actions */} + +
+ + onAddressPRComments(worktree, prInfo)} + className="text-xs text-blue-500 focus:text-blue-600" + > + + Manage PR Comments + + onAutoAddressPRComments(worktree, prInfo)} + className="text-xs text-blue-500 focus:text-blue-600" + > + + Address PR Comments + + +
)} {hasChangesSectionContent && hasDestructiveSectionContent && } {worktree.hasChanges && ( diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx index e23230cc..f195a472 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx @@ -144,8 +144,8 @@ export function WorktreeDropdownItem({ )} - {/* Dev server indicator */} - {devServerRunning && ( + {/* Dev server indicator - hidden when URL detection explicitly failed */} + {devServerRunning && devServerInfo?.urlDetected !== false && ( void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; + onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void; onMerge: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; @@ -131,6 +132,12 @@ export interface WorktreeDropdownProps { onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void; /** Push to a specific remote, bypassing the remote selection dialog */ onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void; + /** Terminal quick scripts configured for the project */ + terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[]; + /** Callback to run a terminal quick script in a new terminal session */ + onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; + /** Callback to open the script editor UI */ + onEditScripts?: () => void; } /** @@ -199,6 +206,7 @@ export function WorktreeDropdown({ onCommit, onCreatePR, onAddressPRComments, + onAutoAddressPRComments, onResolveConflicts, onMerge, onDeleteWorktree, @@ -219,6 +227,9 @@ export function WorktreeDropdown({ remotesCache, onPullWithRemote, onPushWithRemote, + terminalScripts, + onRunTerminalScript, + onEditScripts, }: WorktreeDropdownProps) { // Find the currently selected worktree to display in the trigger const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)); @@ -304,15 +315,11 @@ export function WorktreeDropdown({ )} - {/* Dev server indicator */} - {selectedStatus.devServerRunning && ( + {/* Dev server indicator - only shown when port is confirmed detected */} + {selectedStatus.devServerRunning && selectedStatus.devServerInfo?.urlDetected !== false && ( @@ -520,6 +527,7 @@ export function WorktreeDropdown({ onCommit={onCommit} onCreatePR={onCreatePR} onAddressPRComments={onAddressPRComments} + onAutoAddressPRComments={onAutoAddressPRComments} onResolveConflicts={onResolveConflicts} onMerge={onMerge} onDeleteWorktree={onDeleteWorktree} @@ -538,6 +546,9 @@ export function WorktreeDropdown({ onAbortOperation={onAbortOperation} onContinueOperation={onContinueOperation} hasInitScript={hasInitScript} + terminalScripts={terminalScripts} + onRunTerminalScript={onRunTerminalScript} + onEditScripts={onEditScripts} /> )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 6e97cb50..b94ae959 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -67,6 +67,7 @@ interface WorktreeTabProps { onCommit: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; + onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void; onMerge: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void; @@ -101,6 +102,12 @@ interface WorktreeTabProps { onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void; /** Push to a specific remote, bypassing the remote selection dialog */ onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void; + /** Terminal quick scripts configured for the project */ + terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[]; + /** Callback to run a terminal quick script in a new terminal session */ + onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void; + /** Callback to open the script editor UI */ + onEditScripts?: () => void; } export function WorktreeTab({ @@ -148,6 +155,7 @@ export function WorktreeTab({ onCommit, onCreatePR, onAddressPRComments, + onAutoAddressPRComments, onResolveConflicts, onMerge, onDeleteWorktree, @@ -170,6 +178,9 @@ export function WorktreeTab({ remotes, onPullWithRemote, onPushWithRemote, + terminalScripts, + onRunTerminalScript, + onEditScripts, }: WorktreeTabProps) { // Make the worktree tab a drop target for feature cards const { setNodeRef, isOver } = useDroppable({ @@ -440,7 +451,7 @@ export function WorktreeTab({ )} - {isDevServerRunning && ( + {isDevServerRunning && devServerInfo?.urlDetected !== false && ( + )} + - - - + const trimmed = value.trim(); + if (trimmed && isValidFileName(trimmed)) { + submittedRef.current = true; + onSubmit(trimmed); + } + // If the name is empty or invalid, do NOT call onCancel — keep the + // input open so the user can correct the value (mirrors handleSubmit). + // Optionally re-focus so the user can continue editing. + else { + inputRef.current?.focus(); + } + }} + placeholder={placeholder} + className={cn( + 'text-sm bg-muted border rounded px-1 py-0.5 w-full outline-none focus:border-primary', + errorMessage ? 'border-red-500' : 'border-border' + )} + /> + {errorMessage && {errorMessage}} ); } @@ -276,12 +260,11 @@ function TreeNode({ selectedPaths, toggleSelectedPath, } = useFileEditorStore(); + const { openFileBrowser } = useFileBrowser(); const [isCreatingFile, setIsCreatingFile] = useState(false); const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [menuOpen, setMenuOpen] = useState(false); - const [showCopyPicker, setShowCopyPicker] = useState(false); - const [showMovePicker, setShowMovePicker] = useState(false); const isExpanded = expandedFolders.has(node.path); const isActive = activeFilePath === node.path; @@ -409,30 +392,6 @@ function TreeNode({ return (
- {/* Destination picker dialogs */} - {showCopyPicker && onCopyItem && ( - { - setShowCopyPicker(false); - await onCopyItem(node.path, destPath); - }} - onCancel={() => setShowCopyPicker(false)} - /> - )} - {showMovePicker && onMoveItem && ( - { - setShowMovePicker(false); - await onMoveItem(node.path, destPath); - }} - onCancel={() => setShowMovePicker(false)} - /> - )} - {isRenaming ? (
{ + onClick={async (e) => { e.stopPropagation(); - setShowCopyPicker(true); + try { + const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/'; + const destPath = await openFileBrowser({ + title: `Copy "${node.name}" To...`, + description: 'Select the destination folder for the copy operation', + initialPath: parentPath, + }); + if (destPath) { + await onCopyItem(node.path, destPath); + } + } catch (err) { + console.error('Copy operation failed:', err); + } }} className="gap-2" > @@ -644,9 +615,21 @@ function TreeNode({ {/* Move To... */} {onMoveItem && ( { + onClick={async (e) => { e.stopPropagation(); - setShowMovePicker(true); + try { + const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/'; + const destPath = await openFileBrowser({ + title: `Move "${node.name}" To...`, + description: 'Select the destination folder for the move operation', + initialPath: parentPath, + }); + if (destPath) { + await onMoveItem(node.path, destPath); + } + } catch (err) { + console.error('Move operation failed:', err); + } }} className="gap-2" > @@ -775,8 +758,15 @@ export function FileTree({ onDragDropMove, effectivePath, }: FileTreeProps) { - const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } = - useFileEditorStore(); + const { + fileTree, + showHiddenFiles, + setShowHiddenFiles, + gitStatusMap, + dragState, + setDragState, + gitBranch, + } = useFileEditorStore(); const [isCreatingFile, setIsCreatingFile] = useState(false); const [isCreatingFolder, setIsCreatingFolder] = useState(false); @@ -791,10 +781,13 @@ export function FileTree({ e.preventDefault(); if (effectivePath) { e.dataTransfer.dropEffect = 'move'; - setDragState({ draggedPaths: [], dropTargetPath: effectivePath }); + // Skip redundant state update if already targeting the same path + if (dragState.dropTargetPath !== effectivePath) { + setDragState({ ...dragState, dropTargetPath: effectivePath }); + } } }, - [effectivePath, setDragState] + [effectivePath, dragState, setDragState] ); const handleRootDrop = useCallback( @@ -818,47 +811,54 @@ export function FileTree({ return (
{/* Tree toolbar */} -
-
- - Explorer - - {gitBranch && ( - +
+
+
+ + Explorer + +
+
+ + + + +
+
+ {gitBranch && ( +
+ {gitBranch} - )} -
-
- - - - -
+
+ )}
{/* Tree content */} diff --git a/apps/ui/src/components/views/file-editor-view/file-editor-view.tsx b/apps/ui/src/components/views/file-editor-view/file-editor-view.tsx index 56805417..53070d2b 100644 --- a/apps/ui/src/components/views/file-editor-view/file-editor-view.tsx +++ b/apps/ui/src/components/views/file-editor-view/file-editor-view.tsx @@ -650,6 +650,12 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) { const handleRenameItem = useCallback( async (oldPath: string, newName: string) => { + // Extract the current file/folder name from the old path + const oldName = oldPath.split('/').pop() || ''; + + // If the name hasn't changed, skip the rename entirely (no-op) + if (newName === oldName) return; + const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/')); const newPath = `${parentPath}/${newName}`; @@ -1028,6 +1034,9 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) { onTabSelect={setActiveTab} onTabClose={handleTabClose} onCloseAll={handleCloseAll} + onSave={handleSave} + isDirty={activeTab?.isDirty && !activeTab?.isBinary && !activeTab?.isTooLarge} + showSaveButton={isMobile && !!activeTab && !activeTab.isBinary && !activeTab.isTooLarge} /> {/* Editor content */} @@ -1320,24 +1329,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) { - {/* Mobile: Save button in main toolbar */} - {activeTab && - !activeTab.isBinary && - !activeTab.isTooLarge && - isMobile && - !mobileBrowserVisible && ( - - )} - {/* Tablet/Mobile: actions panel trigger */} (null); + // Add Feature dialog state + const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false); + const [createFeatureIssue, setCreateFeatureIssue] = useState(null); + // Filter state const [filterState, setFilterState] = useState(DEFAULT_ISSUES_FILTER_STATE); - const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore(); + const { currentProject, getCurrentWorktree, worktreesByProject, defaultSkipTests } = + useAppStore(); const queryClient = useQueryClient(); // Model override for validation const validationModelOverride = useModelOverride({ phase: 'validationModel' }); + const isMobile = useIsMobile(); + const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues(); const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } = @@ -108,6 +118,132 @@ export function GitHubIssuesView() { api.openExternalLink(url); }, []); + // Build a prefilled description from a GitHub issue for the feature dialog + const buildIssueDescription = useCallback( + (issue: GitHubIssue) => { + const parts = [ + `**From GitHub Issue #${issue.number}**`, + '', + issue.body || 'No description provided.', + ]; + + // Include labels if present + if (issue.labels.length > 0) { + parts.push('', `**Labels:** ${issue.labels.map((l) => l.name).join(', ')}`); + } + + // Include linked PRs info if present + if (issue.linkedPRs && issue.linkedPRs.length > 0) { + parts.push( + '', + '**Linked Pull Requests:**', + ...issue.linkedPRs.map((pr) => `- #${pr.number}: ${pr.title} (${pr.state})`) + ); + } + + // Include cached validation analysis if available + const cached = cachedValidations.get(issue.number); + if (cached?.result) { + const validation = cached.result; + parts.push('', '---', '', '**AI Validation Analysis:**', validation.reasoning); + if (validation.suggestedFix) { + parts.push('', `**Suggested Approach:**`, validation.suggestedFix); + } + if (validation.relatedFiles?.length) { + parts.push('', '**Related Files:**', ...validation.relatedFiles.map((f) => `- \`${f}\``)); + } + } + + return parts.join('\n'); + }, + [cachedValidations] + ); + + // Memoize the prefilled description to avoid recomputing on every render + const prefilledDescription = useMemo( + () => (createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined), + [createFeatureIssue, buildIssueDescription] + ); + + // Open the Add Feature dialog with pre-filled data from a GitHub issue + const handleCreateFeature = useCallback((issue: GitHubIssue) => { + setCreateFeatureIssue(issue); + setShowAddFeatureDialog(true); + }, []); + + // Handle feature creation from the AddFeatureDialog + const handleAddFeatureFromIssue = useCallback( + async (featureData: { + title: string; + category: string; + description: string; + priority: number; + model: string; + thinkingLevel: string; + reasoningEffort: string; + skipTests: boolean; + branchName: string; + planningMode: string; + requirePlanApproval: boolean; + excludedPipelineSteps?: string[]; + workMode: string; + imagePaths?: Array<{ id: string; path: string; description?: string }>; + textFilePaths?: Array<{ id: string; path: string; description?: string }>; + }) => { + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + try { + const api = getElectronAPI(); + if (api.features?.create) { + const feature = { + id: `issue-${createFeatureIssue?.number || 'new'}-${generateUUID()}`, + title: featureData.title, + description: featureData.description, + category: featureData.category, + status: 'backlog' as const, + passes: false, + priority: featureData.priority, + model: featureData.model, + thinkingLevel: featureData.thinkingLevel, + reasoningEffort: featureData.reasoningEffort, + skipTests: featureData.skipTests, + branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName, + planningMode: featureData.planningMode, + requirePlanApproval: featureData.requirePlanApproval, + excludedPipelineSteps: featureData.excludedPipelineSteps, + ...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}), + ...(featureData.textFilePaths?.length + ? { textFilePaths: featureData.textFilePaths } + : {}), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const result = await api.features.create(currentProject.path, feature); + if (result.success) { + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); + toast.success( + `Created feature: ${featureData.title || featureData.description.slice(0, 50)}` + ); + setShowAddFeatureDialog(false); + setCreateFeatureIssue(null); + } else { + toast.error(result.error || 'Failed to create feature'); + } + } + } catch (err) { + logger.error('Create feature from issue error:', err); + toast.error(err instanceof Error ? err.message : 'Failed to create feature'); + } + }, + [currentProject?.path, currentBranch, queryClient, createFeatureIssue] + ); + const handleConvertToTask = useCallback( async (issue: GitHubIssue, validation: IssueValidationResult) => { if (!currentProject?.path) { @@ -119,7 +255,7 @@ export function GitHubIssuesView() { const api = getElectronAPI(); if (api.features?.create) { // Build description from issue body + validation info - const description = [ + const parts = [ `**From GitHub Issue #${issue.number}**`, '', issue.body || 'No description provided.', @@ -128,13 +264,18 @@ export function GitHubIssuesView() { '', '**AI Validation Analysis:**', validation.reasoning, - validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '', - validation.relatedFiles?.length - ? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}` - : '', - ] - .filter(Boolean) - .join('\n'); + ]; + if (validation.suggestedFix) { + parts.push('', `**Suggested Approach:**`, validation.suggestedFix); + } + if (validation.relatedFiles?.length) { + parts.push( + '', + '**Related Files:**', + ...validation.relatedFiles.map((f) => `- \`${f}\``) + ); + } + const description = parts.join('\n'); const feature = { id: `issue-${issue.number}-${generateUUID()}`, @@ -144,7 +285,7 @@ export function GitHubIssuesView() { status: 'backlog' as const, passes: false, priority: getFeaturePriority(validation.estimatedComplexity), - model: 'opus', + model: resolveModelString('opus'), thinkingLevel: 'none' as const, branchName: currentBranch, createdAt: new Date().toISOString(), @@ -185,11 +326,12 @@ export function GitHubIssuesView() { return (
- {/* Issues List */} + {/* Issues List - hidden on mobile when an issue is selected */}
{/* Header */} @@ -296,8 +438,10 @@ export function GitHubIssuesView() { setPendingRevalidateOptions(options); setShowRevalidateConfirm(true); }} + onCreateFeature={handleCreateFeature} formatDate={formatDate} modelOverride={validationModelOverride} + isMobile={isMobile} /> )} @@ -310,6 +454,28 @@ export function GitHubIssuesView() { onConvertToTask={handleConvertToTask} /> + {/* Add Feature Dialog - opened from issue detail panel */} + { + setShowAddFeatureDialog(open); + if (!open) { + setCreateFeatureIssue(null); + } + }} + onAdd={handleAddFeatureFromIssue} + categorySuggestions={['From GitHub']} + branchSuggestions={[]} + defaultSkipTests={defaultSkipTests} + defaultBranch={currentBranch} + currentBranch={currentBranch || undefined} + isMaximized={false} + projectPath={currentProject?.path} + prefilledTitle={createFeatureIssue?.title} + prefilledDescription={prefilledDescription} + prefilledCategory="From GitHub" + /> + {/* Revalidate Confirmation Dialog */} {/* Detail Header */} -
+
+ {isMobile && ( + + )} {issue.state === 'OPEN' ? ( ) : ( @@ -82,12 +98,12 @@ export function IssueDetailPanel({ #{issue.number} {issue.title}
-
+
{(() => { if (isValidating) { return ( ); } @@ -95,9 +111,15 @@ export function IssueDetailPanel({ if (cached && !isStale) { return ( <> - onValidateIssue(issue, getValidationOptions(true))} + aria-label="Re-validate" + title="Re-validate" > - Re-validate + {!isMobile && 'Re-validate'} ); @@ -154,25 +184,46 @@ export function IssueDetailPanel({ variant="default" size="sm" onClick={() => onValidateIssue(issue, getValidationOptions())} + aria-label="Validate with AI" + title="Validate with AI" > - Validate with AI + {!isMobile && 'Validate with AI'} ); })()} - - + )} + + {!isMobile && ( + + )}
{/* Issue Detail Content */} -
+
{/* Title */}

{issue.title}

@@ -344,8 +395,25 @@ export function IssueDetailPanel({ )}
+ {/* Create Feature CTA - shown on mobile since it's hidden from the header */} + {isMobile && ( +
+
+ + Create Feature +
+

+ Create a new feature task to address this issue. +

+ +
+ )} + {/* Open in GitHub CTA */} -
+

View comments, add reactions, and more on GitHub.

diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts index a66e3a96..200dde98 100644 --- a/apps/ui/src/components/views/github-issues-view/types.ts +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -138,6 +138,8 @@ export interface IssueDetailPanelProps { onClose: () => void; /** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */ onShowRevalidateConfirm: (options: ValidateIssueOptions) => void; + /** Called when user wants to create a feature to address this issue */ + onCreateFeature: (issue: GitHubIssue) => void; formatDate: (date: string) => string; /** Model override state */ modelOverride: { @@ -146,4 +148,6 @@ export interface IssueDetailPanelProps { isOverridden: boolean; setOverride: (entry: PhaseModelEntry | null) => void; }; + /** Whether the view is in mobile mode - shows back button and full-screen detail */ + isMobile?: boolean; } diff --git a/apps/ui/src/components/views/github-prs-view.tsx b/apps/ui/src/components/views/github-prs-view.tsx index 0a9b3417..607016c3 100644 --- a/apps/ui/src/components/views/github-prs-view.tsx +++ b/apps/ui/src/components/views/github-prs-view.tsx @@ -5,18 +5,42 @@ */ import { useState, useCallback } from 'react'; -import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react'; +import { + GitPullRequest, + RefreshCw, + ExternalLink, + GitMerge, + X, + MessageSquare, + MoreHorizontal, + Zap, + ArrowLeft, +} from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI, type GitHubPR } from '@/lib/electron'; -import { useAppStore } from '@/store/app-store'; +import { useAppStore, type Feature } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { Markdown } from '@/components/ui/markdown'; -import { cn } from '@/lib/utils'; +import { cn, generateUUID } from '@/lib/utils'; +import { useIsMobile } from '@/hooks/use-media-query'; import { useGitHubPRs } from '@/hooks/queries'; +import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations'; +import { PRCommentResolutionDialog } from '@/components/dialogs'; +import { resolveModelString } from '@automaker/model-resolver'; +import { toast } from 'sonner'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; export function GitHubPRsView() { const [selectedPR, setSelectedPR] = useState(null); + const [commentDialogPR, setCommentDialogPR] = useState(null); const { currentProject } = useAppStore(); + const isMobile = useIsMobile(); const { data, @@ -38,6 +62,65 @@ export function GitHubPRsView() { api.openExternalLink(url); }, []); + const createFeature = useCreateFeature(currentProject?.path ?? ''); + + const handleAutoAddressComments = useCallback( + async (pr: GitHubPR) => { + if (!pr.number || !currentProject?.path) { + toast.error('Cannot address PR comments', { + description: 'No PR number or project available.', + }); + return; + } + + const featureId = `pr-${pr.number}-${generateUUID()}`; + const feature: Feature = { + id: featureId, + title: `Address PR #${pr.number} Review Comments`, + category: 'bug-fix', + description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`, + steps: [], + status: 'backlog', + model: resolveModelString('opus'), + thinkingLevel: 'none', + planningMode: 'skip', + ...(pr.headRefName ? { branchName: pr.headRefName } : {}), + }; + + try { + await createFeature.mutateAsync(feature); + + // Start the feature immediately after creation + const api = getElectronAPI(); + if (api.features?.run) { + try { + await api.features.run(currentProject.path, featureId); + toast.success('Feature created and started', { + description: `Addressing review comments on PR #${pr.number}`, + }); + } catch (runError) { + toast.error('Feature created but failed to start', { + description: + runError instanceof Error + ? runError.message + : 'An error occurred while starting the feature', + }); + } + } else { + toast.error('Cannot start feature', { + description: + 'Feature API is not available. The feature was created but could not be started.', + }); + } + } catch (error) { + toast.error('Failed to create feature', { + description: error instanceof Error ? error.message : 'An error occurred', + }); + } + }, + [currentProject, createFeature] + ); + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -91,11 +174,12 @@ export function GitHubPRsView() { return (
- {/* PR List */} + {/* PR List - hidden on mobile when a PR is selected */}
{/* Header */} @@ -140,6 +224,8 @@ export function GitHubPRsView() { isSelected={selectedPR?.number === pr.number} onClick={() => setSelectedPR(pr)} onOpenExternal={() => handleOpenInGitHub(pr.url)} + onManageComments={() => setCommentDialogPR(pr)} + onAutoAddressComments={() => handleAutoAddressComments(pr)} formatDate={formatDate} getReviewStatus={getReviewStatus} /> @@ -158,6 +244,8 @@ export function GitHubPRsView() { isSelected={selectedPR?.number === pr.number} onClick={() => setSelectedPR(pr)} onOpenExternal={() => handleOpenInGitHub(pr.url)} + onManageComments={() => setCommentDialogPR(pr)} + onAutoAddressComments={() => handleAutoAddressComments(pr)} formatDate={formatDate} getReviewStatus={getReviewStatus} /> @@ -170,124 +258,187 @@ export function GitHubPRsView() {
{/* PR Detail Panel */} - {selectedPR && ( -
- {/* Detail Header */} -
-
- {selectedPR.state === 'MERGED' ? ( - - ) : ( - - )} - - #{selectedPR.number} {selectedPR.title} - - {selectedPR.isDraft && ( - - Draft - - )} -
-
- - -
-
- - {/* PR Detail Content */} -
- {/* Title */} -

{selectedPR.title}

- - {/* Meta info */} -
- - {selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'} - - {getReviewStatus(selectedPR) && ( - { + const reviewStatus = getReviewStatus(selectedPR); + return ( +
+ {/* Detail Header */} +
+
+ {isMobile && ( + )} - > - {getReviewStatus(selectedPR)!.label} - - )} - - #{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '} - {selectedPR.author.login} - -
- - {/* Branch info */} - {selectedPR.headRefName && ( -
- Branch: - - {selectedPR.headRefName} - -
- )} - - {/* Labels */} - {selectedPR.labels.length > 0 && ( -
- {selectedPR.labels.map((label) => ( - - {label.name} + {selectedPR.state === 'MERGED' ? ( + + ) : ( + + )} + + #{selectedPR.number} {selectedPR.title} - ))} + {selectedPR.isDraft && ( + + Draft + + )} +
+
+ {!isMobile && ( + + )} + + {!isMobile && ( + + )} +
- )} - {/* Body */} - {selectedPR.body ? ( - {selectedPR.body} - ) : ( -

No description provided.

- )} + {/* PR Detail Content */} +
+ {/* Title */} +

{selectedPR.title}

- {/* Open in GitHub CTA */} -
-

- View code changes, comments, and reviews on GitHub. -

- + {/* Meta info */} +
+ + {selectedPR.state === 'MERGED' + ? 'Merged' + : selectedPR.isDraft + ? 'Draft' + : 'Open'} + + {reviewStatus && ( + + {reviewStatus.label} + + )} + + #{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '} + {selectedPR.author.login} + +
+ + {/* Branch info */} + {selectedPR.headRefName && ( +
+ Branch: + + {selectedPR.headRefName} + +
+ )} + + {/* Labels */} + {selectedPR.labels.length > 0 && ( +
+ {selectedPR.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Body */} + {selectedPR.body ? ( + {selectedPR.body} + ) : ( +

No description provided.

+ )} + + {/* Review Comments CTA */} +
+
+ + Review Comments +
+

+ Manage review comments individually or let AI address all feedback + automatically. +

+
+ + +
+
+ + {/* Open in GitHub CTA */} +
+

+ View code changes, comments, and reviews on GitHub. +

+ +
+
-
-
+ ); + })()} + + {/* PR Comment Resolution Dialog */} + {commentDialogPR && ( + { + if (!open) setCommentDialogPR(null); + }} + pr={commentDialogPR} + /> )}
); @@ -298,6 +449,8 @@ interface PRRowProps { isSelected: boolean; onClick: () => void; onOpenExternal: () => void; + onManageComments: () => void; + onAutoAddressComments: () => void; formatDate: (date: string) => string; getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null; } @@ -307,6 +460,8 @@ function PRRow({ isSelected, onClick, onOpenExternal, + onManageComments, + onAutoAddressComments, formatDate, getReviewStatus, }: PRRowProps) { @@ -378,17 +533,52 @@ function PRRow({
- + {/* Actions dropdown menu */} + + + + + + { + e.stopPropagation(); + onManageComments(); + }} + className="text-xs text-blue-500 focus:text-blue-600" + > + + Manage PR Comments + + { + e.stopPropagation(); + onAutoAddressComments(); + }} + className="text-xs text-blue-500 focus:text-blue-600" + > + + Address PR Comments + + + { + e.stopPropagation(); + onOpenExternal(); + }} + className="text-xs" + > + + Open in GitHub + + +
); } diff --git a/apps/ui/src/components/views/project-settings-view/commands-and-scripts-section.tsx b/apps/ui/src/components/views/project-settings-view/commands-and-scripts-section.tsx new file mode 100644 index 00000000..851d64d8 --- /dev/null +++ b/apps/ui/src/components/views/project-settings-view/commands-and-scripts-section.tsx @@ -0,0 +1,657 @@ +import { useState, useEffect, useCallback, useMemo, useRef, type KeyboardEvent } from 'react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Terminal, + Save, + RotateCcw, + Info, + X, + Play, + FlaskConical, + ScrollText, + Plus, + GripVertical, + Trash2, +} from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import { useProjectSettings } from '@/hooks/queries'; +import { useUpdateProjectSettings } from '@/hooks/mutations'; +import type { Project } from '@/lib/electron'; +import { toast } from 'sonner'; +import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants'; + +/** Preset dev server commands for quick selection */ +const DEV_SERVER_PRESETS = [ + { label: 'npm run dev', command: 'npm run dev' }, + { label: 'yarn dev', command: 'yarn dev' }, + { label: 'pnpm dev', command: 'pnpm dev' }, + { label: 'bun dev', command: 'bun dev' }, + { label: 'npm start', command: 'npm start' }, + { label: 'cargo watch', command: 'cargo watch -x run' }, + { label: 'go run', command: 'go run .' }, +] as const; + +/** Preset test commands for quick selection */ +const TEST_PRESETS = [ + { label: 'npm test', command: 'npm test' }, + { label: 'yarn test', command: 'yarn test' }, + { label: 'pnpm test', command: 'pnpm test' }, + { label: 'bun test', command: 'bun test' }, + { label: 'pytest', command: 'pytest' }, + { label: 'cargo test', command: 'cargo test' }, + { label: 'go test', command: 'go test ./...' }, +] as const; + +/** Preset scripts for quick addition */ +const SCRIPT_PRESETS = [ + { name: 'Dev Server', command: 'npm run dev' }, + { name: 'Build', command: 'npm run build' }, + { name: 'Test', command: 'npm run test' }, + { name: 'Lint', command: 'npm run lint' }, + { name: 'Format', command: 'npm run format' }, + { name: 'Type Check', command: 'npm run typecheck' }, + { name: 'Start', command: 'npm start' }, + { name: 'Clean', command: 'npm run clean' }, +] as const; + +interface ScriptEntry { + id: string; + name: string; + command: string; +} + +interface CommandsAndScriptsSectionProps { + project: Project; +} + +/** Generate a unique ID for a new script */ +function generateId(): string { + return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSectionProps) { + // Fetch project settings using TanStack Query + const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path); + + // Mutation hook for updating project settings + const updateSettingsMutation = useUpdateProjectSettings(project.path); + + // ── Commands state ── + const [devCommand, setDevCommand] = useState(''); + const [originalDevCommand, setOriginalDevCommand] = useState(''); + const [testCommand, setTestCommand] = useState(''); + const [originalTestCommand, setOriginalTestCommand] = useState(''); + + // ── Scripts state ── + const [scripts, setScripts] = useState([]); + const [originalScripts, setOriginalScripts] = useState([]); + + // Dragging state for scripts + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + // Track previous project path to detect project switches + const prevProjectPathRef = useRef(project.path); + // Track whether we've done the initial sync for the current project + const isInitializedRef = useRef(false); + + // Sync commands and scripts state when project settings load or project changes + useEffect(() => { + const projectChanged = prevProjectPathRef.current !== project.path; + prevProjectPathRef.current = project.path; + + // Always clear local state on project change to avoid flashing stale data + if (projectChanged) { + isInitializedRef.current = false; + setDevCommand(''); + setOriginalDevCommand(''); + setTestCommand(''); + setOriginalTestCommand(''); + setScripts([]); + setOriginalScripts([]); + } + + // Apply project settings only when they are available + if (projectSettings) { + // Only sync from server if this is the initial load or if there are no unsaved edits. + // This prevents background refetches from overwriting in-progress local edits. + const isDirty = + isInitializedRef.current && + (devCommand !== originalDevCommand || + testCommand !== originalTestCommand || + JSON.stringify(scripts) !== JSON.stringify(originalScripts)); + + if (!isInitializedRef.current || !isDirty) { + // Commands + const dev = projectSettings.devCommand || ''; + const test = projectSettings.testCommand || ''; + setDevCommand(dev); + setOriginalDevCommand(dev); + setTestCommand(test); + setOriginalTestCommand(test); + + // Scripts + const configured = projectSettings.terminalScripts; + const scriptList = + configured && configured.length > 0 + ? configured.map((s) => ({ id: s.id, name: s.name, command: s.command })) + : DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s })); + setScripts(scriptList); + setOriginalScripts(structuredClone(scriptList)); + + isInitializedRef.current = true; + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectSettings, project.path]); + + // ── Change detection ── + const hasDevChanges = devCommand !== originalDevCommand; + const hasTestChanges = testCommand !== originalTestCommand; + const hasCommandChanges = hasDevChanges || hasTestChanges; + const hasScriptChanges = useMemo( + () => JSON.stringify(scripts) !== JSON.stringify(originalScripts), + [scripts, originalScripts] + ); + const hasChanges = hasCommandChanges || hasScriptChanges; + const isSaving = updateSettingsMutation.isPending; + + // ── Save all (commands + scripts) ── + const handleSave = useCallback(() => { + const normalizedDevCommand = devCommand.trim(); + const normalizedTestCommand = testCommand.trim(); + const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim()); + const normalizedScripts = validScripts.map((s) => ({ + id: s.id, + name: s.name.trim(), + command: s.command.trim(), + })); + + updateSettingsMutation.mutate( + { + devCommand: normalizedDevCommand || null, + testCommand: normalizedTestCommand || null, + terminalScripts: normalizedScripts, + }, + { + onSuccess: () => { + setDevCommand(normalizedDevCommand); + setOriginalDevCommand(normalizedDevCommand); + setTestCommand(normalizedTestCommand); + setOriginalTestCommand(normalizedTestCommand); + setScripts(normalizedScripts); + setOriginalScripts(structuredClone(normalizedScripts)); + }, + onError: (error) => { + toast.error('Failed to save settings', { + description: error instanceof Error ? error.message : 'An unexpected error occurred', + }); + }, + } + ); + }, [devCommand, testCommand, scripts, updateSettingsMutation]); + + // ── Reset all ── + const handleReset = useCallback(() => { + setDevCommand(originalDevCommand); + setTestCommand(originalTestCommand); + setScripts(structuredClone(originalScripts)); + }, [originalDevCommand, originalTestCommand, originalScripts]); + + // ── Command handlers ── + const handleUseDevPreset = useCallback((command: string) => { + setDevCommand(command); + }, []); + + const handleUseTestPreset = useCallback((command: string) => { + setTestCommand(command); + }, []); + + const handleClearDev = useCallback(() => { + setDevCommand(''); + }, []); + + const handleClearTest = useCallback(() => { + setTestCommand(''); + }, []); + + // ── Script handlers ── + const handleAddScript = useCallback(() => { + setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]); + }, []); + + const handleAddPreset = useCallback((preset: { name: string; command: string }) => { + setScripts((prev) => [ + ...prev, + { id: generateId(), name: preset.name, command: preset.command }, + ]); + }, []); + + const handleRemoveScript = useCallback((index: number) => { + setScripts((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleUpdateScript = useCallback( + (index: number, field: 'name' | 'command', value: string) => { + setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s))); + }, + [] + ); + + // Handle keyboard shortcuts (Enter to save) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && hasChanges && !isSaving) { + e.preventDefault(); + handleSave(); + } + }, + [hasChanges, isSaving, handleSave] + ); + + // ── Drag and drop handlers for script reordering ── + const handleDragStart = useCallback((index: number) => { + setDraggedIndex(index); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === index) return; + setDragOverIndex(index); + }, + [draggedIndex] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) { + setScripts((prev) => { + const newScripts = [...prev]; + const [removed] = newScripts.splice(draggedIndex, 1); + newScripts.splice(dragOverIndex, 0, removed); + return newScripts; + }); + } + setDraggedIndex(null); + setDragOverIndex(null); + }, + [draggedIndex, dragOverIndex] + ); + + const handleDragEnd = useCallback((_e: React.DragEvent) => { + setDraggedIndex(null); + setDragOverIndex(null); + }, []); + + // ── Keyboard reorder helpers for accessibility ── + const moveScript = useCallback((fromIndex: number, toIndex: number) => { + setScripts((prev) => { + if (toIndex < 0 || toIndex >= prev.length) return prev; + const newScripts = [...prev]; + const [removed] = newScripts.splice(fromIndex, 1); + newScripts.splice(toIndex, 0, removed); + return newScripts; + }); + }, []); + + const handleDragHandleKeyDown = useCallback( + (e: React.KeyboardEvent, index: number) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + moveScript(index, index - 1); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + moveScript(index, index + 1); + } else if (e.key === 'Home') { + e.preventDefault(); + moveScript(index, 0); + } else if (e.key === 'End') { + e.preventDefault(); + moveScript(index, scripts.length - 1); + } + }, + [moveScript, scripts.length] + ); + + return ( +
+ {/* ── Commands Card ── */} +
+
+
+
+ +
+

+ Project Commands +

+
+

+ Configure custom commands for development and testing. +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ Failed to load project settings. Please try again. +
+ ) : ( + <> + {/* Dev Server Command Section */} +
+
+ +

Dev Server

+ {hasDevChanges && ( + (unsaved) + )} +
+ +
+
+ setDevCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., npm run dev, yarn dev, cargo watch" + className="font-mono text-sm pr-8" + data-testid="dev-command-input" + /> + {devCommand && ( + + )} +
+

+ Leave empty to auto-detect based on your package manager. +

+ + {/* Dev Presets */} +
+ {DEV_SERVER_PRESETS.map((preset) => ( + + ))} +
+
+
+ + {/* Divider */} +
+ + {/* Test Command Section */} +
+
+ +

Test Runner

+ {hasTestChanges && ( + (unsaved) + )} +
+ +
+
+ setTestCommand(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="e.g., npm test, pytest, cargo test" + className="font-mono text-sm pr-8" + data-testid="test-command-input" + /> + {testCommand && ( + + )} +
+

+ Leave empty to auto-detect based on your project structure. +

+ + {/* Test Presets */} +
+ {TEST_PRESETS.map((preset) => ( + + ))} +
+
+
+ + {/* Auto-detection Info */} +
+ +
+

Auto-detection

+

+ When no custom command is set, the system automatically detects your package + manager and test framework based on project files (package.json, Cargo.toml, + go.mod, etc.). +

+
+
+ + )} +
+
+ + {/* ── Terminal Quick Scripts Card ── */} +
+ {/* Header */} +
+
+
+ +
+

+ Terminal Quick Scripts +

+
+

+ Configure quick-access scripts that appear in the terminal header dropdown. Click any + script to run it instantly. +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ Failed to load project settings. Please try again. +
+ ) : ( + <> + {/* Scripts List */} +
+ {scripts.map((script, index) => ( +
handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={(e) => handleDrop(e)} + onDragEnd={(e) => handleDragEnd(e)} + > + {/* Drag handle - keyboard accessible */} +
handleDragHandleKeyDown(e, index)} + > + +
+ + {/* Script name */} + handleUpdateScript(index, 'name', e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Script name" + className="h-8 text-sm flex-[0.4] min-w-0" + /> + + {/* Script command */} + handleUpdateScript(index, 'command', e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Command to run" + className="h-8 text-sm font-mono flex-[0.6] min-w-0" + /> + + {/* Remove button */} + +
+ ))} + + {scripts.length === 0 && ( +
+ No scripts configured. Add some below or use a preset. +
+ )} +
+ + {/* Add Script Button */} + + + {/* Divider */} +
+ + {/* Presets */} +
+

Quick Add Presets

+
+ {SCRIPT_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Info Box */} +
+ +
+

Terminal Quick Scripts

+

+ These scripts appear in the terminal header as a dropdown menu (the{' '} + icon). + Clicking a script will type the command into the active terminal and press + Enter. Drag to reorder scripts. +

+
+
+ + )} +
+
+ + {/* ── Shared Action Buttons ── */} +
+ + +
+
+ ); +} diff --git a/apps/ui/src/components/views/project-settings-view/config/navigation.ts b/apps/ui/src/components/views/project-settings-view/config/navigation.ts index 06c8d711..d62fa1a6 100644 --- a/apps/ui/src/components/views/project-settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/project-settings-view/config/navigation.ts @@ -7,7 +7,6 @@ import { Workflow, Database, Terminal, - ScrollText, } from 'lucide-react'; import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view'; @@ -20,8 +19,7 @@ export interface ProjectNavigationItem { export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [ { id: 'identity', label: 'Identity', icon: User }, { id: 'worktrees', label: 'Worktrees', icon: GitBranch }, - { id: 'commands', label: 'Commands', icon: Terminal }, - { id: 'scripts', label: 'Terminal Scripts', icon: ScrollText }, + { id: 'commands-scripts', label: 'Commands & Scripts', icon: Terminal }, { id: 'theme', label: 'Theme', icon: Palette }, { id: 'claude', label: 'Models', icon: Workflow }, { id: 'data', label: 'Data', icon: Database }, diff --git a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts index fc0e736f..18b5d762 100644 --- a/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts +++ b/apps/ui/src/components/views/project-settings-view/hooks/use-project-settings-view.ts @@ -6,6 +6,7 @@ export type ProjectSettingsViewId = | 'worktrees' | 'commands' | 'scripts' + | 'commands-scripts' | 'claude' | 'data' | 'danger'; diff --git a/apps/ui/src/components/views/project-settings-view/index.ts b/apps/ui/src/components/views/project-settings-view/index.ts index 82b99a77..eece0b0e 100644 --- a/apps/ui/src/components/views/project-settings-view/index.ts +++ b/apps/ui/src/components/views/project-settings-view/index.ts @@ -2,6 +2,8 @@ export { ProjectSettingsView } from './project-settings-view'; export { ProjectIdentitySection } from './project-identity-section'; export { ProjectThemeSection } from './project-theme-section'; export { WorktreePreferencesSection } from './worktree-preferences-section'; +export { CommandsAndScriptsSection } from './commands-and-scripts-section'; +// Legacy exports kept for backward compatibility export { CommandsSection } from './commands-section'; export { TerminalScriptsSection } from './terminal-scripts-section'; export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks'; diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx index 35afcfa2..21779799 100644 --- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button'; import { ProjectIdentitySection } from './project-identity-section'; import { ProjectThemeSection } from './project-theme-section'; import { WorktreePreferencesSection } from './worktree-preferences-section'; -import { CommandsSection } from './commands-section'; -import { TerminalScriptsSection } from './terminal-scripts-section'; +import { CommandsAndScriptsSection } from './commands-and-scripts-section'; import { ProjectModelsSection } from './project-models-section'; import { DataManagementSection } from './data-management-section'; import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section'; @@ -15,6 +14,8 @@ import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-fr import { ProjectSettingsNavigation } from './components/project-settings-navigation'; import { useProjectSettingsView } from './hooks/use-project-settings-view'; import type { Project as ElectronProject } from '@/lib/electron'; +import { useSearch } from '@tanstack/react-router'; +import type { ProjectSettingsViewId } from './hooks/use-project-settings-view'; // Breakpoint constant for mobile (matches Tailwind lg breakpoint) const LG_BREAKPOINT = 1024; @@ -34,8 +35,18 @@ export function ProjectSettingsView() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false); + // Read the optional section search param to support deep-linking to a specific section + const search = useSearch({ strict: false }) as { section?: ProjectSettingsViewId }; + // Map legacy 'commands' and 'scripts' IDs to the combined 'commands-scripts' section + const resolvedSection: ProjectSettingsViewId | undefined = + search.section === 'commands' || search.section === 'scripts' + ? 'commands-scripts' + : search.section; + // Use project settings view navigation hook - const { activeView, navigateTo } = useProjectSettingsView(); + const { activeView, navigateTo } = useProjectSettingsView({ + initialView: resolvedSection ?? 'identity', + }); // Mobile navigation state - default to showing on desktop, hidden on mobile const [showNavigation, setShowNavigation] = useState(() => { @@ -91,9 +102,9 @@ export function ProjectSettingsView() { case 'worktrees': return ; case 'commands': - return ; case 'scripts': - return ; + case 'commands-scripts': + return ; case 'claude': return ; case 'data': diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index e350a254..d74f5646 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -240,9 +240,17 @@ interface TerminalViewProps { initialMode?: 'tab' | 'split'; /** Unique nonce to allow opening the same worktree multiple times */ nonce?: number; + /** Command to run automatically when the terminal is created (e.g., from scripts submenu) */ + initialCommand?: string; } -export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) { +export function TerminalView({ + initialCwd, + initialBranch, + initialMode, + nonce, + initialCommand, +}: TerminalViewProps) { const { terminalState, setTerminalUnlocked, @@ -288,6 +296,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: const isCreatingRef = useRef(false); const restoringProjectPathRef = useRef(null); const [newSessionIds, setNewSessionIds] = useState>(new Set()); + // Per-session command overrides (e.g., from scripts submenu), takes priority over defaultRunScript + const [sessionCommandOverrides, setSessionCommandOverrides] = useState>( + new Map() + ); const [serverSessionInfo, setServerSessionInfo] = useState<{ current: number; max: number; @@ -576,7 +588,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: // Skip if we've already handled this exact request (prevents duplicate terminals) // Include mode and nonce in the key to allow opening same cwd multiple times - const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`; + const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}:${initialCommand || ''}`; if (initialCwdHandledRef.current === cwdKey) return; // Skip if terminal is not enabled or not unlocked @@ -618,8 +630,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: } // Mark this session as new for running initial command - if (defaultRunScript) { + if (initialCommand || defaultRunScript) { setNewSessionIds((prev) => new Set(prev).add(data.data.id)); + // Store per-session command override if an explicit command was provided + if (initialCommand) { + setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, initialCommand)); + } } // Show success toast with branch name if provided @@ -654,6 +670,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: initialCwd, initialBranch, initialMode, + initialCommand, nonce, status?.enabled, status?.passwordRequired, @@ -1059,7 +1076,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: // Create terminal in new tab // customCwd: optional working directory (e.g., a specific worktree path) // branchName: optional branch name to display in the terminal panel header - const createTerminalInNewTab = async (customCwd?: string, branchName?: string) => { + // command: optional command to run when the terminal connects (e.g., from scripts menu) + const createTerminalInNewTab = async ( + customCwd?: string, + branchName?: string, + command?: string + ) => { if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) { return; } @@ -1087,8 +1109,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: const { addTerminalToTab } = useAppStore.getState(); addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch); // Mark this session as new for running initial command - if (defaultRunScript) { + if (command || defaultRunScript) { setNewSessionIds((prev) => new Set(prev).add(data.data.id)); + // Store per-session command override if an explicit command was provided + if (command) { + setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, command)); + } } // Refresh session count fetchServerSettings(); @@ -1136,6 +1162,18 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: // Always remove from UI - even if server says 404 (session may have already exited) removeTerminalFromLayout(sessionId); + // Clean up stale entries for killed sessions + setSessionCommandOverrides((prev) => { + const next = new Map(prev); + next.delete(sessionId); + return next; + }); + setNewSessionIds((prev) => { + const next = new Set(prev); + next.delete(sessionId); + return next; + }); + if (!response.ok && response.status !== 404) { // Log non-404 errors but still proceed with UI cleanup const data = await response.json().catch(() => ({})); @@ -1148,6 +1186,17 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: logger.error('Kill session error:', err); // Still remove from UI on network error - better UX than leaving broken terminal removeTerminalFromLayout(sessionId); + // Clean up stale entries for killed sessions (same cleanup as try block) + setSessionCommandOverrides((prev) => { + const next = new Map(prev); + next.delete(sessionId); + return next; + }); + setNewSessionIds((prev) => { + const next = new Set(prev); + next.delete(sessionId); + return next; + }); } }; @@ -1182,6 +1231,22 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: }) ); + // Clean up stale entries for all killed sessions in this tab + setSessionCommandOverrides((prev) => { + const next = new Map(prev); + for (const sessionId of sessionIds) { + next.delete(sessionId); + } + return next; + }); + setNewSessionIds((prev) => { + const next = new Set(prev); + for (const sessionId of sessionIds) { + next.delete(sessionId); + } + return next; + }); + // Now remove the tab from state removeTerminalTab(tabId); // Refresh session count @@ -1255,6 +1320,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: next.delete(sessionId); return next; }); + // Clean up any per-session command override + setSessionCommandOverrides((prev) => { + const next = new Map(prev); + next.delete(sessionId); + return next; + }); }, []); // Navigate between terminal panes with directional awareness @@ -1387,6 +1458,9 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize; // Only run command on new sessions (not restored ones) const isNewSession = newSessionIds.has(content.sessionId); + // Per-session command override takes priority over defaultRunScript + const sessionCommand = sessionCommandOverrides.get(content.sessionId); + const runCommand = isNewSession ? sessionCommand || defaultRunScript : undefined; return ( { + const { cwd, branchName: branch } = getActiveSessionWorktreeInfo(); + createTerminalInNewTab(cwd, branch, command); + }} onNavigateUp={() => navigateToTerminal('up')} onNavigateDown={() => navigateToTerminal('down')} onNavigateLeft={() => navigateToTerminal('left')} @@ -1427,7 +1505,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} fontSize={terminalFontSize} onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)} - runCommandOnConnect={isNewSession ? defaultRunScript : undefined} + runCommandOnConnect={runCommand} onCommandRan={() => handleCommandRan(content.sessionId)} isMaximized={terminalState.maximizedSessionId === content.sessionId} onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)} @@ -1971,6 +2049,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName); }} onNewTab={createTerminalInNewTab} + onRunCommandInNewTab={(command: string) => { + const { cwd, branchName: branch } = getActiveSessionWorktreeInfo(); + createTerminalInNewTab(cwd, branch, command); + }} onSessionInvalid={() => { const sessionId = terminalState.maximizedSessionId!; logger.info(`Maximized session ${sessionId} is invalid, removing from layout`); @@ -1982,6 +2064,13 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: onFontSizeChange={(size) => setTerminalPanelFontSize(terminalState.maximizedSessionId!, size) } + runCommandOnConnect={ + newSessionIds.has(terminalState.maximizedSessionId) + ? sessionCommandOverrides.get(terminalState.maximizedSessionId) || + defaultRunScript + : undefined + } + onCommandRan={() => handleCommandRan(terminalState.maximizedSessionId!)} isMaximized={true} onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)} /> diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 76f0d625..8cf24b50 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useCallback, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { createLogger } from '@automaker/utils/logger'; import { X, @@ -90,6 +91,7 @@ interface TerminalPanelProps { onSplitHorizontal: () => void; onSplitVertical: () => void; onNewTab?: () => void; + onRunCommandInNewTab?: (command: string) => void; // Run a script command in a new terminal tab onNavigateUp?: () => void; // Navigate to terminal pane above onNavigateDown?: () => void; // Navigate to terminal pane below onNavigateLeft?: () => void; // Navigate to terminal pane on the left @@ -120,6 +122,7 @@ export function TerminalPanel({ onSplitHorizontal, onSplitVertical, onNewTab, + onRunCommandInNewTab, onNavigateUp, onNavigateDown, onNavigateLeft, @@ -135,6 +138,7 @@ export function TerminalPanel({ onToggleMaximize, branchName, }: TerminalPanelProps) { + const navigate = useNavigate(); const terminalRef = useRef(null); const containerRef = useRef(null); const xtermRef = useRef(null); @@ -2071,7 +2075,11 @@ export function TerminalPanel({ {/* Quick scripts dropdown */} + navigate({ to: '/project-settings', search: { section: 'commands-scripts' } }) + } /> {/* Settings popover */} diff --git a/apps/ui/src/components/views/terminal-view/terminal-scripts-dropdown.tsx b/apps/ui/src/components/views/terminal-view/terminal-scripts-dropdown.tsx index 26c4ed7a..ac82286b 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-scripts-dropdown.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-scripts-dropdown.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { ScrollText, Play, Settings2 } from 'lucide-react'; +import { ScrollText, Play, Settings2, SquareArrowOutUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -17,6 +17,8 @@ import { DEFAULT_TERMINAL_SCRIPTS } from '../project-settings-view/terminal-scri interface TerminalScriptsDropdownProps { /** Callback to send a command + newline to the terminal */ onRunCommand: (command: string) => void; + /** Callback to run a command in a new terminal tab */ + onRunCommandInNewTab?: (command: string) => void; /** Whether the terminal is connected and ready */ isConnected: boolean; /** Optional callback to navigate to project settings scripts section */ @@ -25,11 +27,13 @@ interface TerminalScriptsDropdownProps { /** * Dropdown menu in the terminal header bar that provides quick-access - * to user-configured project scripts. Clicking a script inserts the - * command into the terminal and presses Enter. + * to user-configured project scripts. Each script is a split button: + * clicking the left side runs the command in the current terminal, + * clicking the "new tab" icon on the right runs it in a new tab. */ export function TerminalScriptsDropdown({ onRunCommand, + onRunCommandInNewTab, isConnected, onOpenSettings, }: TerminalScriptsDropdownProps) { @@ -53,6 +57,14 @@ export function TerminalScriptsDropdown({ [isConnected, onRunCommand] ); + const handleRunScriptInNewTab = useCallback( + (command: string) => { + if (!isConnected || !onRunCommandInNewTab) return; + onRunCommandInNewTab(command); + }, + [isConnected, onRunCommandInNewTab] + ); + return ( @@ -82,7 +94,7 @@ export function TerminalScriptsDropdown({ key={script.id} onClick={() => handleRunScript(script.command)} disabled={!isConnected} - className="gap-2" + className="gap-2 pr-1" >
@@ -91,17 +103,43 @@ export function TerminalScriptsDropdown({ {script.command}
+ {onRunCommandInNewTab && ( + + )} ))} - {onOpenSettings && ( - <> - - - - Configure Scripts... - - - )} + + + + Edit Commands & Scripts +
); diff --git a/apps/ui/src/hooks/mutations/index.ts b/apps/ui/src/hooks/mutations/index.ts index 9cab4bea..e0d591bf 100644 --- a/apps/ui/src/hooks/mutations/index.ts +++ b/apps/ui/src/hooks/mutations/index.ts @@ -62,6 +62,7 @@ export { useValidateIssue, useMarkValidationViewed, useGetValidationStatus, + useResolveReviewThread, } from './use-github-mutations'; // Ideation mutations diff --git a/apps/ui/src/hooks/mutations/use-github-mutations.ts b/apps/ui/src/hooks/mutations/use-github-mutations.ts index 546b1edd..9f921a8a 100644 --- a/apps/ui/src/hooks/mutations/use-github-mutations.ts +++ b/apps/ui/src/hooks/mutations/use-github-mutations.ts @@ -135,6 +135,55 @@ export function useMarkValidationViewed(projectPath: string) { }); } +/** + * Resolve or unresolve a PR review thread + * + * @param projectPath - Path to the project + * @param prNumber - PR number (for cache invalidation) + * @returns Mutation for resolving/unresolving a review thread + * + * @example + * ```tsx + * const resolveThread = useResolveReviewThread(projectPath, prNumber); + * resolveThread.mutate({ threadId: comment.threadId, resolve: true }); + * ``` + */ +export function useResolveReviewThread(projectPath: string, prNumber: number) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ threadId, resolve }: { threadId: string; resolve: boolean }) => { + const api = getElectronAPI(); + if (!api.github?.resolveReviewThread) { + throw new Error('Resolve review thread API not available'); + } + + const result = await api.github.resolveReviewThread(projectPath, threadId, resolve); + + if (!result.success) { + throw new Error(result.error || 'Failed to resolve review thread'); + } + + return { isResolved: result.isResolved ?? resolve }; + }, + onSuccess: (_, variables) => { + const action = variables.resolve ? 'resolved' : 'unresolved'; + toast.success(`Comment ${action}`, { + description: `The review thread has been ${action} on GitHub`, + }); + // Invalidate the PR review comments cache to reflect updated resolved status + queryClient.invalidateQueries({ + queryKey: queryKeys.github.prReviewComments(projectPath, prNumber), + }); + }, + onError: (error) => { + toast.error('Failed to update comment', { + description: error instanceof Error ? error.message : 'Unknown error', + }); + }, + }); +} + /** * Get running validation status * diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 5a5730ac..66733734 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -20,6 +20,7 @@ export { useGitHubValidations, useGitHubRemote, useGitHubIssueComments, + useGitHubPRReviewComments, } from './use-github'; // Usage diff --git a/apps/ui/src/hooks/queries/use-features.ts b/apps/ui/src/hooks/queries/use-features.ts index 67afbfd5..91936af4 100644 --- a/apps/ui/src/hooks/queries/use-features.ts +++ b/apps/ui/src/hooks/queries/use-features.ts @@ -6,8 +6,8 @@ * automatic caching, deduplication, and background refetching. */ -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMemo, useEffect, useRef } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; @@ -151,6 +151,34 @@ export function useFeatures(projectPath: string | undefined) { [projectPath] ); + const queryClient = useQueryClient(); + + // Subscribe to React Query cache changes for features and sync to localStorage. + // This ensures optimistic updates (e.g., status changes to 'verified') are + // persisted to localStorage immediately, not just when queryFn runs. + // Without this, a page refresh after an optimistic update could show stale + // localStorage data where features appear in the wrong column (e.g., verified + // features showing up in backlog). + const projectPathRef = useRef(projectPath); + projectPathRef.current = projectPath; + useEffect(() => { + if (!projectPath) return; + const targetQueryHash = JSON.stringify(queryKeys.features.all(projectPath)); + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.query.queryHash === targetQueryHash + ) { + const features = event.query.state.data as Feature[] | undefined; + if (features && projectPathRef.current) { + writePersistedFeatures(projectPathRef.current, features); + } + } + }); + return unsubscribe; + }, [projectPath, queryClient]); + return useQuery({ queryKey: queryKeys.features.all(projectPath ?? ''), queryFn: async (): Promise => { @@ -166,7 +194,11 @@ export function useFeatures(projectPath: string | undefined) { }, enabled: !!projectPath, initialData: () => persisted?.features, - initialDataUpdatedAt: () => persisted?.timestamp, + // Always treat localStorage cache as stale so React Query immediately + // fetches fresh data from the server on page load. This prevents stale + // feature statuses (e.g., 'verified' features appearing in backlog) + // while still showing cached data instantly for a fast initial render. + initialDataUpdatedAt: 0, staleTime: STALE_TIMES.FEATURES, refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL), refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, diff --git a/apps/ui/src/hooks/queries/use-github.ts b/apps/ui/src/hooks/queries/use-github.ts index 14181956..d11c95b4 100644 --- a/apps/ui/src/hooks/queries/use-github.ts +++ b/apps/ui/src/hooks/queries/use-github.ts @@ -8,7 +8,13 @@ import { useQuery, useInfiniteQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { GitHubIssue, GitHubPR, GitHubComment, StoredValidation } from '@/lib/electron'; +import type { + GitHubIssue, + GitHubPR, + GitHubComment, + PRReviewComment, + StoredValidation, +} from '@/lib/electron'; interface GitHubIssuesResult { openIssues: GitHubIssue[]; @@ -197,3 +203,45 @@ export function useGitHubIssueComments( staleTime: STALE_TIMES.GITHUB, }); } + +/** + * Fetch review comments for a GitHub PR + * + * Fetches both regular PR comments and inline code review comments + * with file path and line context for each. + * + * @param projectPath - Path to the project + * @param prNumber - PR number + * @returns Query result with review comments + * + * @example + * ```tsx + * const { data, isLoading } = useGitHubPRReviewComments(projectPath, prNumber); + * const comments = data?.comments ?? []; + * ``` + */ +export function useGitHubPRReviewComments( + projectPath: string | undefined, + prNumber: number | undefined +) { + return useQuery({ + queryKey: queryKeys.github.prReviewComments(projectPath ?? '', prNumber ?? 0), + queryFn: async (): Promise<{ comments: PRReviewComment[]; totalCount: number }> => { + if (!projectPath || !prNumber) throw new Error('Missing project path or PR number'); + const api = getElectronAPI(); + if (!api.github) { + throw new Error('GitHub API not available'); + } + const result = await api.github.getPRReviewComments(projectPath, prNumber); + if (!result.success) { + throw new Error(result.error || 'Failed to fetch PR review comments'); + } + return { + comments: (result.comments ?? []) as PRReviewComment[], + totalCount: result.totalCount ?? 0, + }; + }, + enabled: !!projectPath && !!prNumber, + staleTime: STALE_TIMES.GITHUB, + }); +} diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 69ef6be1..7fbddf01 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -100,6 +100,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'projectHistory', 'projectHistoryIndex', 'lastSelectedSessionByProject', + 'currentWorktreeByProject', // Codex CLI Settings 'codexAutoLoadAgents', 'codexSandboxMode', @@ -768,6 +769,8 @@ export async function refreshSettingsFromServer(): Promise { projectHistory: serverSettings.projectHistory, projectHistoryIndex: serverSettings.projectHistoryIndex, lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject, + currentWorktreeByProject: + serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject, // UI State (previously in localStorage) worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false, lastProjectDir: serverSettings.lastProjectDir ?? '', diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 0abc8cea..a32dbf20 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -313,6 +313,34 @@ export interface GitHubRemoteStatus { repo: string | null; } +/** A review comment on a pull request (inline code comment or general PR comment) */ +export interface PRReviewComment { + id: string; + author: string; + avatarUrl?: string; + body: string; + /** File path for inline review comments */ + path?: string; + /** Line number for inline review comments */ + line?: number; + createdAt: string; + updatedAt?: string; + /** Whether this is an inline code review comment (vs general PR comment) */ + isReviewComment: boolean; + /** Whether this comment is outdated (code has changed since) */ + isOutdated?: boolean; + /** Whether the review thread containing this comment has been resolved */ + isResolved?: boolean; + /** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */ + threadId?: string; + /** The diff hunk context for the comment */ + diffHunk?: string; + /** The side of the diff (LEFT or RIGHT) */ + side?: string; + /** The commit ID the comment was made on */ + commitId?: string; +} + export interface GitHubAPI { checkRemote: (projectPath: string) => Promise<{ success: boolean; @@ -389,6 +417,26 @@ export interface GitHubAPI { endCursor?: string; error?: string; }>; + /** Fetch review comments for a specific pull request */ + getPRReviewComments: ( + projectPath: string, + prNumber: number + ) => Promise<{ + success: boolean; + comments?: PRReviewComment[]; + totalCount?: number; + error?: string; + }>; + /** Resolve or unresolve a PR review thread */ + resolveReviewThread: ( + projectPath: string, + threadId: string, + resolve: boolean + ) => Promise<{ + success: boolean; + isResolved?: boolean; + error?: string; + }>; } // Spec Regeneration types @@ -3980,6 +4028,21 @@ function createMockGitHubAPI(): GitHubAPI { hasNextPage: false, }; }, + getPRReviewComments: async (projectPath: string, prNumber: number) => { + console.log('[Mock] Getting PR review comments:', { projectPath, prNumber }); + return { + success: true, + comments: [], + totalCount: 0, + }; + }, + resolveReviewThread: async (projectPath: string, threadId: string, resolve: boolean) => { + console.log('[Mock] Resolving review thread:', { projectPath, threadId, resolve }); + return { + success: true, + isResolved: resolve, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index e9e39564..8e6ebb05 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -2474,6 +2474,10 @@ export class HttpApiClient implements ElectronAPI { this.subscribeToEvent('issue-validation:event', callback as EventCallback), getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) => this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }), + getPRReviewComments: (projectPath: string, prNumber: number) => + this.post('/api/github/pr-review-comments', { projectPath, prNumber }), + resolveReviewThread: (projectPath: string, threadId: string, resolve: boolean) => + this.post('/api/github/resolve-pr-comment', { projectPath, threadId, resolve }), }; // Workspace API diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index 70c2679a..864622f1 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -70,6 +70,9 @@ export const queryKeys = { /** Issue comments */ issueComments: (projectPath: string, issueNumber: number) => ['github', 'issues', projectPath, issueNumber, 'comments'] as const, + /** PR review comments */ + prReviewComments: (projectPath: string, prNumber: number) => + ['github', 'prs', projectPath, prNumber, 'review-comments'] as const, /** Remote info */ remote: (projectPath: string) => ['github', 'remote', projectPath] as const, }, diff --git a/apps/ui/src/renderer.tsx b/apps/ui/src/renderer.tsx index 88e0109d..147dc38f 100644 --- a/apps/ui/src/renderer.tsx +++ b/apps/ui/src/renderer.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './app'; +import { AppErrorBoundary } from './components/ui/app-error-boundary'; import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect'; // Defensive fallback: index.html's inline script already applies data-pwa="standalone" @@ -250,8 +251,12 @@ function warmAssetCache(registration: ServiceWorkerRegistration): void { } // Render the app - prioritize First Contentful Paint +// AppErrorBoundary catches uncaught React errors and shows a friendly error screen +// instead of TanStack Router's default "Something went wrong!" overlay. createRoot(document.getElementById('app')!).render( - + + + ); diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index d7e3f73e..7aefe99e 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -400,19 +400,16 @@ function RootLayoutContent() { useEffect(() => { const handleLoggedOut = () => { logger.warn('automaker:logged-out event received!'); + // Only update auth state — the centralized routing effect will handle + // navigation to /logged-out when it detects isAuthenticated is false useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); - - if (location.pathname !== '/logged-out') { - logger.warn('Navigating to /logged-out due to logged-out event'); - navigate({ to: '/logged-out' }); - } }; window.addEventListener('automaker:logged-out', handleLoggedOut); return () => { window.removeEventListener('automaker:logged-out', handleLoggedOut); }; - }, [location.pathname, navigate]); + }, []); // Global listener for server offline/connection errors. // This is triggered when a connection error is detected (e.g., server stopped). @@ -724,33 +721,31 @@ function RootLayoutContent() { } // If we can't load settings, we must NOT start syncing defaults to the server. + // Only update auth state — the routing effect handles navigation to /logged-out. + // Calling navigate() here AND in the routing effect causes duplicate navigations + // that can trigger React error #185 (maximum update depth exceeded) on cold start. useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); signalMigrationComplete(); - if (location.pathname !== '/logged-out' && location.pathname !== '/login') { - navigate({ to: '/logged-out' }); - } return; } } else { - // Session is definitively invalid (server returned 401/403) - treat as not authenticated + // Session is definitively invalid (server returned 401/403) - treat as not authenticated. + // Only update auth state — the routing effect handles navigation to /logged-out. + // Calling navigate() here AND in the routing effect causes duplicate navigations + // that can trigger React error #185 (maximum update depth exceeded) on cold start. useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); // Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated) signalMigrationComplete(); - - // Redirect to logged-out if not already there or login - if (location.pathname !== '/logged-out' && location.pathname !== '/login') { - navigate({ to: '/logged-out' }); - } } } catch (error) { logger.error('Failed to initialize auth:', error); - // On error, treat as not authenticated + // On error, treat as not authenticated. + // Only update auth state — the routing effect handles navigation to /logged-out. + // Calling navigate() here AND in the routing effect causes duplicate navigations + // that can trigger React error #185 (maximum update depth exceeded) on cold start. useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); // Signal migration complete so sync hook doesn't hang signalMigrationComplete(); - if (location.pathname !== '/logged-out' && location.pathname !== '/login') { - navigate({ to: '/logged-out' }); - } } finally { authCheckRunning.current = false; } diff --git a/apps/ui/src/routes/project-settings.tsx b/apps/ui/src/routes/project-settings.tsx index e933d58d..37e72f7c 100644 --- a/apps/ui/src/routes/project-settings.tsx +++ b/apps/ui/src/routes/project-settings.tsx @@ -1,6 +1,16 @@ import { createFileRoute } from '@tanstack/react-router'; import { ProjectSettingsView } from '@/components/views/project-settings-view'; +import type { ProjectSettingsViewId } from '@/components/views/project-settings-view/hooks/use-project-settings-view'; + +interface ProjectSettingsSearchParams { + section?: ProjectSettingsViewId; +} export const Route = createFileRoute('/project-settings')({ component: ProjectSettingsView, + validateSearch: (search: Record): ProjectSettingsSearchParams => { + return { + section: search.section as ProjectSettingsViewId | undefined, + }; + }, }); diff --git a/apps/ui/src/routes/terminal.lazy.tsx b/apps/ui/src/routes/terminal.lazy.tsx index a1a0e8de..63432233 100644 --- a/apps/ui/src/routes/terminal.lazy.tsx +++ b/apps/ui/src/routes/terminal.lazy.tsx @@ -6,6 +6,14 @@ export const Route = createLazyFileRoute('/terminal')({ }); function RouteComponent() { - const { cwd, branch, mode, nonce } = useSearch({ from: '/terminal' }); - return ; + const { cwd, branch, mode, nonce, command } = useSearch({ from: '/terminal' }); + return ( + + ); } diff --git a/apps/ui/src/routes/terminal.tsx b/apps/ui/src/routes/terminal.tsx index ed5621ee..d2878cb5 100644 --- a/apps/ui/src/routes/terminal.tsx +++ b/apps/ui/src/routes/terminal.tsx @@ -6,6 +6,7 @@ const terminalSearchSchema = z.object({ branch: z.string().optional(), mode: z.enum(['tab', 'split']).optional(), nonce: z.coerce.number().optional(), + command: z.string().optional(), }); // Component is lazy-loaded via terminal.lazy.tsx for code splitting