1 Commits

Author SHA1 Message Date
gsxdsm
aa345a50ac feat: Add PR review comments and resolution endpoints, improve prompt handling 2026-02-20 16:08:15 -08:00
30 changed files with 911 additions and 1457 deletions

View File

@@ -1,37 +0,0 @@
/**
* 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);
}

View File

@@ -24,9 +24,7 @@ export function createWriteHandler() {
// Ensure parent directory exists (symlink-safe) // Ensure parent directory exists (symlink-safe)
await mkdirSafe(path.dirname(path.resolve(filePath))); await mkdirSafe(path.dirname(path.resolve(filePath)));
// Default content to empty string if undefined/null to prevent writing await secureFs.writeFile(filePath, content, 'utf-8');
// "undefined" as literal text (e.g. when content field is missing from request)
await secureFs.writeFile(filePath, content ?? '', 'utf-8');
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {

View File

@@ -1,14 +1,38 @@
/** /**
* Common utilities for GitHub routes * 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 { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
const logger = createLogger('GitHub');
export const execAsync = promisify(exec); export const execAsync = promisify(exec);
// Re-export shared utilities from the canonical location // Extended PATH to include common tool installation locations
export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js'; 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);
}

View File

@@ -5,26 +5,287 @@
* for a specific pull request, providing file path and line context. * for a specific pull request, providing file path and line context.
*/ */
import { spawn } from 'child_process';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getErrorMessage, logError } from './common.js'; import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.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 interface PRReviewComment {
export type { PRReviewComment, ListPRReviewCommentsResult }; id: string;
// Re-export service functions so existing callers continue to work author: string;
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus }; 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;
}
interface ListPRReviewCommentsRequest { interface ListPRReviewCommentsRequest {
projectPath: string; projectPath: string;
prNumber: number; prNumber: number;
} }
/** 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: {
nodes: GraphQLReviewThreadComment[];
};
}
interface GraphQLResponse {
data?: {
repository?: {
pullRequest?: {
reviewThreads?: {
nodes: GraphQLReviewThread[];
};
} | null;
};
};
errors?: Array<{ message: string }>;
}
interface ReviewThreadInfo {
isResolved: boolean;
threadId: string;
}
/**
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
* Returns a map of comment ID (string) -> { isResolved, threadId }.
*/
async function fetchReviewThreadResolvedStatus(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<Map<string, ReviewThreadInfo>> {
const resolvedMap = new Map<string, ReviewThreadInfo>();
const query = `
query GetPRReviewThreads(
$owner: String!
$repo: String!
$prNumber: Int!
) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100) {
nodes {
id
isResolved
comments(first: 100) {
nodes {
databaseId
}
}
}
}
}
}
}`;
const variables = { owner, repo, prNumber };
const requestBody = JSON.stringify({ query, variables });
try {
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
const 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);
}
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
for (const thread of threads) {
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)
*/
async function fetchPRReviewComments(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<PRReviewComment[]> {
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 execAsync(
`gh pr view ${prNumber} -R ${owner}/${repo} --json comments`,
{
cwd: projectPath,
env: execEnv,
}
);
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 execAsync(`gh api ${reviewsEndpoint} --paginate`, {
cwd: projectPath,
env: execEnv,
});
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 && !c.line,
// 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;
if (resolvedMap.size > 0) {
for (const comment of allComments) {
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
const info = resolvedMap.get(comment.id);
comment.isResolved = info?.isResolved ?? false;
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;
}
export function createListPRReviewCommentsHandler() { export function createListPRReviewCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {

View File

@@ -5,10 +5,10 @@
* identified by its GraphQL node ID (threadId). * identified by its GraphQL node ID (threadId).
*/ */
import { spawn } from 'child_process';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { getErrorMessage, logError } from './common.js'; import { execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js'; import { checkGitHubRemote } from './check-github-remote.js';
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
export interface ResolvePRCommentResult { export interface ResolvePRCommentResult {
success: boolean; success: boolean;
@@ -22,6 +22,91 @@ interface ResolvePRCommentRequest {
resolve: boolean; resolve: boolean;
} }
/** 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.
*/
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 });
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
const 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 };
}
export function createResolvePRCommentHandler() { export function createResolvePRCommentHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {

View File

@@ -6,7 +6,7 @@
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { execFile } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
@@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js';
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
const logger = createLogger('GenerateCommitMessage'); const logger = createLogger('GenerateCommitMessage');
const execFileAsync = promisify(execFile); const execAsync = promisify(exec);
/** Timeout for AI provider calls in milliseconds (30 seconds) */ /** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000; const AI_TIMEOUT_MS = 30_000;
@@ -33,39 +33,20 @@ async function* withTimeout<T>(
generator: AsyncIterable<T>, generator: AsyncIterable<T>,
timeoutMs: number timeoutMs: number
): AsyncGenerator<T, void, unknown> { ): AsyncGenerator<T, void, unknown> {
let timerId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
timerId = setTimeout( setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
timeoutMs
);
}); });
const iterator = generator[Symbol.asyncIterator](); const iterator = generator[Symbol.asyncIterator]();
let done = false; let done = false;
try { while (!done) {
while (!done) { const result = await Promise.race([iterator.next(), timeoutPromise]);
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => { if (result.done) {
// Capture the original error, then attempt to close the iterator. done = true;
// If iterator.return() throws, log it but rethrow the original error } else {
// so the timeout error (not the teardown error) is preserved. yield result.value;
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);
} }
} }
@@ -136,14 +117,14 @@ export function createGenerateCommitMessageHandler(
let diff = ''; let diff = '';
try { try {
// First try to get staged changes // First try to get staged changes
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], { const { stdout: stagedDiff } = await execAsync('git diff --cached', {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer maxBuffer: 1024 * 1024 * 5, // 5MB buffer
}); });
// If no staged changes, get unstaged changes // If no staged changes, get unstaged changes
if (!stagedDiff.trim()) { if (!stagedDiff.trim()) {
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], { const { stdout: unstagedDiff } = await execAsync('git diff', {
cwd: worktreePath, cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer maxBuffer: 1024 * 1024 * 5, // 5MB buffer
}); });
@@ -241,7 +222,7 @@ export function createGenerateCommitMessageHandler(
const message = responseText.trim(); const message = responseText.trim();
if (!message) { if (!message || message.trim().length === 0) {
logger.warn('Received empty response from model'); logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = { const response: GenerateCommitMessageErrorResponse = {
success: false, success: false,

View File

@@ -1,103 +0,0 @@
/**
* 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<GraphQLMutationResponse>((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 };
}

View File

@@ -1,338 +0,0 @@
/**
* 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<Map<string, ReviewThreadInfo>> {
const resolvedMap = new Map<string, ReviewThreadInfo>();
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<GraphQLResponse>((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<PRReviewComment[]> {
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;
}

View File

@@ -117,8 +117,6 @@ const eslintConfig = defineConfig([
Electron: 'readonly', Electron: 'readonly',
// Console // Console
console: 'readonly', console: 'readonly',
// Structured clone (modern browser/Node API)
structuredClone: 'readonly',
// Vite defines // Vite defines
__APP_VERSION__: 'readonly', __APP_VERSION__: 'readonly',
__APP_BUILD_HASH__: 'readonly', __APP_BUILD_HASH__: 'readonly',

View File

@@ -36,7 +36,8 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Markdown } from '@/components/ui/markdown'; import { Markdown } from '@/components/ui/markdown';
import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils'; import { ScrollArea } from '@/components/ui/scroll-area';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useGitHubPRReviewComments } from '@/hooks/queries'; import { useGitHubPRReviewComments } from '@/hooks/queries';
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations'; import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
@@ -45,8 +46,7 @@ import type { PRReviewComment } from '@/lib/electron';
import type { Feature } from '@/store/app-store'; import type { Feature } from '@/store/app-store';
import type { PhaseModelEntry } from '@automaker/types'; import type { PhaseModelEntry } from '@automaker/types';
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types'; import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults';
// ============================================ // ============================================
// Types // Types
@@ -75,7 +75,7 @@ interface PRCommentResolutionDialogProps {
/** Generate a feature ID */ /** Generate a feature ID */
function generateFeatureId(): string { function generateFeatureId(): string {
return generateUUID(); return `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
} }
/** Format a date string for display */ /** Format a date string for display */
@@ -413,7 +413,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6"> <ScrollArea className="flex-1 min-h-0 h-full -mx-6 px-6">
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
{/* Author & metadata section */} {/* Author & metadata section */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
@@ -495,7 +495,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
</div> </div>
)} )}
</div> </div>
</div> </ScrollArea>
<DialogFooter className="mt-4"> <DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -565,9 +565,6 @@ export function PRCommentResolutionDialog({
>([]); >([]);
const [detailComment, setDetailComment] = useState<PRReviewComment | null>(null); const [detailComment, setDetailComment] = useState<PRReviewComment | null>(null);
// Per-thread resolving state - tracks which threads are currently being resolved/unresolved
const [resolvingThreads, setResolvingThreads] = useState<Set<string>>(new Set());
// Model selection state // Model selection state
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-sonnet' }); const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
@@ -638,8 +635,8 @@ export function PRCommentResolutionDialog({
const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number); const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number);
// Derived state // Derived state
const allSelected = comments.length > 0 && comments.every((c) => selectedIds.has(c.id)); const allSelected = comments.length > 0 && selectedIds.size === comments.length;
const someSelected = selectedIds.size > 0 && !allSelected; const someSelected = selectedIds.size > 0 && selectedIds.size < comments.length;
const noneSelected = selectedIds.size === 0; const noneSelected = selectedIds.size === 0;
// ============================================ // ============================================
@@ -661,24 +658,7 @@ export function PRCommentResolutionDialog({
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
(comment: PRReviewComment, resolve: boolean) => { (comment: PRReviewComment, resolve: boolean) => {
if (!comment.threadId) return; if (!comment.threadId) return;
const threadId = comment.threadId; resolveThread.mutate({ threadId: comment.threadId, resolve });
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] [resolveThread]
); );
@@ -723,7 +703,7 @@ export function PRCommentResolutionDialog({
const selectedComments = comments.filter((c) => selectedIds.has(c.id)); const selectedComments = comments.filter((c) => selectedIds.has(c.id));
// Resolve model settings from the current model entry // Resolve model settings from the current model entry
const selectedModel = resolveModelString(modelEntry.model); const selectedModel = modelEntry.model;
const normalizedThinking = modelSupportsThinking(selectedModel) const normalizedThinking = modelSupportsThinking(selectedModel)
? modelEntry.thinkingLevel || 'none' ? modelEntry.thinkingLevel || 'none'
: 'none'; : 'none';
@@ -830,7 +810,6 @@ export function PRCommentResolutionDialog({
setShowResolved(false); setShowResolved(false);
setCreationErrors([]); setCreationErrors([]);
setDetailComment(null); setDetailComment(null);
setResolvingThreads(new Set());
setModelEntry(effectiveDefaultFeatureModel); setModelEntry(effectiveDefaultFeatureModel);
} }
onOpenChange(newOpen); onOpenChange(newOpen);
@@ -1029,9 +1008,7 @@ export function PRCommentResolutionDialog({
onToggle={() => handleToggleComment(comment.id)} onToggle={() => handleToggleComment(comment.id)}
onExpandDetail={() => setDetailComment(comment)} onExpandDetail={() => setDetailComment(comment)}
onResolve={handleResolveComment} onResolve={handleResolveComment}
isResolvingThread={ isResolvingThread={resolveThread.isPending}
!!comment.threadId && resolvingThreads.has(comment.threadId)
}
/> />
))} ))}
</div> </div>

View File

@@ -108,9 +108,7 @@ export function useProjectPicker({
setIsProjectPickerOpen(false); setIsProjectPickerOpen(false);
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
selectHighlightedProject().catch(() => { selectHighlightedProject();
/* Error already logged upstream */
});
} else if (event.key === 'ArrowDown') { } else if (event.key === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev)); setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));

View File

@@ -1,131 +0,0 @@
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<Props, State> {
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 (
<div
className="flex h-screen w-full flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
data-testid="app-error-boundary"
>
{/* Logo matching the app shell in index.html */}
<svg
className="h-14 w-14 opacity-90"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
className="fill-foreground/[0.08]"
x="16"
y="16"
width="224"
height="224"
rx="56"
/>
<g
className="stroke-foreground/70"
fill="none"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<div className="text-center space-y-2">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="text-sm text-muted-foreground max-w-md">
The application encountered an unexpected error. This is usually temporary and can be
resolved by reloading the page.
</p>
</div>
<button
type="button"
onClick={this.handleReload}
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
Reload Page
</button>
{/* Collapsible technical details for debugging */}
{this.state.error && (
<details className="text-xs text-muted-foreground max-w-lg w-full">
<summary className="cursor-pointer hover:text-foreground text-center">
Technical details
</summary>
<pre className="mt-2 p-3 bg-muted/50 rounded-md text-left overflow-auto max-h-32 border border-border">
{this.state.error.stack || this.state.error.message}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}

View File

@@ -33,11 +33,11 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types'; import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
import { pathsEqual } from '@/lib/utils'; import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { import {
BoardBackgroundModal,
PRCommentResolutionDialog, PRCommentResolutionDialog,
type PRCommentResolutionPRInfo, type PRCommentResolutionPRInfo,
} from '@/components/dialogs'; } from '@/components/dialogs/pr-comment-resolution-dialog';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode'; import { useAutoMode } from '@/hooks/use-auto-mode';
import { resolveModelString } from '@automaker/model-resolver'; import { resolveModelString } from '@automaker/model-resolver';
@@ -1031,7 +1031,7 @@ export function BoardView() {
images: [], images: [],
imagePaths: [], imagePaths: [],
skipTests: defaultSkipTests, skipTests: defaultSkipTests,
model: resolveModelString('opus'), model: 'opus' as const,
thinkingLevel: 'none' as const, thinkingLevel: 'none' as const,
branchName: conflictInfo.targetBranch, branchName: conflictInfo.targetBranch,
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved

View File

@@ -227,9 +227,9 @@ export function AddFeatureDialog({
if (justOpened) { if (justOpened) {
// Initialize with prefilled values if provided, otherwise use defaults // Initialize with prefilled values if provided, otherwise use defaults
setTitle(prefilledTitle ?? ''); setTitle(prefilledTitle || '');
setDescription(prefilledDescription ?? ''); setDescription(prefilledDescription || '');
setCategory(prefilledCategory ?? ''); setCategory(prefilledCategory || '');
setSkipTests(defaultSkipTests); setSkipTests(defaultSkipTests);
// When a non-main worktree is selected, use its branch name for custom mode // When a non-main worktree is selected, use its branch name for custom mode

View File

@@ -299,13 +299,9 @@ export function CreatePRDialog({
const api = getHttpApiClient(); const api = getHttpApiClient();
// Resolve the display name to the actual branch name for the API // Resolve the display name to the actual branch name for the API
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch; const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
// Only strip the remote prefix if the resolved ref differs from the original const branchNameForApi = resolvedRef.includes('/')
// (indicating it was resolved from a full ref like "origin/main"). ? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
// This preserves local branch names that contain slashes (e.g. "release/1.0"). : resolvedRef;
const branchNameForApi =
resolvedRef !== baseBranch && resolvedRef.includes('/')
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
: resolvedRef;
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi); const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
if (result.success) { if (result.success) {
@@ -348,13 +344,11 @@ export function CreatePRDialog({
// since the backend handles branch resolution. However, if the full ref is // since the backend handles branch resolution. However, if the full ref is
// available, we can use it for more precise targeting. // available, we can use it for more precise targeting.
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch; const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
// Only strip the remote prefix if the resolved ref differs from the original // Strip the remote prefix from the resolved ref for the API call
// (indicating it was resolved from a full ref like "origin/main"). // (e.g. "origin/main" → "main") since the backend expects the branch name only
// This preserves local branch names that contain slashes (e.g. "release/1.0"). const baseBranchForApi = resolvedBaseBranch.includes('/')
const baseBranchForApi = ? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
resolvedBaseBranch !== baseBranch && resolvedBaseBranch.includes('/') : resolvedBaseBranch;
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
: resolvedBaseBranch;
const result = await api.worktree.createPR(worktree.path, { const result = await api.worktree.createPR(worktree.path, {
projectPath: projectPath || undefined, projectPath: projectPath || undefined,

View File

@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -252,21 +251,6 @@ export function WorktreeActionsDropdown({
// Determine if the destructive/bottom section has any visible items // Determine if the destructive/bottom section has any visible items
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain; const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
// Pre-compute PR info for the PR submenu (avoids an IIFE in JSX)
const prInfo = useMemo<PRInfo | null>(() => {
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 ( return (
<DropdownMenu onOpenChange={onOpenChange}> <DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -387,18 +371,16 @@ export function WorktreeActionsDropdown({
? 'Dev Server Starting...' ? 'Dev Server Starting...'
: `Dev Server Running (:${devServerInfo?.port})`} : `Dev Server Running (:${devServerInfo?.port})`}
</DropdownMenuLabel> </DropdownMenuLabel>
{devServerInfo != null && {devServerInfo?.urlDetected !== false && (
devServerInfo.port != null && <DropdownMenuItem
devServerInfo.urlDetected !== false && ( onClick={() => onOpenDevServerUrl(worktree)}
<DropdownMenuItem className="text-xs"
onClick={() => onOpenDevServerUrl(worktree)} aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
className="text-xs" >
aria-label={`Open dev server on port ${devServerInfo.port} in browser`} <Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
> Open in Browser
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" /> </DropdownMenuItem>
Open in Browser )}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs"> <DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" /> <ScrollText className="w-3.5 h-3.5 mr-2" />
View Logs View Logs
@@ -610,7 +592,7 @@ export function WorktreeActionsDropdown({
Scripts Scripts
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52"> <DropdownMenuSubContent className="w-52">
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured or no handler */} {/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured */}
{!worktree.isMain && ( {!worktree.isMain && (
<> <>
<DropdownMenuItem <DropdownMenuItem
@@ -631,7 +613,6 @@ export function WorktreeActionsDropdown({
key={script.id} key={script.id}
onClick={() => onRunTerminalScript?.(worktree, script.command)} onClick={() => onRunTerminalScript?.(worktree, script.command)}
className="text-xs" className="text-xs"
disabled={!onRunTerminalScript}
> >
<Play className="w-3.5 h-3.5 mr-2 shrink-0" /> <Play className="w-3.5 h-3.5 mr-2 shrink-0" />
<span className="truncate">{script.name}</span> <span className="truncate">{script.name}</span>
@@ -644,11 +625,7 @@ export function WorktreeActionsDropdown({
)} )}
{/* Divider before Edit Commands & Scripts */} {/* Divider before Edit Commands & Scripts */}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => onEditScripts?.()} className="text-xs">
onClick={() => onEditScripts?.()}
className="text-xs"
disabled={!onEditScripts}
>
<Settings2 className="w-3.5 h-3.5 mr-2" /> <Settings2 className="w-3.5 h-3.5 mr-2" />
Edit Commands & Scripts Edit Commands & Scripts
</DropdownMenuItem> </DropdownMenuItem>
@@ -962,23 +939,32 @@ export function WorktreeActionsDropdown({
- worktree.hasChanges: View Changes action is available - worktree.hasChanges: View Changes action is available
- (worktree.hasChanges && onStashChanges): Create Stash action is possible - (worktree.hasChanges && onStashChanges): Create Stash action is possible
- onViewStashes: viewing existing stashes is possible */} - onViewStashes: viewing existing stashes is possible */}
{/* View Changes split button - show submenu only when there are non-duplicate sub-actions */} {(worktree.hasChanges || onViewStashes) && (
{worktree.hasChanges && (onStashChanges || onViewStashes) ? (
<DropdownMenuSub> <DropdownMenuSub>
<div className="flex items-center"> <div className="flex items-center">
{/* Main clickable area - view changes (primary action) */} {/* Main clickable area - view changes (primary action) */}
<DropdownMenuItem {worktree.hasChanges ? (
onClick={() => onViewChanges(worktree)} <DropdownMenuItem
className="text-xs flex-1 pr-0 rounded-r-none" onClick={() => onViewChanges(worktree)}
> className="text-xs flex-1 pr-0 rounded-r-none"
<Eye className="w-3.5 h-3.5 mr-2" /> >
View Changes <Eye className="w-3.5 h-3.5 mr-2" />
</DropdownMenuItem> View Changes
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => onViewStashes!(worktree)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
)}
{/* Chevron trigger for submenu with stash options */} {/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" /> <DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div> </div>
<DropdownMenuSubContent> <DropdownMenuSubContent>
{onStashChanges && ( {worktree.hasChanges && onStashChanges && (
<TooltipWrapper <TooltipWrapper
showTooltip={!isGitOpsAvailable} showTooltip={!isGitOpsAvailable}
tooltipContent={gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}
@@ -1007,17 +993,7 @@ export function WorktreeActionsDropdown({
)} )}
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
) : worktree.hasChanges ? ( )}
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Changes
</DropdownMenuItem>
) : onViewStashes ? (
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
) : null}
{worktree.hasChanges && ( {worktree.hasChanges && (
<TooltipWrapper <TooltipWrapper
showTooltip={!!gitOpsDisabledReason} showTooltip={!!gitOpsDisabledReason}
@@ -1056,7 +1032,7 @@ export function WorktreeActionsDropdown({
</TooltipWrapper> </TooltipWrapper>
)} )}
{/* Show PR info with Address Comments in sub-menu if PR exists */} {/* Show PR info with Address Comments in sub-menu if PR exists */}
{prInfo && worktree.pr && ( {showPRInfo && worktree.pr && (
<DropdownMenuSub> <DropdownMenuSub>
<div className="flex items-center"> <div className="flex items-center">
{/* Main clickable area - opens PR in browser */} {/* Main clickable area - opens PR in browser */}
@@ -1068,16 +1044,7 @@ export function WorktreeActionsDropdown({
> >
<GitPullRequest className="w-3 h-3 mr-2" /> <GitPullRequest className="w-3 h-3 mr-2" />
PR #{worktree.pr.number} PR #{worktree.pr.number}
<span <span className="ml-auto mr-1 text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
className={cn(
'ml-auto mr-1 text-[10px] px-1.5 py-0.5 rounded uppercase',
worktree.pr.state === 'MERGED'
? 'bg-purple-500/20 text-purple-600'
: worktree.pr.state === 'CLOSED'
? 'bg-gray-500/20 text-gray-500'
: 'bg-green-500/20 text-green-600'
)}
>
{worktree.pr.state} {worktree.pr.state}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
@@ -1086,14 +1053,40 @@ export function WorktreeActionsDropdown({
</div> </div>
<DropdownMenuSubContent> <DropdownMenuSubContent>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onAddressPRComments(worktree, prInfo)} onClick={() => {
// 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" className="text-xs text-blue-500 focus:text-blue-600"
> >
<MessageSquare className="w-3.5 h-3.5 mr-2" /> <MessageSquare className="w-3.5 h-3.5 mr-2" />
Manage PR Comments Manage PR Comments
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onAutoAddressPRComments(worktree, prInfo)} onClick={() => {
const prInfo: PRInfo = {
number: worktree.pr!.number,
title: worktree.pr!.title,
url: worktree.pr!.url,
state: worktree.pr!.state,
author: '',
body: '',
comments: [],
reviewComments: [],
};
onAutoAddressPRComments(worktree, prInfo);
}}
className="text-xs text-blue-500 focus:text-blue-600" className="text-xs text-blue-500 focus:text-blue-600"
> >
<Zap className="w-3.5 h-3.5 mr-2" /> <Zap className="w-3.5 h-3.5 mr-2" />

View File

@@ -144,7 +144,7 @@ export function WorktreeDropdownItem({
</span> </span>
)} )}
{/* Dev server indicator - hidden when URL detection explicitly failed */} {/* Dev server indicator - only shown when port is confirmed detected */}
{devServerRunning && devServerInfo?.urlDetected !== false && ( {devServerRunning && devServerInfo?.urlDetected !== false && (
<span <span
className="inline-flex items-center justify-center h-4 w-4 text-green-500" className="inline-flex items-center justify-center h-4 w-4 text-green-500"

View File

@@ -87,17 +87,8 @@ export function useWorktrees({
} }
}, [worktrees, projectPath, setCurrentWorktree]); }, [worktrees, projectPath, setCurrentWorktree]);
const currentWorktreePath = currentWorktree?.path ?? null;
const handleSelectWorktree = useCallback( const handleSelectWorktree = useCallback(
(worktree: WorktreeInfo) => { (worktree: WorktreeInfo) => {
// Skip invalidation when re-selecting the already-active worktree
const isSameWorktree = worktree.isMain
? currentWorktreePath === null
: pathsEqual(worktree.path, currentWorktreePath ?? '');
if (isSameWorktree) return;
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch); setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
// Invalidate feature queries when switching worktrees to ensure fresh data. // Invalidate feature queries when switching worktrees to ensure fresh data.
@@ -108,7 +99,7 @@ export function useWorktrees({
queryKey: queryKeys.features.all(projectPath), queryKey: queryKeys.features.all(projectPath),
}); });
}, },
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath] [projectPath, setCurrentWorktree, queryClient]
); );
// fetchWorktrees for backward compatibility - now just triggers a refetch // fetchWorktrees for backward compatibility - now just triggers a refetch
@@ -127,6 +118,7 @@ export function useWorktrees({
[projectPath, queryClient, refetch] [projectPath, queryClient, refetch]
); );
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain); : worktrees.find((w) => w.isMain);

View File

@@ -1,4 +1,4 @@
import { X, Circle, MoreHorizontal, Save } from 'lucide-react'; import { X, Circle, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { EditorTab } from '../use-file-editor-store'; import type { EditorTab } from '../use-file-editor-store';
import { import {
@@ -14,12 +14,6 @@ interface EditorTabsProps {
onTabSelect: (tabId: string) => void; onTabSelect: (tabId: string) => void;
onTabClose: (tabId: string) => void; onTabClose: (tabId: string) => void;
onCloseAll: () => void; onCloseAll: () => void;
/** Called when the save button is clicked (mobile only) */
onSave?: () => void;
/** Whether there are unsaved changes (controls enabled state of save button) */
isDirty?: boolean;
/** Whether to show the save button in the tab bar (intended for mobile) */
showSaveButton?: boolean;
} }
/** Get a file icon color based on extension */ /** Get a file icon color based on extension */
@@ -80,9 +74,6 @@ export function EditorTabs({
onTabSelect, onTabSelect,
onTabClose, onTabClose,
onCloseAll, onCloseAll,
onSave,
isDirty,
showSaveButton,
}: EditorTabsProps) { }: EditorTabsProps) {
if (tabs.length === 0) return null; if (tabs.length === 0) return null;
@@ -137,26 +128,8 @@ export function EditorTabs({
); );
})} })}
{/* Tab actions: save button (mobile) + close-all dropdown */} {/* Tab actions dropdown (close all, etc.) */}
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5"> <div className="ml-auto shrink-0 flex items-center px-1">
{/* Save button — shown in the tab bar on mobile */}
{showSaveButton && onSave && (
<button
onClick={onSave}
disabled={!isDirty}
className={cn(
'p-1 rounded transition-colors',
isDirty
? 'text-primary hover:text-primary hover:bg-muted/50'
: 'text-muted-foreground/40 cursor-not-allowed'
)}
title="Save file (Ctrl+S)"
aria-label="Save file"
>
<Save className="w-4 h-4" />
</button>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button

View File

@@ -32,7 +32,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store'; import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
import { useFileBrowser } from '@/contexts/file-browser-context';
interface FileTreeProps { interface FileTreeProps {
onFileSelect: (path: string) => void; onFileSelect: (path: string) => void;
@@ -105,21 +104,6 @@ function getGitStatusLabel(status: string | undefined): string {
} }
} }
/**
* Validate a file/folder name for safety.
* Rejects names containing path separators, relative path components,
* or names that are just dots (which resolve to parent/current directory).
*/
function isValidFileName(name: string): boolean {
// Reject names containing path separators
if (name.includes('/') || name.includes('\\')) return false;
// Reject current/parent directory references
if (name === '.' || name === '..') return false;
// Reject empty or whitespace-only names
if (!name.trim()) return false;
return true;
}
/** Inline input for creating/renaming items */ /** Inline input for creating/renaming items */
function InlineInput({ function InlineInput({
defaultValue, defaultValue,
@@ -133,7 +117,6 @@ function InlineInput({
placeholder?: string; placeholder?: string;
}) { }) {
const [value, setValue] = useState(defaultValue || ''); const [value, setValue] = useState(defaultValue || '');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Guard against double-submission: pressing Enter triggers onKeyDown AND may // Guard against double-submission: pressing Enter triggers onKeyDown AND may
// immediately trigger onBlur (e.g. when the component unmounts after submit). // immediately trigger onBlur (e.g. when the component unmounts after submit).
@@ -142,9 +125,7 @@ function InlineInput({
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
if (defaultValue) { if (defaultValue) {
// Select name without extension for rename. // Select name without extension for rename
// For dotfiles (e.g. ".gitignore"), lastIndexOf('.') returns 0,
// so we fall through to select() which selects the entire name.
const dotIndex = defaultValue.lastIndexOf('.'); const dotIndex = defaultValue.lastIndexOf('.');
if (dotIndex > 0) { if (dotIndex > 0) {
inputRef.current?.setSelectionRange(0, dotIndex); inputRef.current?.setSelectionRange(0, dotIndex);
@@ -154,62 +135,97 @@ function InlineInput({
} }
}, [defaultValue]); }, [defaultValue]);
const handleSubmit = useCallback(() => { return (
if (submittedRef.current) return; <input
const trimmed = value.trim(); ref={inputRef}
if (!trimmed) { value={value}
onCancel(); onChange={(e) => setValue(e.target.value)}
return; onKeyDown={(e) => {
} if (e.key === 'Enter' && value.trim()) {
if (!isValidFileName(trimmed)) { if (submittedRef.current) return;
// Invalid name — surface error, keep editing so the user can fix it submittedRef.current = true;
setErrorMessage('Invalid name: avoid /, \\, ".", or ".."'); onSubmit(value.trim());
inputRef.current?.focus(); } else if (e.key === 'Escape') {
return; onCancel();
} }
setErrorMessage(null); }}
submittedRef.current = true; onBlur={() => {
onSubmit(trimmed); // Prevent duplicate submission if onKeyDown already triggered onSubmit
}, [value, onSubmit, onCancel]); if (submittedRef.current) return;
if (value.trim()) {
submittedRef.current = true;
onSubmit(value.trim());
} else {
onCancel();
}
}}
placeholder={placeholder}
className="text-sm bg-muted border border-border rounded px-1 py-0.5 w-full outline-none focus:border-primary"
/>
);
}
/** Destination path picker dialog for copy/move operations */
function DestinationPicker({
onSubmit,
onCancel,
defaultPath,
action,
}: {
onSubmit: (path: string) => void;
onCancel: () => void;
defaultPath: string;
action: 'Copy' | 'Move';
}) {
const [path, setPath] = useState(defaultPath);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
return ( return (
<div className="flex flex-col gap-0.5"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<input <div className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md">
ref={inputRef} <div className="px-4 py-3 border-b border-border">
value={value} <h3 className="text-sm font-medium">{action} To...</h3>
onChange={(e) => { <p className="text-xs text-muted-foreground mt-0.5">
setValue(e.target.value); Enter the destination path for the {action.toLowerCase()} operation
if (errorMessage) setErrorMessage(null); </p>
}} </div>
onKeyDown={(e) => { <div className="px-4 py-3">
if (e.key === 'Enter') { <input
handleSubmit(); ref={inputRef}
} else if (e.key === 'Escape') { value={path}
onCancel(); onChange={(e) => setPath(e.target.value)}
} onKeyDown={(e) => {
}} if (e.key === 'Enter' && path.trim()) {
onBlur={() => { onSubmit(path.trim());
// Prevent duplicate submission if onKeyDown already triggered onSubmit } else if (e.key === 'Escape') {
if (submittedRef.current) return; onCancel();
const trimmed = value.trim(); }
if (trimmed && isValidFileName(trimmed)) { }}
submittedRef.current = true; placeholder="Enter destination path..."
onSubmit(trimmed); className="w-full text-sm bg-muted border border-border rounded px-3 py-2 outline-none focus:border-primary font-mono"
} />
// If the name is empty or invalid, do NOT call onCancel — keep the </div>
// input open so the user can correct the value (mirrors handleSubmit). <div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
// Optionally re-focus so the user can continue editing. <button
else { onClick={onCancel}
inputRef.current?.focus(); className="px-3 py-1.5 text-sm rounded hover:bg-muted transition-colors"
} >
}} Cancel
placeholder={placeholder} </button>
className={cn( <button
'text-sm bg-muted border rounded px-1 py-0.5 w-full outline-none focus:border-primary', onClick={() => path.trim() && onSubmit(path.trim())}
errorMessage ? 'border-red-500' : 'border-border' disabled={!path.trim()}
)} className="px-3 py-1.5 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
/> >
{errorMessage && <span className="text-[10px] text-red-500 px-0.5">{errorMessage}</span>} {action}
</button>
</div>
</div>
</div> </div>
); );
} }
@@ -260,11 +276,12 @@ function TreeNode({
selectedPaths, selectedPaths,
toggleSelectedPath, toggleSelectedPath,
} = useFileEditorStore(); } = useFileEditorStore();
const { openFileBrowser } = useFileBrowser();
const [isCreatingFile, setIsCreatingFile] = useState(false); const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [showCopyPicker, setShowCopyPicker] = useState(false);
const [showMovePicker, setShowMovePicker] = useState(false);
const isExpanded = expandedFolders.has(node.path); const isExpanded = expandedFolders.has(node.path);
const isActive = activeFilePath === node.path; const isActive = activeFilePath === node.path;
@@ -392,6 +409,30 @@ function TreeNode({
return ( return (
<div key={node.path}> <div key={node.path}>
{/* Destination picker dialogs */}
{showCopyPicker && onCopyItem && (
<DestinationPicker
action="Copy"
defaultPath={node.path}
onSubmit={async (destPath) => {
setShowCopyPicker(false);
await onCopyItem(node.path, destPath);
}}
onCancel={() => setShowCopyPicker(false)}
/>
)}
{showMovePicker && onMoveItem && (
<DestinationPicker
action="Move"
defaultPath={node.path}
onSubmit={async (destPath) => {
setShowMovePicker(false);
await onMoveItem(node.path, destPath);
}}
onCancel={() => setShowMovePicker(false)}
/>
)}
{isRenaming ? ( {isRenaming ? (
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2"> <div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
<InlineInput <InlineInput
@@ -589,21 +630,9 @@ function TreeNode({
{/* Copy To... */} {/* Copy To... */}
{onCopyItem && ( {onCopyItem && (
<DropdownMenuItem <DropdownMenuItem
onClick={async (e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
try { setShowCopyPicker(true);
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" className="gap-2"
> >
@@ -615,21 +644,9 @@ function TreeNode({
{/* Move To... */} {/* Move To... */}
{onMoveItem && ( {onMoveItem && (
<DropdownMenuItem <DropdownMenuItem
onClick={async (e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
try { setShowMovePicker(true);
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" className="gap-2"
> >
@@ -758,15 +775,8 @@ export function FileTree({
onDragDropMove, onDragDropMove,
effectivePath, effectivePath,
}: FileTreeProps) { }: FileTreeProps) {
const { const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } =
fileTree, useFileEditorStore();
showHiddenFiles,
setShowHiddenFiles,
gitStatusMap,
dragState,
setDragState,
gitBranch,
} = useFileEditorStore();
const [isCreatingFile, setIsCreatingFile] = useState(false); const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [isCreatingFolder, setIsCreatingFolder] = useState(false);
@@ -781,13 +791,10 @@ export function FileTree({
e.preventDefault(); e.preventDefault();
if (effectivePath) { if (effectivePath) {
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
// Skip redundant state update if already targeting the same path setDragState({ draggedPaths: [], dropTargetPath: effectivePath });
if (dragState.dropTargetPath !== effectivePath) {
setDragState({ ...dragState, dropTargetPath: effectivePath });
}
} }
}, },
[effectivePath, dragState, setDragState] [effectivePath, setDragState]
); );
const handleRootDrop = useCallback( const handleRootDrop = useCallback(
@@ -811,54 +818,47 @@ export function FileTree({
return ( return (
<div className="flex flex-col h-full" data-testid="file-tree"> <div className="flex flex-col h-full" data-testid="file-tree">
{/* Tree toolbar */} {/* Tree toolbar */}
<div className="px-2 py-1.5 border-b border-border"> <div className="flex items-center justify-between px-2 py-1.5 border-b border-border">
<div className="flex items-center justify-between"> <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 min-w-0"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> Explorer
Explorer </span>
</span> {gitBranch && (
</div> <span className="text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded">
<div className="flex items-center gap-0.5">
<button
onClick={() => setIsCreatingFile(true)}
className="p-1 hover:bg-accent rounded"
title="New file"
>
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setIsCreatingFolder(true)}
className="p-1 hover:bg-accent rounded"
title="New folder"
>
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
className="p-1 hover:bg-accent rounded"
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
>
{showHiddenFiles ? (
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div>
{gitBranch && (
<div className="mt-1 min-w-0">
<span
className="inline-block max-w-full truncate whitespace-nowrap text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded"
title={gitBranch}
>
{gitBranch} {gitBranch}
</span> </span>
</div> )}
)} </div>
<div className="flex items-center gap-0.5">
<button
onClick={() => setIsCreatingFile(true)}
className="p-1 hover:bg-accent rounded"
title="New file"
>
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setIsCreatingFolder(true)}
className="p-1 hover:bg-accent rounded"
title="New folder"
>
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
className="p-1 hover:bg-accent rounded"
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
>
{showHiddenFiles ? (
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
) : (
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
</div> </div>
{/* Tree content */} {/* Tree content */}

View File

@@ -650,12 +650,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const handleRenameItem = useCallback( const handleRenameItem = useCallback(
async (oldPath: string, newName: string) => { 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 parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
const newPath = `${parentPath}/${newName}`; const newPath = `${parentPath}/${newName}`;
@@ -1034,9 +1028,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
onTabSelect={setActiveTab} onTabSelect={setActiveTab}
onTabClose={handleTabClose} onTabClose={handleTabClose}
onCloseAll={handleCloseAll} onCloseAll={handleCloseAll}
onSave={handleSave}
isDirty={activeTab?.isDirty && !activeTab?.isBinary && !activeTab?.isTooLarge}
showSaveButton={isMobile && !!activeTab && !activeTab.isBinary && !activeTab.isTooLarge}
/> />
{/* Editor content */} {/* Editor content */}
@@ -1329,6 +1320,24 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* Mobile: Save button in main toolbar */}
{activeTab &&
!activeTab.isBinary &&
!activeTab.isTooLarge &&
isMobile &&
!mobileBrowserVisible && (
<Button
variant="outline"
size="icon-sm"
onClick={handleSave}
disabled={!activeTab.isDirty}
className="lg:hidden"
title={editorAutoSave ? 'Auto-save enabled (Ctrl+S)' : 'Save file (Ctrl+S)'}
>
<Save className="w-4 h-4" />
</Button>
)}
{/* Tablet/Mobile: actions panel trigger */} {/* Tablet/Mobile: actions panel trigger */}
<HeaderActionsPanelTrigger <HeaderActionsPanelTrigger
isOpen={showActionsPanel} isOpen={showActionsPanel}

View File

@@ -18,7 +18,6 @@ import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-vi
import { ValidationDialog } from './github-issues-view/dialogs'; import { ValidationDialog } from './github-issues-view/dialogs';
import { AddFeatureDialog } from './board-view/dialogs'; import { AddFeatureDialog } from './board-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils'; import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { useModelOverride } from '@/components/shared'; import { useModelOverride } from '@/components/shared';
import type { import type {
ValidateIssueOptions, ValidateIssueOptions,
@@ -154,17 +153,11 @@ export function GitHubIssuesView() {
} }
} }
return parts.join('\n'); return parts.filter(Boolean).join('\n');
}, },
[cachedValidations] [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 // Open the Add Feature dialog with pre-filled data from a GitHub issue
const handleCreateFeature = useCallback((issue: GitHubIssue) => { const handleCreateFeature = useCallback((issue: GitHubIssue) => {
setCreateFeatureIssue(issue); setCreateFeatureIssue(issue);
@@ -185,10 +178,7 @@ export function GitHubIssuesView() {
branchName: string; branchName: string;
planningMode: string; planningMode: string;
requirePlanApproval: boolean; requirePlanApproval: boolean;
excludedPipelineSteps?: string[];
workMode: string; workMode: string;
imagePaths?: Array<{ id: string; path: string; description?: string }>;
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
}) => { }) => {
if (!currentProject?.path) { if (!currentProject?.path) {
toast.error('No project selected'); toast.error('No project selected');
@@ -213,11 +203,6 @@ export function GitHubIssuesView() {
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName, branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
planningMode: featureData.planningMode, planningMode: featureData.planningMode,
requirePlanApproval: featureData.requirePlanApproval, requirePlanApproval: featureData.requirePlanApproval,
excludedPipelineSteps: featureData.excludedPipelineSteps,
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
...(featureData.textFilePaths?.length
? { textFilePaths: featureData.textFilePaths }
: {}),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
@@ -255,7 +240,7 @@ export function GitHubIssuesView() {
const api = getElectronAPI(); const api = getElectronAPI();
if (api.features?.create) { if (api.features?.create) {
// Build description from issue body + validation info // Build description from issue body + validation info
const parts = [ const description = [
`**From GitHub Issue #${issue.number}**`, `**From GitHub Issue #${issue.number}**`,
'', '',
issue.body || 'No description provided.', issue.body || 'No description provided.',
@@ -264,18 +249,13 @@ export function GitHubIssuesView() {
'', '',
'**AI Validation Analysis:**', '**AI Validation Analysis:**',
validation.reasoning, validation.reasoning,
]; validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
if (validation.suggestedFix) { validation.relatedFiles?.length
parts.push('', `**Suggested Approach:**`, validation.suggestedFix); ? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
} : '',
if (validation.relatedFiles?.length) { ]
parts.push( .filter(Boolean)
'', .join('\n');
'**Related Files:**',
...validation.relatedFiles.map((f) => `- \`${f}\``)
);
}
const description = parts.join('\n');
const feature = { const feature = {
id: `issue-${issue.number}-${generateUUID()}`, id: `issue-${issue.number}-${generateUUID()}`,
@@ -285,7 +265,7 @@ export function GitHubIssuesView() {
status: 'backlog' as const, status: 'backlog' as const,
passes: false, passes: false,
priority: getFeaturePriority(validation.estimatedComplexity), priority: getFeaturePriority(validation.estimatedComplexity),
model: resolveModelString('opus'), model: 'opus',
thinkingLevel: 'none' as const, thinkingLevel: 'none' as const,
branchName: currentBranch, branchName: currentBranch,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -472,7 +452,9 @@ export function GitHubIssuesView() {
isMaximized={false} isMaximized={false}
projectPath={currentProject?.path} projectPath={currentProject?.path}
prefilledTitle={createFeatureIssue?.title} prefilledTitle={createFeatureIssue?.title}
prefilledDescription={prefilledDescription} prefilledDescription={
createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined
}
prefilledCategory="From GitHub" prefilledCategory="From GitHub"
/> />

View File

@@ -78,14 +78,7 @@ export function IssueDetailPanel({
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2"> <div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{isMobile && ( {isMobile && (
<Button <Button variant="ghost" size="sm" onClick={onClose} className="shrink-0 -ml-1">
variant="ghost"
size="sm"
onClick={onClose}
className="shrink-0 -ml-1"
aria-label="Back"
title="Back"
>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
)} )}
@@ -111,13 +104,7 @@ export function IssueDetailPanel({
if (cached && !isStale) { if (cached && !isStale) {
return ( return (
<> <>
<Button <Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
variant="outline"
size="sm"
onClick={() => onViewCachedValidation(issue)}
aria-label="View Result"
title="View Result"
>
<CheckCircle className="h-4 w-4 mr-1 text-green-500" /> <CheckCircle className="h-4 w-4 mr-1 text-green-500" />
{!isMobile && 'View Result'} {!isMobile && 'View Result'}
</Button> </Button>
@@ -136,13 +123,7 @@ export function IssueDetailPanel({
if (cached && isStale) { if (cached && isStale) {
return ( return (
<> <>
<Button <Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
variant="outline"
size="sm"
onClick={() => onViewCachedValidation(issue)}
aria-label="View (stale)"
title="View (stale)"
>
<Clock className="h-4 w-4 mr-1 text-yellow-500" /> <Clock className="h-4 w-4 mr-1 text-yellow-500" />
{!isMobile && 'View (stale)'} {!isMobile && 'View (stale)'}
</Button> </Button>
@@ -159,8 +140,6 @@ export function IssueDetailPanel({
variant="default" variant="default"
size="sm" size="sm"
onClick={() => onValidateIssue(issue, getValidationOptions(true))} onClick={() => onValidateIssue(issue, getValidationOptions(true))}
aria-label="Re-validate"
title="Re-validate"
> >
<Wand2 className="h-4 w-4 mr-1" /> <Wand2 className="h-4 w-4 mr-1" />
{!isMobile && 'Re-validate'} {!isMobile && 'Re-validate'}
@@ -184,8 +163,6 @@ export function IssueDetailPanel({
variant="default" variant="default"
size="sm" size="sm"
onClick={() => onValidateIssue(issue, getValidationOptions())} onClick={() => onValidateIssue(issue, getValidationOptions())}
aria-label="Validate with AI"
title="Validate with AI"
> >
<Wand2 className="h-4 w-4 mr-1" /> <Wand2 className="h-4 w-4 mr-1" />
{!isMobile && 'Validate with AI'} {!isMobile && 'Validate with AI'}
@@ -204,13 +181,7 @@ export function IssueDetailPanel({
Create Feature Create Feature
</Button> </Button>
)} )}
<Button <Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
variant="outline"
size="sm"
onClick={() => onOpenInGitHub(issue.url)}
aria-label="Open in GitHub"
title="Open in GitHub"
>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
{!isMobile && <span className="ml-1">Open in GitHub</span>} {!isMobile && <span className="ml-1">Open in GitHub</span>}
</Button> </Button>

View File

@@ -21,12 +21,11 @@ import { getElectronAPI, type GitHubPR } from '@/lib/electron';
import { useAppStore, type Feature } from '@/store/app-store'; import { useAppStore, type Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown'; import { Markdown } from '@/components/ui/markdown';
import { cn, generateUUID } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query'; import { useIsMobile } from '@/hooks/use-media-query';
import { useGitHubPRs } from '@/hooks/queries'; import { useGitHubPRs } from '@/hooks/queries';
import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations'; import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations';
import { PRCommentResolutionDialog } from '@/components/dialogs'; import { PRCommentResolutionDialog } from '@/components/dialogs/pr-comment-resolution-dialog';
import { resolveModelString } from '@automaker/model-resolver';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
DropdownMenu, DropdownMenu,
@@ -73,15 +72,15 @@ export function GitHubPRsView() {
return; return;
} }
const featureId = `pr-${pr.number}-${generateUUID()}`; const featureId = `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const feature: Feature = { const feature: Feature = {
id: featureId, id: featureId,
title: `Address PR #${pr.number} Review Comments`, title: `Address PR #${pr.number} Review Comments`,
category: 'bug-fix', category: 'bug-fix',
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`, description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
steps: [], steps: [],
status: 'backlog', status: 'in_progress',
model: resolveModelString('opus'), model: 'opus',
thinkingLevel: 'none', thinkingLevel: 'none',
planningMode: 'skip', planningMode: 'skip',
...(pr.headRefName ? { branchName: pr.headRefName } : {}), ...(pr.headRefName ? { branchName: pr.headRefName } : {}),
@@ -92,26 +91,11 @@ export function GitHubPRsView() {
// Start the feature immediately after creation // Start the feature immediately after creation
const api = getElectronAPI(); const api = getElectronAPI();
if (api.features?.run) { await api.features?.run(currentProject.path, featureId);
try {
await api.features.run(currentProject.path, featureId); toast.success('Feature created and started', {
toast.success('Feature created and started', { description: `Addressing review comments on PR #${pr.number}`,
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) { } catch (error) {
toast.error('Failed to create feature', { toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred', description: error instanceof Error ? error.message : 'An error occurred',
@@ -258,177 +242,164 @@ export function GitHubPRsView() {
</div> </div>
{/* PR Detail Panel */} {/* PR Detail Panel */}
{selectedPR && {selectedPR && (
(() => { <div className="flex-1 flex flex-col overflow-hidden">
const reviewStatus = getReviewStatus(selectedPR); {/* Detail Header */}
return ( <div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex items-center gap-2 min-w-0">
{/* Detail Header */} {isMobile && (
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2"> <Button
<div className="flex items-center gap-2 min-w-0"> variant="ghost"
{isMobile && ( size="sm"
<Button onClick={() => setSelectedPR(null)}
variant="ghost" className="shrink-0 -ml-1"
size="sm" >
onClick={() => setSelectedPR(null)} <ArrowLeft className="h-4 w-4" />
className="shrink-0 -ml-1" </Button>
> )}
<ArrowLeft className="h-4 w-4" /> {selectedPR.state === 'MERGED' ? (
</Button> <GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
) : (
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
)}
<span className="text-sm font-medium truncate">
#{selectedPR.number} {selectedPR.title}
</span>
{selectedPR.isDraft && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
Draft
</span>
)}
</div>
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
{!isMobile && (
<Button variant="outline" size="sm" onClick={() => setCommentDialogPR(selectedPR)}>
<MessageSquare className="h-4 w-4 mr-1" />
Manage Comments
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedPR.url)}
>
<ExternalLink className="h-4 w-4" />
{!isMobile && <span className="ml-1">Open in GitHub</span>}
</Button>
{!isMobile && (
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* PR Detail Content */}
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
{/* Title */}
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
selectedPR.state === 'MERGED'
? 'bg-purple-500/10 text-purple-500'
: selectedPR.isDraft
? 'bg-muted text-muted-foreground'
: 'bg-green-500/10 text-green-500'
)}
>
{selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'}
</span>
{getReviewStatus(selectedPR) && (
<span
className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
getReviewStatus(selectedPR)!.bg,
getReviewStatus(selectedPR)!.color
)} )}
{selectedPR.state === 'MERGED' ? ( >
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" /> {getReviewStatus(selectedPR)!.label}
) : ( </span>
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" /> )}
)} <span>
<span className="text-sm font-medium truncate"> #{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
#{selectedPR.number} {selectedPR.title} <span className="font-medium text-foreground">{selectedPR.author.login}</span>
</span> </span>
{selectedPR.isDraft && ( </div>
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
Draft {/* Branch info */}
</span> {selectedPR.headRefName && (
)} <div className="flex items-center gap-2 mb-4">
</div> <span className="text-xs text-muted-foreground">Branch:</span>
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}> <span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{!isMobile && ( {selectedPR.headRefName}
<Button </span>
variant="outline"
size="sm"
onClick={() => setCommentDialogPR(selectedPR)}
>
<MessageSquare className="h-4 w-4 mr-1" />
Manage Comments
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedPR.url)}
>
<ExternalLink className="h-4 w-4" />
{!isMobile && <span className="ml-1">Open in GitHub</span>}
</Button>
{!isMobile && (
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
)}
{/* PR Detail Content */} {/* Labels */}
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}> {selectedPR.labels.length > 0 && (
{/* Title */} <div className="flex items-center gap-2 mb-6 flex-wrap">
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1> {selectedPR.labels.map((label) => (
{/* Meta info */}
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
<span <span
className={cn( key={label.name}
'px-2 py-0.5 rounded-full text-xs font-medium', className="px-2 py-0.5 text-xs font-medium rounded-full"
selectedPR.state === 'MERGED' style={{
? 'bg-purple-500/10 text-purple-500' backgroundColor: `#${label.color}20`,
: selectedPR.isDraft color: `#${label.color}`,
? 'bg-muted text-muted-foreground' border: `1px solid #${label.color}40`,
: 'bg-green-500/10 text-green-500' }}
)}
> >
{selectedPR.state === 'MERGED' {label.name}
? 'Merged'
: selectedPR.isDraft
? 'Draft'
: 'Open'}
</span> </span>
{reviewStatus && ( ))}
<span </div>
className={cn( )}
'px-2 py-0.5 rounded-full text-xs font-medium',
reviewStatus.bg,
reviewStatus.color
)}
>
{reviewStatus.label}
</span>
)}
<span>
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
</span>
</div>
{/* Branch info */} {/* Body */}
{selectedPR.headRefName && ( {selectedPR.body ? (
<div className="flex items-center gap-2 mb-4"> <Markdown className="text-sm">{selectedPR.body}</Markdown>
<span className="text-xs text-muted-foreground">Branch:</span> ) : (
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded"> <p className="text-sm text-muted-foreground italic">No description provided.</p>
{selectedPR.headRefName} )}
</span>
</div>
)}
{/* Labels */} {/* Review Comments CTA */}
{selectedPR.labels.length > 0 && ( <div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-center gap-2 mb-6 flex-wrap"> <div className="flex items-center gap-2 mb-2">
{selectedPR.labels.map((label) => ( <MessageSquare className="h-4 w-4 text-blue-500" />
<span <span className="text-sm font-medium">Review Comments</span>
key={label.name} </div>
className="px-2 py-0.5 text-xs font-medium rounded-full" <p className="text-sm text-muted-foreground mb-3">
style={{ Manage review comments individually or let AI address all feedback automatically.
backgroundColor: `#${label.color}20`, </p>
color: `#${label.color}`, <div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
border: `1px solid #${label.color}40`, <Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
}} <MessageSquare className="h-4 w-4 mr-2" />
> Manage Review Comments
{label.name} </Button>
</span> <Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}>
))} <Zap className="h-4 w-4 mr-2" />
</div> Address Review Comments
)} </Button>
{/* Body */}
{selectedPR.body ? (
<Markdown className="text-sm">{selectedPR.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Review Comments CTA */}
<div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
<div className="flex items-center gap-2 mb-2">
<MessageSquare className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Review Comments</span>
</div>
<p className="text-sm text-muted-foreground mb-3">
Manage review comments individually or let AI address all feedback
automatically.
</p>
<div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
<Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
<MessageSquare className="h-4 w-4 mr-2" />
Manage Review Comments
</Button>
<Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}>
<Zap className="h-4 w-4 mr-2" />
Address Review Comments
</Button>
</div>
</div>
{/* Open in GitHub CTA */}
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View code changes, comments, and reviews on GitHub.
</p>
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full PR on GitHub
</Button>
</div>
</div> </div>
</div> </div>
);
})()} {/* Open in GitHub CTA */}
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View code changes, comments, and reviews on GitHub.
</p>
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
<ExternalLink className="h-4 w-4 mr-2" />
View Full PR on GitHub
</Button>
</div>
</div>
</div>
)}
{/* PR Comment Resolution Dialog */} {/* PR Comment Resolution Dialog */}
{commentDialogPR && ( {commentDialogPR && (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef, type KeyboardEvent } from 'react'; import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -19,7 +19,6 @@ import { cn } from '@/lib/utils';
import { useProjectSettings } from '@/hooks/queries'; import { useProjectSettings } from '@/hooks/queries';
import { useUpdateProjectSettings } from '@/hooks/mutations'; import { useUpdateProjectSettings } from '@/hooks/mutations';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { toast } from 'sonner';
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants'; import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
/** Preset dev server commands for quick selection */ /** Preset dev server commands for quick selection */
@@ -92,69 +91,46 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
const [draggedIndex, setDraggedIndex] = useState<number | null>(null); const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Track previous project path to detect project switches // Reset local state when project changes
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(() => { useEffect(() => {
const projectChanged = prevProjectPathRef.current !== project.path; setDevCommand('');
prevProjectPathRef.current = project.path; setOriginalDevCommand('');
setTestCommand('');
setOriginalTestCommand('');
setScripts([]);
setOriginalScripts([]);
}, [project.path]);
// Always clear local state on project change to avoid flashing stale data // Sync commands state when project settings load
if (projectChanged) { useEffect(() => {
isInitializedRef.current = false;
setDevCommand('');
setOriginalDevCommand('');
setTestCommand('');
setOriginalTestCommand('');
setScripts([]);
setOriginalScripts([]);
}
// Apply project settings only when they are available
if (projectSettings) { if (projectSettings) {
// Only sync from server if this is the initial load or if there are no unsaved edits. const dev = projectSettings.devCommand || '';
// This prevents background refetches from overwriting in-progress local edits. const test = projectSettings.testCommand || '';
const isDirty = setDevCommand(dev);
isInitializedRef.current && setOriginalDevCommand(dev);
(devCommand !== originalDevCommand || setTestCommand(test);
testCommand !== originalTestCommand || setOriginalTestCommand(test);
JSON.stringify(scripts) !== JSON.stringify(originalScripts)); }
}, [projectSettings]);
if (!isInitializedRef.current || !isDirty) {
// Commands // Sync scripts state when project settings load
const dev = projectSettings.devCommand || ''; useEffect(() => {
const test = projectSettings.testCommand || ''; if (projectSettings) {
setDevCommand(dev); const configured = projectSettings.terminalScripts;
setOriginalDevCommand(dev); const scriptList =
setTestCommand(test); configured && configured.length > 0
setOriginalTestCommand(test); ? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
// Scripts setScripts(scriptList);
const configured = projectSettings.terminalScripts; setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
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]); }, [projectSettings, project.path]);
// ── Change detection ── // ── Change detection ──
const hasDevChanges = devCommand !== originalDevCommand; const hasDevChanges = devCommand !== originalDevCommand;
const hasTestChanges = testCommand !== originalTestCommand; const hasTestChanges = testCommand !== originalTestCommand;
const hasCommandChanges = hasDevChanges || hasTestChanges; const hasCommandChanges = hasDevChanges || hasTestChanges;
const hasScriptChanges = useMemo( const hasScriptChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
() => JSON.stringify(scripts) !== JSON.stringify(originalScripts),
[scripts, originalScripts]
);
const hasChanges = hasCommandChanges || hasScriptChanges; const hasChanges = hasCommandChanges || hasScriptChanges;
const isSaving = updateSettingsMutation.isPending; const isSaving = updateSettingsMutation.isPending;
@@ -182,12 +158,7 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
setTestCommand(normalizedTestCommand); setTestCommand(normalizedTestCommand);
setOriginalTestCommand(normalizedTestCommand); setOriginalTestCommand(normalizedTestCommand);
setScripts(normalizedScripts); setScripts(normalizedScripts);
setOriginalScripts(structuredClone(normalizedScripts)); setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
},
onError: (error) => {
toast.error('Failed to save settings', {
description: error instanceof Error ? error.message : 'An unexpected error occurred',
});
}, },
} }
); );
@@ -197,7 +168,7 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setDevCommand(originalDevCommand); setDevCommand(originalDevCommand);
setTestCommand(originalTestCommand); setTestCommand(originalTestCommand);
setScripts(structuredClone(originalScripts)); setScripts(JSON.parse(JSON.stringify(originalScripts)));
}, [originalDevCommand, originalTestCommand, originalScripts]); }, [originalDevCommand, originalTestCommand, originalScripts]);
// ── Command handlers ── // ── Command handlers ──
@@ -287,36 +258,6 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
setDragOverIndex(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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── Commands Card ── */} {/* ── Commands Card ── */}
@@ -535,14 +476,10 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
onDrop={(e) => handleDrop(e)} onDrop={(e) => handleDrop(e)}
onDragEnd={(e) => handleDragEnd(e)} onDragEnd={(e) => handleDragEnd(e)}
> >
{/* Drag handle - keyboard accessible */} {/* Drag handle */}
<div <div
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground focus:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded shrink-0 p-0.5" className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
title="Drag to reorder (or use Arrow keys)" title="Drag to reorder"
tabIndex={0}
role="button"
aria-label={`Reorder ${script.name || 'script'}. Use arrow keys to move.`}
onKeyDown={(e) => handleDragHandleKeyDown(e, index)}
> >
<GripVertical className="w-4 h-4" /> <GripVertical className="w-4 h-4" />
</div> </div>

View File

@@ -588,7 +588,7 @@ export function TerminalView({
// Skip if we've already handled this exact request (prevents duplicate terminals) // 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 // Include mode and nonce in the key to allow opening same cwd multiple times
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}:${initialCommand || ''}`; const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
if (initialCwdHandledRef.current === cwdKey) return; if (initialCwdHandledRef.current === cwdKey) return;
// Skip if terminal is not enabled or not unlocked // Skip if terminal is not enabled or not unlocked
@@ -1162,18 +1162,6 @@ export function TerminalView({
// Always remove from UI - even if server says 404 (session may have already exited) // Always remove from UI - even if server says 404 (session may have already exited)
removeTerminalFromLayout(sessionId); 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) { if (!response.ok && response.status !== 404) {
// Log non-404 errors but still proceed with UI cleanup // Log non-404 errors but still proceed with UI cleanup
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
@@ -1186,17 +1174,6 @@ export function TerminalView({
logger.error('Kill session error:', err); logger.error('Kill session error:', err);
// Still remove from UI on network error - better UX than leaving broken terminal // Still remove from UI on network error - better UX than leaving broken terminal
removeTerminalFromLayout(sessionId); 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;
});
} }
}; };
@@ -1231,22 +1208,6 @@ export function TerminalView({
}) })
); );
// 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 // Now remove the tab from state
removeTerminalTab(tabId); removeTerminalTab(tabId);
// Refresh session count // Refresh session count
@@ -2064,13 +2025,6 @@ export function TerminalView({
onFontSizeChange={(size) => onFontSizeChange={(size) =>
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size) setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
} }
runCommandOnConnect={
newSessionIds.has(terminalState.maximizedSessionId)
? sessionCommandOverrides.get(terminalState.maximizedSessionId) ||
defaultRunScript
: undefined
}
onCommandRan={() => handleCommandRan(terminalState.maximizedSessionId!)}
isMaximized={true} isMaximized={true}
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)} onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
/> />

View File

@@ -6,8 +6,8 @@
* automatic caching, deduplication, and background refetching. * automatic caching, deduplication, and background refetching.
*/ */
import { useMemo, useEffect, useRef } from 'react'; import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys'; import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client'; import { STALE_TIMES } from '@/lib/query-client';
@@ -151,34 +151,6 @@ export function useFeatures(projectPath: string | undefined) {
[projectPath] [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({ return useQuery({
queryKey: queryKeys.features.all(projectPath ?? ''), queryKey: queryKeys.features.all(projectPath ?? ''),
queryFn: async (): Promise<Feature[]> => { queryFn: async (): Promise<Feature[]> => {
@@ -194,11 +166,7 @@ export function useFeatures(projectPath: string | undefined) {
}, },
enabled: !!projectPath, enabled: !!projectPath,
initialData: () => persisted?.features, initialData: () => persisted?.features,
// Always treat localStorage cache as stale so React Query immediately initialDataUpdatedAt: () => persisted?.timestamp,
// 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, staleTime: STALE_TIMES.FEATURES,
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL), refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS, refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,

View File

@@ -100,7 +100,6 @@ const SETTINGS_FIELDS_TO_SYNC = [
'projectHistory', 'projectHistory',
'projectHistoryIndex', 'projectHistoryIndex',
'lastSelectedSessionByProject', 'lastSelectedSessionByProject',
'currentWorktreeByProject',
// Codex CLI Settings // Codex CLI Settings
'codexAutoLoadAgents', 'codexAutoLoadAgents',
'codexSandboxMode', 'codexSandboxMode',
@@ -769,8 +768,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
projectHistory: serverSettings.projectHistory, projectHistory: serverSettings.projectHistory,
projectHistoryIndex: serverSettings.projectHistoryIndex, projectHistoryIndex: serverSettings.projectHistoryIndex,
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject, lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
currentWorktreeByProject:
serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject,
// UI State (previously in localStorage) // UI State (previously in localStorage)
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false, worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
lastProjectDir: serverSettings.lastProjectDir ?? '', lastProjectDir: serverSettings.lastProjectDir ?? '',

View File

@@ -1,7 +1,6 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './app'; import App from './app';
import { AppErrorBoundary } from './components/ui/app-error-boundary';
import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect'; import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect';
// Defensive fallback: index.html's inline script already applies data-pwa="standalone" // Defensive fallback: index.html's inline script already applies data-pwa="standalone"
@@ -251,12 +250,8 @@ function warmAssetCache(registration: ServiceWorkerRegistration): void {
} }
// Render the app - prioritize First Contentful Paint // 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( createRoot(document.getElementById('app')!).render(
<StrictMode> <StrictMode>
<AppErrorBoundary> <App />
<App />
</AppErrorBoundary>
</StrictMode> </StrictMode>
); );

View File

@@ -400,16 +400,19 @@ function RootLayoutContent() {
useEffect(() => { useEffect(() => {
const handleLoggedOut = () => { const handleLoggedOut = () => {
logger.warn('automaker:logged-out event received!'); 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 }); 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); window.addEventListener('automaker:logged-out', handleLoggedOut);
return () => { return () => {
window.removeEventListener('automaker:logged-out', handleLoggedOut); window.removeEventListener('automaker:logged-out', handleLoggedOut);
}; };
}, []); }, [location.pathname, navigate]);
// Global listener for server offline/connection errors. // Global listener for server offline/connection errors.
// This is triggered when a connection error is detected (e.g., server stopped). // This is triggered when a connection error is detected (e.g., server stopped).
@@ -721,31 +724,33 @@ function RootLayoutContent() {
} }
// If we can't load settings, we must NOT start syncing defaults to the server. // 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 }); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
signalMigrationComplete(); signalMigrationComplete();
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' });
}
return; return;
} }
} else { } 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 }); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated) // Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
signalMigrationComplete(); 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) { } catch (error) {
logger.error('Failed to initialize auth:', 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 }); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// Signal migration complete so sync hook doesn't hang // Signal migration complete so sync hook doesn't hang
signalMigrationComplete(); signalMigrationComplete();
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' });
}
} finally { } finally {
authCheckRunning.current = false; authCheckRunning.current = false;
} }