mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-04-06 16:43:07 +00:00
Compare commits
1 Commits
c81ea768a7
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa345a50ac |
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared execution utilities
|
|
||||||
*
|
|
||||||
* Common helpers for spawning child processes with the correct environment.
|
|
||||||
* Used by both route handlers and service layers.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('ExecUtils');
|
|
||||||
|
|
||||||
// Extended PATH to include common tool installation locations
|
|
||||||
export const extendedPath = [
|
|
||||||
process.env.PATH,
|
|
||||||
'/opt/homebrew/bin',
|
|
||||||
'/usr/local/bin',
|
|
||||||
'/home/linuxbrew/.linuxbrew/bin',
|
|
||||||
`${process.env.HOME}/.local/bin`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(':');
|
|
||||||
|
|
||||||
export const execEnv = {
|
|
||||||
...process.env,
|
|
||||||
PATH: extendedPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logError(error: unknown, context: string): void {
|
|
||||||
logger.error(`${context}:`, error);
|
|
||||||
}
|
|
||||||
@@ -24,9 +24,7 @@ export function createWriteHandler() {
|
|||||||
|
|
||||||
// Ensure parent directory exists (symlink-safe)
|
// Ensure parent directory exists (symlink-safe)
|
||||||
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
await mkdirSafe(path.dirname(path.resolve(filePath)));
|
||||||
// Default content to empty string if undefined/null to prevent writing
|
await secureFs.writeFile(filePath, content, 'utf-8');
|
||||||
// "undefined" as literal text (e.g. when content field is missing from request)
|
|
||||||
await secureFs.writeFile(filePath, content ?? '', 'utf-8');
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* Common utilities for GitHub routes
|
* Common utilities for GitHub routes
|
||||||
*
|
|
||||||
* Re-exports shared utilities from lib/exec-utils so route consumers
|
|
||||||
* can continue importing from this module unchanged.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('GitHub');
|
||||||
|
|
||||||
export const execAsync = promisify(exec);
|
export const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Re-export shared utilities from the canonical location
|
// Extended PATH to include common tool installation locations
|
||||||
export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js';
|
export const extendedPath = [
|
||||||
|
process.env.PATH,
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin',
|
||||||
|
`${process.env.HOME}/.local/bin`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(':');
|
||||||
|
|
||||||
|
export const execEnv = {
|
||||||
|
...process.env,
|
||||||
|
PATH: extendedPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logError(error: unknown, context: string): void {
|
||||||
|
logger.error(`${context}:`, error);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,26 +5,287 @@
|
|||||||
* for a specific pull request, providing file path and line context.
|
* for a specific pull request, providing file path and line context.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getErrorMessage, logError } from './common.js';
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
import {
|
|
||||||
fetchPRReviewComments,
|
|
||||||
fetchReviewThreadResolvedStatus,
|
|
||||||
type PRReviewComment,
|
|
||||||
type ListPRReviewCommentsResult,
|
|
||||||
} from '../../../services/pr-review-comments.service.js';
|
|
||||||
|
|
||||||
// Re-export types so existing callers continue to work
|
export interface PRReviewComment {
|
||||||
export type { PRReviewComment, ListPRReviewCommentsResult };
|
id: string;
|
||||||
// Re-export service functions so existing callers continue to work
|
author: string;
|
||||||
export { fetchPRReviewComments, fetchReviewThreadResolvedStatus };
|
avatarUrl?: string;
|
||||||
|
body: string;
|
||||||
|
path?: string;
|
||||||
|
line?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
isReviewComment: boolean;
|
||||||
|
/** Whether this is an outdated review comment (code has changed since) */
|
||||||
|
isOutdated?: boolean;
|
||||||
|
/** Whether the review thread containing this comment has been resolved */
|
||||||
|
isResolved?: boolean;
|
||||||
|
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
|
||||||
|
threadId?: string;
|
||||||
|
/** The diff hunk context for the comment */
|
||||||
|
diffHunk?: string;
|
||||||
|
/** The side of the diff (LEFT or RIGHT) */
|
||||||
|
side?: string;
|
||||||
|
/** The commit ID the comment was made on */
|
||||||
|
commitId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPRReviewCommentsResult {
|
||||||
|
success: boolean;
|
||||||
|
comments?: PRReviewComment[];
|
||||||
|
totalCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ListPRReviewCommentsRequest {
|
interface ListPRReviewCommentsRequest {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
prNumber: number;
|
prNumber: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
||||||
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
interface GraphQLReviewThreadComment {
|
||||||
|
databaseId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLReviewThread {
|
||||||
|
id: string;
|
||||||
|
isResolved: boolean;
|
||||||
|
comments: {
|
||||||
|
nodes: GraphQLReviewThreadComment[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphQLResponse {
|
||||||
|
data?: {
|
||||||
|
repository?: {
|
||||||
|
pullRequest?: {
|
||||||
|
reviewThreads?: {
|
||||||
|
nodes: GraphQLReviewThread[];
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewThreadInfo {
|
||||||
|
isResolved: boolean;
|
||||||
|
threadId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
|
||||||
|
* Returns a map of comment ID (string) -> { isResolved, threadId }.
|
||||||
|
*/
|
||||||
|
async function fetchReviewThreadResolvedStatus(
|
||||||
|
projectPath: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number
|
||||||
|
): Promise<Map<string, ReviewThreadInfo>> {
|
||||||
|
const resolvedMap = new Map<string, ReviewThreadInfo>();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetPRReviewThreads(
|
||||||
|
$owner: String!
|
||||||
|
$repo: String!
|
||||||
|
$prNumber: Int!
|
||||||
|
) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $prNumber) {
|
||||||
|
reviewThreads(first: 100) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
isResolved
|
||||||
|
comments(first: 100) {
|
||||||
|
nodes {
|
||||||
|
databaseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const variables = { owner, repo, prNumber };
|
||||||
|
const requestBody = JSON.stringify({ query, variables });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
||||||
|
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
gh.kill();
|
||||||
|
reject(new Error('GitHub GraphQL API request timed out'));
|
||||||
|
}, GITHUB_API_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||||
|
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||||
|
|
||||||
|
gh.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (code !== 0) {
|
||||||
|
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.stdin.write(requestBody);
|
||||||
|
gh.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.errors && response.errors.length > 0) {
|
||||||
|
throw new Error(response.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||||
|
for (const thread of threads) {
|
||||||
|
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
|
||||||
|
for (const comment of thread.comments.nodes) {
|
||||||
|
resolvedMap.set(String(comment.databaseId), info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail — resolved status is best-effort
|
||||||
|
logError(error, 'Failed to fetch PR review thread resolved status');
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all comments for a PR (both regular and inline review comments)
|
||||||
|
*/
|
||||||
|
async function fetchPRReviewComments(
|
||||||
|
projectPath: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number
|
||||||
|
): Promise<PRReviewComment[]> {
|
||||||
|
const allComments: PRReviewComment[] = [];
|
||||||
|
|
||||||
|
// Fetch review thread resolved status in parallel with comment fetching
|
||||||
|
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
|
||||||
|
|
||||||
|
// 1. Fetch regular PR comments (issue-level comments)
|
||||||
|
try {
|
||||||
|
const { stdout: commentsOutput } = await execAsync(
|
||||||
|
`gh pr view ${prNumber} -R ${owner}/${repo} --json comments`,
|
||||||
|
{
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const commentsData = JSON.parse(commentsOutput);
|
||||||
|
const regularComments = (commentsData.comments || []).map(
|
||||||
|
(c: {
|
||||||
|
id: string;
|
||||||
|
author: { login: string; avatarUrl?: string };
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
author: c.author?.login || 'unknown',
|
||||||
|
avatarUrl: c.author?.avatarUrl,
|
||||||
|
body: c.body,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
isReviewComment: false,
|
||||||
|
isOutdated: false,
|
||||||
|
// Regular PR comments are not part of review threads, so not resolvable
|
||||||
|
isResolved: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...regularComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch regular PR comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch inline review comments (code-level comments with file/line info)
|
||||||
|
try {
|
||||||
|
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
||||||
|
const { stdout: reviewsOutput } = await execAsync(`gh api ${reviewsEndpoint} --paginate`, {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewsData = JSON.parse(reviewsOutput);
|
||||||
|
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
|
||||||
|
(c: {
|
||||||
|
id: number;
|
||||||
|
user: { login: string; avatar_url?: string };
|
||||||
|
body: string;
|
||||||
|
path: string;
|
||||||
|
line?: number;
|
||||||
|
original_line?: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
diff_hunk?: string;
|
||||||
|
side?: string;
|
||||||
|
commit_id?: string;
|
||||||
|
position?: number | null;
|
||||||
|
}) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
author: c.user?.login || 'unknown',
|
||||||
|
avatarUrl: c.user?.avatar_url,
|
||||||
|
body: c.body,
|
||||||
|
path: c.path,
|
||||||
|
line: c.line || c.original_line,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
updatedAt: c.updated_at,
|
||||||
|
isReviewComment: true,
|
||||||
|
// A review comment is "outdated" if position is null (code has changed)
|
||||||
|
isOutdated: c.position === null && !c.line,
|
||||||
|
// isResolved will be filled in below from GraphQL data
|
||||||
|
isResolved: false,
|
||||||
|
diffHunk: c.diff_hunk,
|
||||||
|
side: c.side,
|
||||||
|
commitId: c.commit_id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...reviewComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch inline review comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for resolved status and apply to inline review comments
|
||||||
|
const resolvedMap = await resolvedStatusPromise;
|
||||||
|
if (resolvedMap.size > 0) {
|
||||||
|
for (const comment of allComments) {
|
||||||
|
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
|
||||||
|
const info = resolvedMap.get(comment.id);
|
||||||
|
comment.isResolved = info?.isResolved ?? false;
|
||||||
|
comment.threadId = info?.threadId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending (newest first)
|
||||||
|
allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
|
return allComments;
|
||||||
|
}
|
||||||
|
|
||||||
export function createListPRReviewCommentsHandler() {
|
export function createListPRReviewCommentsHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
* identified by its GraphQL node ID (threadId).
|
* identified by its GraphQL node ID (threadId).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { getErrorMessage, logError } from './common.js';
|
import { execEnv, getErrorMessage, logError } from './common.js';
|
||||||
import { checkGitHubRemote } from './check-github-remote.js';
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js';
|
|
||||||
|
|
||||||
export interface ResolvePRCommentResult {
|
export interface ResolvePRCommentResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -22,6 +22,91 @@ interface ResolvePRCommentRequest {
|
|||||||
resolve: boolean;
|
resolve: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
||||||
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
interface GraphQLMutationResponse {
|
||||||
|
data?: {
|
||||||
|
resolveReviewThread?: {
|
||||||
|
thread?: { isResolved: boolean; id: string } | null;
|
||||||
|
} | null;
|
||||||
|
unresolveReviewThread?: {
|
||||||
|
thread?: { isResolved: boolean; id: string } | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL mutation to resolve or unresolve a review thread.
|
||||||
|
*/
|
||||||
|
async function executeReviewThreadMutation(
|
||||||
|
projectPath: string,
|
||||||
|
threadId: string,
|
||||||
|
resolve: boolean
|
||||||
|
): Promise<{ isResolved: boolean }> {
|
||||||
|
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
|
||||||
|
|
||||||
|
const mutation = `
|
||||||
|
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
|
||||||
|
${mutationName}(input: { threadId: $threadId }) {
|
||||||
|
thread {
|
||||||
|
id
|
||||||
|
isResolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const variables = { threadId };
|
||||||
|
const requestBody = JSON.stringify({ query: mutation, variables });
|
||||||
|
|
||||||
|
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
|
||||||
|
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
gh.kill();
|
||||||
|
rej(new Error('GitHub GraphQL API request timed out'));
|
||||||
|
}, GITHUB_API_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||||
|
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||||
|
|
||||||
|
gh.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (code !== 0) {
|
||||||
|
return rej(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res(JSON.parse(stdout));
|
||||||
|
} catch (e) {
|
||||||
|
rej(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gh.stdin.write(requestBody);
|
||||||
|
gh.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.errors && response.errors.length > 0) {
|
||||||
|
throw new Error(response.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadData = resolve
|
||||||
|
? response.data?.resolveReviewThread?.thread
|
||||||
|
: response.data?.unresolveReviewThread?.thread;
|
||||||
|
|
||||||
|
if (!threadData) {
|
||||||
|
throw new Error('No thread data returned from GitHub API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isResolved: threadData.isResolved };
|
||||||
|
}
|
||||||
|
|
||||||
export function createResolvePRCommentHandler() {
|
export function createResolvePRCommentHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
@@ -20,7 +20,7 @@ import { getErrorMessage, logError } from '../common.js';
|
|||||||
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
||||||
|
|
||||||
const logger = createLogger('GenerateCommitMessage');
|
const logger = createLogger('GenerateCommitMessage');
|
||||||
const execFileAsync = promisify(execFile);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
/** Timeout for AI provider calls in milliseconds (30 seconds) */
|
||||||
const AI_TIMEOUT_MS = 30_000;
|
const AI_TIMEOUT_MS = 30_000;
|
||||||
@@ -33,39 +33,20 @@ async function* withTimeout<T>(
|
|||||||
generator: AsyncIterable<T>,
|
generator: AsyncIterable<T>,
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
): AsyncGenerator<T, void, unknown> {
|
): AsyncGenerator<T, void, unknown> {
|
||||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
timerId = setTimeout(
|
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||||
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
|
|
||||||
timeoutMs
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const iterator = generator[Symbol.asyncIterator]();
|
const iterator = generator[Symbol.asyncIterator]();
|
||||||
let done = false;
|
let done = false;
|
||||||
|
|
||||||
try {
|
while (!done) {
|
||||||
while (!done) {
|
const result = await Promise.race([iterator.next(), timeoutPromise]);
|
||||||
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
|
if (result.done) {
|
||||||
// Capture the original error, then attempt to close the iterator.
|
done = true;
|
||||||
// If iterator.return() throws, log it but rethrow the original error
|
} else {
|
||||||
// so the timeout error (not the teardown error) is preserved.
|
yield result.value;
|
||||||
try {
|
|
||||||
await iterator.return?.();
|
|
||||||
} catch (teardownErr) {
|
|
||||||
logger.warn('Error during iterator cleanup after timeout:', teardownErr);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
if (result.done) {
|
|
||||||
done = true;
|
|
||||||
} else {
|
|
||||||
yield result.value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
clearTimeout(timerId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,14 +117,14 @@ export function createGenerateCommitMessageHandler(
|
|||||||
let diff = '';
|
let diff = '';
|
||||||
try {
|
try {
|
||||||
// First try to get staged changes
|
// First try to get staged changes
|
||||||
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no staged changes, get unstaged changes
|
// If no staged changes, get unstaged changes
|
||||||
if (!stagedDiff.trim()) {
|
if (!stagedDiff.trim()) {
|
||||||
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
const { stdout: unstagedDiff } = await execAsync('git diff', {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
});
|
});
|
||||||
@@ -241,7 +222,7 @@ export function createGenerateCommitMessageHandler(
|
|||||||
|
|
||||||
const message = responseText.trim();
|
const message = responseText.trim();
|
||||||
|
|
||||||
if (!message) {
|
if (!message || message.trim().length === 0) {
|
||||||
logger.warn('Received empty response from model');
|
logger.warn('Received empty response from model');
|
||||||
const response: GenerateCommitMessageErrorResponse = {
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* GitHub PR Comment Service
|
|
||||||
*
|
|
||||||
* Domain logic for resolving/unresolving PR review threads via the
|
|
||||||
* GitHub GraphQL API. Extracted from the route handler so the route
|
|
||||||
* only deals with request/response plumbing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { execEnv } from '../lib/exec-utils.js';
|
|
||||||
|
|
||||||
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
|
||||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
|
||||||
|
|
||||||
interface GraphQLMutationResponse {
|
|
||||||
data?: {
|
|
||||||
resolveReviewThread?: {
|
|
||||||
thread?: { isResolved: boolean; id: string } | null;
|
|
||||||
} | null;
|
|
||||||
unresolveReviewThread?: {
|
|
||||||
thread?: { isResolved: boolean; id: string } | null;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
errors?: Array<{ message: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a GraphQL mutation to resolve or unresolve a review thread.
|
|
||||||
*/
|
|
||||||
export async function executeReviewThreadMutation(
|
|
||||||
projectPath: string,
|
|
||||||
threadId: string,
|
|
||||||
resolve: boolean
|
|
||||||
): Promise<{ isResolved: boolean }> {
|
|
||||||
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
|
|
||||||
|
|
||||||
const mutation = `
|
|
||||||
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
|
|
||||||
${mutationName}(input: { threadId: $threadId }) {
|
|
||||||
thread {
|
|
||||||
id
|
|
||||||
isResolved
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const variables = { threadId };
|
|
||||||
const requestBody = JSON.stringify({ query: mutation, variables });
|
|
||||||
|
|
||||||
// Declare timeoutId before registering the error handler to avoid TDZ confusion
|
|
||||||
let timeoutId: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
|
|
||||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
gh.on('error', (err) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
rej(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
gh.kill();
|
|
||||||
rej(new Error('GitHub GraphQL API request timed out'));
|
|
||||||
}, GITHUB_API_TIMEOUT_MS);
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
|
||||||
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
|
||||||
|
|
||||||
gh.on('close', (code) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (code !== 0) {
|
|
||||||
return rej(new Error(`gh process exited with code ${code}: ${stderr}`));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
res(JSON.parse(stdout));
|
|
||||||
} catch (e) {
|
|
||||||
rej(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gh.stdin.write(requestBody);
|
|
||||||
gh.stdin.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.errors && response.errors.length > 0) {
|
|
||||||
throw new Error(response.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadData = resolve
|
|
||||||
? response.data?.resolveReviewThread?.thread
|
|
||||||
: response.data?.unresolveReviewThread?.thread;
|
|
||||||
|
|
||||||
if (!threadData) {
|
|
||||||
throw new Error('No thread data returned from GitHub API');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isResolved: threadData.isResolved };
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
/**
|
|
||||||
* PR Review Comments Service
|
|
||||||
*
|
|
||||||
* Domain logic for fetching PR review comments, enriching them with
|
|
||||||
* resolved-thread status, and sorting. Extracted from the route handler
|
|
||||||
* so the route only deals with request/response plumbing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn, execFile } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import { createLogger } from '@automaker/utils';
|
|
||||||
import { execEnv, logError } from '../lib/exec-utils.js';
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
// ── Public types (re-exported for callers) ──
|
|
||||||
|
|
||||||
export interface PRReviewComment {
|
|
||||||
id: string;
|
|
||||||
author: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
body: string;
|
|
||||||
path?: string;
|
|
||||||
line?: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
isReviewComment: boolean;
|
|
||||||
/** Whether this is an outdated review comment (code has changed since) */
|
|
||||||
isOutdated?: boolean;
|
|
||||||
/** Whether the review thread containing this comment has been resolved */
|
|
||||||
isResolved?: boolean;
|
|
||||||
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
|
|
||||||
threadId?: string;
|
|
||||||
/** The diff hunk context for the comment */
|
|
||||||
diffHunk?: string;
|
|
||||||
/** The side of the diff (LEFT or RIGHT) */
|
|
||||||
side?: string;
|
|
||||||
/** The commit ID the comment was made on */
|
|
||||||
commitId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListPRReviewCommentsResult {
|
|
||||||
success: boolean;
|
|
||||||
comments?: PRReviewComment[];
|
|
||||||
totalCount?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal types ──
|
|
||||||
|
|
||||||
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
|
||||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
|
||||||
|
|
||||||
interface GraphQLReviewThreadComment {
|
|
||||||
databaseId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphQLReviewThread {
|
|
||||||
id: string;
|
|
||||||
isResolved: boolean;
|
|
||||||
comments: {
|
|
||||||
pageInfo?: {
|
|
||||||
hasNextPage: boolean;
|
|
||||||
};
|
|
||||||
nodes: GraphQLReviewThreadComment[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphQLResponse {
|
|
||||||
data?: {
|
|
||||||
repository?: {
|
|
||||||
pullRequest?: {
|
|
||||||
reviewThreads?: {
|
|
||||||
nodes: GraphQLReviewThread[];
|
|
||||||
pageInfo?: {
|
|
||||||
hasNextPage: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
errors?: Array<{ message: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewThreadInfo {
|
|
||||||
isResolved: boolean;
|
|
||||||
threadId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Logger ──
|
|
||||||
|
|
||||||
const logger = createLogger('PRReviewCommentsService');
|
|
||||||
|
|
||||||
// ── Service functions ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
|
|
||||||
* Returns a map of comment ID (string) -> { isResolved, threadId }.
|
|
||||||
*/
|
|
||||||
export async function fetchReviewThreadResolvedStatus(
|
|
||||||
projectPath: string,
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
prNumber: number
|
|
||||||
): Promise<Map<string, ReviewThreadInfo>> {
|
|
||||||
const resolvedMap = new Map<string, ReviewThreadInfo>();
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query GetPRReviewThreads(
|
|
||||||
$owner: String!
|
|
||||||
$repo: String!
|
|
||||||
$prNumber: Int!
|
|
||||||
) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
pullRequest(number: $prNumber) {
|
|
||||||
reviewThreads(first: 100) {
|
|
||||||
pageInfo {
|
|
||||||
hasNextPage
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
isResolved
|
|
||||||
comments(first: 100) {
|
|
||||||
pageInfo {
|
|
||||||
hasNextPage
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
databaseId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const variables = { owner, repo, prNumber };
|
|
||||||
const requestBody = JSON.stringify({ query, variables });
|
|
||||||
|
|
||||||
try {
|
|
||||||
let timeoutId: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
|
||||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
});
|
|
||||||
|
|
||||||
gh.on('error', (err) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
gh.kill();
|
|
||||||
reject(new Error('GitHub GraphQL API request timed out'));
|
|
||||||
}, GITHUB_API_TIMEOUT_MS);
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
|
||||||
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
|
||||||
|
|
||||||
gh.on('close', (code) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (code !== 0) {
|
|
||||||
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(stdout));
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gh.stdin.write(requestBody);
|
|
||||||
gh.stdin.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.errors && response.errors.length > 0) {
|
|
||||||
throw new Error(response.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if reviewThreads data was truncated (more than 100 threads)
|
|
||||||
const pageInfo = response.data?.repository?.pullRequest?.reviewThreads?.pageInfo;
|
|
||||||
if (pageInfo?.hasNextPage) {
|
|
||||||
logger.warn(
|
|
||||||
`PR #${prNumber} in ${owner}/${repo} has more than 100 review threads — ` +
|
|
||||||
'results are truncated. Some comments may be missing resolved status.'
|
|
||||||
);
|
|
||||||
// TODO: Implement cursor-based pagination by iterating with
|
|
||||||
// reviewThreads.nodes pageInfo.endCursor across spawn calls.
|
|
||||||
}
|
|
||||||
|
|
||||||
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
||||||
for (const thread of threads) {
|
|
||||||
if (thread.comments.pageInfo?.hasNextPage) {
|
|
||||||
logger.warn(
|
|
||||||
`Review thread ${thread.id} in PR #${prNumber} has more than 100 comments — ` +
|
|
||||||
'comment list is truncated. Some comments may be missing resolved status.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
|
|
||||||
for (const comment of thread.comments.nodes) {
|
|
||||||
resolvedMap.set(String(comment.databaseId), info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Log but don't fail — resolved status is best-effort
|
|
||||||
logError(error, 'Failed to fetch PR review thread resolved status');
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all comments for a PR (both regular and inline review comments)
|
|
||||||
*/
|
|
||||||
export async function fetchPRReviewComments(
|
|
||||||
projectPath: string,
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
prNumber: number
|
|
||||||
): Promise<PRReviewComment[]> {
|
|
||||||
const allComments: PRReviewComment[] = [];
|
|
||||||
|
|
||||||
// Fetch review thread resolved status in parallel with comment fetching
|
|
||||||
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
|
|
||||||
|
|
||||||
// 1. Fetch regular PR comments (issue-level comments)
|
|
||||||
try {
|
|
||||||
const { stdout: commentsOutput } = await execFileAsync(
|
|
||||||
'gh',
|
|
||||||
['pr', 'view', String(prNumber), '-R', `${owner}/${repo}`, '--json', 'comments'],
|
|
||||||
{
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
|
|
||||||
timeout: GITHUB_API_TIMEOUT_MS,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const commentsData = JSON.parse(commentsOutput);
|
|
||||||
const regularComments = (commentsData.comments || []).map(
|
|
||||||
(c: {
|
|
||||||
id: string;
|
|
||||||
author: { login: string; avatarUrl?: string };
|
|
||||||
body: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}) => ({
|
|
||||||
id: String(c.id),
|
|
||||||
author: c.author?.login || 'unknown',
|
|
||||||
avatarUrl: c.author?.avatarUrl,
|
|
||||||
body: c.body,
|
|
||||||
createdAt: c.createdAt,
|
|
||||||
updatedAt: c.updatedAt,
|
|
||||||
isReviewComment: false,
|
|
||||||
isOutdated: false,
|
|
||||||
// Regular PR comments are not part of review threads, so not resolvable
|
|
||||||
isResolved: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
allComments.push(...regularComments);
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Failed to fetch regular PR comments');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch inline review comments (code-level comments with file/line info)
|
|
||||||
try {
|
|
||||||
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
|
||||||
const { stdout: reviewsOutput } = await execFileAsync(
|
|
||||||
'gh',
|
|
||||||
['api', reviewsEndpoint, '--paginate', '--slurp', '--jq', 'add // []'],
|
|
||||||
{
|
|
||||||
cwd: projectPath,
|
|
||||||
env: execEnv,
|
|
||||||
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
|
|
||||||
timeout: GITHUB_API_TIMEOUT_MS,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const reviewsData = JSON.parse(reviewsOutput);
|
|
||||||
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
|
|
||||||
(c: {
|
|
||||||
id: number;
|
|
||||||
user: { login: string; avatar_url?: string };
|
|
||||||
body: string;
|
|
||||||
path: string;
|
|
||||||
line?: number;
|
|
||||||
original_line?: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at?: string;
|
|
||||||
diff_hunk?: string;
|
|
||||||
side?: string;
|
|
||||||
commit_id?: string;
|
|
||||||
position?: number | null;
|
|
||||||
}) => ({
|
|
||||||
id: String(c.id),
|
|
||||||
author: c.user?.login || 'unknown',
|
|
||||||
avatarUrl: c.user?.avatar_url,
|
|
||||||
body: c.body,
|
|
||||||
path: c.path,
|
|
||||||
line: c.line ?? c.original_line,
|
|
||||||
createdAt: c.created_at,
|
|
||||||
updatedAt: c.updated_at,
|
|
||||||
isReviewComment: true,
|
|
||||||
// A review comment is "outdated" if position is null (code has changed)
|
|
||||||
isOutdated: c.position === null,
|
|
||||||
// isResolved will be filled in below from GraphQL data
|
|
||||||
isResolved: false,
|
|
||||||
diffHunk: c.diff_hunk,
|
|
||||||
side: c.side,
|
|
||||||
commitId: c.commit_id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
allComments.push(...reviewComments);
|
|
||||||
} catch (error) {
|
|
||||||
logError(error, 'Failed to fetch inline review comments');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for resolved status and apply to inline review comments
|
|
||||||
const resolvedMap = await resolvedStatusPromise;
|
|
||||||
for (const comment of allComments) {
|
|
||||||
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
|
|
||||||
const info = resolvedMap.get(comment.id)!;
|
|
||||||
comment.isResolved = info.isResolved;
|
|
||||||
comment.threadId = info.threadId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by createdAt descending (newest first)
|
|
||||||
allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
||||||
|
|
||||||
return allComments;
|
|
||||||
}
|
|
||||||
@@ -117,8 +117,6 @@ const eslintConfig = defineConfig([
|
|||||||
Electron: 'readonly',
|
Electron: 'readonly',
|
||||||
// Console
|
// Console
|
||||||
console: 'readonly',
|
console: 'readonly',
|
||||||
// Structured clone (modern browser/Node API)
|
|
||||||
structuredClone: 'readonly',
|
|
||||||
// Vite defines
|
// Vite defines
|
||||||
__APP_VERSION__: 'readonly',
|
__APP_VERSION__: 'readonly',
|
||||||
__APP_BUILD_HASH__: 'readonly',
|
__APP_BUILD_HASH__: 'readonly',
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useGitHubPRReviewComments } from '@/hooks/queries';
|
import { useGitHubPRReviewComments } from '@/hooks/queries';
|
||||||
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
|
import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations';
|
||||||
@@ -45,8 +46,7 @@ import type { PRReviewComment } from '@/lib/electron';
|
|||||||
import type { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
import type { PhaseModelEntry } from '@automaker/types';
|
import type { PhaseModelEntry } from '@automaker/types';
|
||||||
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
|
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults';
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Types
|
// Types
|
||||||
@@ -75,7 +75,7 @@ interface PRCommentResolutionDialogProps {
|
|||||||
|
|
||||||
/** Generate a feature ID */
|
/** Generate a feature ID */
|
||||||
function generateFeatureId(): string {
|
function generateFeatureId(): string {
|
||||||
return generateUUID();
|
return `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a date string for display */
|
/** Format a date string for display */
|
||||||
@@ -413,7 +413,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
|
<ScrollArea className="flex-1 min-h-0 h-full -mx-6 px-6">
|
||||||
<div className="space-y-4 pb-2">
|
<div className="space-y-4 pb-2">
|
||||||
{/* Author & metadata section */}
|
{/* Author & metadata section */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -495,7 +495,7 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4">
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
@@ -565,9 +565,6 @@ export function PRCommentResolutionDialog({
|
|||||||
>([]);
|
>([]);
|
||||||
const [detailComment, setDetailComment] = useState<PRReviewComment | null>(null);
|
const [detailComment, setDetailComment] = useState<PRReviewComment | null>(null);
|
||||||
|
|
||||||
// Per-thread resolving state - tracks which threads are currently being resolved/unresolved
|
|
||||||
const [resolvingThreads, setResolvingThreads] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Model selection state
|
// Model selection state
|
||||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
|
||||||
|
|
||||||
@@ -638,8 +635,8 @@ export function PRCommentResolutionDialog({
|
|||||||
const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number);
|
const resolveThread = useResolveReviewThread(currentProject?.path ?? '', pr.number);
|
||||||
|
|
||||||
// Derived state
|
// Derived state
|
||||||
const allSelected = comments.length > 0 && comments.every((c) => selectedIds.has(c.id));
|
const allSelected = comments.length > 0 && selectedIds.size === comments.length;
|
||||||
const someSelected = selectedIds.size > 0 && !allSelected;
|
const someSelected = selectedIds.size > 0 && selectedIds.size < comments.length;
|
||||||
const noneSelected = selectedIds.size === 0;
|
const noneSelected = selectedIds.size === 0;
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -661,24 +658,7 @@ export function PRCommentResolutionDialog({
|
|||||||
const handleResolveComment = useCallback(
|
const handleResolveComment = useCallback(
|
||||||
(comment: PRReviewComment, resolve: boolean) => {
|
(comment: PRReviewComment, resolve: boolean) => {
|
||||||
if (!comment.threadId) return;
|
if (!comment.threadId) return;
|
||||||
const threadId = comment.threadId;
|
resolveThread.mutate({ threadId: comment.threadId, resolve });
|
||||||
setResolvingThreads((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(threadId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
resolveThread.mutate(
|
|
||||||
{ threadId, resolve },
|
|
||||||
{
|
|
||||||
onSettled: () => {
|
|
||||||
setResolvingThreads((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(threadId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[resolveThread]
|
[resolveThread]
|
||||||
);
|
);
|
||||||
@@ -723,7 +703,7 @@ export function PRCommentResolutionDialog({
|
|||||||
const selectedComments = comments.filter((c) => selectedIds.has(c.id));
|
const selectedComments = comments.filter((c) => selectedIds.has(c.id));
|
||||||
|
|
||||||
// Resolve model settings from the current model entry
|
// Resolve model settings from the current model entry
|
||||||
const selectedModel = resolveModelString(modelEntry.model);
|
const selectedModel = modelEntry.model;
|
||||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||||
? modelEntry.thinkingLevel || 'none'
|
? modelEntry.thinkingLevel || 'none'
|
||||||
: 'none';
|
: 'none';
|
||||||
@@ -830,7 +810,6 @@ export function PRCommentResolutionDialog({
|
|||||||
setShowResolved(false);
|
setShowResolved(false);
|
||||||
setCreationErrors([]);
|
setCreationErrors([]);
|
||||||
setDetailComment(null);
|
setDetailComment(null);
|
||||||
setResolvingThreads(new Set());
|
|
||||||
setModelEntry(effectiveDefaultFeatureModel);
|
setModelEntry(effectiveDefaultFeatureModel);
|
||||||
}
|
}
|
||||||
onOpenChange(newOpen);
|
onOpenChange(newOpen);
|
||||||
@@ -1029,9 +1008,7 @@ export function PRCommentResolutionDialog({
|
|||||||
onToggle={() => handleToggleComment(comment.id)}
|
onToggle={() => handleToggleComment(comment.id)}
|
||||||
onExpandDetail={() => setDetailComment(comment)}
|
onExpandDetail={() => setDetailComment(comment)}
|
||||||
onResolve={handleResolveComment}
|
onResolve={handleResolveComment}
|
||||||
isResolvingThread={
|
isResolvingThread={resolveThread.isPending}
|
||||||
!!comment.threadId && resolvingThreads.has(comment.threadId)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,9 +108,7 @@ export function useProjectPicker({
|
|||||||
setIsProjectPickerOpen(false);
|
setIsProjectPickerOpen(false);
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
selectHighlightedProject().catch(() => {
|
selectHighlightedProject();
|
||||||
/* Error already logged upstream */
|
|
||||||
});
|
|
||||||
} else if (event.key === 'ArrowDown') {
|
} else if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));
|
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
|
||||||
|
|
||||||
const logger = createLogger('AppErrorBoundary');
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Root-level error boundary for the entire application.
|
|
||||||
*
|
|
||||||
* Catches uncaught React errors that would otherwise show TanStack Router's
|
|
||||||
* default "Something went wrong!" screen with a raw error message.
|
|
||||||
*
|
|
||||||
* Provides a user-friendly error screen with a reload button to recover.
|
|
||||||
* This is especially important for transient errors during initial app load
|
|
||||||
* (e.g., race conditions during auth/hydration on fresh browser sessions).
|
|
||||||
*/
|
|
||||||
export class AppErrorBoundary extends Component<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
logger.error('Uncaught application error:', {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
componentStack: errorInfo.componentStack,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReload = () => {
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex h-screen w-full flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
|
|
||||||
data-testid="app-error-boundary"
|
|
||||||
>
|
|
||||||
{/* Logo matching the app shell in index.html */}
|
|
||||||
<svg
|
|
||||||
className="h-14 w-14 opacity-90"
|
|
||||||
viewBox="0 0 256 256"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
className="fill-foreground/[0.08]"
|
|
||||||
x="16"
|
|
||||||
y="16"
|
|
||||||
width="224"
|
|
||||||
height="224"
|
|
||||||
rx="56"
|
|
||||||
/>
|
|
||||||
<g
|
|
||||||
className="stroke-foreground/70"
|
|
||||||
fill="none"
|
|
||||||
strokeWidth="20"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M92 92 L52 128 L92 164" />
|
|
||||||
<path d="M144 72 L116 184" />
|
|
||||||
<path d="M164 92 L204 128 L164 164" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md">
|
|
||||||
The application encountered an unexpected error. This is usually temporary and can be
|
|
||||||
resolved by reloading the page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={this.handleReload}
|
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
||||||
<path d="M3 3v5h5" />
|
|
||||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
|
||||||
<path d="M16 21h5v-5" />
|
|
||||||
</svg>
|
|
||||||
Reload Page
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Collapsible technical details for debugging */}
|
|
||||||
{this.state.error && (
|
|
||||||
<details className="text-xs text-muted-foreground max-w-lg w-full">
|
|
||||||
<summary className="cursor-pointer hover:text-foreground text-center">
|
|
||||||
Technical details
|
|
||||||
</summary>
|
|
||||||
<pre className="mt-2 p-3 bg-muted/50 rounded-md text-left overflow-auto max-h-32 border border-border">
|
|
||||||
{this.state.error.stack || this.state.error.message}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,11 +33,11 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
|||||||
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
|
import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
import { pathsEqual } from '@/lib/utils';
|
import { pathsEqual } from '@/lib/utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||||
import {
|
import {
|
||||||
BoardBackgroundModal,
|
|
||||||
PRCommentResolutionDialog,
|
PRCommentResolutionDialog,
|
||||||
type PRCommentResolutionPRInfo,
|
type PRCommentResolutionPRInfo,
|
||||||
} from '@/components/dialogs';
|
} from '@/components/dialogs/pr-comment-resolution-dialog';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
@@ -1031,7 +1031,7 @@ export function BoardView() {
|
|||||||
images: [],
|
images: [],
|
||||||
imagePaths: [],
|
imagePaths: [],
|
||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
model: resolveModelString('opus'),
|
model: 'opus' as const,
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: conflictInfo.targetBranch,
|
branchName: conflictInfo.targetBranch,
|
||||||
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
|
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
|
||||||
|
|||||||
@@ -227,9 +227,9 @@ export function AddFeatureDialog({
|
|||||||
|
|
||||||
if (justOpened) {
|
if (justOpened) {
|
||||||
// Initialize with prefilled values if provided, otherwise use defaults
|
// Initialize with prefilled values if provided, otherwise use defaults
|
||||||
setTitle(prefilledTitle ?? '');
|
setTitle(prefilledTitle || '');
|
||||||
setDescription(prefilledDescription ?? '');
|
setDescription(prefilledDescription || '');
|
||||||
setCategory(prefilledCategory ?? '');
|
setCategory(prefilledCategory || '');
|
||||||
|
|
||||||
setSkipTests(defaultSkipTests);
|
setSkipTests(defaultSkipTests);
|
||||||
// When a non-main worktree is selected, use its branch name for custom mode
|
// When a non-main worktree is selected, use its branch name for custom mode
|
||||||
|
|||||||
@@ -299,13 +299,9 @@ export function CreatePRDialog({
|
|||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
// Resolve the display name to the actual branch name for the API
|
// Resolve the display name to the actual branch name for the API
|
||||||
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
// Only strip the remote prefix if the resolved ref differs from the original
|
const branchNameForApi = resolvedRef.includes('/')
|
||||||
// (indicating it was resolved from a full ref like "origin/main").
|
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
|
||||||
// This preserves local branch names that contain slashes (e.g. "release/1.0").
|
: resolvedRef;
|
||||||
const branchNameForApi =
|
|
||||||
resolvedRef !== baseBranch && resolvedRef.includes('/')
|
|
||||||
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
|
|
||||||
: resolvedRef;
|
|
||||||
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
|
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -348,13 +344,11 @@ export function CreatePRDialog({
|
|||||||
// since the backend handles branch resolution. However, if the full ref is
|
// since the backend handles branch resolution. However, if the full ref is
|
||||||
// available, we can use it for more precise targeting.
|
// available, we can use it for more precise targeting.
|
||||||
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
// Only strip the remote prefix if the resolved ref differs from the original
|
// Strip the remote prefix from the resolved ref for the API call
|
||||||
// (indicating it was resolved from a full ref like "origin/main").
|
// (e.g. "origin/main" → "main") since the backend expects the branch name only
|
||||||
// This preserves local branch names that contain slashes (e.g. "release/1.0").
|
const baseBranchForApi = resolvedBaseBranch.includes('/')
|
||||||
const baseBranchForApi =
|
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
|
||||||
resolvedBaseBranch !== baseBranch && resolvedBaseBranch.includes('/')
|
: resolvedBaseBranch;
|
||||||
? resolvedBaseBranch.substring(resolvedBaseBranch.indexOf('/') + 1)
|
|
||||||
: resolvedBaseBranch;
|
|
||||||
|
|
||||||
const result = await api.worktree.createPR(worktree.path, {
|
const result = await api.worktree.createPR(worktree.path, {
|
||||||
projectPath: projectPath || undefined,
|
projectPath: projectPath || undefined,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -252,21 +251,6 @@ export function WorktreeActionsDropdown({
|
|||||||
// Determine if the destructive/bottom section has any visible items
|
// Determine if the destructive/bottom section has any visible items
|
||||||
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
const hasDestructiveSectionContent = worktree.hasChanges || !worktree.isMain;
|
||||||
|
|
||||||
// Pre-compute PR info for the PR submenu (avoids an IIFE in JSX)
|
|
||||||
const prInfo = useMemo<PRInfo | null>(() => {
|
|
||||||
if (!showPRInfo || !worktree.pr) return null;
|
|
||||||
return {
|
|
||||||
number: worktree.pr.number,
|
|
||||||
title: worktree.pr.title,
|
|
||||||
url: worktree.pr.url,
|
|
||||||
state: worktree.pr.state,
|
|
||||||
author: '',
|
|
||||||
body: '',
|
|
||||||
comments: [],
|
|
||||||
reviewComments: [],
|
|
||||||
};
|
|
||||||
}, [showPRInfo, worktree.pr]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -387,18 +371,16 @@ export function WorktreeActionsDropdown({
|
|||||||
? 'Dev Server Starting...'
|
? 'Dev Server Starting...'
|
||||||
: `Dev Server Running (:${devServerInfo?.port})`}
|
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{devServerInfo != null &&
|
{devServerInfo?.urlDetected !== false && (
|
||||||
devServerInfo.port != null &&
|
<DropdownMenuItem
|
||||||
devServerInfo.urlDetected !== false && (
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
<DropdownMenuItem
|
className="text-xs"
|
||||||
onClick={() => onOpenDevServerUrl(worktree)}
|
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||||
className="text-xs"
|
>
|
||||||
aria-label={`Open dev server on port ${devServerInfo.port} in browser`}
|
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||||
>
|
Open in Browser
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
</DropdownMenuItem>
|
||||||
Open in Browser
|
)}
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
View Logs
|
View Logs
|
||||||
@@ -610,7 +592,7 @@ export function WorktreeActionsDropdown({
|
|||||||
Scripts
|
Scripts
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent className="w-52">
|
<DropdownMenuSubContent className="w-52">
|
||||||
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured or no handler */}
|
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured */}
|
||||||
{!worktree.isMain && (
|
{!worktree.isMain && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -631,7 +613,6 @@ export function WorktreeActionsDropdown({
|
|||||||
key={script.id}
|
key={script.id}
|
||||||
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
disabled={!onRunTerminalScript}
|
|
||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
||||||
<span className="truncate">{script.name}</span>
|
<span className="truncate">{script.name}</span>
|
||||||
@@ -644,11 +625,7 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
{/* Divider before Edit Commands & Scripts */}
|
{/* Divider before Edit Commands & Scripts */}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => onEditScripts?.()} className="text-xs">
|
||||||
onClick={() => onEditScripts?.()}
|
|
||||||
className="text-xs"
|
|
||||||
disabled={!onEditScripts}
|
|
||||||
>
|
|
||||||
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
||||||
Edit Commands & Scripts
|
Edit Commands & Scripts
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -962,23 +939,32 @@ export function WorktreeActionsDropdown({
|
|||||||
- worktree.hasChanges: View Changes action is available
|
- worktree.hasChanges: View Changes action is available
|
||||||
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
||||||
- onViewStashes: viewing existing stashes is possible */}
|
- onViewStashes: viewing existing stashes is possible */}
|
||||||
{/* View Changes split button - show submenu only when there are non-duplicate sub-actions */}
|
{(worktree.hasChanges || onViewStashes) && (
|
||||||
{worktree.hasChanges && (onStashChanges || onViewStashes) ? (
|
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* Main clickable area - view changes (primary action) */}
|
{/* Main clickable area - view changes (primary action) */}
|
||||||
<DropdownMenuItem
|
{worktree.hasChanges ? (
|
||||||
onClick={() => onViewChanges(worktree)}
|
<DropdownMenuItem
|
||||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
onClick={() => onViewChanges(worktree)}
|
||||||
>
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
>
|
||||||
View Changes
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
</DropdownMenuItem>
|
View Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onViewStashes!(worktree)}
|
||||||
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Stashes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{/* Chevron trigger for submenu with stash options */}
|
{/* Chevron trigger for submenu with stash options */}
|
||||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{onStashChanges && (
|
{worktree.hasChanges && onStashChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!isGitOpsAvailable}
|
showTooltip={!isGitOpsAvailable}
|
||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
@@ -1007,17 +993,7 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
) : worktree.hasChanges ? (
|
)}
|
||||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Changes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : onViewStashes ? (
|
|
||||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Stashes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
@@ -1056,7 +1032,7 @@ export function WorktreeActionsDropdown({
|
|||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
||||||
{prInfo && worktree.pr && (
|
{showPRInfo && worktree.pr && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* Main clickable area - opens PR in browser */}
|
{/* Main clickable area - opens PR in browser */}
|
||||||
@@ -1068,16 +1044,7 @@ export function WorktreeActionsDropdown({
|
|||||||
>
|
>
|
||||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||||
PR #{worktree.pr.number}
|
PR #{worktree.pr.number}
|
||||||
<span
|
<span className="ml-auto mr-1 text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
||||||
className={cn(
|
|
||||||
'ml-auto mr-1 text-[10px] px-1.5 py-0.5 rounded uppercase',
|
|
||||||
worktree.pr.state === 'MERGED'
|
|
||||||
? 'bg-purple-500/20 text-purple-600'
|
|
||||||
: worktree.pr.state === 'CLOSED'
|
|
||||||
? 'bg-gray-500/20 text-gray-500'
|
|
||||||
: 'bg-green-500/20 text-green-600'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{worktree.pr.state}
|
{worktree.pr.state}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -1086,14 +1053,40 @@ export function WorktreeActionsDropdown({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onAddressPRComments(worktree, prInfo)}
|
onClick={() => {
|
||||||
|
// Convert stored PR info to the full PRInfo format for the handler
|
||||||
|
// The handler will fetch full comments from GitHub
|
||||||
|
const prInfo: PRInfo = {
|
||||||
|
number: worktree.pr!.number,
|
||||||
|
title: worktree.pr!.title,
|
||||||
|
url: worktree.pr!.url,
|
||||||
|
state: worktree.pr!.state,
|
||||||
|
author: '', // Will be fetched
|
||||||
|
body: '', // Will be fetched
|
||||||
|
comments: [],
|
||||||
|
reviewComments: [],
|
||||||
|
};
|
||||||
|
onAddressPRComments(worktree, prInfo);
|
||||||
|
}}
|
||||||
className="text-xs text-blue-500 focus:text-blue-600"
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||||
Manage PR Comments
|
Manage PR Comments
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onAutoAddressPRComments(worktree, prInfo)}
|
onClick={() => {
|
||||||
|
const prInfo: PRInfo = {
|
||||||
|
number: worktree.pr!.number,
|
||||||
|
title: worktree.pr!.title,
|
||||||
|
url: worktree.pr!.url,
|
||||||
|
state: worktree.pr!.state,
|
||||||
|
author: '',
|
||||||
|
body: '',
|
||||||
|
comments: [],
|
||||||
|
reviewComments: [],
|
||||||
|
};
|
||||||
|
onAutoAddressPRComments(worktree, prInfo);
|
||||||
|
}}
|
||||||
className="text-xs text-blue-500 focus:text-blue-600"
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
>
|
>
|
||||||
<Zap className="w-3.5 h-3.5 mr-2" />
|
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function WorktreeDropdownItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator - hidden when URL detection explicitly failed */}
|
{/* Dev server indicator - only shown when port is confirmed detected */}
|
||||||
{devServerRunning && devServerInfo?.urlDetected !== false && (
|
{devServerRunning && devServerInfo?.urlDetected !== false && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
||||||
|
|||||||
@@ -87,17 +87,8 @@ export function useWorktrees({
|
|||||||
}
|
}
|
||||||
}, [worktrees, projectPath, setCurrentWorktree]);
|
}, [worktrees, projectPath, setCurrentWorktree]);
|
||||||
|
|
||||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
|
||||||
|
|
||||||
const handleSelectWorktree = useCallback(
|
const handleSelectWorktree = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
// Skip invalidation when re-selecting the already-active worktree
|
|
||||||
const isSameWorktree = worktree.isMain
|
|
||||||
? currentWorktreePath === null
|
|
||||||
: pathsEqual(worktree.path, currentWorktreePath ?? '');
|
|
||||||
|
|
||||||
if (isSameWorktree) return;
|
|
||||||
|
|
||||||
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
setCurrentWorktree(projectPath, worktree.isMain ? null : worktree.path, worktree.branch);
|
||||||
|
|
||||||
// Invalidate feature queries when switching worktrees to ensure fresh data.
|
// Invalidate feature queries when switching worktrees to ensure fresh data.
|
||||||
@@ -108,7 +99,7 @@ export function useWorktrees({
|
|||||||
queryKey: queryKeys.features.all(projectPath),
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[projectPath, setCurrentWorktree, queryClient, currentWorktreePath]
|
[projectPath, setCurrentWorktree, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||||
@@ -127,6 +118,7 @@ export function useWorktrees({
|
|||||||
[projectPath, queryClient, refetch]
|
[projectPath, queryClient, refetch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||||
const selectedWorktree = currentWorktreePath
|
const selectedWorktree = currentWorktreePath
|
||||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||||
: worktrees.find((w) => w.isMain);
|
: worktrees.find((w) => w.isMain);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { X, Circle, MoreHorizontal, Save } from 'lucide-react';
|
import { X, Circle, MoreHorizontal } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { EditorTab } from '../use-file-editor-store';
|
import type { EditorTab } from '../use-file-editor-store';
|
||||||
import {
|
import {
|
||||||
@@ -14,12 +14,6 @@ interface EditorTabsProps {
|
|||||||
onTabSelect: (tabId: string) => void;
|
onTabSelect: (tabId: string) => void;
|
||||||
onTabClose: (tabId: string) => void;
|
onTabClose: (tabId: string) => void;
|
||||||
onCloseAll: () => void;
|
onCloseAll: () => void;
|
||||||
/** Called when the save button is clicked (mobile only) */
|
|
||||||
onSave?: () => void;
|
|
||||||
/** Whether there are unsaved changes (controls enabled state of save button) */
|
|
||||||
isDirty?: boolean;
|
|
||||||
/** Whether to show the save button in the tab bar (intended for mobile) */
|
|
||||||
showSaveButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a file icon color based on extension */
|
/** Get a file icon color based on extension */
|
||||||
@@ -80,9 +74,6 @@ export function EditorTabs({
|
|||||||
onTabSelect,
|
onTabSelect,
|
||||||
onTabClose,
|
onTabClose,
|
||||||
onCloseAll,
|
onCloseAll,
|
||||||
onSave,
|
|
||||||
isDirty,
|
|
||||||
showSaveButton,
|
|
||||||
}: EditorTabsProps) {
|
}: EditorTabsProps) {
|
||||||
if (tabs.length === 0) return null;
|
if (tabs.length === 0) return null;
|
||||||
|
|
||||||
@@ -137,26 +128,8 @@ export function EditorTabs({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Tab actions: save button (mobile) + close-all dropdown */}
|
{/* Tab actions dropdown (close all, etc.) */}
|
||||||
<div className="ml-auto shrink-0 flex items-center px-1 gap-0.5">
|
<div className="ml-auto shrink-0 flex items-center px-1">
|
||||||
{/* Save button — shown in the tab bar on mobile */}
|
|
||||||
{showSaveButton && onSave && (
|
|
||||||
<button
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={!isDirty}
|
|
||||||
className={cn(
|
|
||||||
'p-1 rounded transition-colors',
|
|
||||||
isDirty
|
|
||||||
? 'text-primary hover:text-primary hover:bg-muted/50'
|
|
||||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
title="Save file (Ctrl+S)"
|
|
||||||
aria-label="Save file"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
|
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
|
||||||
import { useFileBrowser } from '@/contexts/file-browser-context';
|
|
||||||
|
|
||||||
interface FileTreeProps {
|
interface FileTreeProps {
|
||||||
onFileSelect: (path: string) => void;
|
onFileSelect: (path: string) => void;
|
||||||
@@ -105,21 +104,6 @@ function getGitStatusLabel(status: string | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a file/folder name for safety.
|
|
||||||
* Rejects names containing path separators, relative path components,
|
|
||||||
* or names that are just dots (which resolve to parent/current directory).
|
|
||||||
*/
|
|
||||||
function isValidFileName(name: string): boolean {
|
|
||||||
// Reject names containing path separators
|
|
||||||
if (name.includes('/') || name.includes('\\')) return false;
|
|
||||||
// Reject current/parent directory references
|
|
||||||
if (name === '.' || name === '..') return false;
|
|
||||||
// Reject empty or whitespace-only names
|
|
||||||
if (!name.trim()) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inline input for creating/renaming items */
|
/** Inline input for creating/renaming items */
|
||||||
function InlineInput({
|
function InlineInput({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -133,7 +117,6 @@ function InlineInput({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
const [value, setValue] = useState(defaultValue || '');
|
const [value, setValue] = useState(defaultValue || '');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
// Guard against double-submission: pressing Enter triggers onKeyDown AND may
|
// Guard against double-submission: pressing Enter triggers onKeyDown AND may
|
||||||
// immediately trigger onBlur (e.g. when the component unmounts after submit).
|
// immediately trigger onBlur (e.g. when the component unmounts after submit).
|
||||||
@@ -142,9 +125,7 @@ function InlineInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
if (defaultValue) {
|
if (defaultValue) {
|
||||||
// Select name without extension for rename.
|
// Select name without extension for rename
|
||||||
// For dotfiles (e.g. ".gitignore"), lastIndexOf('.') returns 0,
|
|
||||||
// so we fall through to select() which selects the entire name.
|
|
||||||
const dotIndex = defaultValue.lastIndexOf('.');
|
const dotIndex = defaultValue.lastIndexOf('.');
|
||||||
if (dotIndex > 0) {
|
if (dotIndex > 0) {
|
||||||
inputRef.current?.setSelectionRange(0, dotIndex);
|
inputRef.current?.setSelectionRange(0, dotIndex);
|
||||||
@@ -154,62 +135,97 @@ function InlineInput({
|
|||||||
}
|
}
|
||||||
}, [defaultValue]);
|
}, [defaultValue]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
return (
|
||||||
if (submittedRef.current) return;
|
<input
|
||||||
const trimmed = value.trim();
|
ref={inputRef}
|
||||||
if (!trimmed) {
|
value={value}
|
||||||
onCancel();
|
onChange={(e) => setValue(e.target.value)}
|
||||||
return;
|
onKeyDown={(e) => {
|
||||||
}
|
if (e.key === 'Enter' && value.trim()) {
|
||||||
if (!isValidFileName(trimmed)) {
|
if (submittedRef.current) return;
|
||||||
// Invalid name — surface error, keep editing so the user can fix it
|
submittedRef.current = true;
|
||||||
setErrorMessage('Invalid name: avoid /, \\, ".", or ".."');
|
onSubmit(value.trim());
|
||||||
inputRef.current?.focus();
|
} else if (e.key === 'Escape') {
|
||||||
return;
|
onCancel();
|
||||||
}
|
}
|
||||||
setErrorMessage(null);
|
}}
|
||||||
submittedRef.current = true;
|
onBlur={() => {
|
||||||
onSubmit(trimmed);
|
// Prevent duplicate submission if onKeyDown already triggered onSubmit
|
||||||
}, [value, onSubmit, onCancel]);
|
if (submittedRef.current) return;
|
||||||
|
if (value.trim()) {
|
||||||
|
submittedRef.current = true;
|
||||||
|
onSubmit(value.trim());
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="text-sm bg-muted border border-border rounded px-1 py-0.5 w-full outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destination path picker dialog for copy/move operations */
|
||||||
|
function DestinationPicker({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
defaultPath,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
onSubmit: (path: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
defaultPath: string;
|
||||||
|
action: 'Copy' | 'Move';
|
||||||
|
}) {
|
||||||
|
const [path, setPath] = useState(defaultPath);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<input
|
<div className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md">
|
||||||
ref={inputRef}
|
<div className="px-4 py-3 border-b border-border">
|
||||||
value={value}
|
<h3 className="text-sm font-medium">{action} To...</h3>
|
||||||
onChange={(e) => {
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
setValue(e.target.value);
|
Enter the destination path for the {action.toLowerCase()} operation
|
||||||
if (errorMessage) setErrorMessage(null);
|
</p>
|
||||||
}}
|
</div>
|
||||||
onKeyDown={(e) => {
|
<div className="px-4 py-3">
|
||||||
if (e.key === 'Enter') {
|
<input
|
||||||
handleSubmit();
|
ref={inputRef}
|
||||||
} else if (e.key === 'Escape') {
|
value={path}
|
||||||
onCancel();
|
onChange={(e) => setPath(e.target.value)}
|
||||||
}
|
onKeyDown={(e) => {
|
||||||
}}
|
if (e.key === 'Enter' && path.trim()) {
|
||||||
onBlur={() => {
|
onSubmit(path.trim());
|
||||||
// Prevent duplicate submission if onKeyDown already triggered onSubmit
|
} else if (e.key === 'Escape') {
|
||||||
if (submittedRef.current) return;
|
onCancel();
|
||||||
const trimmed = value.trim();
|
}
|
||||||
if (trimmed && isValidFileName(trimmed)) {
|
}}
|
||||||
submittedRef.current = true;
|
placeholder="Enter destination path..."
|
||||||
onSubmit(trimmed);
|
className="w-full text-sm bg-muted border border-border rounded px-3 py-2 outline-none focus:border-primary font-mono"
|
||||||
}
|
/>
|
||||||
// If the name is empty or invalid, do NOT call onCancel — keep the
|
</div>
|
||||||
// input open so the user can correct the value (mirrors handleSubmit).
|
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
||||||
// Optionally re-focus so the user can continue editing.
|
<button
|
||||||
else {
|
onClick={onCancel}
|
||||||
inputRef.current?.focus();
|
className="px-3 py-1.5 text-sm rounded hover:bg-muted transition-colors"
|
||||||
}
|
>
|
||||||
}}
|
Cancel
|
||||||
placeholder={placeholder}
|
</button>
|
||||||
className={cn(
|
<button
|
||||||
'text-sm bg-muted border rounded px-1 py-0.5 w-full outline-none focus:border-primary',
|
onClick={() => path.trim() && onSubmit(path.trim())}
|
||||||
errorMessage ? 'border-red-500' : 'border-border'
|
disabled={!path.trim()}
|
||||||
)}
|
className="px-3 py-1.5 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
/>
|
>
|
||||||
{errorMessage && <span className="text-[10px] text-red-500 px-0.5">{errorMessage}</span>}
|
{action}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,11 +276,12 @@ function TreeNode({
|
|||||||
selectedPaths,
|
selectedPaths,
|
||||||
toggleSelectedPath,
|
toggleSelectedPath,
|
||||||
} = useFileEditorStore();
|
} = useFileEditorStore();
|
||||||
const { openFileBrowser } = useFileBrowser();
|
|
||||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [showCopyPicker, setShowCopyPicker] = useState(false);
|
||||||
|
const [showMovePicker, setShowMovePicker] = useState(false);
|
||||||
|
|
||||||
const isExpanded = expandedFolders.has(node.path);
|
const isExpanded = expandedFolders.has(node.path);
|
||||||
const isActive = activeFilePath === node.path;
|
const isActive = activeFilePath === node.path;
|
||||||
@@ -392,6 +409,30 @@ function TreeNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.path}>
|
<div key={node.path}>
|
||||||
|
{/* Destination picker dialogs */}
|
||||||
|
{showCopyPicker && onCopyItem && (
|
||||||
|
<DestinationPicker
|
||||||
|
action="Copy"
|
||||||
|
defaultPath={node.path}
|
||||||
|
onSubmit={async (destPath) => {
|
||||||
|
setShowCopyPicker(false);
|
||||||
|
await onCopyItem(node.path, destPath);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowCopyPicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showMovePicker && onMoveItem && (
|
||||||
|
<DestinationPicker
|
||||||
|
action="Move"
|
||||||
|
defaultPath={node.path}
|
||||||
|
onSubmit={async (destPath) => {
|
||||||
|
setShowMovePicker(false);
|
||||||
|
await onMoveItem(node.path, destPath);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowMovePicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
|
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
|
||||||
<InlineInput
|
<InlineInput
|
||||||
@@ -589,21 +630,9 @@ function TreeNode({
|
|||||||
{/* Copy To... */}
|
{/* Copy To... */}
|
||||||
{onCopyItem && (
|
{onCopyItem && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async (e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
setShowCopyPicker(true);
|
||||||
const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/';
|
|
||||||
const destPath = await openFileBrowser({
|
|
||||||
title: `Copy "${node.name}" To...`,
|
|
||||||
description: 'Select the destination folder for the copy operation',
|
|
||||||
initialPath: parentPath,
|
|
||||||
});
|
|
||||||
if (destPath) {
|
|
||||||
await onCopyItem(node.path, destPath);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy operation failed:', err);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
@@ -615,21 +644,9 @@ function TreeNode({
|
|||||||
{/* Move To... */}
|
{/* Move To... */}
|
||||||
{onMoveItem && (
|
{onMoveItem && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async (e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
setShowMovePicker(true);
|
||||||
const parentPath = node.path.substring(0, node.path.lastIndexOf('/')) || '/';
|
|
||||||
const destPath = await openFileBrowser({
|
|
||||||
title: `Move "${node.name}" To...`,
|
|
||||||
description: 'Select the destination folder for the move operation',
|
|
||||||
initialPath: parentPath,
|
|
||||||
});
|
|
||||||
if (destPath) {
|
|
||||||
await onMoveItem(node.path, destPath);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Move operation failed:', err);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
@@ -758,15 +775,8 @@ export function FileTree({
|
|||||||
onDragDropMove,
|
onDragDropMove,
|
||||||
effectivePath,
|
effectivePath,
|
||||||
}: FileTreeProps) {
|
}: FileTreeProps) {
|
||||||
const {
|
const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } =
|
||||||
fileTree,
|
useFileEditorStore();
|
||||||
showHiddenFiles,
|
|
||||||
setShowHiddenFiles,
|
|
||||||
gitStatusMap,
|
|
||||||
dragState,
|
|
||||||
setDragState,
|
|
||||||
gitBranch,
|
|
||||||
} = useFileEditorStore();
|
|
||||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||||
|
|
||||||
@@ -781,13 +791,10 @@ export function FileTree({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (effectivePath) {
|
if (effectivePath) {
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
// Skip redundant state update if already targeting the same path
|
setDragState({ draggedPaths: [], dropTargetPath: effectivePath });
|
||||||
if (dragState.dropTargetPath !== effectivePath) {
|
|
||||||
setDragState({ ...dragState, dropTargetPath: effectivePath });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[effectivePath, dragState, setDragState]
|
[effectivePath, setDragState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRootDrop = useCallback(
|
const handleRootDrop = useCallback(
|
||||||
@@ -811,54 +818,47 @@ export function FileTree({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" data-testid="file-tree">
|
<div className="flex flex-col h-full" data-testid="file-tree">
|
||||||
{/* Tree toolbar */}
|
{/* Tree toolbar */}
|
||||||
<div className="px-2 py-1.5 border-b border-border">
|
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
Explorer
|
||||||
Explorer
|
</span>
|
||||||
</span>
|
{gitBranch && (
|
||||||
</div>
|
<span className="text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded">
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreatingFile(true)}
|
|
||||||
className="p-1 hover:bg-accent rounded"
|
|
||||||
title="New file"
|
|
||||||
>
|
|
||||||
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsCreatingFolder(true)}
|
|
||||||
className="p-1 hover:bg-accent rounded"
|
|
||||||
title="New folder"
|
|
||||||
>
|
|
||||||
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
|
|
||||||
className="p-1 hover:bg-accent rounded"
|
|
||||||
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
|
|
||||||
>
|
|
||||||
{showHiddenFiles ? (
|
|
||||||
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{gitBranch && (
|
|
||||||
<div className="mt-1 min-w-0">
|
|
||||||
<span
|
|
||||||
className="inline-block max-w-full truncate whitespace-nowrap text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded"
|
|
||||||
title={gitBranch}
|
|
||||||
>
|
|
||||||
{gitBranch}
|
{gitBranch}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreatingFile(true)}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
title="New file"
|
||||||
|
>
|
||||||
|
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreatingFolder(true)}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
title="New folder"
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
|
||||||
|
>
|
||||||
|
{showHiddenFiles ? (
|
||||||
|
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tree content */}
|
{/* Tree content */}
|
||||||
|
|||||||
@@ -650,12 +650,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
|
|
||||||
const handleRenameItem = useCallback(
|
const handleRenameItem = useCallback(
|
||||||
async (oldPath: string, newName: string) => {
|
async (oldPath: string, newName: string) => {
|
||||||
// Extract the current file/folder name from the old path
|
|
||||||
const oldName = oldPath.split('/').pop() || '';
|
|
||||||
|
|
||||||
// If the name hasn't changed, skip the rename entirely (no-op)
|
|
||||||
if (newName === oldName) return;
|
|
||||||
|
|
||||||
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
||||||
const newPath = `${parentPath}/${newName}`;
|
const newPath = `${parentPath}/${newName}`;
|
||||||
|
|
||||||
@@ -1034,9 +1028,6 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
onTabSelect={setActiveTab}
|
onTabSelect={setActiveTab}
|
||||||
onTabClose={handleTabClose}
|
onTabClose={handleTabClose}
|
||||||
onCloseAll={handleCloseAll}
|
onCloseAll={handleCloseAll}
|
||||||
onSave={handleSave}
|
|
||||||
isDirty={activeTab?.isDirty && !activeTab?.isBinary && !activeTab?.isTooLarge}
|
|
||||||
showSaveButton={isMobile && !!activeTab && !activeTab.isBinary && !activeTab.isTooLarge}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Editor content */}
|
{/* Editor content */}
|
||||||
@@ -1329,6 +1320,24 @@ export function FileEditorView({ initialPath }: FileEditorViewProps) {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
{/* Mobile: Save button in main toolbar */}
|
||||||
|
{activeTab &&
|
||||||
|
!activeTab.isBinary &&
|
||||||
|
!activeTab.isTooLarge &&
|
||||||
|
isMobile &&
|
||||||
|
!mobileBrowserVisible && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!activeTab.isDirty}
|
||||||
|
className="lg:hidden"
|
||||||
|
title={editorAutoSave ? 'Auto-save enabled (Ctrl+S)' : 'Save file (Ctrl+S)'}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tablet/Mobile: actions panel trigger */}
|
{/* Tablet/Mobile: actions panel trigger */}
|
||||||
<HeaderActionsPanelTrigger
|
<HeaderActionsPanelTrigger
|
||||||
isOpen={showActionsPanel}
|
isOpen={showActionsPanel}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-vi
|
|||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
import { ValidationDialog } from './github-issues-view/dialogs';
|
||||||
import { AddFeatureDialog } from './board-view/dialogs';
|
import { AddFeatureDialog } from './board-view/dialogs';
|
||||||
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
|
||||||
import { useModelOverride } from '@/components/shared';
|
import { useModelOverride } from '@/components/shared';
|
||||||
import type {
|
import type {
|
||||||
ValidateIssueOptions,
|
ValidateIssueOptions,
|
||||||
@@ -154,17 +153,11 @@ export function GitHubIssuesView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join('\n');
|
return parts.filter(Boolean).join('\n');
|
||||||
},
|
},
|
||||||
[cachedValidations]
|
[cachedValidations]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize the prefilled description to avoid recomputing on every render
|
|
||||||
const prefilledDescription = useMemo(
|
|
||||||
() => (createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined),
|
|
||||||
[createFeatureIssue, buildIssueDescription]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Open the Add Feature dialog with pre-filled data from a GitHub issue
|
// Open the Add Feature dialog with pre-filled data from a GitHub issue
|
||||||
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
|
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
|
||||||
setCreateFeatureIssue(issue);
|
setCreateFeatureIssue(issue);
|
||||||
@@ -185,10 +178,7 @@ export function GitHubIssuesView() {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
planningMode: string;
|
planningMode: string;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
excludedPipelineSteps?: string[];
|
|
||||||
workMode: string;
|
workMode: string;
|
||||||
imagePaths?: Array<{ id: string; path: string; description?: string }>;
|
|
||||||
textFilePaths?: Array<{ id: string; path: string; description?: string }>;
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!currentProject?.path) {
|
if (!currentProject?.path) {
|
||||||
toast.error('No project selected');
|
toast.error('No project selected');
|
||||||
@@ -213,11 +203,6 @@ export function GitHubIssuesView() {
|
|||||||
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
||||||
planningMode: featureData.planningMode,
|
planningMode: featureData.planningMode,
|
||||||
requirePlanApproval: featureData.requirePlanApproval,
|
requirePlanApproval: featureData.requirePlanApproval,
|
||||||
excludedPipelineSteps: featureData.excludedPipelineSteps,
|
|
||||||
...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}),
|
|
||||||
...(featureData.textFilePaths?.length
|
|
||||||
? { textFilePaths: featureData.textFilePaths }
|
|
||||||
: {}),
|
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -255,7 +240,7 @@ export function GitHubIssuesView() {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api.features?.create) {
|
if (api.features?.create) {
|
||||||
// Build description from issue body + validation info
|
// Build description from issue body + validation info
|
||||||
const parts = [
|
const description = [
|
||||||
`**From GitHub Issue #${issue.number}**`,
|
`**From GitHub Issue #${issue.number}**`,
|
||||||
'',
|
'',
|
||||||
issue.body || 'No description provided.',
|
issue.body || 'No description provided.',
|
||||||
@@ -264,18 +249,13 @@ export function GitHubIssuesView() {
|
|||||||
'',
|
'',
|
||||||
'**AI Validation Analysis:**',
|
'**AI Validation Analysis:**',
|
||||||
validation.reasoning,
|
validation.reasoning,
|
||||||
];
|
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
|
||||||
if (validation.suggestedFix) {
|
validation.relatedFiles?.length
|
||||||
parts.push('', `**Suggested Approach:**`, validation.suggestedFix);
|
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
|
||||||
}
|
: '',
|
||||||
if (validation.relatedFiles?.length) {
|
]
|
||||||
parts.push(
|
.filter(Boolean)
|
||||||
'',
|
.join('\n');
|
||||||
'**Related Files:**',
|
|
||||||
...validation.relatedFiles.map((f) => `- \`${f}\``)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const description = parts.join('\n');
|
|
||||||
|
|
||||||
const feature = {
|
const feature = {
|
||||||
id: `issue-${issue.number}-${generateUUID()}`,
|
id: `issue-${issue.number}-${generateUUID()}`,
|
||||||
@@ -285,7 +265,7 @@ export function GitHubIssuesView() {
|
|||||||
status: 'backlog' as const,
|
status: 'backlog' as const,
|
||||||
passes: false,
|
passes: false,
|
||||||
priority: getFeaturePriority(validation.estimatedComplexity),
|
priority: getFeaturePriority(validation.estimatedComplexity),
|
||||||
model: resolveModelString('opus'),
|
model: 'opus',
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: currentBranch,
|
branchName: currentBranch,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -472,7 +452,9 @@ export function GitHubIssuesView() {
|
|||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
projectPath={currentProject?.path}
|
projectPath={currentProject?.path}
|
||||||
prefilledTitle={createFeatureIssue?.title}
|
prefilledTitle={createFeatureIssue?.title}
|
||||||
prefilledDescription={prefilledDescription}
|
prefilledDescription={
|
||||||
|
createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined
|
||||||
|
}
|
||||||
prefilledCategory="From GitHub"
|
prefilledCategory="From GitHub"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -78,14 +78,7 @@ export function IssueDetailPanel({
|
|||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={onClose} className="shrink-0 -ml-1">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
className="shrink-0 -ml-1"
|
|
||||||
aria-label="Back"
|
|
||||||
title="Back"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -111,13 +104,7 @@ export function IssueDetailPanel({
|
|||||||
if (cached && !isStale) {
|
if (cached && !isStale) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onViewCachedValidation(issue)}
|
|
||||||
aria-label="View Result"
|
|
||||||
title="View Result"
|
|
||||||
>
|
|
||||||
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
||||||
{!isMobile && 'View Result'}
|
{!isMobile && 'View Result'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -136,13 +123,7 @@ export function IssueDetailPanel({
|
|||||||
if (cached && isStale) {
|
if (cached && isStale) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onViewCachedValidation(issue)}
|
|
||||||
aria-label="View (stale)"
|
|
||||||
title="View (stale)"
|
|
||||||
>
|
|
||||||
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
||||||
{!isMobile && 'View (stale)'}
|
{!isMobile && 'View (stale)'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -159,8 +140,6 @@ export function IssueDetailPanel({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
||||||
aria-label="Re-validate"
|
|
||||||
title="Re-validate"
|
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
<Wand2 className="h-4 w-4 mr-1" />
|
||||||
{!isMobile && 'Re-validate'}
|
{!isMobile && 'Re-validate'}
|
||||||
@@ -184,8 +163,6 @@ export function IssueDetailPanel({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
||||||
aria-label="Validate with AI"
|
|
||||||
title="Validate with AI"
|
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
<Wand2 className="h-4 w-4 mr-1" />
|
||||||
{!isMobile && 'Validate with AI'}
|
{!isMobile && 'Validate with AI'}
|
||||||
@@ -204,13 +181,7 @@ export function IssueDetailPanel({
|
|||||||
Create Feature
|
Create Feature
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onOpenInGitHub(issue.url)}
|
|
||||||
aria-label="Open in GitHub"
|
|
||||||
title="Open in GitHub"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
|||||||
import { useAppStore, type Feature } from '@/store/app-store';
|
import { useAppStore, type Feature } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { cn, generateUUID } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { useGitHubPRs } from '@/hooks/queries';
|
import { useGitHubPRs } from '@/hooks/queries';
|
||||||
import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations';
|
import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations';
|
||||||
import { PRCommentResolutionDialog } from '@/components/dialogs';
|
import { PRCommentResolutionDialog } from '@/components/dialogs/pr-comment-resolution-dialog';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -73,15 +72,15 @@ export function GitHubPRsView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureId = `pr-${pr.number}-${generateUUID()}`;
|
const featureId = `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const feature: Feature = {
|
const feature: Feature = {
|
||||||
id: featureId,
|
id: featureId,
|
||||||
title: `Address PR #${pr.number} Review Comments`,
|
title: `Address PR #${pr.number} Review Comments`,
|
||||||
category: 'bug-fix',
|
category: 'bug-fix',
|
||||||
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
|
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
|
||||||
steps: [],
|
steps: [],
|
||||||
status: 'backlog',
|
status: 'in_progress',
|
||||||
model: resolveModelString('opus'),
|
model: 'opus',
|
||||||
thinkingLevel: 'none',
|
thinkingLevel: 'none',
|
||||||
planningMode: 'skip',
|
planningMode: 'skip',
|
||||||
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||||
@@ -92,26 +91,11 @@ export function GitHubPRsView() {
|
|||||||
|
|
||||||
// Start the feature immediately after creation
|
// Start the feature immediately after creation
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (api.features?.run) {
|
await api.features?.run(currentProject.path, featureId);
|
||||||
try {
|
|
||||||
await api.features.run(currentProject.path, featureId);
|
toast.success('Feature created and started', {
|
||||||
toast.success('Feature created and started', {
|
description: `Addressing review comments on PR #${pr.number}`,
|
||||||
description: `Addressing review comments on PR #${pr.number}`,
|
});
|
||||||
});
|
|
||||||
} catch (runError) {
|
|
||||||
toast.error('Feature created but failed to start', {
|
|
||||||
description:
|
|
||||||
runError instanceof Error
|
|
||||||
? runError.message
|
|
||||||
: 'An error occurred while starting the feature',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('Cannot start feature', {
|
|
||||||
description:
|
|
||||||
'Feature API is not available. The feature was created but could not be started.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to create feature', {
|
toast.error('Failed to create feature', {
|
||||||
description: error instanceof Error ? error.message : 'An error occurred',
|
description: error instanceof Error ? error.message : 'An error occurred',
|
||||||
@@ -258,177 +242,164 @@ export function GitHubPRsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PR Detail Panel */}
|
{/* PR Detail Panel */}
|
||||||
{selectedPR &&
|
{selectedPR && (
|
||||||
(() => {
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
const reviewStatus = getReviewStatus(selectedPR);
|
{/* Detail Header */}
|
||||||
return (
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{/* Detail Header */}
|
{isMobile && (
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
<Button
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
variant="ghost"
|
||||||
{isMobile && (
|
size="sm"
|
||||||
<Button
|
onClick={() => setSelectedPR(null)}
|
||||||
variant="ghost"
|
className="shrink-0 -ml-1"
|
||||||
size="sm"
|
>
|
||||||
onClick={() => setSelectedPR(null)}
|
<ArrowLeft className="h-4 w-4" />
|
||||||
className="shrink-0 -ml-1"
|
</Button>
|
||||||
>
|
)}
|
||||||
<ArrowLeft className="h-4 w-4" />
|
{selectedPR.state === 'MERGED' ? (
|
||||||
</Button>
|
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
#{selectedPR.number} {selectedPR.title}
|
||||||
|
</span>
|
||||||
|
{selectedPR.isDraft && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
||||||
|
Draft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setCommentDialogPR(selectedPR)}>
|
||||||
|
<MessageSquare className="h-4 w-4 mr-1" />
|
||||||
|
Manage Comments
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenInGitHub(selectedPR.url)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
||||||
|
</Button>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PR Detail Content */}
|
||||||
|
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
|
||||||
|
|
||||||
|
{/* Meta info */}
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
selectedPR.state === 'MERGED'
|
||||||
|
? 'bg-purple-500/10 text-purple-500'
|
||||||
|
: selectedPR.isDraft
|
||||||
|
? 'bg-muted text-muted-foreground'
|
||||||
|
: 'bg-green-500/10 text-green-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedPR.state === 'MERGED' ? 'Merged' : selectedPR.isDraft ? 'Draft' : 'Open'}
|
||||||
|
</span>
|
||||||
|
{getReviewStatus(selectedPR) && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 rounded-full text-xs font-medium',
|
||||||
|
getReviewStatus(selectedPR)!.bg,
|
||||||
|
getReviewStatus(selectedPR)!.color
|
||||||
)}
|
)}
|
||||||
{selectedPR.state === 'MERGED' ? (
|
>
|
||||||
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
{getReviewStatus(selectedPR)!.label}
|
||||||
) : (
|
</span>
|
||||||
<GitPullRequest className="h-4 w-4 text-green-500 shrink-0" />
|
)}
|
||||||
)}
|
<span>
|
||||||
<span className="text-sm font-medium truncate">
|
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
|
||||||
#{selectedPR.number} {selectedPR.title}
|
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedPR.isDraft && (
|
</div>
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground">
|
|
||||||
Draft
|
{/* Branch info */}
|
||||||
</span>
|
{selectedPR.headRefName && (
|
||||||
)}
|
<div className="flex items-center gap-2 mb-4">
|
||||||
</div>
|
<span className="text-xs text-muted-foreground">Branch:</span>
|
||||||
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
|
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||||
{!isMobile && (
|
{selectedPR.headRefName}
|
||||||
<Button
|
</span>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCommentDialogPR(selectedPR)}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-4 w-4 mr-1" />
|
|
||||||
Manage Comments
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleOpenInGitHub(selectedPR.url)}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
|
||||||
</Button>
|
|
||||||
{!isMobile && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* PR Detail Content */}
|
{/* Labels */}
|
||||||
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
|
{selectedPR.labels.length > 0 && (
|
||||||
{/* Title */}
|
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||||
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
|
{selectedPR.labels.map((label) => (
|
||||||
|
|
||||||
{/* Meta info */}
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap">
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
key={label.name}
|
||||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||||
selectedPR.state === 'MERGED'
|
style={{
|
||||||
? 'bg-purple-500/10 text-purple-500'
|
backgroundColor: `#${label.color}20`,
|
||||||
: selectedPR.isDraft
|
color: `#${label.color}`,
|
||||||
? 'bg-muted text-muted-foreground'
|
border: `1px solid #${label.color}40`,
|
||||||
: 'bg-green-500/10 text-green-500'
|
}}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{selectedPR.state === 'MERGED'
|
{label.name}
|
||||||
? 'Merged'
|
|
||||||
: selectedPR.isDraft
|
|
||||||
? 'Draft'
|
|
||||||
: 'Open'}
|
|
||||||
</span>
|
</span>
|
||||||
{reviewStatus && (
|
))}
|
||||||
<span
|
</div>
|
||||||
className={cn(
|
)}
|
||||||
'px-2 py-0.5 rounded-full text-xs font-medium',
|
|
||||||
reviewStatus.bg,
|
|
||||||
reviewStatus.color
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{reviewStatus.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
#{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '}
|
|
||||||
<span className="font-medium text-foreground">{selectedPR.author.login}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Branch info */}
|
{/* Body */}
|
||||||
{selectedPR.headRefName && (
|
{selectedPR.body ? (
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<Markdown className="text-sm">{selectedPR.body}</Markdown>
|
||||||
<span className="text-xs text-muted-foreground">Branch:</span>
|
) : (
|
||||||
<span className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||||
{selectedPR.headRefName}
|
)}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Review Comments CTA */}
|
||||||
{selectedPR.labels.length > 0 && (
|
<div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{selectedPR.labels.map((label) => (
|
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||||
<span
|
<span className="text-sm font-medium">Review Comments</span>
|
||||||
key={label.name}
|
</div>
|
||||||
className="px-2 py-0.5 text-xs font-medium rounded-full"
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
style={{
|
Manage review comments individually or let AI address all feedback automatically.
|
||||||
backgroundColor: `#${label.color}20`,
|
</p>
|
||||||
color: `#${label.color}`,
|
<div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
|
||||||
border: `1px solid #${label.color}40`,
|
<Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
|
||||||
}}
|
<MessageSquare className="h-4 w-4 mr-2" />
|
||||||
>
|
Manage Review Comments
|
||||||
{label.name}
|
</Button>
|
||||||
</span>
|
<Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}>
|
||||||
))}
|
<Zap className="h-4 w-4 mr-2" />
|
||||||
</div>
|
Address Review Comments
|
||||||
)}
|
</Button>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
{selectedPR.body ? (
|
|
||||||
<Markdown className="text-sm">{selectedPR.body}</Markdown>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Review Comments CTA */}
|
|
||||||
<div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<MessageSquare className="h-4 w-4 text-blue-500" />
|
|
||||||
<span className="text-sm font-medium">Review Comments</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
Manage review comments individually or let AI address all feedback
|
|
||||||
automatically.
|
|
||||||
</p>
|
|
||||||
<div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}>
|
|
||||||
<Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}>
|
|
||||||
<MessageSquare className="h-4 w-4 mr-2" />
|
|
||||||
Manage Review Comments
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}>
|
|
||||||
<Zap className="h-4 w-4 mr-2" />
|
|
||||||
Address Review Comments
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Open in GitHub CTA */}
|
|
||||||
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
View code changes, comments, and reviews on GitHub.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
|
|
||||||
<ExternalLink className="h-4 w-4 mr-2" />
|
|
||||||
View Full PR on GitHub
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()}
|
{/* Open in GitHub CTA */}
|
||||||
|
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
View code changes, comments, and reviews on GitHub.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenInGitHub(selectedPR.url)}>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
View Full PR on GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* PR Comment Resolution Dialog */}
|
{/* PR Comment Resolution Dialog */}
|
||||||
{commentDialogPR && (
|
{commentDialogPR && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, type KeyboardEvent } from 'react';
|
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +19,6 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useProjectSettings } from '@/hooks/queries';
|
import { useProjectSettings } from '@/hooks/queries';
|
||||||
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
||||||
|
|
||||||
/** Preset dev server commands for quick selection */
|
/** Preset dev server commands for quick selection */
|
||||||
@@ -92,69 +91,46 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// Track previous project path to detect project switches
|
// Reset local state when project changes
|
||||||
const prevProjectPathRef = useRef(project.path);
|
|
||||||
// Track whether we've done the initial sync for the current project
|
|
||||||
const isInitializedRef = useRef(false);
|
|
||||||
|
|
||||||
// Sync commands and scripts state when project settings load or project changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const projectChanged = prevProjectPathRef.current !== project.path;
|
setDevCommand('');
|
||||||
prevProjectPathRef.current = project.path;
|
setOriginalDevCommand('');
|
||||||
|
setTestCommand('');
|
||||||
|
setOriginalTestCommand('');
|
||||||
|
setScripts([]);
|
||||||
|
setOriginalScripts([]);
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
// Always clear local state on project change to avoid flashing stale data
|
// Sync commands state when project settings load
|
||||||
if (projectChanged) {
|
useEffect(() => {
|
||||||
isInitializedRef.current = false;
|
|
||||||
setDevCommand('');
|
|
||||||
setOriginalDevCommand('');
|
|
||||||
setTestCommand('');
|
|
||||||
setOriginalTestCommand('');
|
|
||||||
setScripts([]);
|
|
||||||
setOriginalScripts([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply project settings only when they are available
|
|
||||||
if (projectSettings) {
|
if (projectSettings) {
|
||||||
// Only sync from server if this is the initial load or if there are no unsaved edits.
|
const dev = projectSettings.devCommand || '';
|
||||||
// This prevents background refetches from overwriting in-progress local edits.
|
const test = projectSettings.testCommand || '';
|
||||||
const isDirty =
|
setDevCommand(dev);
|
||||||
isInitializedRef.current &&
|
setOriginalDevCommand(dev);
|
||||||
(devCommand !== originalDevCommand ||
|
setTestCommand(test);
|
||||||
testCommand !== originalTestCommand ||
|
setOriginalTestCommand(test);
|
||||||
JSON.stringify(scripts) !== JSON.stringify(originalScripts));
|
}
|
||||||
|
}, [projectSettings]);
|
||||||
if (!isInitializedRef.current || !isDirty) {
|
|
||||||
// Commands
|
// Sync scripts state when project settings load
|
||||||
const dev = projectSettings.devCommand || '';
|
useEffect(() => {
|
||||||
const test = projectSettings.testCommand || '';
|
if (projectSettings) {
|
||||||
setDevCommand(dev);
|
const configured = projectSettings.terminalScripts;
|
||||||
setOriginalDevCommand(dev);
|
const scriptList =
|
||||||
setTestCommand(test);
|
configured && configured.length > 0
|
||||||
setOriginalTestCommand(test);
|
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
||||||
|
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
||||||
// Scripts
|
setScripts(scriptList);
|
||||||
const configured = projectSettings.terminalScripts;
|
setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
|
||||||
const scriptList =
|
|
||||||
configured && configured.length > 0
|
|
||||||
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
|
||||||
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
|
||||||
setScripts(scriptList);
|
|
||||||
setOriginalScripts(structuredClone(scriptList));
|
|
||||||
|
|
||||||
isInitializedRef.current = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [projectSettings, project.path]);
|
}, [projectSettings, project.path]);
|
||||||
|
|
||||||
// ── Change detection ──
|
// ── Change detection ──
|
||||||
const hasDevChanges = devCommand !== originalDevCommand;
|
const hasDevChanges = devCommand !== originalDevCommand;
|
||||||
const hasTestChanges = testCommand !== originalTestCommand;
|
const hasTestChanges = testCommand !== originalTestCommand;
|
||||||
const hasCommandChanges = hasDevChanges || hasTestChanges;
|
const hasCommandChanges = hasDevChanges || hasTestChanges;
|
||||||
const hasScriptChanges = useMemo(
|
const hasScriptChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
|
||||||
() => JSON.stringify(scripts) !== JSON.stringify(originalScripts),
|
|
||||||
[scripts, originalScripts]
|
|
||||||
);
|
|
||||||
const hasChanges = hasCommandChanges || hasScriptChanges;
|
const hasChanges = hasCommandChanges || hasScriptChanges;
|
||||||
const isSaving = updateSettingsMutation.isPending;
|
const isSaving = updateSettingsMutation.isPending;
|
||||||
|
|
||||||
@@ -182,12 +158,7 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
setTestCommand(normalizedTestCommand);
|
setTestCommand(normalizedTestCommand);
|
||||||
setOriginalTestCommand(normalizedTestCommand);
|
setOriginalTestCommand(normalizedTestCommand);
|
||||||
setScripts(normalizedScripts);
|
setScripts(normalizedScripts);
|
||||||
setOriginalScripts(structuredClone(normalizedScripts));
|
setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error('Failed to save settings', {
|
|
||||||
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -197,7 +168,7 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setDevCommand(originalDevCommand);
|
setDevCommand(originalDevCommand);
|
||||||
setTestCommand(originalTestCommand);
|
setTestCommand(originalTestCommand);
|
||||||
setScripts(structuredClone(originalScripts));
|
setScripts(JSON.parse(JSON.stringify(originalScripts)));
|
||||||
}, [originalDevCommand, originalTestCommand, originalScripts]);
|
}, [originalDevCommand, originalTestCommand, originalScripts]);
|
||||||
|
|
||||||
// ── Command handlers ──
|
// ── Command handlers ──
|
||||||
@@ -287,36 +258,6 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
setDragOverIndex(null);
|
setDragOverIndex(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Keyboard reorder helpers for accessibility ──
|
|
||||||
const moveScript = useCallback((fromIndex: number, toIndex: number) => {
|
|
||||||
setScripts((prev) => {
|
|
||||||
if (toIndex < 0 || toIndex >= prev.length) return prev;
|
|
||||||
const newScripts = [...prev];
|
|
||||||
const [removed] = newScripts.splice(fromIndex, 1);
|
|
||||||
newScripts.splice(toIndex, 0, removed);
|
|
||||||
return newScripts;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragHandleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent, index: number) => {
|
|
||||||
if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
moveScript(index, index - 1);
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
moveScript(index, index + 1);
|
|
||||||
} else if (e.key === 'Home') {
|
|
||||||
e.preventDefault();
|
|
||||||
moveScript(index, 0);
|
|
||||||
} else if (e.key === 'End') {
|
|
||||||
e.preventDefault();
|
|
||||||
moveScript(index, scripts.length - 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[moveScript, scripts.length]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── Commands Card ── */}
|
{/* ── Commands Card ── */}
|
||||||
@@ -535,14 +476,10 @@ export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSection
|
|||||||
onDrop={(e) => handleDrop(e)}
|
onDrop={(e) => handleDrop(e)}
|
||||||
onDragEnd={(e) => handleDragEnd(e)}
|
onDragEnd={(e) => handleDragEnd(e)}
|
||||||
>
|
>
|
||||||
{/* Drag handle - keyboard accessible */}
|
{/* Drag handle */}
|
||||||
<div
|
<div
|
||||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground focus:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded shrink-0 p-0.5"
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
|
||||||
title="Drag to reorder (or use Arrow keys)"
|
title="Drag to reorder"
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
aria-label={`Reorder ${script.name || 'script'}. Use arrow keys to move.`}
|
|
||||||
onKeyDown={(e) => handleDragHandleKeyDown(e, index)}
|
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4" />
|
<GripVertical className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -588,7 +588,7 @@ export function TerminalView({
|
|||||||
|
|
||||||
// Skip if we've already handled this exact request (prevents duplicate terminals)
|
// Skip if we've already handled this exact request (prevents duplicate terminals)
|
||||||
// Include mode and nonce in the key to allow opening same cwd multiple times
|
// Include mode and nonce in the key to allow opening same cwd multiple times
|
||||||
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}:${initialCommand || ''}`;
|
const cwdKey = `${initialCwd}:${initialMode || 'default'}:${nonce || 0}`;
|
||||||
if (initialCwdHandledRef.current === cwdKey) return;
|
if (initialCwdHandledRef.current === cwdKey) return;
|
||||||
|
|
||||||
// Skip if terminal is not enabled or not unlocked
|
// Skip if terminal is not enabled or not unlocked
|
||||||
@@ -1162,18 +1162,6 @@ export function TerminalView({
|
|||||||
// Always remove from UI - even if server says 404 (session may have already exited)
|
// Always remove from UI - even if server says 404 (session may have already exited)
|
||||||
removeTerminalFromLayout(sessionId);
|
removeTerminalFromLayout(sessionId);
|
||||||
|
|
||||||
// Clean up stale entries for killed sessions
|
|
||||||
setSessionCommandOverrides((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(sessionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setNewSessionIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(sessionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
// Log non-404 errors but still proceed with UI cleanup
|
// Log non-404 errors but still proceed with UI cleanup
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
@@ -1186,17 +1174,6 @@ export function TerminalView({
|
|||||||
logger.error('Kill session error:', err);
|
logger.error('Kill session error:', err);
|
||||||
// Still remove from UI on network error - better UX than leaving broken terminal
|
// Still remove from UI on network error - better UX than leaving broken terminal
|
||||||
removeTerminalFromLayout(sessionId);
|
removeTerminalFromLayout(sessionId);
|
||||||
// Clean up stale entries for killed sessions (same cleanup as try block)
|
|
||||||
setSessionCommandOverrides((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(sessionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setNewSessionIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(sessionId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1231,22 +1208,6 @@ export function TerminalView({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean up stale entries for all killed sessions in this tab
|
|
||||||
setSessionCommandOverrides((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
for (const sessionId of sessionIds) {
|
|
||||||
next.delete(sessionId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setNewSessionIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
for (const sessionId of sessionIds) {
|
|
||||||
next.delete(sessionId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now remove the tab from state
|
// Now remove the tab from state
|
||||||
removeTerminalTab(tabId);
|
removeTerminalTab(tabId);
|
||||||
// Refresh session count
|
// Refresh session count
|
||||||
@@ -2064,13 +2025,6 @@ export function TerminalView({
|
|||||||
onFontSizeChange={(size) =>
|
onFontSizeChange={(size) =>
|
||||||
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
|
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
|
||||||
}
|
}
|
||||||
runCommandOnConnect={
|
|
||||||
newSessionIds.has(terminalState.maximizedSessionId)
|
|
||||||
? sessionCommandOverrides.get(terminalState.maximizedSessionId) ||
|
|
||||||
defaultRunScript
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onCommandRan={() => handleCommandRan(terminalState.maximizedSessionId!)}
|
|
||||||
isMaximized={true}
|
isMaximized={true}
|
||||||
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
|
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* automatic caching, deduplication, and background refetching.
|
* automatic caching, deduplication, and background refetching.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useEffect, useRef } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { STALE_TIMES } from '@/lib/query-client';
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
@@ -151,34 +151,6 @@ export function useFeatures(projectPath: string | undefined) {
|
|||||||
[projectPath]
|
[projectPath]
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Subscribe to React Query cache changes for features and sync to localStorage.
|
|
||||||
// This ensures optimistic updates (e.g., status changes to 'verified') are
|
|
||||||
// persisted to localStorage immediately, not just when queryFn runs.
|
|
||||||
// Without this, a page refresh after an optimistic update could show stale
|
|
||||||
// localStorage data where features appear in the wrong column (e.g., verified
|
|
||||||
// features showing up in backlog).
|
|
||||||
const projectPathRef = useRef(projectPath);
|
|
||||||
projectPathRef.current = projectPath;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectPath) return;
|
|
||||||
const targetQueryHash = JSON.stringify(queryKeys.features.all(projectPath));
|
|
||||||
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
|
||||||
if (
|
|
||||||
event.type === 'updated' &&
|
|
||||||
event.action.type === 'success' &&
|
|
||||||
event.query.queryHash === targetQueryHash
|
|
||||||
) {
|
|
||||||
const features = event.query.state.data as Feature[] | undefined;
|
|
||||||
if (features && projectPathRef.current) {
|
|
||||||
writePersistedFeatures(projectPathRef.current, features);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [projectPath, queryClient]);
|
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.features.all(projectPath ?? ''),
|
queryKey: queryKeys.features.all(projectPath ?? ''),
|
||||||
queryFn: async (): Promise<Feature[]> => {
|
queryFn: async (): Promise<Feature[]> => {
|
||||||
@@ -194,11 +166,7 @@ export function useFeatures(projectPath: string | undefined) {
|
|||||||
},
|
},
|
||||||
enabled: !!projectPath,
|
enabled: !!projectPath,
|
||||||
initialData: () => persisted?.features,
|
initialData: () => persisted?.features,
|
||||||
// Always treat localStorage cache as stale so React Query immediately
|
initialDataUpdatedAt: () => persisted?.timestamp,
|
||||||
// fetches fresh data from the server on page load. This prevents stale
|
|
||||||
// feature statuses (e.g., 'verified' features appearing in backlog)
|
|
||||||
// while still showing cached data instantly for a fast initial render.
|
|
||||||
initialDataUpdatedAt: 0,
|
|
||||||
staleTime: STALE_TIMES.FEATURES,
|
staleTime: STALE_TIMES.FEATURES,
|
||||||
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
||||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'projectHistory',
|
'projectHistory',
|
||||||
'projectHistoryIndex',
|
'projectHistoryIndex',
|
||||||
'lastSelectedSessionByProject',
|
'lastSelectedSessionByProject',
|
||||||
'currentWorktreeByProject',
|
|
||||||
// Codex CLI Settings
|
// Codex CLI Settings
|
||||||
'codexAutoLoadAgents',
|
'codexAutoLoadAgents',
|
||||||
'codexSandboxMode',
|
'codexSandboxMode',
|
||||||
@@ -769,8 +768,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
projectHistory: serverSettings.projectHistory,
|
projectHistory: serverSettings.projectHistory,
|
||||||
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
||||||
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
||||||
currentWorktreeByProject:
|
|
||||||
serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject,
|
|
||||||
// UI State (previously in localStorage)
|
// UI State (previously in localStorage)
|
||||||
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
||||||
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './app';
|
import App from './app';
|
||||||
import { AppErrorBoundary } from './components/ui/app-error-boundary';
|
|
||||||
import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect';
|
import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect';
|
||||||
|
|
||||||
// Defensive fallback: index.html's inline script already applies data-pwa="standalone"
|
// Defensive fallback: index.html's inline script already applies data-pwa="standalone"
|
||||||
@@ -251,12 +250,8 @@ function warmAssetCache(registration: ServiceWorkerRegistration): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render the app - prioritize First Contentful Paint
|
// Render the app - prioritize First Contentful Paint
|
||||||
// AppErrorBoundary catches uncaught React errors and shows a friendly error screen
|
|
||||||
// instead of TanStack Router's default "Something went wrong!" overlay.
|
|
||||||
createRoot(document.getElementById('app')!).render(
|
createRoot(document.getElementById('app')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppErrorBoundary>
|
<App />
|
||||||
<App />
|
|
||||||
</AppErrorBoundary>
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -400,16 +400,19 @@ function RootLayoutContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLoggedOut = () => {
|
const handleLoggedOut = () => {
|
||||||
logger.warn('automaker:logged-out event received!');
|
logger.warn('automaker:logged-out event received!');
|
||||||
// Only update auth state — the centralized routing effect will handle
|
|
||||||
// navigation to /logged-out when it detects isAuthenticated is false
|
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
|
if (location.pathname !== '/logged-out') {
|
||||||
|
logger.warn('Navigating to /logged-out due to logged-out event');
|
||||||
|
navigate({ to: '/logged-out' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('automaker:logged-out', handleLoggedOut);
|
window.addEventListener('automaker:logged-out', handleLoggedOut);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('automaker:logged-out', handleLoggedOut);
|
window.removeEventListener('automaker:logged-out', handleLoggedOut);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [location.pathname, navigate]);
|
||||||
|
|
||||||
// Global listener for server offline/connection errors.
|
// Global listener for server offline/connection errors.
|
||||||
// This is triggered when a connection error is detected (e.g., server stopped).
|
// This is triggered when a connection error is detected (e.g., server stopped).
|
||||||
@@ -721,31 +724,33 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we can't load settings, we must NOT start syncing defaults to the server.
|
// If we can't load settings, we must NOT start syncing defaults to the server.
|
||||||
// Only update auth state — the routing effect handles navigation to /logged-out.
|
|
||||||
// Calling navigate() here AND in the routing effect causes duplicate navigations
|
|
||||||
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
|
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
|
navigate({ to: '/logged-out' });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Session is definitively invalid (server returned 401/403) - treat as not authenticated.
|
// Session is definitively invalid (server returned 401/403) - treat as not authenticated
|
||||||
// Only update auth state — the routing effect handles navigation to /logged-out.
|
|
||||||
// Calling navigate() here AND in the routing effect causes duplicate navigations
|
|
||||||
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
|
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
|
// Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
|
||||||
|
// Redirect to logged-out if not already there or login
|
||||||
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
|
navigate({ to: '/logged-out' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to initialize auth:', error);
|
logger.error('Failed to initialize auth:', error);
|
||||||
// On error, treat as not authenticated.
|
// On error, treat as not authenticated
|
||||||
// Only update auth state — the routing effect handles navigation to /logged-out.
|
|
||||||
// Calling navigate() here AND in the routing effect causes duplicate navigations
|
|
||||||
// that can trigger React error #185 (maximum update depth exceeded) on cold start.
|
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
// Signal migration complete so sync hook doesn't hang
|
// Signal migration complete so sync hook doesn't hang
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
|
navigate({ to: '/logged-out' });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
authCheckRunning.current = false;
|
authCheckRunning.current = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user