Feature: Add PR review comments and resolution, improve AI prompt handling (#790)

* feat: Add PR review comments and resolution endpoints, improve prompt handling

* Feature: File Editor (#789)

* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component

* ```
fix: Improve error handling and stabilize async operations

- Add error event handlers to GraphQL process spawns to prevent unhandled rejections
- Replace execAsync with execFile for safer command execution and better control
- Fix timeout cleanup in withTimeout generator to prevent memory leaks
- Improve outdated comment detection logic by removing redundant condition
- Use resolveModelString for consistent model string handling
- Replace || with ?? for proper falsy value handling in dialog initialization
- Add comments clarifying branch name resolution logic for local branches with slashes
- Add catch handler for project selection to handle async errors gracefully
```

* refactor: Extract PR review comments logic to dedicated service

* fix: Improve robustness and UX for PR review and file operations

* fix: Consolidate exec utilities and improve type safety

* refactor: Replace ScrollArea with div and improve file tree layout
This commit is contained in:
gsxdsm
2026-02-20 21:34:40 -08:00
committed by GitHub
parent 0e020f7e4a
commit c81ea768a7
60 changed files with 4568 additions and 681 deletions

View File

@@ -0,0 +1,37 @@
/**
* Shared execution utilities
*
* Common helpers for spawning child processes with the correct environment.
* Used by both route handlers and service layers.
*/
import { createLogger } from '@automaker/utils';
const logger = createLogger('ExecUtils');
// Extended PATH to include common tool installation locations
export const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
export const execEnv = {
...process.env,
PATH: extendedPath,
};
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`, error);
}

View File

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

View File

@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js';
import { createListCommentsHandler } from './routes/list-comments.js';
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
import { createValidateIssueHandler } from './routes/validate-issue.js';
import {
createValidationStatusHandler,
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
router.post(
'/pr-review-comments',
validatePathParams('projectPath'),
createListPRReviewCommentsHandler()
);
router.post(
'/resolve-pr-comment',
validatePathParams('projectPath'),
createResolvePRCommentHandler()
);
router.post(
'/validate-issue',
validatePathParams('projectPath'),

View File

@@ -1,38 +1,14 @@
/**
* Common utilities for GitHub routes
*
* Re-exports shared utilities from lib/exec-utils so route consumers
* can continue importing from this module unchanged.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
const logger = createLogger('GitHub');
export const execAsync = promisify(exec);
// Extended PATH to include common tool installation locations
export const extendedPath = [
process.env.PATH,
'/opt/homebrew/bin',
'/usr/local/bin',
'/home/linuxbrew/.linuxbrew/bin',
`${process.env.HOME}/.local/bin`,
]
.filter(Boolean)
.join(':');
export const execEnv = {
...process.env,
PATH: extendedPath,
};
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`, error);
}
// Re-export shared utilities from the canonical location
export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js';

View File

@@ -0,0 +1,72 @@
/**
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
*
* Fetches both regular PR comments and inline code review comments
* for a specific pull request, providing file path and line context.
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
import {
fetchPRReviewComments,
fetchReviewThreadResolvedStatus,
type PRReviewComment,
type ListPRReviewCommentsResult,
} from '../../../services/pr-review-comments.service.js';
// Re-export types so existing callers continue to work
export type { PRReviewComment, ListPRReviewCommentsResult };
// Re-export service functions so existing callers continue to work
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus };
interface ListPRReviewCommentsRequest {
projectPath: string;
prNumber: number;
}
export function createListPRReviewCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!prNumber || typeof prNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'prNumber is required and must be a number' });
return;
}
// Check if this is a GitHub repo and get owner/repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const comments = await fetchPRReviewComments(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
prNumber
);
res.json({
success: true,
comments,
totalCount: comments.length,
});
} catch (error) {
logError(error, 'Fetch PR review comments failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,66 @@
/**
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
*
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
* identified by its GraphQL node ID (threadId).
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
export interface ResolvePRCommentResult {
success: boolean;
isResolved?: boolean;
error?: string;
}
interface ResolvePRCommentRequest {
projectPath: string;
threadId: string;
resolve: boolean;
}
export function createResolvePRCommentHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!threadId) {
res.status(400).json({ success: false, error: 'threadId is required' });
return;
}
if (typeof resolve !== 'boolean') {
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
return;
}
// Check if this is a GitHub repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
res.json({
success: true,
isResolved: result.isResolved,
});
} catch (error) {
logError(error, 'Resolve PR comment failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

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

View File

@@ -30,6 +30,8 @@ const MAX_DIFF_SIZE = 15_000;
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
Output your response in EXACTLY this format (including the markers):
---TITLE---
<a concise PR title, 50-72 chars, imperative mood>
@@ -41,6 +43,7 @@ Output your response in EXACTLY this format (including the markers):
<Detailed list of what was changed and why>
Rules:
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
- The title should be concise and descriptive (50-72 characters)
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
- The description should explain WHAT changed and WHY
@@ -397,7 +400,10 @@ export function createGeneratePRDescriptionHandler(
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
responseText = msg.result;
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
@@ -413,7 +419,9 @@ export function createGeneratePRDescriptionHandler(
return;
}
// Parse the response to extract title and body
// Parse the response to extract title and body.
// The model may include conversational preamble before the structured markers,
// so we search for the markers anywhere in the response, not just at the start.
let title = '';
let body = '';
@@ -424,14 +432,46 @@ export function createGeneratePRDescriptionHandler(
title = titleMatch[1].trim();
body = bodyMatch[1].trim();
} else {
// Fallback: treat first line as title, rest as body
const lines = fullResponse.split('\n');
title = lines[0].trim();
body = lines.slice(1).join('\n').trim();
// Fallback: try to extract meaningful content, skipping any conversational preamble.
// Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
// Skip lines that look like conversational preamble
let startIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if this line looks like conversational AI preamble
if (
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
line
) ||
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
line
)
) {
startIndex = i + 1;
continue;
}
break;
}
// Use remaining lines after skipping preamble
const contentLines = lines.slice(startIndex);
if (contentLines.length > 0) {
title = contentLines[0].trim();
body = contentLines.slice(1).join('\n').trim();
} else {
// If all lines were filtered as preamble, use the original first non-empty line
title = lines[0]?.trim() || '';
body = lines.slice(1).join('\n').trim();
}
}
// Clean up title - remove any markdown or quotes
title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, '');
// Clean up title - remove any markdown headings, quotes, or marker artifacts
title = title
.replace(/^#+\s*/, '')
.replace(/^["']|["']$/g, '')
.replace(/^---\w+---\s*/, '');
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);

View File

@@ -0,0 +1,103 @@
/**
* GitHub PR Comment Service
*
* Domain logic for resolving/unresolving PR review threads via the
* GitHub GraphQL API. Extracted from the route handler so the route
* only deals with request/response plumbing.
*/
import { spawn } from 'child_process';
import { execEnv } from '../lib/exec-utils.js';
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
interface GraphQLMutationResponse {
data?: {
resolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
unresolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
};
errors?: Array<{ message: string }>;
}
/**
* Execute a GraphQL mutation to resolve or unresolve a review thread.
*/
export async function executeReviewThreadMutation(
projectPath: string,
threadId: string,
resolve: boolean
): Promise<{ isResolved: boolean }> {
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
const mutation = `
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
${mutationName}(input: { threadId: $threadId }) {
thread {
id
isResolved
}
}
}`;
const variables = { threadId };
const requestBody = JSON.stringify({ query: mutation, variables });
// Declare timeoutId before registering the error handler to avoid TDZ confusion
let timeoutId: NodeJS.Timeout | undefined;
const response = await new Promise<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

@@ -0,0 +1,338 @@
/**
* PR Review Comments Service
*
* Domain logic for fetching PR review comments, enriching them with
* resolved-thread status, and sorting. Extracted from the route handler
* so the route only deals with request/response plumbing.
*/
import { spawn, execFile } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '@automaker/utils';
import { execEnv, logError } from '../lib/exec-utils.js';
const execFileAsync = promisify(execFile);
// ── Public types (re-exported for callers) ──
export interface PRReviewComment {
id: string;
author: string;
avatarUrl?: string;
body: string;
path?: string;
line?: number;
createdAt: string;
updatedAt?: string;
isReviewComment: boolean;
/** Whether this is an outdated review comment (code has changed since) */
isOutdated?: boolean;
/** Whether the review thread containing this comment has been resolved */
isResolved?: boolean;
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
threadId?: string;
/** The diff hunk context for the comment */
diffHunk?: string;
/** The side of the diff (LEFT or RIGHT) */
side?: string;
/** The commit ID the comment was made on */
commitId?: string;
}
export interface ListPRReviewCommentsResult {
success: boolean;
comments?: PRReviewComment[];
totalCount?: number;
error?: string;
}
// ── Internal types ──
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
interface GraphQLReviewThreadComment {
databaseId: number;
}
interface GraphQLReviewThread {
id: string;
isResolved: boolean;
comments: {
pageInfo?: {
hasNextPage: boolean;
};
nodes: GraphQLReviewThreadComment[];
};
}
interface GraphQLResponse {
data?: {
repository?: {
pullRequest?: {
reviewThreads?: {
nodes: GraphQLReviewThread[];
pageInfo?: {
hasNextPage: boolean;
};
};
} | null;
};
};
errors?: Array<{ message: string }>;
}
interface ReviewThreadInfo {
isResolved: boolean;
threadId: string;
}
// ── Logger ──
const logger = createLogger('PRReviewCommentsService');
// ── Service functions ──
/**
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
* Returns a map of comment ID (string) -> { isResolved, threadId }.
*/
export async function fetchReviewThreadResolvedStatus(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<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,6 +117,8 @@ const eslintConfig = defineConfig([
Electron: 'readonly',
// Console
console: 'readonly',
// Structured clone (modern browser/Node API)
structuredClone: 'readonly',
// Vite defines
__APP_VERSION__: 'readonly',
__APP_BUILD_HASH__: 'readonly',

View File

@@ -5,4 +5,6 @@ export { FileBrowserDialog } from './file-browser-dialog';
export { NewProjectModal } from './new-project-modal';
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
export { SandboxRiskDialog } from './sandbox-risk-dialog';
export { PRCommentResolutionDialog } from './pr-comment-resolution-dialog';
export type { PRCommentResolutionPRInfo } from './pr-comment-resolution-dialog';
export { WorkspacePickerModal } from './workspace-picker-modal';

File diff suppressed because it is too large Load Diff

View File

@@ -103,7 +103,15 @@ export function ProjectSwitcher() {
};
const handleProjectClick = useCallback(
(project: Project) => {
async (project: Project) => {
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(project.path);
} catch (error) {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
setCurrentProject(project);
// Navigate to board view when switching projects
navigate({ to: '/board' });

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import {
Folder,
ChevronDown,
@@ -15,6 +16,8 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
import { initializeProject } from '@/lib/project-init';
import type { Project } from '@/lib/electron';
import {
DropdownMenu,
DropdownMenuContent,
@@ -87,6 +90,22 @@ export function ProjectSelectorWithOptions({
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
// Wrap setCurrentProject to ensure .automaker is initialized before switching
const setCurrentProjectWithInit = useCallback(
async (p: Project) => {
try {
// Ensure .automaker directory structure exists before switching
await initializeProject(p.path);
} catch (error) {
console.error('Failed to initialize project during switch:', error);
// Continue with switch even if initialization fails -
// the project may already be initialized
}
setCurrentProject(p);
},
[setCurrentProject]
);
const {
projectSearchQuery,
setProjectSearchQuery,
@@ -99,7 +118,7 @@ export function ProjectSelectorWithOptions({
currentProject,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject,
setCurrentProject: setCurrentProjectWithInit,
});
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
@@ -107,6 +126,14 @@ export function ProjectSelectorWithOptions({
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
useProjectTheme();
const handleSelectProject = useCallback(
async (p: Project) => {
await setCurrentProjectWithInit(p);
setIsProjectPickerOpen(false);
},
[setCurrentProjectWithInit, setIsProjectPickerOpen]
);
if (!sidebarOpen || projects.length === 0) {
return null;
}
@@ -204,10 +231,7 @@ export function ProjectSelectorWithOptions({
project={project}
currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex}
onSelect={(p) => {
setCurrentProject(p);
setIsProjectPickerOpen(false);
}}
onSelect={handleSelectProject}
/>
))}
</div>

View File

@@ -6,7 +6,7 @@ interface UseProjectPickerProps {
currentProject: Project | null;
isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setCurrentProject: (project: Project) => void;
setCurrentProject: (project: Project) => void | Promise<void>;
}
export function useProjectPicker({
@@ -92,9 +92,9 @@ export function useProjectPicker({
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
const selectHighlightedProject = useCallback(async () => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
setCurrentProject(filteredProjects[selectedProjectIndex]);
await setCurrentProject(filteredProjects[selectedProjectIndex]);
setIsProjectPickerOpen(false);
}
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
@@ -108,7 +108,9 @@ export function useProjectPicker({
setIsProjectPickerOpen(false);
} else if (event.key === 'Enter') {
event.preventDefault();
selectHighlightedProject();
selectHighlightedProject().catch(() => {
/* Error already logged upstream */
});
} else if (event.key === 'ArrowDown') {
event.preventDefault();
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));

View File

@@ -25,7 +25,7 @@ export interface SortableProjectItemProps {
project: Project;
currentProjectId: string | undefined;
isHighlighted: boolean;
onSelect: (project: Project) => void;
onSelect: (project: Project) => void | Promise<void>;
}
export interface ThemeMenuItemProps {

View File

@@ -0,0 +1,131 @@
import { Component, type ReactNode, type ErrorInfo } from 'react';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('AppErrorBoundary');
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* Root-level error boundary for the entire application.
*
* Catches uncaught React errors that would otherwise show TanStack Router's
* default "Something went wrong!" screen with a raw error message.
*
* Provides a user-friendly error screen with a reload button to recover.
* This is especially important for transient errors during initial app load
* (e.g., race conditions during auth/hydration on fresh browser sessions).
*/
export class AppErrorBoundary extends Component<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

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import type { PointerEvent as ReactPointerEvent } from 'react';
import {
@@ -33,7 +33,11 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import {
BoardBackgroundModal,
PRCommentResolutionDialog,
type PRCommentResolutionPRInfo,
} from '@/components/dialogs';
import { useShallow } from 'zustand/react/shallow';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { resolveModelString } from '@automaker/model-resolver';
@@ -184,6 +188,9 @@ export function BoardView() {
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
const [prCommentDialogPRInfo, setPRCommentDialogPRInfo] =
useState<PRCommentResolutionPRInfo | null>(null);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
null
);
@@ -429,6 +436,29 @@ export function BoardView() {
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Track the previous worktree path to detect worktree switches
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
// When the active worktree changes, invalidate feature queries to ensure
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
// Without this, cards that unmount when filtered out and remount when the user
// switches back may show stale or missing todo list data until the next polling cycle.
useEffect(() => {
// Skip the initial mount (prevWorktreePathRef starts as undefined)
if (prevWorktreePathRef.current === undefined) {
prevWorktreePathRef.current = currentWorktreePath;
return;
}
// Only invalidate when the worktree actually changed
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
prevWorktreePathRef.current = currentWorktreePath;
}, [currentWorktreePath, currentProject?.path, queryClient]);
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
() =>
@@ -922,26 +952,39 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation]
);
// Handler for addressing PR comments - creates a feature and starts it automatically
const handleAddressPRComments = useCallback(
// Handler for managing PR comments - opens the PR Comment Resolution dialog
const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
setPRCommentDialogPRInfo({
number: prInfo.number,
title: prInfo.title,
// Pass the worktree's branch so features are created on the correct worktree
headRefName: worktree.branch,
});
setShowPRCommentDialog(true);
}, []);
// Handler for auto-addressing PR comments - immediately creates and starts a feature task
const handleAutoAddressPRComments = useCallback(
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
// Use a simple prompt that instructs the agent to read and address PR feedback
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
const prNumber = prInfo.number;
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
if (!prInfo.number) {
toast.error('Cannot address PR comments', {
description: 'No PR number available for this worktree.',
});
return;
}
const featureData = {
title: `Address PR #${prNumber} Review Comments`,
category: 'PR Review',
description,
title: `Address PR #${prInfo.number} Review Comments`,
category: 'Maintenance',
description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: worktree.branch,
workMode: 'custom' as const, // Use the worktree's branch
priority: 1, // High priority for PR feedback
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -988,7 +1031,7 @@ export function BoardView() {
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: conflictInfo.targetBranch,
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
@@ -1508,6 +1551,7 @@ export function BoardView() {
setShowCreateBranchDialog(true);
}}
onAddressPRComments={handleAddressPRComments}
onAutoAddressPRComments={handleAutoAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict}
@@ -1985,6 +2029,18 @@ export function BoardView() {
}}
/>
{/* PR Comment Resolution Dialog */}
{prCommentDialogPRInfo && (
<PRCommentResolutionDialog
open={showPRCommentDialog}
onOpenChange={(open) => {
setShowPRCommentDialog(open);
if (!open) setPRCommentDialogPRInfo(null);
}}
pr={prCommentDialogPRInfo}
/>
)}
{/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} />

View File

@@ -1,4 +1,5 @@
import { memo, useEffect, useState, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
import { getProviderFromModel } from '@/lib/utils';
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
@@ -10,6 +11,7 @@ import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
import { useFeature, useAgentOutput } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
/**
* Formats thinking level for compact display
@@ -58,6 +60,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
summary,
isActivelyRunning,
}: AgentInfoPanelProps) {
const queryClient = useQueryClient();
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
// Track real-time task status updates from WebSocket events
@@ -130,6 +133,25 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
pollingInterval,
});
// On mount, ensure feature and agent output queries are fresh.
// This handles the worktree switch scenario where cards unmount when filtered out
// and remount when the user switches back. Without this, the React Query cache
// may serve stale data (or no data) for the individual feature query, causing
// the todo list to appear empty until the next polling cycle.
useEffect(() => {
if (shouldFetchData && projectPath && feature.id && !contextContent) {
// Invalidate both the single feature and agent output queries to trigger immediate refetch
queryClient.invalidateQueries({
queryKey: queryKeys.features.single(projectPath, feature.id),
});
queryClient.invalidateQueries({
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
});
}
// Only run on mount (feature.id and projectPath identify this specific card instance)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [feature.id, projectPath]);
// Parse agent output into agentInfo
const agentInfo = useMemo(() => {
if (contextContent) {
@@ -305,9 +327,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// Agent Info Panel for non-backlog cards
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
// OR if the feature has effective todos from any source (handles initial mount after worktree switch)
// OR if the feature is actively running (ensures panel stays visible during execution)
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
// (The backlog case was already handled above and returned early)
if (agentInfo || hasPlanSpecTasks) {
if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) {
return (
<>
<div className="mb-3 space-y-2 overflow-hidden">

View File

@@ -123,6 +123,18 @@ interface AddFeatureDialogProps {
* This is used when the "Default to worktree mode" setting is disabled.
*/
forceCurrentBranchMode?: boolean;
/**
* Pre-filled title for the feature (e.g., from a GitHub issue).
*/
prefilledTitle?: string;
/**
* Pre-filled description for the feature (e.g., from a GitHub issue).
*/
prefilledDescription?: string;
/**
* Pre-filled category for the feature (e.g., 'From GitHub').
*/
prefilledCategory?: string;
}
/**
@@ -149,6 +161,9 @@ export function AddFeatureDialog({
projectPath,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
prefilledTitle,
prefilledDescription,
prefilledCategory,
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const navigate = useNavigate();
@@ -211,6 +226,11 @@ export function AddFeatureDialog({
wasOpenRef.current = open;
if (justOpened) {
// Initialize with prefilled values if provided, otherwise use defaults
setTitle(prefilledTitle ?? '');
setDescription(prefilledDescription ?? '');
setCategory(prefilledCategory ?? '');
setSkipTests(defaultSkipTests);
// When a non-main worktree is selected, use its branch name for custom mode
// Otherwise, use the default branch
@@ -254,6 +274,9 @@ export function AddFeatureDialog({
forceCurrentBranchMode,
parentFeature,
allFeatures,
prefilledTitle,
prefilledDescription,
prefilledCategory,
]);
// Clear requirePlanApproval when planning mode is skip or lite

View File

@@ -105,43 +105,106 @@ export function CreatePRDialog({
const branchAheadCount = branchesData?.aheadCount ?? 0;
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
// Filter out current worktree branch from the list
// When a target remote is selected, only show branches from that remote
const branches = useMemo(() => {
if (!branchesData?.branches) return [];
const allBranches = branchesData.branches
.map((b) => b.name)
.filter((name) => name !== worktree?.branch);
// Determine the active remote to scope branches to.
// For multi-remote: use the selected target remote.
// For single remote: automatically scope to that remote.
const activeRemote = useMemo(() => {
if (remotes.length === 1) return remotes[0].name;
if (selectedTargetRemote) return selectedTargetRemote;
return '';
}, [remotes, selectedTargetRemote]);
// If a target remote is selected and we have remote info with branches,
// only show that remote's branches (not branches from other remotes)
if (selectedTargetRemote) {
const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote);
if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) {
const targetBranchNames = new Set(targetRemoteInfo.branches);
// Filter to only include branches that exist on the target remote
// Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main")
return allBranches.filter((name) => {
// Check if the branch name matches a target remote branch directly
if (targetBranchNames.has(name)) return true;
// Check if it's a prefixed remote branch (e.g. "upstream/main")
const prefix = `${selectedTargetRemote}/`;
if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length)))
return true;
return false;
// Filter branches by the active remote and strip remote prefixes for display.
// Returns display names (e.g. "main") without remote prefix.
// Also builds a map from display name → full ref (e.g. "origin/main") for PR creation.
const { branches, branchFullRefMap } = useMemo(() => {
if (!branchesData?.branches)
return { branches: [], branchFullRefMap: new Map<string, string>() };
const refMap = new Map<string, string>();
// If we have an active remote with branch info from the remotes endpoint, use that as the source
const activeRemoteInfo = activeRemote
? remotes.find((r) => r.name === activeRemote)
: undefined;
if (activeRemoteInfo?.branches && activeRemoteInfo.branches.length > 0) {
// Use the remote's branch list — these are already short names (e.g. "main")
const filteredBranches = activeRemoteInfo.branches
.filter((branchName) => {
// Exclude the current worktree branch
return branchName !== worktree?.branch;
})
.map((branchName) => {
// Map display name to full ref
const fullRef = `${activeRemote}/${branchName}`;
refMap.set(branchName, fullRef);
return branchName;
});
return { branches: filteredBranches, branchFullRefMap: refMap };
}
// Fallback: if no remote info available, use the branches from the branches endpoint
// Filter and strip prefixes
const seen = new Set<string>();
const filteredBranches: string[] = [];
for (const b of branchesData.branches) {
// Skip the current worktree branch
if (b.name === worktree?.branch) continue;
if (b.isRemote) {
// Remote branch: check if it belongs to the active remote
const slashIndex = b.name.indexOf('/');
if (slashIndex === -1) continue;
const remoteName = b.name.substring(0, slashIndex);
const branchName = b.name.substring(slashIndex + 1);
// If we have an active remote, only include branches from that remote
if (activeRemote && remoteName !== activeRemote) continue;
// Strip the remote prefix for display
if (!seen.has(branchName)) {
seen.add(branchName);
filteredBranches.push(branchName);
refMap.set(branchName, b.name);
}
} else {
// Local branch — only include if it has a remote counterpart on the active remote
// or if no active remote is set (no remotes at all)
if (!activeRemote) {
if (!seen.has(b.name)) {
seen.add(b.name);
filteredBranches.push(b.name);
refMap.set(b.name, b.name);
}
}
// When active remote is set, skip local-only branches — the remote version
// will be included from the remote branches above
}
}
return allBranches;
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]);
return { branches: filteredBranches, branchFullRefMap: refMap };
}, [branchesData?.branches, worktree?.branch, activeRemote, remotes]);
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
useEffect(() => {
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
// Current base branch is not in the filtered list — pick the best match
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
setBaseBranch(mainBranch || branches[0]);
// Strip any existing remote prefix from the current base branch for comparison
const strippedBaseBranch = baseBranch.includes('/')
? baseBranch.substring(baseBranch.indexOf('/') + 1)
: baseBranch;
// Check if the stripped version exists in the list
if (branches.includes(strippedBaseBranch)) {
setBaseBranch(strippedBaseBranch);
} else {
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
setBaseBranch(mainBranch || branches[0]);
}
}
}, [branches, baseBranch]);
@@ -234,7 +297,16 @@ export function CreatePRDialog({
try {
const api = getHttpApiClient();
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
// Resolve the display name to the actual branch name for the API
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
// Only strip the remote prefix if the resolved ref differs from the original
// (indicating it was resolved from a full ref like "origin/main").
// This preserves local branch names that contain slashes (e.g. "release/1.0").
const branchNameForApi =
resolvedRef !== baseBranch && resolvedRef.includes('/')
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
: resolvedRef;
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
if (result.success) {
if (result.title) {
@@ -270,12 +342,26 @@ export function CreatePRDialog({
setError('Worktree API not available');
return;
}
// Resolve the display branch name to the full ref for the API call.
// The baseBranch state holds the display name (e.g. "main"), but the API
// may need the short name without the remote prefix. We pass the display name
// since the backend handles branch resolution. However, if the full ref is
// available, we can use it for more precise targeting.
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
// Only strip the remote prefix if the resolved ref differs from the original
// (indicating it was resolved from a full ref like "origin/main").
// This preserves local branch names that contain slashes (e.g. "release/1.0").
const baseBranchForApi =
resolvedBaseBranch !== baseBranch && resolvedBaseBranch.includes('/')
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
: resolvedBaseBranch;
const result = await api.worktree.createPR(worktree.path, {
projectPath: projectPath || undefined,
commitMessage: commitMessage || undefined,
prTitle: title || worktree.branch,
prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch,
baseBranch: baseBranchForApi,
draft: isDraft,
remote: selectedRemote || undefined,
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
@@ -626,9 +712,13 @@ export function CreatePRDialog({
onChange={setBaseBranch}
branches={branches}
placeholder="Select base branch..."
disabled={isLoadingBranches}
disabled={isLoadingBranches || isLoadingRemotes}
allowCreate={false}
emptyMessage="No matching branches found."
emptyMessage={
activeRemote
? `No branches found on remote "${activeRemote}".`
: 'No matching branches found.'
}
data-testid="base-branch-autocomplete"
/>
</div>

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -40,6 +41,7 @@ import {
AlertTriangle,
XCircle,
CheckCircle,
Settings2,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -54,6 +56,7 @@ import {
import { getEditorIcon } from '@/components/icons/editor-icons';
import { getTerminalIcon } from '@/components/icons/terminal-icons';
import { useAppStore } from '@/store/app-store';
import type { TerminalScript } from '@/components/views/project-settings-view/terminal-scripts-constants';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -102,6 +105,7 @@ interface WorktreeActionsDropdownProps {
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void;
@@ -128,6 +132,12 @@ interface WorktreeActionsDropdownProps {
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
/** Terminal quick scripts configured for the project */
terminalScripts?: TerminalScript[];
/** Callback to run a terminal quick script in a new terminal session */
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
}
export function WorktreeActionsDropdown({
@@ -166,6 +176,7 @@ export function WorktreeActionsDropdown({
onCommit,
onCreatePR,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
onDeleteWorktree,
onStartDevServer,
@@ -184,6 +195,9 @@ export function WorktreeActionsDropdown({
onAbortOperation,
onContinueOperation,
hasInitScript,
terminalScripts,
onRunTerminalScript,
onEditScripts,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -238,6 +252,21 @@ export function WorktreeActionsDropdown({
// Determine if the destructive/bottom section has any visible items
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
// Pre-compute PR info for the PR submenu (avoids an IIFE in JSX)
const prInfo = useMemo<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 (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
@@ -358,19 +387,18 @@ export function WorktreeActionsDropdown({
? 'Dev Server Starting...'
: `Dev Server Running (:${devServerInfo?.port})`}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
disabled={devServerInfo?.urlDetected === false}
aria-label={
devServerInfo?.urlDetected === false
? 'Open dev server in browser'
: `Open dev server on port ${devServerInfo?.port} in browser`
}
>
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
Open in Browser
</DropdownMenuItem>
{devServerInfo != null &&
devServerInfo.port != null &&
devServerInfo.urlDetected !== false && (
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
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
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Logs
@@ -575,12 +603,57 @@ export function WorktreeActionsDropdown({
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
)}
{/* Scripts submenu - consolidates init script and terminal quick scripts */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
Scripts
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured or no handler */}
{!worktree.isMain && (
<>
<DropdownMenuItem
onClick={() => onRunInitScript(worktree)}
className="text-xs"
disabled={!hasInitScript}
>
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Terminal quick scripts */}
{terminalScripts && terminalScripts.length > 0 ? (
terminalScripts.map((script) => (
<DropdownMenuItem
key={script.id}
onClick={() => onRunTerminalScript?.(worktree, script.command)}
className="text-xs"
disabled={!onRunTerminalScript}
>
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
<span className="truncate">{script.name}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
No scripts configured
</DropdownMenuItem>
)}
{/* Divider before Edit Commands & Scripts */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onEditScripts?.()}
className="text-xs"
disabled={!onEditScripts}
>
<Settings2 className="w-3.5 h-3.5 mr-2" />
Edit Commands & Scripts
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
{remotes && remotes.length > 1 && onPullWithRemote ? (
@@ -815,32 +888,67 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
{/* Cherry-pick commits from another branch */}
{onCherryPick && (
{/* View Commits - split button when Cherry Pick is available:
click main area to view commits directly, chevron opens sub-menu with Cherry Pick */}
{onCherryPick ? (
<DropdownMenuSub>
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<div className="flex items-center">
{/* Main clickable area - opens commit history directly */}
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for sub-menu containing Cherry Pick */}
<DropdownMenuSubTrigger
disabled={!isGitOpsAvailable}
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
/>
</div>
</TooltipWrapper>
<DropdownMenuSubContent>
{/* Cherry-pick commits from another branch */}
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
@@ -849,81 +957,67 @@ export function WorktreeActionsDropdown({
)}
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
{worktree.hasChanges && (
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Changes
</DropdownMenuItem>
)}
{/* Stash operations - combined submenu or simple item.
{/* View Changes split button - main action views changes directly, chevron reveals stash options.
Only render when at least one action is meaningful:
- (worktree.hasChanges && onStashChanges): stashing changes is possible
- onViewStashes: viewing existing stashes is possible
Without this guard, the item would appear clickable but be a silent no-op
when hasChanges is false and onViewStashes is undefined. */}
{((worktree.hasChanges && onStashChanges) || onViewStashes) && (
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
{onViewStashes && worktree.hasChanges && onStashChanges ? (
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
- worktree.hasChanges: View Changes action is available
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
- onViewStashes: viewing existing stashes is possible */}
{/* View Changes split button - show submenu only when there are non-duplicate sub-actions */}
{worktree.hasChanges && (onStashChanges || onViewStashes) ? (
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - view changes (primary action) */}
<DropdownMenuItem
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
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{onStashChanges && (
<TooltipWrapper
showTooltip={!isGitOpsAvailable}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
onStashChanges(worktree);
}}
disabled={!isGitOpsAvailable}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Archive className="w-3.5 h-3.5 mr-2" />
Stash Changes
Create Stash
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
)}
disabled={!isGitOpsAvailable}
/>
</div>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
// Only one action is meaningful - render a simple menu item without submenu
<DropdownMenuItem
onClick={() => {
if (!isGitOpsAvailable) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!isGitOpsAvailable}
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!isGitOpsAvailable && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
)}
</TooltipWrapper>
)}
</TooltipWrapper>
)}
{onViewStashes && (
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</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 && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
@@ -961,43 +1055,52 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Show PR info and Address Comments button if PR exists */}
{showPRInfo && worktree.pr && (
<>
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
}}
className="text-xs"
>
<GitPullRequest className="w-3 h-3 mr-2" />
PR #{worktree.pr.number}
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
{worktree.pr.state}
</span>
</DropdownMenuItem>
<DropdownMenuItem
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"
>
<MessageSquare className="w-3.5 h-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
</>
{/* Show PR info with Address Comments in sub-menu if PR exists */}
{prInfo && worktree.pr && (
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - opens PR in browser */}
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
}}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<GitPullRequest className="w-3 h-3 mr-2" />
PR #{worktree.pr.number}
<span
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}
</span>
</DropdownMenuItem>
{/* Chevron trigger for submenu with PR actions */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => onAddressPRComments(worktree, prInfo)}
className="text-xs text-blue-500 focus:text-blue-600"
>
<MessageSquare className="w-3.5 h-3.5 mr-2" />
Manage PR Comments
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onAutoAddressPRComments(worktree, prInfo)}
className="text-xs text-blue-500 focus:text-blue-600"
>
<Zap className="w-3.5 h-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
{worktree.hasChanges && (

View File

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

View File

@@ -103,6 +103,7 @@ export interface WorktreeDropdownProps {
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
@@ -131,6 +132,12 @@ export interface WorktreeDropdownProps {
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Terminal quick scripts configured for the project */
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
/** Callback to run a terminal quick script in a new terminal session */
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
}
/**
@@ -199,6 +206,7 @@ export function WorktreeDropdown({
onCommit,
onCreatePR,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
@@ -219,6 +227,9 @@ export function WorktreeDropdown({
remotesCache,
onPullWithRemote,
onPushWithRemote,
terminalScripts,
onRunTerminalScript,
onEditScripts,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -304,15 +315,11 @@ export function WorktreeDropdown({
</span>
)}
{/* Dev server indicator */}
{selectedStatus.devServerRunning && (
{/* Dev server indicator - only shown when port is confirmed detected */}
{selectedStatus.devServerRunning && selectedStatus.devServerInfo?.urlDetected !== false && (
<span
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
title={
selectedStatus.devServerInfo?.urlDetected === false
? 'Dev server starting...'
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
}
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
>
<Globe className="w-3 h-3" />
</span>
@@ -520,6 +527,7 @@ export function WorktreeDropdown({
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
@@ -538,6 +546,9 @@ export function WorktreeDropdown({
onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript}
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
/>
)}
</div>

View File

@@ -67,6 +67,7 @@ interface WorktreeTabProps {
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void;
@@ -101,6 +102,12 @@ interface WorktreeTabProps {
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Push to a specific remote, bypassing the remote selection dialog */
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
/** Terminal quick scripts configured for the project */
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
/** Callback to run a terminal quick script in a new terminal session */
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
/** Callback to open the script editor UI */
onEditScripts?: () => void;
}
export function WorktreeTab({
@@ -148,6 +155,7 @@ export function WorktreeTab({
onCommit,
onCreatePR,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
onMerge,
onDeleteWorktree,
@@ -170,6 +178,9 @@ export function WorktreeTab({
remotes,
onPullWithRemote,
onPushWithRemote,
terminalScripts,
onRunTerminalScript,
onEditScripts,
}: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
@@ -440,7 +451,7 @@ export function WorktreeTab({
</Button>
)}
{isDevServerRunning && (
{isDevServerRunning && devServerInfo?.urlDetected !== false && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -517,6 +528,7 @@ export function WorktreeTab({
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={onMerge}
onDeleteWorktree={onDeleteWorktree}
@@ -535,6 +547,9 @@ export function WorktreeTab({
onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript}
terminalScripts={terminalScripts}
onRunTerminalScript={onRunTerminalScript}
onEditScripts={onEditScripts}
/>
</div>
);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { normalizePath } from '@/lib/utils';
@@ -11,10 +11,32 @@ interface UseDevServersOptions {
projectPath: string;
}
/**
* Helper to build the browser-accessible dev server URL by rewriting the hostname
* to match the current window's hostname (supports remote access).
* Returns null if the URL is invalid or uses an unsupported protocol.
*/
function buildDevServerBrowserUrl(serverUrl: string): string | null {
try {
const devServerUrl = new URL(serverUrl);
// Security: Only allow http/https protocols
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
return null;
}
devServerUrl.hostname = window.location.hostname;
return devServerUrl.toString();
} catch {
return null;
}
}
export function useDevServers({ projectPath }: UseDevServersOptions) {
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
const toastShownForRef = useRef<Set<string>>(new Set());
const fetchDevServers = useCallback(async () => {
try {
const api = getElectronAPI();
@@ -25,10 +47,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(normalizePath(server.worktreePath), {
const key = normalizePath(server.worktreePath);
serversMap.set(key, {
...server,
urlDetected: server.urlDetected ?? true,
});
// Mark already-detected servers as having shown the toast
// so we don't re-trigger on initial load
if (server.urlDetected !== false) {
toastShownForRef.current.add(key);
}
}
setRunningDevServers(serversMap);
}
@@ -41,7 +69,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
fetchDevServers();
}, [fetchDevServers]);
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
// Subscribe to all dev server lifecycle events for reactive state updates
useEffect(() => {
const api = getElectronAPI();
if (!api?.worktree?.onDevServerLogEvent) return;
@@ -54,6 +82,8 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
setRunningDevServers((prev) => {
const existing = prev.get(key);
if (!existing) return prev;
// Avoid updating if already detected with same url/port
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
const next = new Map(prev);
next.set(key, {
...existing,
@@ -66,8 +96,53 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
});
if (didUpdate) {
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
toast.success(`Dev server running on port ${port}`);
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
if (!toastShownForRef.current.has(key)) {
toastShownForRef.current.add(key);
const browserUrl = buildDevServerBrowserUrl(url);
toast.success(`Dev server running on port ${port}`, {
description: browserUrl ? browserUrl : url,
action: browserUrl
? {
label: 'Open in Browser',
onClick: () => {
window.open(browserUrl, '_blank', 'noopener,noreferrer');
},
}
: undefined,
duration: 8000,
});
}
}
} else if (event.type === 'dev-server:stopped') {
// Reactively remove the server from state when it stops
const { worktreePath } = event.payload;
const key = normalizePath(worktreePath);
setRunningDevServers((prev) => {
if (!prev.has(key)) return prev;
const next = new Map(prev);
next.delete(key);
return next;
});
// Clear the toast tracking so a fresh detection will show a new toast
toastShownForRef.current.delete(key);
logger.info(`Dev server stopped for ${worktreePath} (reactive update)`);
} else if (event.type === 'dev-server:started') {
// Reactively add/update the server when it starts
const { worktreePath, port, url } = event.payload;
const key = normalizePath(worktreePath);
// Clear previous toast tracking for this key so a new detection triggers a fresh toast
toastShownForRef.current.delete(key);
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(key, {
worktreePath,
port,
url,
urlDetected: false,
});
return next;
});
}
});
@@ -98,9 +173,12 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const result = await api.worktree.startDevServer(projectPath, targetPath);
if (result.success && result.result) {
const key = normalizePath(targetPath);
// Clear toast tracking so the new port detection shows a fresh toast
toastShownForRef.current.delete(key);
setRunningDevServers((prev) => {
const next = new Map(prev);
next.set(normalizePath(targetPath), {
next.set(key, {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
@@ -135,11 +213,14 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const result = await api.worktree.stopDevServer(targetPath);
if (result.success) {
const key = normalizePath(targetPath);
setRunningDevServers((prev) => {
const next = new Map(prev);
next.delete(normalizePath(targetPath));
next.delete(key);
return next;
});
// Clear toast tracking so future restarts get a fresh toast
toastShownForRef.current.delete(key);
toast.success(result.result?.message || 'Dev server stopped');
} else {
toast.error(result.error || 'Failed to stop dev server');
@@ -163,30 +244,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
return;
}
try {
// Rewrite URL hostname to match the current browser's hostname.
// This ensures dev server URLs work when accessing Automaker from
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
const devServerUrl = new URL(serverInfo.url);
// Security: Only allow http/https protocols to prevent potential attacks
// via data:, javascript:, file:, or other dangerous URL schemes
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
toast.error('Invalid dev server URL', {
description: 'The server returned an unsupported URL protocol.',
});
return;
}
devServerUrl.hostname = window.location.hostname;
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
} catch (error) {
logger.error('Failed to parse dev server URL:', error);
toast.error('Failed to open dev server', {
description: 'The server URL could not be processed. Please try again.',
const browserUrl = buildDevServerBrowserUrl(serverInfo.url);
if (!browserUrl) {
logger.error('Invalid dev server URL:', serverInfo.url);
toast.error('Invalid dev server URL', {
description: 'The server returned an unsupported URL protocol.',
});
return;
}
window.open(browserUrl, '_blank', 'noopener,noreferrer');
},
[runningDevServers, getWorktreeKey]
);

View File

@@ -163,6 +163,24 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
[navigate]
);
const handleRunTerminalScript = useCallback(
(worktree: WorktreeInfo, command: string) => {
// Navigate to the terminal view with the worktree path, branch, and command to run
// The terminal view will create a new terminal and automatically execute the command
navigate({
to: '/terminal',
search: {
cwd: worktree.path,
branch: worktree.branch,
mode: 'tab' as const,
nonce: Date.now(),
command,
},
});
},
[navigate]
);
const handleOpenInEditor = useCallback(
async (worktree: WorktreeInfo, editorCommand?: string) => {
openInEditorMutation.mutate({
@@ -204,6 +222,7 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor,
handleOpenInExternalTerminal,
// Stash confirmation state for branch switching

View File

@@ -87,11 +87,28 @@ export function useWorktrees({
}
}, [worktrees, projectPath, setCurrentWorktree]);
const currentWorktreePath = currentWorktree?.path ?? null;
const handleSelectWorktree = useCallback(
(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);
// Invalidate feature queries when switching worktrees to ensure fresh data.
// Without this, feature cards that remount after the worktree switch may have stale
// or missing planSpec/task data, causing todo lists to appear empty until the next
// polling cycle or user interaction triggers a re-render.
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(projectPath),
});
},
[projectPath, setCurrentWorktree]
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
);
// fetchWorktrees for backward compatibility - now just triggers a refetch
@@ -110,7 +127,6 @@ export function useWorktrees({
[projectPath, queryClient, refetch]
);
const currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
: worktrees.find((w) => w.isMain);

View File

@@ -122,6 +122,7 @@ export interface WorktreePanelProps {
onCreatePR: (worktree: WorktreeInfo) => void;
onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when branch switch stash reapply results in merge conflicts */

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
@@ -9,6 +10,7 @@ import { useIsMobile } from '@/hooks/use-media-query';
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
import { useTestRunnersStore } from '@/store/test-runners-store';
import { DEFAULT_TERMINAL_SCRIPTS } from '@/components/views/project-settings-view/terminal-scripts-constants';
import type {
TestRunnerStartedEvent,
TestRunnerOutputEvent,
@@ -59,6 +61,7 @@ export function WorktreePanel({
onCreatePR,
onCreateBranch,
onAddressPRComments,
onAutoAddressPRComments,
onResolveConflicts,
onCreateMergeConflictResolutionFeature,
onBranchSwitchConflict,
@@ -116,6 +119,7 @@ export function WorktreePanel({
handlePull: _handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleRunTerminalScript,
handleOpenInEditor,
handleOpenInExternalTerminal,
pendingSwitch,
@@ -209,6 +213,21 @@ export function WorktreePanel({
const { data: projectSettings } = useProjectSettings(projectPath);
const hasTestCommand = !!projectSettings?.testCommand;
// Get terminal quick scripts from project settings (or fall back to defaults)
const terminalScripts = useMemo(() => {
const configured = projectSettings?.terminalScripts;
if (configured && configured.length > 0) {
return configured;
}
return DEFAULT_TERMINAL_SCRIPTS;
}, [projectSettings?.terminalScripts]);
// Navigate to project settings to edit scripts
const navigate = useNavigate();
const handleEditScripts = useCallback(() => {
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } });
}, [navigate]);
// Test runner state management
// Use the test runners store to get global state for all worktrees
const testRunnersStore = useTestRunnersStore();
@@ -914,6 +933,7 @@ export function WorktreePanel({
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
@@ -932,6 +952,9 @@ export function WorktreePanel({
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/>
)}
@@ -1154,6 +1177,7 @@ export function WorktreePanel({
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
@@ -1171,6 +1195,9 @@ export function WorktreePanel({
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/>
{useWorktreesEnabled && (
@@ -1257,6 +1284,7 @@ export function WorktreePanel({
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
@@ -1276,6 +1304,9 @@ export function WorktreePanel({
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/>
)}
</div>
@@ -1340,6 +1371,7 @@ export function WorktreePanel({
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onAutoAddressPRComments={onAutoAddressPRComments}
onResolveConflicts={onResolveConflicts}
onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree}
@@ -1359,6 +1391,9 @@ export function WorktreePanel({
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
terminalScripts={terminalScripts}
onRunTerminalScript={handleRunTerminalScript}
onEditScripts={handleEditScripts}
/>
);
})}

View File

@@ -1,4 +1,4 @@
import { X, Circle, MoreHorizontal } from 'lucide-react';
import { X, Circle, MoreHorizontal, Save } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EditorTab } from '../use-file-editor-store';
import {
@@ -14,6 +14,12 @@ interface EditorTabsProps {
onTabSelect: (tabId: string) => void;
onTabClose: (tabId: string) => 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 */
@@ -74,6 +80,9 @@ export function EditorTabs({
onTabSelect,
onTabClose,
onCloseAll,
onSave,
isDirty,
showSaveButton,
}: EditorTabsProps) {
if (tabs.length === 0) return null;
@@ -128,8 +137,26 @@ export function EditorTabs({
);
})}
{/* Tab actions dropdown (close all, etc.) */}
<div className="ml-auto shrink-0 flex items-center px-1">
{/* Tab actions: save button (mobile) + close-all dropdown */}
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5">
{/* 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>
<DropdownMenuTrigger asChild>
<button

View File

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

View File

@@ -650,6 +650,12 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
const handleRenameItem = useCallback(
async (oldPath: string, newName: string) => {
// Extract the current file/folder name from the old path
const oldName = oldPath.split('/').pop() || '';
// If the name hasn't changed, skip the rename entirely (no-op)
if (newName === oldName) return;
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
const newPath = `${parentPath}/${newName}`;
@@ -1028,6 +1034,9 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
onTabSelect={setActiveTab}
onTabClose={handleTabClose}
onCloseAll={handleCloseAll}
onSave={handleSave}
isDirty={activeTab?.isDirty && !activeTab?.isBinary && !activeTab?.isTooLarge}
showSaveButton={isMobile && !!activeTab && !activeTab.isBinary && !activeTab.isTooLarge}
/>
{/* Editor content */}
@@ -1320,24 +1329,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
</PopoverContent>
</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 */}
<HeaderActionsPanelTrigger
isOpen={showActionsPanel}

View File

@@ -10,12 +10,15 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { toast } from 'sonner';
import { queryKeys } from '@/lib/query-keys';
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { AddFeatureDialog } from './board-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { useModelOverride } from '@/components/shared';
import type {
ValidateIssueOptions,
@@ -34,15 +37,22 @@ export function GitHubIssuesView() {
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
// Add Feature dialog state
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
const [createFeatureIssue, setCreateFeatureIssue] = useState<GitHubIssue | null>(null);
// Filter state
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
const { currentProject, getCurrentWorktree, worktreesByProject, defaultSkipTests } =
useAppStore();
const queryClient = useQueryClient();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
const isMobile = useIsMobile();
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
@@ -108,6 +118,132 @@ export function GitHubIssuesView() {
api.openExternalLink(url);
}, []);
// Build a prefilled description from a GitHub issue for the feature dialog
const buildIssueDescription = useCallback(
(issue: GitHubIssue) => {
const parts = [
`**From GitHub Issue #${issue.number}**`,
'',
issue.body || 'No description provided.',
];
// Include labels if present
if (issue.labels.length > 0) {
parts.push('', `**Labels:** ${issue.labels.map((l) => l.name).join(', ')}`);
}
// Include linked PRs info if present
if (issue.linkedPRs && issue.linkedPRs.length > 0) {
parts.push(
'',
'**Linked Pull Requests:**',
...issue.linkedPRs.map((pr) => `- #${pr.number}: ${pr.title} (${pr.state})`)
);
}
// Include cached validation analysis if available
const cached = cachedValidations.get(issue.number);
if (cached?.result) {
const validation = cached.result;
parts.push('', '---', '', '**AI Validation Analysis:**', validation.reasoning);
if (validation.suggestedFix) {
parts.push('', `**Suggested Approach:**`, validation.suggestedFix);
}
if (validation.relatedFiles?.length) {
parts.push('', '**Related Files:**', ...validation.relatedFiles.map((f) => `- \`${f}\``));
}
}
return parts.join('\n');
},
[cachedValidations]
);
// Memoize the prefilled description to avoid recomputing on every render
const prefilledDescription = useMemo(
() => (createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined),
[createFeatureIssue, buildIssueDescription]
);
// Open the Add Feature dialog with pre-filled data from a GitHub issue
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
setCreateFeatureIssue(issue);
setShowAddFeatureDialog(true);
}, []);
// Handle feature creation from the AddFeatureDialog
const handleAddFeatureFromIssue = useCallback(
async (featureData: {
title: string;
category: string;
description: string;
priority: number;
model: string;
thinkingLevel: string;
reasoningEffort: string;
skipTests: boolean;
branchName: string;
planningMode: string;
requirePlanApproval: boolean;
excludedPipelineSteps?: string[];
workMode: string;
imagePaths?: Array<{ id: string; path: string; description?: string }>;
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
}) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
try {
const api = getElectronAPI();
if (api.features?.create) {
const feature = {
id: `issue-${createFeatureIssue?.number || 'new'}-${generateUUID()}`,
title: featureData.title,
description: featureData.description,
category: featureData.category,
status: 'backlog' as const,
passes: false,
priority: featureData.priority,
model: featureData.model,
thinkingLevel: featureData.thinkingLevel,
reasoningEffort: featureData.reasoningEffort,
skipTests: featureData.skipTests,
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
planningMode: featureData.planningMode,
requirePlanApproval: featureData.requirePlanApproval,
excludedPipelineSteps: featureData.excludedPipelineSteps,
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
...(featureData.textFilePaths?.length
? { textFilePaths: featureData.textFilePaths }
: {}),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
toast.success(
`Created feature: ${featureData.title || featureData.description.slice(0, 50)}`
);
setShowAddFeatureDialog(false);
setCreateFeatureIssue(null);
} else {
toast.error(result.error || 'Failed to create feature');
}
}
} catch (err) {
logger.error('Create feature from issue error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to create feature');
}
},
[currentProject?.path, currentBranch, queryClient, createFeatureIssue]
);
const handleConvertToTask = useCallback(
async (issue: GitHubIssue, validation: IssueValidationResult) => {
if (!currentProject?.path) {
@@ -119,7 +255,7 @@ export function GitHubIssuesView() {
const api = getElectronAPI();
if (api.features?.create) {
// Build description from issue body + validation info
const description = [
const parts = [
`**From GitHub Issue #${issue.number}**`,
'',
issue.body || 'No description provided.',
@@ -128,13 +264,18 @@ export function GitHubIssuesView() {
'',
'**AI Validation Analysis:**',
validation.reasoning,
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
validation.relatedFiles?.length
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
: '',
]
.filter(Boolean)
.join('\n');
];
if (validation.suggestedFix) {
parts.push('', `**Suggested Approach:**`, validation.suggestedFix);
}
if (validation.relatedFiles?.length) {
parts.push(
'',
'**Related Files:**',
...validation.relatedFiles.map((f) => `- \`${f}\``)
);
}
const description = parts.join('\n');
const feature = {
id: `issue-${issue.number}-${generateUUID()}`,
@@ -144,7 +285,7 @@ export function GitHubIssuesView() {
status: 'backlog' as const,
passes: false,
priority: getFeaturePriority(validation.estimatedComplexity),
model: 'opus',
model: resolveModelString('opus'),
thinkingLevel: 'none' as const,
branchName: currentBranch,
createdAt: new Date().toISOString(),
@@ -185,11 +326,12 @@ export function GitHubIssuesView() {
return (
<div className="flex-1 flex overflow-hidden">
{/* Issues List */}
{/* Issues List - hidden on mobile when an issue is selected */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedIssue ? 'w-80' : 'flex-1'
selectedIssue ? 'w-80' : 'flex-1',
isMobile && selectedIssue && 'hidden'
)}
>
{/* Header */}
@@ -296,8 +438,10 @@ export function GitHubIssuesView() {
setPendingRevalidateOptions(options);
setShowRevalidateConfirm(true);
}}
onCreateFeature={handleCreateFeature}
formatDate={formatDate}
modelOverride={validationModelOverride}
isMobile={isMobile}
/>
)}
@@ -310,6 +454,28 @@ export function GitHubIssuesView() {
onConvertToTask={handleConvertToTask}
/>
{/* Add Feature Dialog - opened from issue detail panel */}
<AddFeatureDialog
open={showAddFeatureDialog}
onOpenChange={(open) => {
setShowAddFeatureDialog(open);
if (!open) {
setCreateFeatureIssue(null);
}
}}
onAdd={handleAddFeatureFromIssue}
categorySuggestions={['From GitHub']}
branchSuggestions={[]}
defaultSkipTests={defaultSkipTests}
defaultBranch={currentBranch}
currentBranch={currentBranch || undefined}
isMaximized={false}
projectPath={currentProject?.path}
prefilledTitle={createFeatureIssue?.title}
prefilledDescription={prefilledDescription}
prefilledCategory="From GitHub"
/>
{/* Revalidate Confirmation Dialog */}
<ConfirmDialog
open={showRevalidateConfirm}

View File

@@ -12,6 +12,8 @@ import {
MessageSquare,
ChevronDown,
ChevronUp,
Plus,
ArrowLeft,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useState } from 'react';
@@ -34,8 +36,10 @@ export function IssueDetailPanel({
onOpenInGitHub,
onClose,
onShowRevalidateConfirm,
onCreateFeature,
formatDate,
modelOverride,
isMobile = false,
}: IssueDetailPanelProps) {
const isValidating = validatingIssues.has(issue.number);
const cached = cachedValidations.get(issue.number);
@@ -71,8 +75,20 @@ export function IssueDetailPanel({
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<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">
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="shrink-0 -ml-1"
aria-label="Back"
title="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{issue.state === 'OPEN' ? (
<Circle className="h-4 w-4 text-green-500 shrink-0" />
) : (
@@ -82,12 +98,12 @@ export function IssueDetailPanel({
#{issue.number} {issue.title}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
{(() => {
if (isValidating) {
return (
<Button variant="default" size="sm" loading>
Validating...
{isMobile ? '...' : 'Validating...'}
</Button>
);
}
@@ -95,9 +111,15 @@ export function IssueDetailPanel({
if (cached && !isStale) {
return (
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<Button
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" />
View Result
{!isMobile && 'View Result'}
</Button>
<Button
variant="ghost"
@@ -114,9 +136,15 @@ export function IssueDetailPanel({
if (cached && isStale) {
return (
<>
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
<Button
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" />
View (stale)
{!isMobile && 'View (stale)'}
</Button>
<ModelOverrideTrigger
currentModelEntry={modelOverride.effectiveModelEntry}
@@ -131,9 +159,11 @@ export function IssueDetailPanel({
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
aria-label="Re-validate"
title="Re-validate"
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
{!isMobile && 'Re-validate'}
</Button>
</>
);
@@ -154,25 +184,46 @@ export function IssueDetailPanel({
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, getValidationOptions())}
aria-label="Validate with AI"
title="Validate with AI"
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
{!isMobile && 'Validate with AI'}
</Button>
</>
);
})()}
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
{!isMobile && (
<Button
variant="secondary"
size="sm"
onClick={() => onCreateFeature(issue)}
title="Create a new feature to address this issue"
>
<Plus className="h-4 w-4 mr-1" />
Create Feature
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => onOpenInGitHub(issue.url)}
aria-label="Open in GitHub"
title="Open in GitHub"
>
<ExternalLink className="h-4 w-4" />
{!isMobile && <span className="ml-1">Open in GitHub</span>}
</Button>
{!isMobile && (
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Issue Detail Content */}
<div className="flex-1 overflow-auto p-6">
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
{/* Title */}
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
@@ -344,8 +395,25 @@ export function IssueDetailPanel({
)}
</div>
{/* Create Feature CTA - shown on mobile since it's hidden from the header */}
{isMobile && (
<div className="mt-6 p-4 rounded-lg bg-primary/5 border border-primary/20">
<div className="flex items-center gap-2 mb-2">
<Plus className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Create Feature</span>
</div>
<p className="text-sm text-muted-foreground mb-3">
Create a new feature task to address this issue.
</p>
<Button variant="secondary" onClick={() => onCreateFeature(issue)}>
<Plus className="h-4 w-4 mr-2" />
Create Feature
</Button>
</div>
)}
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">
View comments, add reactions, and more on GitHub.
</p>

View File

@@ -138,6 +138,8 @@ export interface IssueDetailPanelProps {
onClose: () => void;
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
/** Called when user wants to create a feature to address this issue */
onCreateFeature: (issue: GitHubIssue) => void;
formatDate: (date: string) => string;
/** Model override state */
modelOverride: {
@@ -146,4 +148,6 @@ export interface IssueDetailPanelProps {
isOverridden: boolean;
setOverride: (entry: PhaseModelEntry | null) => void;
};
/** Whether the view is in mobile mode - shows back button and full-screen detail */
isMobile?: boolean;
}

View File

@@ -5,18 +5,42 @@
*/
import { useState, useCallback } from 'react';
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import {
GitPullRequest,
RefreshCw,
ExternalLink,
GitMerge,
X,
MessageSquare,
MoreHorizontal,
Zap,
ArrowLeft,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useAppStore, type Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import { cn, generateUUID } from '@/lib/utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { useGitHubPRs } from '@/hooks/queries';
import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations';
import { PRCommentResolutionDialog } from '@/components/dialogs';
import { resolveModelString } from '@automaker/model-resolver';
import { toast } from 'sonner';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function GitHubPRsView() {
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
const [commentDialogPR, setCommentDialogPR] = useState<GitHubPR | null>(null);
const { currentProject } = useAppStore();
const isMobile = useIsMobile();
const {
data,
@@ -38,6 +62,65 @@ export function GitHubPRsView() {
api.openExternalLink(url);
}, []);
const createFeature = useCreateFeature(currentProject?.path ?? '');
const handleAutoAddressComments = useCallback(
async (pr: GitHubPR) => {
if (!pr.number || !currentProject?.path) {
toast.error('Cannot address PR comments', {
description: 'No PR number or project available.',
});
return;
}
const featureId = `pr-${pr.number}-${generateUUID()}`;
const feature: Feature = {
id: featureId,
title: `Address PR #${pr.number} Review Comments`,
category: 'bug-fix',
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
steps: [],
status: 'backlog',
model: resolveModelString('opus'),
thinkingLevel: 'none',
planningMode: 'skip',
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
};
try {
await createFeature.mutateAsync(feature);
// Start the feature immediately after creation
const api = getElectronAPI();
if (api.features?.run) {
try {
await api.features.run(currentProject.path, featureId);
toast.success('Feature created and started', {
description: `Addressing review comments on PR #${pr.number}`,
});
} catch (runError) {
toast.error('Feature created but failed to start', {
description:
runError instanceof Error
? runError.message
: 'An error occurred while starting the feature',
});
}
} else {
toast.error('Cannot start feature', {
description:
'Feature API is not available. The feature was created but could not be started.',
});
}
} catch (error) {
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
}
},
[currentProject, createFeature]
);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
@@ -91,11 +174,12 @@ export function GitHubPRsView() {
return (
<div className="flex-1 flex overflow-hidden">
{/* PR List */}
{/* PR List - hidden on mobile when a PR is selected */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedPR ? 'w-80' : 'flex-1'
selectedPR ? 'w-80' : 'flex-1',
isMobile && selectedPR && 'hidden'
)}
>
{/* Header */}
@@ -140,6 +224,8 @@ export function GitHubPRsView() {
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
onManageComments={() => setCommentDialogPR(pr)}
onAutoAddressComments={() => handleAutoAddressComments(pr)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
@@ -158,6 +244,8 @@ export function GitHubPRsView() {
isSelected={selectedPR?.number === pr.number}
onClick={() => setSelectedPR(pr)}
onOpenExternal={() => handleOpenInGitHub(pr.url)}
onManageComments={() => setCommentDialogPR(pr)}
onAutoAddressComments={() => handleAutoAddressComments(pr)}
formatDate={formatDate}
getReviewStatus={getReviewStatus}
/>
@@ -170,124 +258,187 @@ export function GitHubPRsView() {
</div>
{/* PR Detail Panel */}
{selectedPR && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2 min-w-0">
{selectedPR.state === 'MERGED' ? (
<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="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenInGitHub(selectedPR.url)}
>
<ExternalLink className="h-4 w-4 mr-1" />
Open in GitHub
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* PR Detail Content */}
<div className="flex-1 overflow-auto 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 &&
(() => {
const reviewStatus = getReviewStatus(selectedPR);
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
<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">
{isMobile && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedPR(null)}
className="shrink-0 -ml-1"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
>
{getReviewStatus(selectedPR)!.label}
</span>
)}
<span>
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
</span>
</div>
{/* Branch info */}
{selectedPR.headRefName && (
<div className="flex items-center gap-2 mb-4">
<span className="text-xs text-muted-foreground">Branch:</span>
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{selectedPR.headRefName}
</span>
</div>
)}
{/* Labels */}
{selectedPR.labels.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
{selectedPR.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
{selectedPR.state === 'MERGED' ? (
<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>
)}
{/* Body */}
{selectedPR.body ? (
<Markdown className="text-sm">{selectedPR.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* 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>
{/* Open in GitHub CTA */}
<div className="mt-8 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>
{/* 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>
{reviewStatus && (
<span
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 */}
{selectedPR.headRefName && (
<div className="flex items-center gap-2 mb-4">
<span className="text-xs text-muted-foreground">Branch:</span>
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
{selectedPR.headRefName}
</span>
</div>
)}
{/* Labels */}
{selectedPR.labels.length > 0 && (
<div className="flex items-center gap-2 mb-6 flex-wrap">
{selectedPR.labels.map((label) => (
<span
key={label.name}
className="px-2 py-0.5 text-xs font-medium rounded-full"
style={{
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
border: `1px solid #${label.color}40`,
}}
>
{label.name}
</span>
))}
</div>
)}
{/* 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>
);
})()}
{/* PR Comment Resolution Dialog */}
{commentDialogPR && (
<PRCommentResolutionDialog
open={!!commentDialogPR}
onOpenChange={(open) => {
if (!open) setCommentDialogPR(null);
}}
pr={commentDialogPR}
/>
)}
</div>
);
@@ -298,6 +449,8 @@ interface PRRowProps {
isSelected: boolean;
onClick: () => void;
onOpenExternal: () => void;
onManageComments: () => void;
onAutoAddressComments: () => void;
formatDate: (date: string) => string;
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
}
@@ -307,6 +460,8 @@ function PRRow({
isSelected,
onClick,
onOpenExternal,
onManageComments,
onAutoAddressComments,
formatDate,
getReviewStatus,
}: PRRowProps) {
@@ -378,17 +533,52 @@ function PRRow({
</div>
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
{/* Actions dropdown menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="shrink-0 h-7 w-7 p-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onManageComments();
}}
className="text-xs text-blue-500 focus:text-blue-600"
>
<MessageSquare className="h-3.5 w-3.5 mr-2" />
Manage PR Comments
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onAutoAddressComments();
}}
className="text-xs text-blue-500 focus:text-blue-600"
>
<Zap className="h-3.5 w-3.5 mr-2" />
Address PR Comments
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onOpenExternal();
}}
className="text-xs"
>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
Open in GitHub
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,657 @@
import { useState, useEffect, useCallback, useMemo, useRef, type KeyboardEvent } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Terminal,
Save,
RotateCcw,
Info,
X,
Play,
FlaskConical,
ScrollText,
Plus,
GripVertical,
Trash2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useProjectSettings } from '@/hooks/queries';
import { useUpdateProjectSettings } from '@/hooks/mutations';
import type { Project } from '@/lib/electron';
import { toast } from 'sonner';
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
/** Preset dev server commands for quick selection */
const DEV_SERVER_PRESETS = [
{ label: 'npm run dev', command: 'npm run dev' },
{ label: 'yarn dev', command: 'yarn dev' },
{ label: 'pnpm dev', command: 'pnpm dev' },
{ label: 'bun dev', command: 'bun dev' },
{ label: 'npm start', command: 'npm start' },
{ label: 'cargo watch', command: 'cargo watch -x run' },
{ label: 'go run', command: 'go run .' },
] as const;
/** Preset test commands for quick selection */
const TEST_PRESETS = [
{ label: 'npm test', command: 'npm test' },
{ label: 'yarn test', command: 'yarn test' },
{ label: 'pnpm test', command: 'pnpm test' },
{ label: 'bun test', command: 'bun test' },
{ label: 'pytest', command: 'pytest' },
{ label: 'cargo test', command: 'cargo test' },
{ label: 'go test', command: 'go test ./...' },
] as const;
/** Preset scripts for quick addition */
const SCRIPT_PRESETS = [
{ name: 'Dev Server', command: 'npm run dev' },
{ name: 'Build', command: 'npm run build' },
{ name: 'Test', command: 'npm run test' },
{ name: 'Lint', command: 'npm run lint' },
{ name: 'Format', command: 'npm run format' },
{ name: 'Type Check', command: 'npm run typecheck' },
{ name: 'Start', command: 'npm start' },
{ name: 'Clean', command: 'npm run clean' },
] as const;
interface ScriptEntry {
id: string;
name: string;
command: string;
}
interface CommandsAndScriptsSectionProps {
project: Project;
}
/** Generate a unique ID for a new script */
function generateId(): string {
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSectionProps) {
// Fetch project settings using TanStack Query
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
// Mutation hook for updating project settings
const updateSettingsMutation = useUpdateProjectSettings(project.path);
// ── Commands state ──
const [devCommand, setDevCommand] = useState('');
const [originalDevCommand, setOriginalDevCommand] = useState('');
const [testCommand, setTestCommand] = useState('');
const [originalTestCommand, setOriginalTestCommand] = useState('');
// ── Scripts state ──
const [scripts, setScripts] = useState<ScriptEntry[]>([]);
const [originalScripts, setOriginalScripts] = useState<ScriptEntry[]>([]);
// Dragging state for scripts
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Track previous project path to detect project switches
const prevProjectPathRef = useRef(project.path);
// Track whether we've done the initial sync for the current project
const isInitializedRef = useRef(false);
// Sync commands and scripts state when project settings load or project changes
useEffect(() => {
const projectChanged = prevProjectPathRef.current !== project.path;
prevProjectPathRef.current = project.path;
// Always clear local state on project change to avoid flashing stale data
if (projectChanged) {
isInitializedRef.current = false;
setDevCommand('');
setOriginalDevCommand('');
setTestCommand('');
setOriginalTestCommand('');
setScripts([]);
setOriginalScripts([]);
}
// Apply project settings only when they are available
if (projectSettings) {
// Only sync from server if this is the initial load or if there are no unsaved edits.
// This prevents background refetches from overwriting in-progress local edits.
const isDirty =
isInitializedRef.current &&
(devCommand !== originalDevCommand ||
testCommand !== originalTestCommand ||
JSON.stringify(scripts) !== JSON.stringify(originalScripts));
if (!isInitializedRef.current || !isDirty) {
// Commands
const dev = projectSettings.devCommand || '';
const test = projectSettings.testCommand || '';
setDevCommand(dev);
setOriginalDevCommand(dev);
setTestCommand(test);
setOriginalTestCommand(test);
// Scripts
const configured = projectSettings.terminalScripts;
const scriptList =
configured && configured.length > 0
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
setScripts(scriptList);
setOriginalScripts(structuredClone(scriptList));
isInitializedRef.current = true;
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectSettings, project.path]);
// ── Change detection ──
const hasDevChanges = devCommand !== originalDevCommand;
const hasTestChanges = testCommand !== originalTestCommand;
const hasCommandChanges = hasDevChanges || hasTestChanges;
const hasScriptChanges = useMemo(
() => JSON.stringify(scripts) !== JSON.stringify(originalScripts),
[scripts, originalScripts]
);
const hasChanges = hasCommandChanges || hasScriptChanges;
const isSaving = updateSettingsMutation.isPending;
// ── Save all (commands + scripts) ──
const handleSave = useCallback(() => {
const normalizedDevCommand = devCommand.trim();
const normalizedTestCommand = testCommand.trim();
const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim());
const normalizedScripts = validScripts.map((s) => ({
id: s.id,
name: s.name.trim(),
command: s.command.trim(),
}));
updateSettingsMutation.mutate(
{
devCommand: normalizedDevCommand || null,
testCommand: normalizedTestCommand || null,
terminalScripts: normalizedScripts,
},
{
onSuccess: () => {
setDevCommand(normalizedDevCommand);
setOriginalDevCommand(normalizedDevCommand);
setTestCommand(normalizedTestCommand);
setOriginalTestCommand(normalizedTestCommand);
setScripts(normalizedScripts);
setOriginalScripts(structuredClone(normalizedScripts));
},
onError: (error) => {
toast.error('Failed to save settings', {
description: error instanceof Error ? error.message : 'An unexpected error occurred',
});
},
}
);
}, [devCommand, testCommand, scripts, updateSettingsMutation]);
// ── Reset all ──
const handleReset = useCallback(() => {
setDevCommand(originalDevCommand);
setTestCommand(originalTestCommand);
setScripts(structuredClone(originalScripts));
}, [originalDevCommand, originalTestCommand, originalScripts]);
// ── Command handlers ──
const handleUseDevPreset = useCallback((command: string) => {
setDevCommand(command);
}, []);
const handleUseTestPreset = useCallback((command: string) => {
setTestCommand(command);
}, []);
const handleClearDev = useCallback(() => {
setDevCommand('');
}, []);
const handleClearTest = useCallback(() => {
setTestCommand('');
}, []);
// ── Script handlers ──
const handleAddScript = useCallback(() => {
setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]);
}, []);
const handleAddPreset = useCallback((preset: { name: string; command: string }) => {
setScripts((prev) => [
...prev,
{ id: generateId(), name: preset.name, command: preset.command },
]);
}, []);
const handleRemoveScript = useCallback((index: number) => {
setScripts((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleUpdateScript = useCallback(
(index: number, field: 'name' | 'command', value: string) => {
setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
},
[]
);
// Handle keyboard shortcuts (Enter to save)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && hasChanges && !isSaving) {
e.preventDefault();
handleSave();
}
},
[hasChanges, isSaving, handleSave]
);
// ── Drag and drop handlers for script reordering ──
const handleDragStart = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
setDragOverIndex(index);
},
[draggedIndex]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) {
setScripts((prev) => {
const newScripts = [...prev];
const [removed] = newScripts.splice(draggedIndex, 1);
newScripts.splice(dragOverIndex, 0, removed);
return newScripts;
});
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[draggedIndex, dragOverIndex]
);
const handleDragEnd = useCallback((_e: React.DragEvent) => {
setDraggedIndex(null);
setDragOverIndex(null);
}, []);
// ── Keyboard reorder helpers for accessibility ──
const moveScript = useCallback((fromIndex: number, toIndex: number) => {
setScripts((prev) => {
if (toIndex < 0 || toIndex >= prev.length) return prev;
const newScripts = [...prev];
const [removed] = newScripts.splice(fromIndex, 1);
newScripts.splice(toIndex, 0, removed);
return newScripts;
});
}, []);
const handleDragHandleKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
moveScript(index, index - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
moveScript(index, index + 1);
} else if (e.key === 'Home') {
e.preventDefault();
moveScript(index, 0);
} else if (e.key === 'End') {
e.preventDefault();
moveScript(index, scripts.length - 1);
}
},
[moveScript, scripts.length]
);
return (
<div className="space-y-6">
{/* ── Commands Card ── */}
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
data-testid="commands-section"
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Project Commands
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure custom commands for development and testing.
</p>
</div>
<div className="p-6 space-y-8">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : isError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
Failed to load project settings. Please try again.
</div>
) : (
<>
{/* Dev Server Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Play className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Dev Server</h3>
{hasDevChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="dev-command"
value={devCommand}
onChange={(e) => setDevCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm run dev, yarn dev, cargo watch"
className="font-mono text-sm pr-8"
data-testid="dev-command-input"
/>
{devCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearDev}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear dev command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your package manager.
</p>
{/* Dev Presets */}
<div className="flex flex-wrap gap-1.5">
{DEV_SERVER_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseDevPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-border/30" />
{/* Test Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Test Runner</h3>
{hasTestChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="test-command"
value={testCommand}
onChange={(e) => setTestCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm test, pytest, cargo test"
className="font-mono text-sm pr-8"
data-testid="test-command-input"
/>
{testCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearTest}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear test command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your project structure.
</p>
{/* Test Presets */}
<div className="flex flex-wrap gap-1.5">
{TEST_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseTestPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Auto-detection Info */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Auto-detection</p>
<p>
When no custom command is set, the system automatically detects your package
manager and test framework based on project files (package.json, Cargo.toml,
go.mod, etc.).
</p>
</div>
</div>
</>
)}
</div>
</div>
{/* ── Terminal Quick Scripts Card ── */}
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
data-testid="scripts-section"
>
{/* Header */}
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<ScrollText className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Terminal Quick Scripts
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure quick-access scripts that appear in the terminal header dropdown. Click any
script to run it instantly.
</p>
</div>
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : isError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
Failed to load project settings. Please try again.
</div>
) : (
<>
{/* Scripts List */}
<div className="space-y-2">
{scripts.map((script, index) => (
<div
key={script.id}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border border-border/30 bg-accent/10 transition-all',
draggedIndex === index && 'opacity-50',
dragOverIndex === index && 'border-brand-500/50 bg-brand-500/5'
)}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e)}
onDragEnd={(e) => handleDragEnd(e)}
>
{/* Drag handle - keyboard accessible */}
<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"
title="Drag to reorder (or use Arrow keys)"
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" />
</div>
{/* Script name */}
<Input
value={script.name}
onChange={(e) => handleUpdateScript(index, 'name', e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Script name"
className="h-8 text-sm flex-[0.4] min-w-0"
/>
{/* Script command */}
<Input
value={script.command}
onChange={(e) => handleUpdateScript(index, 'command', e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Command to run"
className="h-8 text-sm font-mono flex-[0.6] min-w-0"
/>
{/* Remove button */}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveScript(index)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
aria-label={`Remove ${script.name || 'script'}`}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
))}
{scripts.length === 0 && (
<div className="text-center py-6 text-sm text-muted-foreground">
No scripts configured. Add some below or use a preset.
</div>
)}
</div>
{/* Add Script Button */}
<Button variant="outline" size="sm" onClick={handleAddScript} className="gap-1.5">
<Plus className="w-3.5 h-3.5" />
Add Script
</Button>
{/* Divider */}
<div className="border-t border-border/30" />
{/* Presets */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-foreground">Quick Add Presets</h3>
<div className="flex flex-wrap gap-1.5">
{SCRIPT_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleAddPreset(preset)}
className="text-xs font-mono h-7 px-2"
>
{preset.command}
</Button>
))}
</div>
</div>
{/* Info Box */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Terminal Quick Scripts</p>
<p>
These scripts appear in the terminal header as a dropdown menu (the{' '}
<ScrollText className="inline-block w-3 h-3 mx-0.5 align-middle" /> icon).
Clicking a script will type the command into the active terminal and press
Enter. Drag to reorder scripts.
</p>
</div>
</div>
</>
)}
</div>
</div>
{/* ── Shared Action Buttons ── */}
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>
</div>
);
}

View File

@@ -7,7 +7,6 @@ import {
Workflow,
Database,
Terminal,
ScrollText,
} from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
@@ -20,8 +19,7 @@ export interface ProjectNavigationItem {
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'commands', label: 'Commands', icon: Terminal },
{ id: 'scripts', label: 'Terminal Scripts', icon: ScrollText },
{ id: 'commands-scripts', label: 'Commands & Scripts', icon: Terminal },
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'claude', label: 'Models', icon: Workflow },
{ id: 'data', label: 'Data', icon: Database },

View File

@@ -6,6 +6,7 @@ export type ProjectSettingsViewId =
| 'worktrees'
| 'commands'
| 'scripts'
| 'commands-scripts'
| 'claude'
| 'data'
| 'danger';

View File

@@ -2,6 +2,8 @@ export { ProjectSettingsView } from './project-settings-view';
export { ProjectIdentitySection } from './project-identity-section';
export { ProjectThemeSection } from './project-theme-section';
export { WorktreePreferencesSection } from './worktree-preferences-section';
export { CommandsAndScriptsSection } from './commands-and-scripts-section';
// Legacy exports kept for backward compatibility
export { CommandsSection } from './commands-section';
export { TerminalScriptsSection } from './terminal-scripts-section';
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';

View File

@@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
import { CommandsSection } from './commands-section';
import { TerminalScriptsSection } from './terminal-scripts-section';
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
@@ -15,6 +14,8 @@ import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-fr
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
import { useProjectSettingsView } from './hooks/use-project-settings-view';
import type { Project as ElectronProject } from '@/lib/electron';
import { useSearch } from '@tanstack/react-router';
import type { ProjectSettingsViewId } from './hooks/use-project-settings-view';
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
const LG_BREAKPOINT = 1024;
@@ -34,8 +35,18 @@ export function ProjectSettingsView() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
// Read the optional section search param to support deep-linking to a specific section
const search = useSearch({ strict: false }) as { section?: ProjectSettingsViewId };
// Map legacy 'commands' and 'scripts' IDs to the combined 'commands-scripts' section
const resolvedSection: ProjectSettingsViewId | undefined =
search.section === 'commands' || search.section === 'scripts'
? 'commands-scripts'
: search.section;
// Use project settings view navigation hook
const { activeView, navigateTo } = useProjectSettingsView();
const { activeView, navigateTo } = useProjectSettingsView({
initialView: resolvedSection ?? 'identity',
});
// Mobile navigation state - default to showing on desktop, hidden on mobile
const [showNavigation, setShowNavigation] = useState(() => {
@@ -91,9 +102,9 @@ export function ProjectSettingsView() {
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'commands':
return <CommandsSection project={currentProject} />;
case 'scripts':
return <TerminalScriptsSection project={currentProject} />;
case 'commands-scripts':
return <CommandsAndScriptsSection project={currentProject} />;
case 'claude':
return <ProjectModelsSection project={currentProject} />;
case 'data':

View File

@@ -240,9 +240,17 @@ interface TerminalViewProps {
initialMode?: 'tab' | 'split';
/** Unique nonce to allow opening the same worktree multiple times */
nonce?: number;
/** Command to run automatically when the terminal is created (e.g., from scripts submenu) */
initialCommand?: string;
}
export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) {
export function TerminalView({
initialCwd,
initialBranch,
initialMode,
nonce,
initialCommand,
}: TerminalViewProps) {
const {
terminalState,
setTerminalUnlocked,
@@ -288,6 +296,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const isCreatingRef = useRef<boolean>(false);
const restoringProjectPathRef = useRef<string | null>(null);
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
// Per-session command overrides (e.g., from scripts submenu), takes priority over defaultRunScript
const [sessionCommandOverrides, setSessionCommandOverrides] = useState<Map<string, string>>(
new Map()
);
const [serverSessionInfo, setServerSessionInfo] = useState<{
current: number;
max: number;
@@ -576,7 +588,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
// Skip if we've already handled this exact request (prevents duplicate terminals)
// Include mode and nonce in the key to allow opening same cwd multiple times
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}:${initialCommand || ''}`;
if (initialCwdHandledRef.current === cwdKey) return;
// Skip if terminal is not enabled or not unlocked
@@ -618,8 +630,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
}
// Mark this session as new for running initial command
if (defaultRunScript) {
if (initialCommand || defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
// Store per-session command override if an explicit command was provided
if (initialCommand) {
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, initialCommand));
}
}
// Show success toast with branch name if provided
@@ -654,6 +670,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
initialCwd,
initialBranch,
initialMode,
initialCommand,
nonce,
status?.enabled,
status?.passwordRequired,
@@ -1059,7 +1076,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
// Create terminal in new tab
// customCwd: optional working directory (e.g., a specific worktree path)
// branchName: optional branch name to display in the terminal panel header
const createTerminalInNewTab = async (customCwd?: string, branchName?: string) => {
// command: optional command to run when the terminal connects (e.g., from scripts menu)
const createTerminalInNewTab = async (
customCwd?: string,
branchName?: string,
command?: string
) => {
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
return;
}
@@ -1087,8 +1109,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const { addTerminalToTab } = useAppStore.getState();
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
// Mark this session as new for running initial command
if (defaultRunScript) {
if (command || defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
// Store per-session command override if an explicit command was provided
if (command) {
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, command));
}
}
// Refresh session count
fetchServerSettings();
@@ -1136,6 +1162,18 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
// Always remove from UI - even if server says 404 (session may have already exited)
removeTerminalFromLayout(sessionId);
// Clean up stale entries for killed sessions
setSessionCommandOverrides((prev) => {
const next = new Map(prev);
next.delete(sessionId);
return next;
});
setNewSessionIds((prev) => {
const next = new Set(prev);
next.delete(sessionId);
return next;
});
if (!response.ok && response.status !== 404) {
// Log non-404 errors but still proceed with UI cleanup
const data = await response.json().catch(() => ({}));
@@ -1148,6 +1186,17 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
logger.error('Kill session error:', err);
// Still remove from UI on network error - better UX than leaving broken terminal
removeTerminalFromLayout(sessionId);
// Clean up stale entries for killed sessions (same cleanup as try block)
setSessionCommandOverrides((prev) => {
const next = new Map(prev);
next.delete(sessionId);
return next;
});
setNewSessionIds((prev) => {
const next = new Set(prev);
next.delete(sessionId);
return next;
});
}
};
@@ -1182,6 +1231,22 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
})
);
// Clean up stale entries for all killed sessions in this tab
setSessionCommandOverrides((prev) => {
const next = new Map(prev);
for (const sessionId of sessionIds) {
next.delete(sessionId);
}
return next;
});
setNewSessionIds((prev) => {
const next = new Set(prev);
for (const sessionId of sessionIds) {
next.delete(sessionId);
}
return next;
});
// Now remove the tab from state
removeTerminalTab(tabId);
// Refresh session count
@@ -1255,6 +1320,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
next.delete(sessionId);
return next;
});
// Clean up any per-session command override
setSessionCommandOverrides((prev) => {
const next = new Map(prev);
next.delete(sessionId);
return next;
});
}, []);
// Navigate between terminal panes with directional awareness
@@ -1387,6 +1458,9 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
// Only run command on new sessions (not restored ones)
const isNewSession = newSessionIds.has(content.sessionId);
// Per-session command override takes priority over defaultRunScript
const sessionCommand = sessionCommandOverrides.get(content.sessionId);
const runCommand = isNewSession ? sessionCommand || defaultRunScript : undefined;
return (
<TerminalErrorBoundary
key={`boundary-${content.sessionId}`}
@@ -1413,6 +1487,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
createTerminal('vertical', content.sessionId, cwd, branchName);
}}
onNewTab={createTerminalInNewTab}
onRunCommandInNewTab={(command: string) => {
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
createTerminalInNewTab(cwd, branch, command);
}}
onNavigateUp={() => navigateToTerminal('up')}
onNavigateDown={() => navigateToTerminal('down')}
onNavigateLeft={() => navigateToTerminal('left')}
@@ -1427,7 +1505,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
fontSize={terminalFontSize}
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
runCommandOnConnect={isNewSession ? defaultRunScript : undefined}
runCommandOnConnect={runCommand}
onCommandRan={() => handleCommandRan(content.sessionId)}
isMaximized={terminalState.maximizedSessionId === content.sessionId}
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
@@ -1971,6 +2049,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
}}
onNewTab={createTerminalInNewTab}
onRunCommandInNewTab={(command: string) => {
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
createTerminalInNewTab(cwd, branch, command);
}}
onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!;
logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);
@@ -1982,6 +2064,13 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
onFontSizeChange={(size) =>
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
}
runCommandOnConnect={
newSessionIds.has(terminalState.maximizedSessionId)
? sessionCommandOverrides.get(terminalState.maximizedSessionId) ||
defaultRunScript
: undefined
}
onCommandRan={() => handleCommandRan(terminalState.maximizedSessionId!)}
isMaximized={true}
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
/>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import {
X,
@@ -90,6 +91,7 @@ interface TerminalPanelProps {
onSplitHorizontal: () => void;
onSplitVertical: () => void;
onNewTab?: () => void;
onRunCommandInNewTab?: (command: string) => void; // Run a script command in a new terminal tab
onNavigateUp?: () => void; // Navigate to terminal pane above
onNavigateDown?: () => void; // Navigate to terminal pane below
onNavigateLeft?: () => void; // Navigate to terminal pane on the left
@@ -120,6 +122,7 @@ export function TerminalPanel({
onSplitHorizontal,
onSplitVertical,
onNewTab,
onRunCommandInNewTab,
onNavigateUp,
onNavigateDown,
onNavigateLeft,
@@ -135,6 +138,7 @@ export function TerminalPanel({
onToggleMaximize,
branchName,
}: TerminalPanelProps) {
const navigate = useNavigate();
const terminalRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null);
@@ -2071,7 +2075,11 @@ export function TerminalPanel({
{/* Quick scripts dropdown */}
<TerminalScriptsDropdown
onRunCommand={sendCommand}
onRunCommandInNewTab={onRunCommandInNewTab}
isConnected={connectionStatus === 'connected'}
onOpenSettings={() =>
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } })
}
/>
{/* Settings popover */}

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ScrollText, Play, Settings2 } from 'lucide-react';
import { ScrollText, Play, Settings2, SquareArrowOutUpRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -17,6 +17,8 @@ import { DEFAULT_TERMINAL_SCRIPTS } from '../project-settings-view/terminal-scri
interface TerminalScriptsDropdownProps {
/** Callback to send a command + newline to the terminal */
onRunCommand: (command: string) => void;
/** Callback to run a command in a new terminal tab */
onRunCommandInNewTab?: (command: string) => void;
/** Whether the terminal is connected and ready */
isConnected: boolean;
/** Optional callback to navigate to project settings scripts section */
@@ -25,11 +27,13 @@ interface TerminalScriptsDropdownProps {
/**
* Dropdown menu in the terminal header bar that provides quick-access
* to user-configured project scripts. Clicking a script inserts the
* command into the terminal and presses Enter.
* to user-configured project scripts. Each script is a split button:
* clicking the left side runs the command in the current terminal,
* clicking the "new tab" icon on the right runs it in a new tab.
*/
export function TerminalScriptsDropdown({
onRunCommand,
onRunCommandInNewTab,
isConnected,
onOpenSettings,
}: TerminalScriptsDropdownProps) {
@@ -53,6 +57,14 @@ export function TerminalScriptsDropdown({
[isConnected, onRunCommand]
);
const handleRunScriptInNewTab = useCallback(
(command: string) => {
if (!isConnected || !onRunCommandInNewTab) return;
onRunCommandInNewTab(command);
},
[isConnected, onRunCommandInNewTab]
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -82,7 +94,7 @@ export function TerminalScriptsDropdown({
key={script.id}
onClick={() => handleRunScript(script.command)}
disabled={!isConnected}
className="gap-2"
className="gap-2 pr-1"
>
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
<div className="flex flex-col min-w-0 flex-1">
@@ -91,17 +103,43 @@ export function TerminalScriptsDropdown({
{script.command}
</span>
</div>
{onRunCommandInNewTab && (
<button
type="button"
className={cn(
'shrink-0 ml-1 p-1 rounded-sm border-l border-border',
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
'transition-colors',
!isConnected && 'pointer-events-none opacity-50'
)}
title="Run in new tab"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRunScriptInNewTab(script.command);
}}
onPointerDown={(e) => {
// Prevent the DropdownMenuItem from handling this pointer event
e.stopPropagation();
}}
onPointerUp={(e) => {
e.stopPropagation();
}}
>
<SquareArrowOutUpRight className="h-3 w-3" />
</button>
)}
</DropdownMenuItem>
))}
{onOpenSettings && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onOpenSettings} className="gap-2 text-muted-foreground">
<Settings2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-sm">Configure Scripts...</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onOpenSettings}
className="gap-2 text-muted-foreground"
disabled={!onOpenSettings}
>
<Settings2 className="h-3.5 w-3.5 shrink-0" />
<span className="text-sm">Edit Commands & Scripts</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -62,6 +62,7 @@ export {
useValidateIssue,
useMarkValidationViewed,
useGetValidationStatus,
useResolveReviewThread,
} from './use-github-mutations';
// Ideation mutations

View File

@@ -135,6 +135,55 @@ export function useMarkValidationViewed(projectPath: string) {
});
}
/**
* Resolve or unresolve a PR review thread
*
* @param projectPath - Path to the project
* @param prNumber - PR number (for cache invalidation)
* @returns Mutation for resolving/unresolving a review thread
*
* @example
* ```tsx
* const resolveThread = useResolveReviewThread(projectPath, prNumber);
* resolveThread.mutate({ threadId: comment.threadId, resolve: true });
* ```
*/
export function useResolveReviewThread(projectPath: string, prNumber: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ threadId, resolve }: { threadId: string; resolve: boolean }) => {
const api = getElectronAPI();
if (!api.github?.resolveReviewThread) {
throw new Error('Resolve review thread API not available');
}
const result = await api.github.resolveReviewThread(projectPath, threadId, resolve);
if (!result.success) {
throw new Error(result.error || 'Failed to resolve review thread');
}
return { isResolved: result.isResolved ?? resolve };
},
onSuccess: (_, variables) => {
const action = variables.resolve ? 'resolved' : 'unresolved';
toast.success(`Comment ${action}`, {
description: `The review thread has been ${action} on GitHub`,
});
// Invalidate the PR review comments cache to reflect updated resolved status
queryClient.invalidateQueries({
queryKey: queryKeys.github.prReviewComments(projectPath, prNumber),
});
},
onError: (error) => {
toast.error('Failed to update comment', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
});
}
/**
* Get running validation status
*

View File

@@ -20,6 +20,7 @@ export {
useGitHubValidations,
useGitHubRemote,
useGitHubIssueComments,
useGitHubPRReviewComments,
} from './use-github';
// Usage

View File

@@ -6,8 +6,8 @@
* automatic caching, deduplication, and background refetching.
*/
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
@@ -151,6 +151,34 @@ export function useFeatures(projectPath: string | undefined) {
[projectPath]
);
const queryClient = useQueryClient();
// Subscribe to React Query cache changes for features and sync to localStorage.
// This ensures optimistic updates (e.g., status changes to 'verified') are
// persisted to localStorage immediately, not just when queryFn runs.
// Without this, a page refresh after an optimistic update could show stale
// localStorage data where features appear in the wrong column (e.g., verified
// features showing up in backlog).
const projectPathRef = useRef(projectPath);
projectPathRef.current = projectPath;
useEffect(() => {
if (!projectPath) return;
const targetQueryHash = JSON.stringify(queryKeys.features.all(projectPath));
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (
event.type === 'updated' &&
event.action.type === 'success' &&
event.query.queryHash === targetQueryHash
) {
const features = event.query.state.data as Feature[] | undefined;
if (features && projectPathRef.current) {
writePersistedFeatures(projectPathRef.current, features);
}
}
});
return unsubscribe;
}, [projectPath, queryClient]);
return useQuery({
queryKey: queryKeys.features.all(projectPath ?? ''),
queryFn: async (): Promise<Feature[]> => {
@@ -166,7 +194,11 @@ export function useFeatures(projectPath: string | undefined) {
},
enabled: !!projectPath,
initialData: () => persisted?.features,
initialDataUpdatedAt: () => persisted?.timestamp,
// Always treat localStorage cache as stale so React Query immediately
// fetches fresh data from the server on page load. This prevents stale
// feature statuses (e.g., 'verified' features appearing in backlog)
// while still showing cached data instantly for a fast initial render.
initialDataUpdatedAt: 0,
staleTime: STALE_TIMES.FEATURES,
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,

View File

@@ -8,7 +8,13 @@ import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { GitHubIssue, GitHubPR, GitHubComment, StoredValidation } from '@/lib/electron';
import type {
GitHubIssue,
GitHubPR,
GitHubComment,
PRReviewComment,
StoredValidation,
} from '@/lib/electron';
interface GitHubIssuesResult {
openIssues: GitHubIssue[];
@@ -197,3 +203,45 @@ export function useGitHubIssueComments(
staleTime: STALE_TIMES.GITHUB,
});
}
/**
* Fetch review comments for a GitHub PR
*
* Fetches both regular PR comments and inline code review comments
* with file path and line context for each.
*
* @param projectPath - Path to the project
* @param prNumber - PR number
* @returns Query result with review comments
*
* @example
* ```tsx
* const { data, isLoading } = useGitHubPRReviewComments(projectPath, prNumber);
* const comments = data?.comments ?? [];
* ```
*/
export function useGitHubPRReviewComments(
projectPath: string | undefined,
prNumber: number | undefined
) {
return useQuery({
queryKey: queryKeys.github.prReviewComments(projectPath ?? '', prNumber ?? 0),
queryFn: async (): Promise<{ comments: PRReviewComment[]; totalCount: number }> => {
if (!projectPath || !prNumber) throw new Error('Missing project path or PR number');
const api = getElectronAPI();
if (!api.github) {
throw new Error('GitHub API not available');
}
const result = await api.github.getPRReviewComments(projectPath, prNumber);
if (!result.success) {
throw new Error(result.error || 'Failed to fetch PR review comments');
}
return {
comments: (result.comments ?? []) as PRReviewComment[],
totalCount: result.totalCount ?? 0,
};
},
enabled: !!projectPath && !!prNumber,
staleTime: STALE_TIMES.GITHUB,
});
}

View File

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

View File

@@ -313,6 +313,34 @@ export interface GitHubRemoteStatus {
repo: string | null;
}
/** A review comment on a pull request (inline code comment or general PR comment) */
export interface PRReviewComment {
id: string;
author: string;
avatarUrl?: string;
body: string;
/** File path for inline review comments */
path?: string;
/** Line number for inline review comments */
line?: number;
createdAt: string;
updatedAt?: string;
/** Whether this is an inline code review comment (vs general PR comment) */
isReviewComment: boolean;
/** Whether this comment is outdated (code has changed since) */
isOutdated?: boolean;
/** Whether the review thread containing this comment has been resolved */
isResolved?: boolean;
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
threadId?: string;
/** The diff hunk context for the comment */
diffHunk?: string;
/** The side of the diff (LEFT or RIGHT) */
side?: string;
/** The commit ID the comment was made on */
commitId?: string;
}
export interface GitHubAPI {
checkRemote: (projectPath: string) => Promise<{
success: boolean;
@@ -389,6 +417,26 @@ export interface GitHubAPI {
endCursor?: string;
error?: string;
}>;
/** Fetch review comments for a specific pull request */
getPRReviewComments: (
projectPath: string,
prNumber: number
) => Promise<{
success: boolean;
comments?: PRReviewComment[];
totalCount?: number;
error?: string;
}>;
/** Resolve or unresolve a PR review thread */
resolveReviewThread: (
projectPath: string,
threadId: string,
resolve: boolean
) => Promise<{
success: boolean;
isResolved?: boolean;
error?: string;
}>;
}
// Spec Regeneration types
@@ -3980,6 +4028,21 @@ function createMockGitHubAPI(): GitHubAPI {
hasNextPage: false,
};
},
getPRReviewComments: async (projectPath: string, prNumber: number) => {
console.log('[Mock] Getting PR review comments:', { projectPath, prNumber });
return {
success: true,
comments: [],
totalCount: 0,
};
},
resolveReviewThread: async (projectPath: string, threadId: string, resolve: boolean) => {
console.log('[Mock] Resolving review thread:', { projectPath, threadId, resolve });
return {
success: true,
isResolved: resolve,
};
},
};
}

View File

@@ -2474,6 +2474,10 @@ export class HttpApiClient implements ElectronAPI {
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
getPRReviewComments: (projectPath: string, prNumber: number) =>
this.post('/api/github/pr-review-comments', { projectPath, prNumber }),
resolveReviewThread: (projectPath: string, threadId: string, resolve: boolean) =>
this.post('/api/github/resolve-pr-comment', { projectPath, threadId, resolve }),
};
// Workspace API

View File

@@ -70,6 +70,9 @@ export const queryKeys = {
/** Issue comments */
issueComments: (projectPath: string, issueNumber: number) =>
['github', 'issues', projectPath, issueNumber, 'comments'] as const,
/** PR review comments */
prReviewComments: (projectPath: string, prNumber: number) =>
['github', 'prs', projectPath, prNumber, 'review-comments'] as const,
/** Remote info */
remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
},

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './app';
import { AppErrorBoundary } from './components/ui/app-error-boundary';
import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect';
// Defensive fallback: index.html's inline script already applies data-pwa="standalone"
@@ -250,8 +251,12 @@ function warmAssetCache(registration: ServiceWorkerRegistration): void {
}
// Render the app - prioritize First Contentful Paint
// AppErrorBoundary catches uncaught React errors and shows a friendly error screen
// instead of TanStack Router's default "Something went wrong!" overlay.
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
<AppErrorBoundary>
<App />
</AppErrorBoundary>
</StrictMode>
);

View File

@@ -400,19 +400,16 @@ function RootLayoutContent() {
useEffect(() => {
const handleLoggedOut = () => {
logger.warn('automaker:logged-out event received!');
// Only update auth state — the centralized routing effect will handle
// navigation to /logged-out when it detects isAuthenticated is false
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
if (location.pathname !== '/logged-out') {
logger.warn('Navigating to /logged-out due to logged-out event');
navigate({ to: '/logged-out' });
}
};
window.addEventListener('automaker:logged-out', handleLoggedOut);
return () => {
window.removeEventListener('automaker:logged-out', handleLoggedOut);
};
}, [location.pathname, navigate]);
}, []);
// Global listener for server offline/connection errors.
// This is triggered when a connection error is detected (e.g., server stopped).
@@ -724,33 +721,31 @@ function RootLayoutContent() {
}
// If we can't load settings, we must NOT start syncing defaults to the server.
// Only update auth state — the routing effect handles navigation to /logged-out.
// Calling navigate() here AND in the routing effect causes duplicate navigations
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
signalMigrationComplete();
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' });
}
return;
}
} else {
// Session is definitively invalid (server returned 401/403) - treat as not authenticated
// Session is definitively invalid (server returned 401/403) - treat as not authenticated.
// Only update auth state — the routing effect handles navigation to /logged-out.
// Calling navigate() here AND in the routing effect causes duplicate navigations
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
signalMigrationComplete();
// Redirect to logged-out if not already there or login
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' });
}
}
} catch (error) {
logger.error('Failed to initialize auth:', error);
// On error, treat as not authenticated
// On error, treat as not authenticated.
// Only update auth state — the routing effect handles navigation to /logged-out.
// Calling navigate() here AND in the routing effect causes duplicate navigations
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// Signal migration complete so sync hook doesn't hang
signalMigrationComplete();
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' });
}
} finally {
authCheckRunning.current = false;
}

View File

@@ -1,6 +1,16 @@
import { createFileRoute } from '@tanstack/react-router';
import { ProjectSettingsView } from '@/components/views/project-settings-view';
import type { ProjectSettingsViewId } from '@/components/views/project-settings-view/hooks/use-project-settings-view';
interface ProjectSettingsSearchParams {
section?: ProjectSettingsViewId;
}
export const Route = createFileRoute('/project-settings')({
component: ProjectSettingsView,
validateSearch: (search: Record<string, unknown>): ProjectSettingsSearchParams => {
return {
section: search.section as ProjectSettingsViewId | undefined,
};
},
});

View File

@@ -6,6 +6,14 @@ export const Route = createLazyFileRoute('/terminal')({
});
function RouteComponent() {
const { cwd, branch, mode, nonce } = useSearch({ from: '/terminal' });
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
const { cwd, branch, mode, nonce, command } = useSearch({ from: '/terminal' });
return (
<TerminalView
initialCwd={cwd}
initialBranch={branch}
initialMode={mode}
nonce={nonce}
initialCommand={command}
/>
);
}

View File

@@ -6,6 +6,7 @@ const terminalSearchSchema = z.object({
branch: z.string().optional(),
mode: z.enum(['tab', 'split']).optional(),
nonce: z.coerce.number().optional(),
command: z.string().optional(),
});
// Component is lazy-loaded via terminal.lazy.tsx for code splitting