mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
Compare commits
1 Commits
d37ced1c6e
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa345a50ac |
@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
|
|||||||
import { createListIssuesHandler } from './routes/list-issues.js';
|
import { createListIssuesHandler } from './routes/list-issues.js';
|
||||||
import { createListPRsHandler } from './routes/list-prs.js';
|
import { createListPRsHandler } from './routes/list-prs.js';
|
||||||
import { createListCommentsHandler } from './routes/list-comments.js';
|
import { createListCommentsHandler } from './routes/list-comments.js';
|
||||||
|
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
|
||||||
|
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
|
||||||
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
import { createValidateIssueHandler } from './routes/validate-issue.js';
|
||||||
import {
|
import {
|
||||||
createValidationStatusHandler,
|
createValidationStatusHandler,
|
||||||
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
|
|||||||
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
|
||||||
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
|
||||||
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
|
||||||
|
router.post(
|
||||||
|
'/pr-review-comments',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createListPRReviewCommentsHandler()
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/resolve-pr-comment',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
createResolvePRCommentHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/validate-issue',
|
'/validate-issue',
|
||||||
validatePathParams('projectPath'),
|
validatePathParams('projectPath'),
|
||||||
|
|||||||
333
apps/server/src/routes/github/routes/list-pr-review-comments.ts
Normal file
333
apps/server/src/routes/github/routes/list-pr-review-comments.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
|
||||||
|
*
|
||||||
|
* Fetches both regular PR comments and inline code review comments
|
||||||
|
* for a specific pull request, providing file path and line context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListPRReviewCommentsRequest {
|
||||||
|
projectPath: string;
|
||||||
|
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() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prNumber || typeof prNumber !== 'number') {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, error: 'prNumber is required and must be a number' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo and get owner/repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await fetchPRReviewComments(
|
||||||
|
projectPath,
|
||||||
|
remoteStatus.owner,
|
||||||
|
remoteStatus.repo,
|
||||||
|
prNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
comments,
|
||||||
|
totalCount: comments.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Fetch PR review comments failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
151
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
151
apps/server/src/routes/github/routes/resolve-pr-comment.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
|
||||||
|
*
|
||||||
|
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
|
||||||
|
* identified by its GraphQL node ID (threadId).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { execEnv, getErrorMessage, logError } from './common.js';
|
||||||
|
import { checkGitHubRemote } from './check-github-remote.js';
|
||||||
|
|
||||||
|
export interface ResolvePRCommentResult {
|
||||||
|
success: boolean;
|
||||||
|
isResolved?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvePRCommentRequest {
|
||||||
|
projectPath: string;
|
||||||
|
threadId: string;
|
||||||
|
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() {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) {
|
||||||
|
res.status(400).json({ success: false, error: 'threadId is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof resolve !== 'boolean') {
|
||||||
|
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a GitHub repo
|
||||||
|
const remoteStatus = await checkGitHubRemote(projectPath);
|
||||||
|
if (!remoteStatus.hasGitHubRemote) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Project does not have a GitHub remote',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
isResolved: result.isResolved,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Resolve PR comment failed');
|
||||||
|
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -213,8 +213,10 @@ export function createGenerateCommitMessageHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
// Use result if available (some providers return final text here)
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
responseText = msg.result;
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const MAX_DIFF_SIZE = 15_000;
|
|||||||
|
|
||||||
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
|
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
|
||||||
|
|
||||||
|
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
|
||||||
|
|
||||||
Output your response in EXACTLY this format (including the markers):
|
Output your response in EXACTLY this format (including the markers):
|
||||||
---TITLE---
|
---TITLE---
|
||||||
<a concise PR title, 50-72 chars, imperative mood>
|
<a concise PR title, 50-72 chars, imperative mood>
|
||||||
@@ -41,6 +43,7 @@ Output your response in EXACTLY this format (including the markers):
|
|||||||
<Detailed list of what was changed and why>
|
<Detailed list of what was changed and why>
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
|
||||||
- The title should be concise and descriptive (50-72 characters)
|
- The title should be concise and descriptive (50-72 characters)
|
||||||
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
|
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
|
||||||
- The description should explain WHAT changed and WHY
|
- The description should explain WHAT changed and WHY
|
||||||
@@ -397,7 +400,10 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||||
responseText = msg.result;
|
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
|
||||||
|
if (msg.result.length > responseText.length) {
|
||||||
|
responseText = msg.result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +419,9 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the response to extract title and body
|
// Parse the response to extract title and body.
|
||||||
|
// The model may include conversational preamble before the structured markers,
|
||||||
|
// so we search for the markers anywhere in the response, not just at the start.
|
||||||
let title = '';
|
let title = '';
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
||||||
@@ -424,14 +432,46 @@ export function createGeneratePRDescriptionHandler(
|
|||||||
title = titleMatch[1].trim();
|
title = titleMatch[1].trim();
|
||||||
body = bodyMatch[1].trim();
|
body = bodyMatch[1].trim();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: treat first line as title, rest as body
|
// Fallback: try to extract meaningful content, skipping any conversational preamble.
|
||||||
const lines = fullResponse.split('\n');
|
// Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
|
||||||
title = lines[0].trim();
|
const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
|
||||||
body = lines.slice(1).join('\n').trim();
|
|
||||||
|
// Skip lines that look like conversational preamble
|
||||||
|
let startIndex = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
// Check if this line looks like conversational AI preamble
|
||||||
|
if (
|
||||||
|
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
|
||||||
|
line
|
||||||
|
) ||
|
||||||
|
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
|
||||||
|
line
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
startIndex = i + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use remaining lines after skipping preamble
|
||||||
|
const contentLines = lines.slice(startIndex);
|
||||||
|
if (contentLines.length > 0) {
|
||||||
|
title = contentLines[0].trim();
|
||||||
|
body = contentLines.slice(1).join('\n').trim();
|
||||||
|
} else {
|
||||||
|
// If all lines were filtered as preamble, use the original first non-empty line
|
||||||
|
title = lines[0]?.trim() || '';
|
||||||
|
body = lines.slice(1).join('\n').trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up title - remove any markdown or quotes
|
// Clean up title - remove any markdown headings, quotes, or marker artifacts
|
||||||
title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, '');
|
title = title
|
||||||
|
.replace(/^#+\s*/, '')
|
||||||
|
.replace(/^["']|["']$/g, '')
|
||||||
|
.replace(/^---\w+---\s*/, '');
|
||||||
|
|
||||||
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
|
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export { FileBrowserDialog } from './file-browser-dialog';
|
|||||||
export { NewProjectModal } from './new-project-modal';
|
export { NewProjectModal } from './new-project-modal';
|
||||||
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
|
||||||
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
export { SandboxRiskDialog } from './sandbox-risk-dialog';
|
||||||
|
export { PRCommentResolutionDialog } from './pr-comment-resolution-dialog';
|
||||||
|
export type { PRCommentResolutionPRInfo } from './pr-comment-resolution-dialog';
|
||||||
export { WorkspacePickerModal } from './workspace-picker-modal';
|
export { WorkspacePickerModal } from './workspace-picker-modal';
|
||||||
|
|||||||
1095
apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx
Normal file
1095
apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -103,7 +103,15 @@ export function ProjectSwitcher() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleProjectClick = useCallback(
|
const handleProjectClick = useCallback(
|
||||||
(project: Project) => {
|
async (project: Project) => {
|
||||||
|
try {
|
||||||
|
// Ensure .automaker directory structure exists before switching
|
||||||
|
await initializeProject(project.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize project during switch:', error);
|
||||||
|
// Continue with switch even if initialization fails -
|
||||||
|
// the project may already be initialized
|
||||||
|
}
|
||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
// Navigate to board view when switching projects
|
// Navigate to board view when switching projects
|
||||||
navigate({ to: '/board' });
|
navigate({ to: '/board' });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
|
||||||
|
import { initializeProject } from '@/lib/project-init';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -87,6 +90,22 @@ export function ProjectSelectorWithOptions({
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
|
// Wrap setCurrentProject to ensure .automaker is initialized before switching
|
||||||
|
const setCurrentProjectWithInit = useCallback(
|
||||||
|
async (p: Project) => {
|
||||||
|
try {
|
||||||
|
// Ensure .automaker directory structure exists before switching
|
||||||
|
await initializeProject(p.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize project during switch:', error);
|
||||||
|
// Continue with switch even if initialization fails -
|
||||||
|
// the project may already be initialized
|
||||||
|
}
|
||||||
|
setCurrentProject(p);
|
||||||
|
},
|
||||||
|
[setCurrentProject]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
projectSearchQuery,
|
projectSearchQuery,
|
||||||
setProjectSearchQuery,
|
setProjectSearchQuery,
|
||||||
@@ -99,7 +118,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
currentProject,
|
currentProject,
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setCurrentProject,
|
setCurrentProject: setCurrentProjectWithInit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||||
@@ -107,6 +126,14 @@ export function ProjectSelectorWithOptions({
|
|||||||
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
|
||||||
useProjectTheme();
|
useProjectTheme();
|
||||||
|
|
||||||
|
const handleSelectProject = useCallback(
|
||||||
|
async (p: Project) => {
|
||||||
|
await setCurrentProjectWithInit(p);
|
||||||
|
setIsProjectPickerOpen(false);
|
||||||
|
},
|
||||||
|
[setCurrentProjectWithInit, setIsProjectPickerOpen]
|
||||||
|
);
|
||||||
|
|
||||||
if (!sidebarOpen || projects.length === 0) {
|
if (!sidebarOpen || projects.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -204,10 +231,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
project={project}
|
project={project}
|
||||||
currentProjectId={currentProject?.id}
|
currentProjectId={currentProject?.id}
|
||||||
isHighlighted={index === selectedProjectIndex}
|
isHighlighted={index === selectedProjectIndex}
|
||||||
onSelect={(p) => {
|
onSelect={handleSelectProject}
|
||||||
setCurrentProject(p);
|
|
||||||
setIsProjectPickerOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface UseProjectPickerProps {
|
|||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
isProjectPickerOpen: boolean;
|
isProjectPickerOpen: boolean;
|
||||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
setCurrentProject: (project: Project) => void;
|
setCurrentProject: (project: Project) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProjectPicker({
|
export function useProjectPicker({
|
||||||
@@ -92,9 +92,9 @@ export function useProjectPicker({
|
|||||||
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
||||||
|
|
||||||
// Handle selecting the currently highlighted project
|
// Handle selecting the currently highlighted project
|
||||||
const selectHighlightedProject = useCallback(() => {
|
const selectHighlightedProject = useCallback(async () => {
|
||||||
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
||||||
setCurrentProject(filteredProjects[selectedProjectIndex]);
|
await setCurrentProject(filteredProjects[selectedProjectIndex]);
|
||||||
setIsProjectPickerOpen(false);
|
setIsProjectPickerOpen(false);
|
||||||
}
|
}
|
||||||
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface SortableProjectItemProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
currentProjectId: string | undefined;
|
currentProjectId: string | undefined;
|
||||||
isHighlighted: boolean;
|
isHighlighted: boolean;
|
||||||
onSelect: (project: Project) => void;
|
onSelect: (project: Project) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeMenuItemProps {
|
export interface ThemeMenuItemProps {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +34,10 @@ import type { BacklogPlanResult, FeatureStatusWithPipeline } from '@automaker/ty
|
|||||||
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 { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
|
||||||
|
import {
|
||||||
|
PRCommentResolutionDialog,
|
||||||
|
type PRCommentResolutionPRInfo,
|
||||||
|
} 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';
|
||||||
@@ -184,6 +188,9 @@ export function BoardView() {
|
|||||||
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
|
||||||
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
|
||||||
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
|
||||||
|
const [showPRCommentDialog, setShowPRCommentDialog] = useState(false);
|
||||||
|
const [prCommentDialogPRInfo, setPRCommentDialogPRInfo] =
|
||||||
|
useState<PRCommentResolutionPRInfo | null>(null);
|
||||||
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
@@ -429,6 +436,29 @@ export function BoardView() {
|
|||||||
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
|
||||||
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||||
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
|
||||||
|
|
||||||
|
// Track the previous worktree path to detect worktree switches
|
||||||
|
const prevWorktreePathRef = useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
|
// When the active worktree changes, invalidate feature queries to ensure
|
||||||
|
// feature cards (especially their todo lists / planSpec tasks) render fresh data.
|
||||||
|
// Without this, cards that unmount when filtered out and remount when the user
|
||||||
|
// switches back may show stale or missing todo list data until the next polling cycle.
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip the initial mount (prevWorktreePathRef starts as undefined)
|
||||||
|
if (prevWorktreePathRef.current === undefined) {
|
||||||
|
prevWorktreePathRef.current = currentWorktreePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only invalidate when the worktree actually changed
|
||||||
|
if (prevWorktreePathRef.current !== currentWorktreePath && currentProject?.path) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prevWorktreePathRef.current = currentWorktreePath;
|
||||||
|
}, [currentWorktreePath, currentProject?.path, queryClient]);
|
||||||
|
|
||||||
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
|
||||||
const worktrees = useMemo(
|
const worktrees = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -922,26 +952,39 @@ export function BoardView() {
|
|||||||
[handleAddFeature, handleStartImplementation]
|
[handleAddFeature, handleStartImplementation]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
// Handler for managing PR comments - opens the PR Comment Resolution dialog
|
||||||
const handleAddressPRComments = useCallback(
|
const handleAddressPRComments = useCallback((worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
|
setPRCommentDialogPRInfo({
|
||||||
|
number: prInfo.number,
|
||||||
|
title: prInfo.title,
|
||||||
|
// Pass the worktree's branch so features are created on the correct worktree
|
||||||
|
headRefName: worktree.branch,
|
||||||
|
});
|
||||||
|
setShowPRCommentDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler for auto-addressing PR comments - immediately creates and starts a feature task
|
||||||
|
const handleAutoAddressPRComments = useCallback(
|
||||||
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||||
// Use a simple prompt that instructs the agent to read and address PR feedback
|
if (!prInfo.number) {
|
||||||
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
|
toast.error('Cannot address PR comments', {
|
||||||
const prNumber = prInfo.number;
|
description: 'No PR number available for this worktree.',
|
||||||
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const featureData = {
|
const featureData = {
|
||||||
title: `Address PR #${prNumber} Review Comments`,
|
title: `Address PR #${prInfo.number} Review Comments`,
|
||||||
category: 'PR Review',
|
category: 'Maintenance',
|
||||||
description,
|
description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`,
|
||||||
images: [],
|
images: [],
|
||||||
imagePaths: [],
|
imagePaths: [],
|
||||||
skipTests: defaultSkipTests,
|
skipTests: defaultSkipTests,
|
||||||
model: 'opus' as const,
|
model: resolveModelString('opus'),
|
||||||
thinkingLevel: 'none' as const,
|
thinkingLevel: 'none' as const,
|
||||||
branchName: worktree.branch,
|
branchName: worktree.branch,
|
||||||
workMode: 'custom' as const, // Use the worktree's branch
|
workMode: 'custom' as const,
|
||||||
priority: 1, // High priority for PR feedback
|
priority: 1,
|
||||||
planningMode: 'skip' as const,
|
planningMode: 'skip' as const,
|
||||||
requirePlanApproval: false,
|
requirePlanApproval: false,
|
||||||
};
|
};
|
||||||
@@ -1508,6 +1551,7 @@ export function BoardView() {
|
|||||||
setShowCreateBranchDialog(true);
|
setShowCreateBranchDialog(true);
|
||||||
}}
|
}}
|
||||||
onAddressPRComments={handleAddressPRComments}
|
onAddressPRComments={handleAddressPRComments}
|
||||||
|
onAutoAddressPRComments={handleAutoAddressPRComments}
|
||||||
onResolveConflicts={handleResolveConflicts}
|
onResolveConflicts={handleResolveConflicts}
|
||||||
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||||
onBranchSwitchConflict={handleBranchSwitchConflict}
|
onBranchSwitchConflict={handleBranchSwitchConflict}
|
||||||
@@ -1985,6 +2029,18 @@ export function BoardView() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* PR Comment Resolution Dialog */}
|
||||||
|
{prCommentDialogPRInfo && (
|
||||||
|
<PRCommentResolutionDialog
|
||||||
|
open={showPRCommentDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setShowPRCommentDialog(open);
|
||||||
|
if (!open) setPRCommentDialogPRInfo(null);
|
||||||
|
}}
|
||||||
|
pr={prCommentDialogPRInfo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
{/* Init Script Indicator - floating overlay for worktree init script status */}
|
||||||
{getShowInitScriptIndicator(currentProject.path) && (
|
{getShowInitScriptIndicator(currentProject.path) && (
|
||||||
<InitScriptIndicator projectPath={currentProject.path} />
|
<InitScriptIndicator projectPath={currentProject.path} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
import { memo, useEffect, useState, useMemo, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
import { Feature, ThinkingLevel, ReasoningEffort, ParsedTask } from '@/store/app-store';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
import { parseAgentContext, formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||||
@@ -10,6 +11,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { SummaryDialog } from './summary-dialog';
|
import { SummaryDialog } from './summary-dialog';
|
||||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||||
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
import { useFeature, useAgentOutput } from '@/hooks/queries';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats thinking level for compact display
|
* Formats thinking level for compact display
|
||||||
@@ -58,6 +60,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
summary,
|
summary,
|
||||||
isActivelyRunning,
|
isActivelyRunning,
|
||||||
}: AgentInfoPanelProps) {
|
}: AgentInfoPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
|
||||||
// Track real-time task status updates from WebSocket events
|
// Track real-time task status updates from WebSocket events
|
||||||
@@ -130,6 +133,25 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
pollingInterval,
|
pollingInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On mount, ensure feature and agent output queries are fresh.
|
||||||
|
// This handles the worktree switch scenario where cards unmount when filtered out
|
||||||
|
// and remount when the user switches back. Without this, the React Query cache
|
||||||
|
// may serve stale data (or no data) for the individual feature query, causing
|
||||||
|
// the todo list to appear empty until the next polling cycle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFetchData && projectPath && feature.id && !contextContent) {
|
||||||
|
// Invalidate both the single feature and agent output queries to trigger immediate refetch
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.single(projectPath, feature.id),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.agentOutput(projectPath, feature.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Only run on mount (feature.id and projectPath identify this specific card instance)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [feature.id, projectPath]);
|
||||||
|
|
||||||
// Parse agent output into agentInfo
|
// Parse agent output into agentInfo
|
||||||
const agentInfo = useMemo(() => {
|
const agentInfo = useMemo(() => {
|
||||||
if (contextContent) {
|
if (contextContent) {
|
||||||
@@ -305,9 +327,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
|
|
||||||
// Agent Info Panel for non-backlog cards
|
// Agent Info Panel for non-backlog cards
|
||||||
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
// Show panel if we have agentInfo OR planSpec.tasks (for spec/full mode)
|
||||||
|
// OR if the feature has effective todos from any source (handles initial mount after worktree switch)
|
||||||
|
// OR if the feature is actively running (ensures panel stays visible during execution)
|
||||||
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
// Note: hasPlanSpecTasks is already defined above and includes freshPlanSpec
|
||||||
// (The backlog case was already handled above and returned early)
|
// (The backlog case was already handled above and returned early)
|
||||||
if (agentInfo || hasPlanSpecTasks) {
|
if (agentInfo || hasPlanSpecTasks || effectiveTodos.length > 0 || isActivelyRunning) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 space-y-2 overflow-hidden">
|
<div className="mb-3 space-y-2 overflow-hidden">
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ interface AddFeatureDialogProps {
|
|||||||
* This is used when the "Default to worktree mode" setting is disabled.
|
* This is used when the "Default to worktree mode" setting is disabled.
|
||||||
*/
|
*/
|
||||||
forceCurrentBranchMode?: boolean;
|
forceCurrentBranchMode?: boolean;
|
||||||
|
/**
|
||||||
|
* Pre-filled title for the feature (e.g., from a GitHub issue).
|
||||||
|
*/
|
||||||
|
prefilledTitle?: string;
|
||||||
|
/**
|
||||||
|
* Pre-filled description for the feature (e.g., from a GitHub issue).
|
||||||
|
*/
|
||||||
|
prefilledDescription?: string;
|
||||||
|
/**
|
||||||
|
* Pre-filled category for the feature (e.g., 'From GitHub').
|
||||||
|
*/
|
||||||
|
prefilledCategory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +161,9 @@ export function AddFeatureDialog({
|
|||||||
projectPath,
|
projectPath,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
|
prefilledTitle,
|
||||||
|
prefilledDescription,
|
||||||
|
prefilledCategory,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
const isSpawnMode = !!parentFeature;
|
const isSpawnMode = !!parentFeature;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -211,6 +226,11 @@ export function AddFeatureDialog({
|
|||||||
wasOpenRef.current = open;
|
wasOpenRef.current = open;
|
||||||
|
|
||||||
if (justOpened) {
|
if (justOpened) {
|
||||||
|
// Initialize with prefilled values if provided, otherwise use defaults
|
||||||
|
setTitle(prefilledTitle || '');
|
||||||
|
setDescription(prefilledDescription || '');
|
||||||
|
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
|
||||||
// Otherwise, use the default branch
|
// Otherwise, use the default branch
|
||||||
@@ -254,6 +274,9 @@ export function AddFeatureDialog({
|
|||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
parentFeature,
|
parentFeature,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
|
prefilledTitle,
|
||||||
|
prefilledDescription,
|
||||||
|
prefilledCategory,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Clear requirePlanApproval when planning mode is skip or lite
|
// Clear requirePlanApproval when planning mode is skip or lite
|
||||||
|
|||||||
@@ -105,43 +105,106 @@ export function CreatePRDialog({
|
|||||||
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
const branchAheadCount = branchesData?.aheadCount ?? 0;
|
||||||
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
const needsPush = !branchHasRemote || branchAheadCount > 0 || !!worktree?.hasChanges;
|
||||||
|
|
||||||
// Filter out current worktree branch from the list
|
// Determine the active remote to scope branches to.
|
||||||
// When a target remote is selected, only show branches from that remote
|
// For multi-remote: use the selected target remote.
|
||||||
const branches = useMemo(() => {
|
// For single remote: automatically scope to that remote.
|
||||||
if (!branchesData?.branches) return [];
|
const activeRemote = useMemo(() => {
|
||||||
const allBranches = branchesData.branches
|
if (remotes.length === 1) return remotes[0].name;
|
||||||
.map((b) => b.name)
|
if (selectedTargetRemote) return selectedTargetRemote;
|
||||||
.filter((name) => name !== worktree?.branch);
|
return '';
|
||||||
|
}, [remotes, selectedTargetRemote]);
|
||||||
|
|
||||||
// If a target remote is selected and we have remote info with branches,
|
// Filter branches by the active remote and strip remote prefixes for display.
|
||||||
// only show that remote's branches (not branches from other remotes)
|
// Returns display names (e.g. "main") without remote prefix.
|
||||||
if (selectedTargetRemote) {
|
// Also builds a map from display name → full ref (e.g. "origin/main") for PR creation.
|
||||||
const targetRemoteInfo = remotes.find((r) => r.name === selectedTargetRemote);
|
const { branches, branchFullRefMap } = useMemo(() => {
|
||||||
if (targetRemoteInfo?.branches && targetRemoteInfo.branches.length > 0) {
|
if (!branchesData?.branches)
|
||||||
const targetBranchNames = new Set(targetRemoteInfo.branches);
|
return { branches: [], branchFullRefMap: new Map<string, string>() };
|
||||||
// Filter to only include branches that exist on the target remote
|
|
||||||
// Match both short names (e.g. "main") and prefixed names (e.g. "upstream/main")
|
const refMap = new Map<string, string>();
|
||||||
return allBranches.filter((name) => {
|
|
||||||
// Check if the branch name matches a target remote branch directly
|
// If we have an active remote with branch info from the remotes endpoint, use that as the source
|
||||||
if (targetBranchNames.has(name)) return true;
|
const activeRemoteInfo = activeRemote
|
||||||
// Check if it's a prefixed remote branch (e.g. "upstream/main")
|
? remotes.find((r) => r.name === activeRemote)
|
||||||
const prefix = `${selectedTargetRemote}/`;
|
: undefined;
|
||||||
if (name.startsWith(prefix) && targetBranchNames.has(name.slice(prefix.length)))
|
|
||||||
return true;
|
if (activeRemoteInfo?.branches && activeRemoteInfo.branches.length > 0) {
|
||||||
return false;
|
// Use the remote's branch list — these are already short names (e.g. "main")
|
||||||
|
const filteredBranches = activeRemoteInfo.branches
|
||||||
|
.filter((branchName) => {
|
||||||
|
// Exclude the current worktree branch
|
||||||
|
return branchName !== worktree?.branch;
|
||||||
|
})
|
||||||
|
.map((branchName) => {
|
||||||
|
// Map display name to full ref
|
||||||
|
const fullRef = `${activeRemote}/${branchName}`;
|
||||||
|
refMap.set(branchName, fullRef);
|
||||||
|
return branchName;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if no remote info available, use the branches from the branches endpoint
|
||||||
|
// Filter and strip prefixes
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const filteredBranches: string[] = [];
|
||||||
|
|
||||||
|
for (const b of branchesData.branches) {
|
||||||
|
// Skip the current worktree branch
|
||||||
|
if (b.name === worktree?.branch) continue;
|
||||||
|
|
||||||
|
if (b.isRemote) {
|
||||||
|
// Remote branch: check if it belongs to the active remote
|
||||||
|
const slashIndex = b.name.indexOf('/');
|
||||||
|
if (slashIndex === -1) continue;
|
||||||
|
|
||||||
|
const remoteName = b.name.substring(0, slashIndex);
|
||||||
|
const branchName = b.name.substring(slashIndex + 1);
|
||||||
|
|
||||||
|
// If we have an active remote, only include branches from that remote
|
||||||
|
if (activeRemote && remoteName !== activeRemote) continue;
|
||||||
|
|
||||||
|
// Strip the remote prefix for display
|
||||||
|
if (!seen.has(branchName)) {
|
||||||
|
seen.add(branchName);
|
||||||
|
filteredBranches.push(branchName);
|
||||||
|
refMap.set(branchName, b.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local branch — only include if it has a remote counterpart on the active remote
|
||||||
|
// or if no active remote is set (no remotes at all)
|
||||||
|
if (!activeRemote) {
|
||||||
|
if (!seen.has(b.name)) {
|
||||||
|
seen.add(b.name);
|
||||||
|
filteredBranches.push(b.name);
|
||||||
|
refMap.set(b.name, b.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// When active remote is set, skip local-only branches — the remote version
|
||||||
|
// will be included from the remote branches above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allBranches;
|
return { branches: filteredBranches, branchFullRefMap: refMap };
|
||||||
}, [branchesData?.branches, worktree?.branch, selectedTargetRemote, remotes]);
|
}, [branchesData?.branches, worktree?.branch, activeRemote, remotes]);
|
||||||
|
|
||||||
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
|
// When branches change (e.g. target remote changed), reset base branch if current selection is no longer valid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
|
if (branches.length > 0 && baseBranch && !branches.includes(baseBranch)) {
|
||||||
// Current base branch is not in the filtered list — pick the best match
|
// Current base branch is not in the filtered list — pick the best match
|
||||||
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
// Strip any existing remote prefix from the current base branch for comparison
|
||||||
setBaseBranch(mainBranch || branches[0]);
|
const strippedBaseBranch = baseBranch.includes('/')
|
||||||
|
? baseBranch.substring(baseBranch.indexOf('/') + 1)
|
||||||
|
: baseBranch;
|
||||||
|
|
||||||
|
// Check if the stripped version exists in the list
|
||||||
|
if (branches.includes(strippedBaseBranch)) {
|
||||||
|
setBaseBranch(strippedBaseBranch);
|
||||||
|
} else {
|
||||||
|
const mainBranch = branches.find((b) => b === 'main' || b === 'master');
|
||||||
|
setBaseBranch(mainBranch || branches[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [branches, baseBranch]);
|
}, [branches, baseBranch]);
|
||||||
|
|
||||||
@@ -234,7 +297,12 @@ export function CreatePRDialog({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
|
// Resolve the display name to the actual branch name for the API
|
||||||
|
const resolvedRef = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
|
const branchNameForApi = resolvedRef.includes('/')
|
||||||
|
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
|
||||||
|
: resolvedRef;
|
||||||
|
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.title) {
|
if (result.title) {
|
||||||
@@ -270,12 +338,24 @@ export function CreatePRDialog({
|
|||||||
setError('Worktree API not available');
|
setError('Worktree API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Resolve the display branch name to the full ref for the API call.
|
||||||
|
// The baseBranch state holds the display name (e.g. "main"), but the API
|
||||||
|
// may need the short name without the remote prefix. We pass the display name
|
||||||
|
// since the backend handles branch resolution. However, if the full ref is
|
||||||
|
// available, we can use it for more precise targeting.
|
||||||
|
const resolvedBaseBranch = branchFullRefMap.get(baseBranch) || baseBranch;
|
||||||
|
// Strip the remote prefix from the resolved ref for the API call
|
||||||
|
// (e.g. "origin/main" → "main") since the backend expects the branch name only
|
||||||
|
const baseBranchForApi = resolvedBaseBranch.includes('/')
|
||||||
|
? 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,
|
||||||
commitMessage: commitMessage || undefined,
|
commitMessage: commitMessage || undefined,
|
||||||
prTitle: title || worktree.branch,
|
prTitle: title || worktree.branch,
|
||||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||||
baseBranch,
|
baseBranch: baseBranchForApi,
|
||||||
draft: isDraft,
|
draft: isDraft,
|
||||||
remote: selectedRemote || undefined,
|
remote: selectedRemote || undefined,
|
||||||
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
targetRemote: remotes.length > 1 ? selectedTargetRemote || undefined : undefined,
|
||||||
@@ -626,9 +706,13 @@ export function CreatePRDialog({
|
|||||||
onChange={setBaseBranch}
|
onChange={setBaseBranch}
|
||||||
branches={branches}
|
branches={branches}
|
||||||
placeholder="Select base branch..."
|
placeholder="Select base branch..."
|
||||||
disabled={isLoadingBranches}
|
disabled={isLoadingBranches || isLoadingRemotes}
|
||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
emptyMessage="No matching branches found."
|
emptyMessage={
|
||||||
|
activeRemote
|
||||||
|
? `No branches found on remote "${activeRemote}".`
|
||||||
|
: 'No matching branches found.'
|
||||||
|
}
|
||||||
data-testid="base-branch-autocomplete"
|
data-testid="base-branch-autocomplete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
XCircle,
|
XCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Settings2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -54,6 +55,7 @@ import {
|
|||||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import type { TerminalScript } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
@@ -102,6 +104,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||||
@@ -128,6 +131,12 @@ interface WorktreeActionsDropdownProps {
|
|||||||
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
|
||||||
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
onContinueOperation?: (worktree: WorktreeInfo) => void;
|
||||||
hasInitScript: boolean;
|
hasInitScript: boolean;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeActionsDropdown({
|
export function WorktreeActionsDropdown({
|
||||||
@@ -166,6 +175,7 @@ export function WorktreeActionsDropdown({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
onStartDevServer,
|
onStartDevServer,
|
||||||
@@ -184,6 +194,9 @@ export function WorktreeActionsDropdown({
|
|||||||
onAbortOperation,
|
onAbortOperation,
|
||||||
onContinueOperation,
|
onContinueOperation,
|
||||||
hasInitScript,
|
hasInitScript,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeActionsDropdownProps) {
|
}: WorktreeActionsDropdownProps) {
|
||||||
// Get available editors for the "Open In" submenu
|
// Get available editors for the "Open In" submenu
|
||||||
const { editors } = useAvailableEditors();
|
const { editors } = useAvailableEditors();
|
||||||
@@ -358,19 +371,16 @@ export function WorktreeActionsDropdown({
|
|||||||
? 'Dev Server Starting...'
|
? 'Dev Server Starting...'
|
||||||
: `Dev Server Running (:${devServerInfo?.port})`}
|
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem
|
{devServerInfo?.urlDetected !== false && (
|
||||||
onClick={() => onOpenDevServerUrl(worktree)}
|
<DropdownMenuItem
|
||||||
className="text-xs"
|
onClick={() => onOpenDevServerUrl(worktree)}
|
||||||
disabled={devServerInfo?.urlDetected === false}
|
className="text-xs"
|
||||||
aria-label={
|
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||||
devServerInfo?.urlDetected === false
|
>
|
||||||
? 'Open dev server in browser'
|
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||||
: `Open dev server on port ${devServerInfo?.port} in browser`
|
Open in Browser
|
||||||
}
|
</DropdownMenuItem>
|
||||||
>
|
)}
|
||||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
|
||||||
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
|
||||||
@@ -575,12 +585,52 @@ export function WorktreeActionsDropdown({
|
|||||||
})}
|
})}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
{!worktree.isMain && hasInitScript && (
|
{/* Scripts submenu - consolidates init script and terminal quick scripts */}
|
||||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
<DropdownMenuSub>
|
||||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
<DropdownMenuSubTrigger className="text-xs">
|
||||||
Re-run Init Script
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
</DropdownMenuItem>
|
Scripts
|
||||||
)}
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent className="w-52">
|
||||||
|
{/* Re-run Init Script - always shown for non-main worktrees, disabled when no init script configured */}
|
||||||
|
{!worktree.isMain && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onRunInitScript(worktree)}
|
||||||
|
className="text-xs"
|
||||||
|
disabled={!hasInitScript}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Re-run Init Script
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Terminal quick scripts */}
|
||||||
|
{terminalScripts && terminalScripts.length > 0 ? (
|
||||||
|
terminalScripts.map((script) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={script.id}
|
||||||
|
onClick={() => onRunTerminalScript?.(worktree, script.command)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 mr-2 shrink-0" />
|
||||||
|
<span className="truncate">{script.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
|
||||||
|
No scripts configured
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{/* Divider before Edit Commands & Scripts */}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onEditScripts?.()} className="text-xs">
|
||||||
|
<Settings2 className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Edit Commands & Scripts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
{remotes && remotes.length > 1 && onPullWithRemote ? (
|
{remotes && remotes.length > 1 && onPullWithRemote ? (
|
||||||
@@ -815,32 +865,67 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
{/* View Commits - split button when Cherry Pick is available:
|
||||||
<DropdownMenuItem
|
click main area to view commits directly, chevron opens sub-menu with Cherry Pick */}
|
||||||
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
{onCherryPick ? (
|
||||||
disabled={!isGitOpsAvailable}
|
<DropdownMenuSub>
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
<TooltipWrapper
|
||||||
>
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
<History className="w-3.5 h-3.5 mr-2" />
|
tooltipContent={gitOpsDisabledReason}
|
||||||
View Commits
|
>
|
||||||
{!isGitOpsAvailable && (
|
<div className="flex items-center">
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
{/* Main clickable area - opens commit history directly */}
|
||||||
)}
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||||
</TooltipWrapper>
|
disabled={!isGitOpsAvailable}
|
||||||
{/* Cherry-pick commits from another branch */}
|
className={cn(
|
||||||
{onCherryPick && (
|
'text-xs flex-1 pr-0 rounded-r-none',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<History className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Commits
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* Chevron trigger for sub-menu containing Cherry Pick */}
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{/* Cherry-pick commits from another branch */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
||||||
|
disabled={!isGitOpsAvailable}
|
||||||
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<Cherry className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Cherry Pick
|
||||||
|
{!isGitOpsAvailable && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
) : (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
showTooltip={!!gitOpsDisabledReason}
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
tooltipContent={gitOpsDisabledReason}
|
tooltipContent={gitOpsDisabledReason}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => isGitOpsAvailable && onCherryPick(worktree)}
|
onClick={() => isGitOpsAvailable && onViewCommits(worktree)}
|
||||||
disabled={!isGitOpsAvailable}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Cherry className="w-3.5 h-3.5 mr-2" />
|
<History className="w-3.5 h-3.5 mr-2" />
|
||||||
Cherry Pick
|
View Commits
|
||||||
{!isGitOpsAvailable && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
@@ -849,80 +934,65 @@ export function WorktreeActionsDropdown({
|
|||||||
)}
|
)}
|
||||||
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
{(hasChangesSectionContent || hasDestructiveSectionContent) && <DropdownMenuSeparator />}
|
||||||
|
|
||||||
{worktree.hasChanges && (
|
{/* View Changes split button - main action views changes directly, chevron reveals stash options.
|
||||||
<DropdownMenuItem onClick={() => onViewChanges(worktree)} className="text-xs">
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Changes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{/* Stash operations - combined submenu or simple item.
|
|
||||||
Only render when at least one action is meaningful:
|
Only render when at least one action is meaningful:
|
||||||
- (worktree.hasChanges && onStashChanges): stashing changes is possible
|
- worktree.hasChanges: View Changes action is available
|
||||||
- onViewStashes: viewing existing stashes is possible
|
- (worktree.hasChanges && onStashChanges): Create Stash action is possible
|
||||||
Without this guard, the item would appear clickable but be a silent no-op
|
- onViewStashes: viewing existing stashes is possible */}
|
||||||
when hasChanges is false and onViewStashes is undefined. */}
|
{(worktree.hasChanges || onViewStashes) && (
|
||||||
{((worktree.hasChanges && onStashChanges) || onViewStashes) && (
|
<DropdownMenuSub>
|
||||||
<TooltipWrapper showTooltip={!isGitOpsAvailable} tooltipContent={gitOpsDisabledReason}>
|
<div className="flex items-center">
|
||||||
{onViewStashes && worktree.hasChanges && onStashChanges ? (
|
{/* Main clickable area - view changes (primary action) */}
|
||||||
// Both "Stash Changes" (primary) and "View Stashes" (secondary) are available - show split submenu
|
{worktree.hasChanges ? (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuItem
|
||||||
<div className="flex items-center">
|
onClick={() => onViewChanges(worktree)}
|
||||||
{/* Main clickable area - stash changes (primary action) */}
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
|
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 */}
|
||||||
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
{worktree.hasChanges && onStashChanges && (
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!isGitOpsAvailable}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isGitOpsAvailable) return;
|
if (!isGitOpsAvailable) return;
|
||||||
onStashChanges(worktree);
|
onStashChanges(worktree);
|
||||||
}}
|
}}
|
||||||
disabled={!isGitOpsAvailable}
|
disabled={!isGitOpsAvailable}
|
||||||
className={cn(
|
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
||||||
'text-xs flex-1 pr-0 rounded-r-none',
|
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
<Archive className="w-3.5 h-3.5 mr-2" />
|
||||||
Stash Changes
|
Create Stash
|
||||||
{!isGitOpsAvailable && (
|
{!isGitOpsAvailable && (
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{/* Chevron trigger for submenu with stash options */}
|
</TooltipWrapper>
|
||||||
<DropdownMenuSubTrigger
|
)}
|
||||||
className={cn(
|
{onViewStashes && (
|
||||||
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
||||||
!isGitOpsAvailable && 'opacity-50 cursor-not-allowed'
|
<Eye className="w-3.5 h-3.5 mr-2" />
|
||||||
)}
|
View Stashes
|
||||||
disabled={!isGitOpsAvailable}
|
</DropdownMenuItem>
|
||||||
/>
|
)}
|
||||||
</div>
|
</DropdownMenuSubContent>
|
||||||
<DropdownMenuSubContent>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
|
|
||||||
<Eye className="w-3.5 h-3.5 mr-2" />
|
|
||||||
View Stashes
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
) : (
|
|
||||||
// Only one action is meaningful - render a simple menu item without submenu
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
if (!isGitOpsAvailable) return;
|
|
||||||
if (worktree.hasChanges && onStashChanges) {
|
|
||||||
onStashChanges(worktree);
|
|
||||||
} else if (onViewStashes) {
|
|
||||||
onViewStashes(worktree);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isGitOpsAvailable}
|
|
||||||
className={cn('text-xs', !isGitOpsAvailable && 'opacity-50 cursor-not-allowed')}
|
|
||||||
>
|
|
||||||
<Archive className="w-3.5 h-3.5 mr-2" />
|
|
||||||
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
|
|
||||||
{!isGitOpsAvailable && (
|
|
||||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</TooltipWrapper>
|
|
||||||
)}
|
)}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
@@ -961,43 +1031,69 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info and Address Comments button if PR exists */}
|
{/* Show PR info with Address Comments in sub-menu if PR exists */}
|
||||||
{showPRInfo && worktree.pr && (
|
{showPRInfo && worktree.pr && (
|
||||||
<>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuItem
|
<div className="flex items-center">
|
||||||
onClick={() => {
|
{/* Main clickable area - opens PR in browser */}
|
||||||
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
<DropdownMenuItem
|
||||||
}}
|
onClick={() => {
|
||||||
className="text-xs"
|
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
|
||||||
>
|
}}
|
||||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
PR #{worktree.pr.number}
|
>
|
||||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||||
{worktree.pr.state}
|
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">
|
||||||
</DropdownMenuItem>
|
{worktree.pr.state}
|
||||||
<DropdownMenuItem
|
</span>
|
||||||
onClick={() => {
|
</DropdownMenuItem>
|
||||||
// Convert stored PR info to the full PRInfo format for the handler
|
{/* Chevron trigger for submenu with PR actions */}
|
||||||
// The handler will fetch full comments from GitHub
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
const prInfo: PRInfo = {
|
</div>
|
||||||
number: worktree.pr!.number,
|
<DropdownMenuSubContent>
|
||||||
title: worktree.pr!.title,
|
<DropdownMenuItem
|
||||||
url: worktree.pr!.url,
|
onClick={() => {
|
||||||
state: worktree.pr!.state,
|
// Convert stored PR info to the full PRInfo format for the handler
|
||||||
author: '', // Will be fetched
|
// The handler will fetch full comments from GitHub
|
||||||
body: '', // Will be fetched
|
const prInfo: PRInfo = {
|
||||||
comments: [],
|
number: worktree.pr!.number,
|
||||||
reviewComments: [],
|
title: worktree.pr!.title,
|
||||||
};
|
url: worktree.pr!.url,
|
||||||
onAddressPRComments(worktree, prInfo);
|
state: worktree.pr!.state,
|
||||||
}}
|
author: '', // Will be fetched
|
||||||
className="text-xs text-blue-500 focus:text-blue-600"
|
body: '', // Will be fetched
|
||||||
>
|
comments: [],
|
||||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
reviewComments: [],
|
||||||
Address PR Comments
|
};
|
||||||
</DropdownMenuItem>
|
onAddressPRComments(worktree, prInfo);
|
||||||
</>
|
}}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Manage PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Zap className="w-3.5 h-3.5 mr-2" />
|
||||||
|
Address PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
{hasChangesSectionContent && hasDestructiveSectionContent && <DropdownMenuSeparator />}
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export function WorktreeDropdownItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator */}
|
{/* Dev server indicator - only shown when port is confirmed detected */}
|
||||||
{devServerRunning && (
|
{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"
|
||||||
title={`Dev server running on port ${devServerInfo?.port}`}
|
title={`Dev server running on port ${devServerInfo?.port}`}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export interface WorktreeDropdownProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
@@ -131,6 +132,12 @@ export interface WorktreeDropdownProps {
|
|||||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,6 +206,7 @@ export function WorktreeDropdown({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
@@ -219,6 +227,9 @@ export function WorktreeDropdown({
|
|||||||
remotesCache,
|
remotesCache,
|
||||||
onPullWithRemote,
|
onPullWithRemote,
|
||||||
onPushWithRemote,
|
onPushWithRemote,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeDropdownProps) {
|
}: WorktreeDropdownProps) {
|
||||||
// Find the currently selected worktree to display in the trigger
|
// Find the currently selected worktree to display in the trigger
|
||||||
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
|
||||||
@@ -304,15 +315,11 @@ export function WorktreeDropdown({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dev server indicator */}
|
{/* Dev server indicator - only shown when port is confirmed detected */}
|
||||||
{selectedStatus.devServerRunning && (
|
{selectedStatus.devServerRunning && selectedStatus.devServerInfo?.urlDetected !== false && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||||
title={
|
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||||
selectedStatus.devServerInfo?.urlDetected === false
|
|
||||||
? 'Dev server starting...'
|
|
||||||
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
@@ -520,6 +527,7 @@ export function WorktreeDropdown({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -538,6 +546,9 @@ export function WorktreeDropdown({
|
|||||||
onAbortOperation={onAbortOperation}
|
onAbortOperation={onAbortOperation}
|
||||||
onContinueOperation={onContinueOperation}
|
onContinueOperation={onContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
|
onEditScripts={onEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ interface WorktreeTabProps {
|
|||||||
onCommit: (worktree: WorktreeInfo) => void;
|
onCommit: (worktree: WorktreeInfo) => void;
|
||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onMerge: (worktree: WorktreeInfo) => void;
|
onMerge: (worktree: WorktreeInfo) => void;
|
||||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||||
@@ -101,6 +102,12 @@ interface WorktreeTabProps {
|
|||||||
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPullWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
/** Push to a specific remote, bypassing the remote selection dialog */
|
/** Push to a specific remote, bypassing the remote selection dialog */
|
||||||
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
onPushWithRemote?: (worktree: WorktreeInfo, remote: string) => void;
|
||||||
|
/** Terminal quick scripts configured for the project */
|
||||||
|
terminalScripts?: import('@/components/views/project-settings-view/terminal-scripts-constants').TerminalScript[];
|
||||||
|
/** Callback to run a terminal quick script in a new terminal session */
|
||||||
|
onRunTerminalScript?: (worktree: WorktreeInfo, command: string) => void;
|
||||||
|
/** Callback to open the script editor UI */
|
||||||
|
onEditScripts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorktreeTab({
|
export function WorktreeTab({
|
||||||
@@ -148,6 +155,7 @@ export function WorktreeTab({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCreatePR,
|
onCreatePR,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onMerge,
|
onMerge,
|
||||||
onDeleteWorktree,
|
onDeleteWorktree,
|
||||||
@@ -170,6 +178,9 @@ export function WorktreeTab({
|
|||||||
remotes,
|
remotes,
|
||||||
onPullWithRemote,
|
onPullWithRemote,
|
||||||
onPushWithRemote,
|
onPushWithRemote,
|
||||||
|
terminalScripts,
|
||||||
|
onRunTerminalScript,
|
||||||
|
onEditScripts,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
// Make the worktree tab a drop target for feature cards
|
// Make the worktree tab a drop target for feature cards
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
@@ -440,7 +451,7 @@ export function WorktreeTab({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDevServerRunning && (
|
{isDevServerRunning && devServerInfo?.urlDetected !== false && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -517,6 +528,7 @@ export function WorktreeTab({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={onMerge}
|
onMerge={onMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -535,6 +547,9 @@ export function WorktreeTab({
|
|||||||
onAbortOperation={onAbortOperation}
|
onAbortOperation={onAbortOperation}
|
||||||
onContinueOperation={onContinueOperation}
|
onContinueOperation={onContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={onRunTerminalScript}
|
||||||
|
onEditScripts={onEditScripts}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { normalizePath } from '@/lib/utils';
|
import { normalizePath } from '@/lib/utils';
|
||||||
@@ -11,10 +11,32 @@ interface UseDevServersOptions {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to build the browser-accessible dev server URL by rewriting the hostname
|
||||||
|
* to match the current window's hostname (supports remote access).
|
||||||
|
* Returns null if the URL is invalid or uses an unsupported protocol.
|
||||||
|
*/
|
||||||
|
function buildDevServerBrowserUrl(serverUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const devServerUrl = new URL(serverUrl);
|
||||||
|
// Security: Only allow http/https protocols
|
||||||
|
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
devServerUrl.hostname = window.location.hostname;
|
||||||
|
return devServerUrl.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||||
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
|
||||||
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
const [runningDevServers, setRunningDevServers] = useState<Map<string, DevServerInfo>>(new Map());
|
||||||
|
|
||||||
|
// Track which worktrees have had their url-detected toast shown to prevent re-triggering
|
||||||
|
const toastShownForRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const fetchDevServers = useCallback(async () => {
|
const fetchDevServers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -25,10 +47,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
if (result.success && result.result?.servers) {
|
if (result.success && result.result?.servers) {
|
||||||
const serversMap = new Map<string, DevServerInfo>();
|
const serversMap = new Map<string, DevServerInfo>();
|
||||||
for (const server of result.result.servers) {
|
for (const server of result.result.servers) {
|
||||||
serversMap.set(normalizePath(server.worktreePath), {
|
const key = normalizePath(server.worktreePath);
|
||||||
|
serversMap.set(key, {
|
||||||
...server,
|
...server,
|
||||||
urlDetected: server.urlDetected ?? true,
|
urlDetected: server.urlDetected ?? true,
|
||||||
});
|
});
|
||||||
|
// Mark already-detected servers as having shown the toast
|
||||||
|
// so we don't re-trigger on initial load
|
||||||
|
if (server.urlDetected !== false) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setRunningDevServers(serversMap);
|
setRunningDevServers(serversMap);
|
||||||
}
|
}
|
||||||
@@ -41,7 +69,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
fetchDevServers();
|
fetchDevServers();
|
||||||
}, [fetchDevServers]);
|
}, [fetchDevServers]);
|
||||||
|
|
||||||
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
|
// Subscribe to all dev server lifecycle events for reactive state updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.worktree?.onDevServerLogEvent) return;
|
if (!api?.worktree?.onDevServerLogEvent) return;
|
||||||
@@ -54,6 +82,8 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const existing = prev.get(key);
|
const existing = prev.get(key);
|
||||||
if (!existing) return prev;
|
if (!existing) return prev;
|
||||||
|
// Avoid updating if already detected with same url/port
|
||||||
|
if (existing.urlDetected && existing.url === url && existing.port === port) return prev;
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(key, {
|
next.set(key, {
|
||||||
...existing,
|
...existing,
|
||||||
@@ -66,8 +96,53 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
});
|
});
|
||||||
if (didUpdate) {
|
if (didUpdate) {
|
||||||
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
||||||
toast.success(`Dev server running on port ${port}`);
|
// Only show toast on the transition from undetected → detected (not on re-renders/polls)
|
||||||
|
if (!toastShownForRef.current.has(key)) {
|
||||||
|
toastShownForRef.current.add(key);
|
||||||
|
const browserUrl = buildDevServerBrowserUrl(url);
|
||||||
|
toast.success(`Dev server running on port ${port}`, {
|
||||||
|
description: browserUrl ? browserUrl : url,
|
||||||
|
action: browserUrl
|
||||||
|
? {
|
||||||
|
label: 'Open in Browser',
|
||||||
|
onClick: () => {
|
||||||
|
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'dev-server:stopped') {
|
||||||
|
// Reactively remove the server from state when it stops
|
||||||
|
const { worktreePath } = event.payload;
|
||||||
|
const key = normalizePath(worktreePath);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Clear the toast tracking so a fresh detection will show a new toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
logger.info(`Dev server stopped for ${worktreePath} (reactive update)`);
|
||||||
|
} else if (event.type === 'dev-server:started') {
|
||||||
|
// Reactively add/update the server when it starts
|
||||||
|
const { worktreePath, port, url } = event.payload;
|
||||||
|
const key = normalizePath(worktreePath);
|
||||||
|
// Clear previous toast tracking for this key so a new detection triggers a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
|
setRunningDevServers((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(key, {
|
||||||
|
worktreePath,
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
urlDetected: false,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,9 +173,12 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
const result = await api.worktree.startDevServer(projectPath, targetPath);
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
|
const key = normalizePath(targetPath);
|
||||||
|
// Clear toast tracking so the new port detection shows a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(normalizePath(targetPath), {
|
next.set(key, {
|
||||||
worktreePath: result.result!.worktreePath,
|
worktreePath: result.result!.worktreePath,
|
||||||
port: result.result!.port,
|
port: result.result!.port,
|
||||||
url: result.result!.url,
|
url: result.result!.url,
|
||||||
@@ -135,11 +213,14 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
const result = await api.worktree.stopDevServer(targetPath);
|
const result = await api.worktree.stopDevServer(targetPath);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
const key = normalizePath(targetPath);
|
||||||
setRunningDevServers((prev) => {
|
setRunningDevServers((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.delete(normalizePath(targetPath));
|
next.delete(key);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Clear toast tracking so future restarts get a fresh toast
|
||||||
|
toastShownForRef.current.delete(key);
|
||||||
toast.success(result.result?.message || 'Dev server stopped');
|
toast.success(result.result?.message || 'Dev server stopped');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to stop dev server');
|
toast.error(result.error || 'Failed to stop dev server');
|
||||||
@@ -163,30 +244,16 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const browserUrl = buildDevServerBrowserUrl(serverInfo.url);
|
||||||
// Rewrite URL hostname to match the current browser's hostname.
|
if (!browserUrl) {
|
||||||
// This ensures dev server URLs work when accessing Automaker from
|
logger.error('Invalid dev server URL:', serverInfo.url);
|
||||||
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
|
toast.error('Invalid dev server URL', {
|
||||||
const devServerUrl = new URL(serverInfo.url);
|
description: 'The server returned an unsupported URL protocol.',
|
||||||
|
|
||||||
// Security: Only allow http/https protocols to prevent potential attacks
|
|
||||||
// via data:, javascript:, file:, or other dangerous URL schemes
|
|
||||||
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
|
|
||||||
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
|
|
||||||
toast.error('Invalid dev server URL', {
|
|
||||||
description: 'The server returned an unsupported URL protocol.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
devServerUrl.hostname = window.location.hostname;
|
|
||||||
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to parse dev server URL:', error);
|
|
||||||
toast.error('Failed to open dev server', {
|
|
||||||
description: 'The server URL could not be processed. Please try again.',
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.open(browserUrl, '_blank', 'noopener,noreferrer');
|
||||||
},
|
},
|
||||||
[runningDevServers, getWorktreeKey]
|
[runningDevServers, getWorktreeKey]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,6 +163,24 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRunTerminalScript = useCallback(
|
||||||
|
(worktree: WorktreeInfo, command: string) => {
|
||||||
|
// Navigate to the terminal view with the worktree path, branch, and command to run
|
||||||
|
// The terminal view will create a new terminal and automatically execute the command
|
||||||
|
navigate({
|
||||||
|
to: '/terminal',
|
||||||
|
search: {
|
||||||
|
cwd: worktree.path,
|
||||||
|
branch: worktree.branch,
|
||||||
|
mode: 'tab' as const,
|
||||||
|
nonce: Date.now(),
|
||||||
|
command,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenInEditor = useCallback(
|
const handleOpenInEditor = useCallback(
|
||||||
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||||
openInEditorMutation.mutate({
|
openInEditorMutation.mutate({
|
||||||
@@ -204,6 +222,7 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
|||||||
handlePull,
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
// Stash confirmation state for branch switching
|
// Stash confirmation state for branch switching
|
||||||
|
|||||||
@@ -90,8 +90,16 @@ export function useWorktrees({
|
|||||||
const handleSelectWorktree = useCallback(
|
const handleSelectWorktree = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
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.
|
||||||
|
// Without this, feature cards that remount after the worktree switch may have stale
|
||||||
|
// or missing planSpec/task data, causing todo lists to appear empty until the next
|
||||||
|
// polling cycle or user interaction triggers a re-render.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(projectPath),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[projectPath, setCurrentWorktree]
|
[projectPath, setCurrentWorktree, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
// fetchWorktrees for backward compatibility - now just triggers a refetch
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export interface WorktreePanelProps {
|
|||||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
|
onAutoAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||||
/** Called when branch switch stash reapply results in merge conflicts */
|
/** Called when branch switch stash reapply results in merge conflicts */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
@@ -9,6 +10,7 @@ import { useIsMobile } from '@/hooks/use-media-query';
|
|||||||
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
import { useWorktreeInitScript, useProjectSettings } from '@/hooks/queries';
|
||||||
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
import { useTestRunnerEvents } from '@/hooks/use-test-runners';
|
||||||
import { useTestRunnersStore } from '@/store/test-runners-store';
|
import { useTestRunnersStore } from '@/store/test-runners-store';
|
||||||
|
import { DEFAULT_TERMINAL_SCRIPTS } from '@/components/views/project-settings-view/terminal-scripts-constants';
|
||||||
import type {
|
import type {
|
||||||
TestRunnerStartedEvent,
|
TestRunnerStartedEvent,
|
||||||
TestRunnerOutputEvent,
|
TestRunnerOutputEvent,
|
||||||
@@ -59,6 +61,7 @@ export function WorktreePanel({
|
|||||||
onCreatePR,
|
onCreatePR,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onAddressPRComments,
|
onAddressPRComments,
|
||||||
|
onAutoAddressPRComments,
|
||||||
onResolveConflicts,
|
onResolveConflicts,
|
||||||
onCreateMergeConflictResolutionFeature,
|
onCreateMergeConflictResolutionFeature,
|
||||||
onBranchSwitchConflict,
|
onBranchSwitchConflict,
|
||||||
@@ -116,6 +119,7 @@ export function WorktreePanel({
|
|||||||
handlePull: _handlePull,
|
handlePull: _handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleOpenInIntegratedTerminal,
|
handleOpenInIntegratedTerminal,
|
||||||
|
handleRunTerminalScript,
|
||||||
handleOpenInEditor,
|
handleOpenInEditor,
|
||||||
handleOpenInExternalTerminal,
|
handleOpenInExternalTerminal,
|
||||||
pendingSwitch,
|
pendingSwitch,
|
||||||
@@ -209,6 +213,21 @@ export function WorktreePanel({
|
|||||||
const { data: projectSettings } = useProjectSettings(projectPath);
|
const { data: projectSettings } = useProjectSettings(projectPath);
|
||||||
const hasTestCommand = !!projectSettings?.testCommand;
|
const hasTestCommand = !!projectSettings?.testCommand;
|
||||||
|
|
||||||
|
// Get terminal quick scripts from project settings (or fall back to defaults)
|
||||||
|
const terminalScripts = useMemo(() => {
|
||||||
|
const configured = projectSettings?.terminalScripts;
|
||||||
|
if (configured && configured.length > 0) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return DEFAULT_TERMINAL_SCRIPTS;
|
||||||
|
}, [projectSettings?.terminalScripts]);
|
||||||
|
|
||||||
|
// Navigate to project settings to edit scripts
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleEditScripts = useCallback(() => {
|
||||||
|
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Test runner state management
|
// Test runner state management
|
||||||
// Use the test runners store to get global state for all worktrees
|
// Use the test runners store to get global state for all worktrees
|
||||||
const testRunnersStore = useTestRunnersStore();
|
const testRunnersStore = useTestRunnersStore();
|
||||||
@@ -914,6 +933,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -932,6 +952,9 @@ export function WorktreePanel({
|
|||||||
onAbortOperation={handleAbortOperation}
|
onAbortOperation={handleAbortOperation}
|
||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1154,6 +1177,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -1171,6 +1195,9 @@ export function WorktreePanel({
|
|||||||
onCherryPick={handleCherryPick}
|
onCherryPick={handleCherryPick}
|
||||||
onAbortOperation={handleAbortOperation}
|
onAbortOperation={handleAbortOperation}
|
||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{useWorktreesEnabled && (
|
{useWorktreesEnabled && (
|
||||||
@@ -1257,6 +1284,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -1276,6 +1304,9 @@ export function WorktreePanel({
|
|||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
hasTestCommand={hasTestCommand}
|
hasTestCommand={hasTestCommand}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1340,6 +1371,7 @@ export function WorktreePanel({
|
|||||||
onCommit={onCommit}
|
onCommit={onCommit}
|
||||||
onCreatePR={onCreatePR}
|
onCreatePR={onCreatePR}
|
||||||
onAddressPRComments={onAddressPRComments}
|
onAddressPRComments={onAddressPRComments}
|
||||||
|
onAutoAddressPRComments={onAutoAddressPRComments}
|
||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
@@ -1359,6 +1391,9 @@ export function WorktreePanel({
|
|||||||
onContinueOperation={handleContinueOperation}
|
onContinueOperation={handleContinueOperation}
|
||||||
hasInitScript={hasInitScript}
|
hasInitScript={hasInitScript}
|
||||||
hasTestCommand={hasTestCommand}
|
hasTestCommand={hasTestCommand}
|
||||||
|
terminalScripts={terminalScripts}
|
||||||
|
onRunTerminalScript={handleRunTerminalScript}
|
||||||
|
onEditScripts={handleEditScripts}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|||||||
import { LoadingState } from '@/components/ui/loading-state';
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
import { ErrorState } from '@/components/ui/error-state';
|
import { ErrorState } from '@/components/ui/error-state';
|
||||||
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
|
||||||
|
import { useIsMobile } from '@/hooks/use-media-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
|
||||||
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
|
||||||
import { ValidationDialog } from './github-issues-view/dialogs';
|
import { ValidationDialog } from './github-issues-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 { useModelOverride } from '@/components/shared';
|
import { useModelOverride } from '@/components/shared';
|
||||||
import type {
|
import type {
|
||||||
@@ -34,15 +36,22 @@ export function GitHubIssuesView() {
|
|||||||
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
|
||||||
useState<ValidateIssueOptions | null>(null);
|
useState<ValidateIssueOptions | null>(null);
|
||||||
|
|
||||||
|
// Add Feature dialog state
|
||||||
|
const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false);
|
||||||
|
const [createFeatureIssue, setCreateFeatureIssue] = useState<GitHubIssue | null>(null);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
|
||||||
|
|
||||||
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
|
const { currentProject, getCurrentWorktree, worktreesByProject, defaultSkipTests } =
|
||||||
|
useAppStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Model override for validation
|
// Model override for validation
|
||||||
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
|
||||||
|
|
||||||
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
|
||||||
@@ -108,6 +117,118 @@ export function GitHubIssuesView() {
|
|||||||
api.openExternalLink(url);
|
api.openExternalLink(url);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Build a prefilled description from a GitHub issue for the feature dialog
|
||||||
|
const buildIssueDescription = useCallback(
|
||||||
|
(issue: GitHubIssue) => {
|
||||||
|
const parts = [
|
||||||
|
`**From GitHub Issue #${issue.number}**`,
|
||||||
|
'',
|
||||||
|
issue.body || 'No description provided.',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include labels if present
|
||||||
|
if (issue.labels.length > 0) {
|
||||||
|
parts.push('', `**Labels:** ${issue.labels.map((l) => l.name).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include linked PRs info if present
|
||||||
|
if (issue.linkedPRs && issue.linkedPRs.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
'',
|
||||||
|
'**Linked Pull Requests:**',
|
||||||
|
...issue.linkedPRs.map((pr) => `- #${pr.number}: ${pr.title} (${pr.state})`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include cached validation analysis if available
|
||||||
|
const cached = cachedValidations.get(issue.number);
|
||||||
|
if (cached?.result) {
|
||||||
|
const validation = cached.result;
|
||||||
|
parts.push('', '---', '', '**AI Validation Analysis:**', validation.reasoning);
|
||||||
|
if (validation.suggestedFix) {
|
||||||
|
parts.push('', `**Suggested Approach:**`, validation.suggestedFix);
|
||||||
|
}
|
||||||
|
if (validation.relatedFiles?.length) {
|
||||||
|
parts.push('', '**Related Files:**', ...validation.relatedFiles.map((f) => `- \`${f}\``));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.filter(Boolean).join('\n');
|
||||||
|
},
|
||||||
|
[cachedValidations]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the Add Feature dialog with pre-filled data from a GitHub issue
|
||||||
|
const handleCreateFeature = useCallback((issue: GitHubIssue) => {
|
||||||
|
setCreateFeatureIssue(issue);
|
||||||
|
setShowAddFeatureDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle feature creation from the AddFeatureDialog
|
||||||
|
const handleAddFeatureFromIssue = useCallback(
|
||||||
|
async (featureData: {
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
priority: number;
|
||||||
|
model: string;
|
||||||
|
thinkingLevel: string;
|
||||||
|
reasoningEffort: string;
|
||||||
|
skipTests: boolean;
|
||||||
|
branchName: string;
|
||||||
|
planningMode: string;
|
||||||
|
requirePlanApproval: boolean;
|
||||||
|
workMode: string;
|
||||||
|
}) => {
|
||||||
|
if (!currentProject?.path) {
|
||||||
|
toast.error('No project selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api.features?.create) {
|
||||||
|
const feature = {
|
||||||
|
id: `issue-${createFeatureIssue?.number || 'new'}-${generateUUID()}`,
|
||||||
|
title: featureData.title,
|
||||||
|
description: featureData.description,
|
||||||
|
category: featureData.category,
|
||||||
|
status: 'backlog' as const,
|
||||||
|
passes: false,
|
||||||
|
priority: featureData.priority,
|
||||||
|
model: featureData.model,
|
||||||
|
thinkingLevel: featureData.thinkingLevel,
|
||||||
|
reasoningEffort: featureData.reasoningEffort,
|
||||||
|
skipTests: featureData.skipTests,
|
||||||
|
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
||||||
|
planningMode: featureData.planningMode,
|
||||||
|
requirePlanApproval: featureData.requirePlanApproval,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await api.features.create(currentProject.path, feature);
|
||||||
|
if (result.success) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.features.all(currentProject.path),
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
`Created feature: ${featureData.title || featureData.description.slice(0, 50)}`
|
||||||
|
);
|
||||||
|
setShowAddFeatureDialog(false);
|
||||||
|
setCreateFeatureIssue(null);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Failed to create feature');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Create feature from issue error:', err);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create feature');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject?.path, currentBranch, queryClient, createFeatureIssue]
|
||||||
|
);
|
||||||
|
|
||||||
const handleConvertToTask = useCallback(
|
const handleConvertToTask = useCallback(
|
||||||
async (issue: GitHubIssue, validation: IssueValidationResult) => {
|
async (issue: GitHubIssue, validation: IssueValidationResult) => {
|
||||||
if (!currentProject?.path) {
|
if (!currentProject?.path) {
|
||||||
@@ -185,11 +306,12 @@ export function GitHubIssuesView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Issues List */}
|
{/* Issues List - hidden on mobile when an issue is selected */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col overflow-hidden border-r border-border',
|
'flex flex-col overflow-hidden border-r border-border',
|
||||||
selectedIssue ? 'w-80' : 'flex-1'
|
selectedIssue ? 'w-80' : 'flex-1',
|
||||||
|
isMobile && selectedIssue && 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -296,8 +418,10 @@ export function GitHubIssuesView() {
|
|||||||
setPendingRevalidateOptions(options);
|
setPendingRevalidateOptions(options);
|
||||||
setShowRevalidateConfirm(true);
|
setShowRevalidateConfirm(true);
|
||||||
}}
|
}}
|
||||||
|
onCreateFeature={handleCreateFeature}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
modelOverride={validationModelOverride}
|
modelOverride={validationModelOverride}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -310,6 +434,30 @@ export function GitHubIssuesView() {
|
|||||||
onConvertToTask={handleConvertToTask}
|
onConvertToTask={handleConvertToTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Feature Dialog - opened from issue detail panel */}
|
||||||
|
<AddFeatureDialog
|
||||||
|
open={showAddFeatureDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setShowAddFeatureDialog(open);
|
||||||
|
if (!open) {
|
||||||
|
setCreateFeatureIssue(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAdd={handleAddFeatureFromIssue}
|
||||||
|
categorySuggestions={['From GitHub']}
|
||||||
|
branchSuggestions={[]}
|
||||||
|
defaultSkipTests={defaultSkipTests}
|
||||||
|
defaultBranch={currentBranch}
|
||||||
|
currentBranch={currentBranch || undefined}
|
||||||
|
isMaximized={false}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
|
prefilledTitle={createFeatureIssue?.title}
|
||||||
|
prefilledDescription={
|
||||||
|
createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined
|
||||||
|
}
|
||||||
|
prefilledCategory="From GitHub"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Revalidate Confirmation Dialog */}
|
{/* Revalidate Confirmation Dialog */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showRevalidateConfirm}
|
open={showRevalidateConfirm}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -34,8 +36,10 @@ export function IssueDetailPanel({
|
|||||||
onOpenInGitHub,
|
onOpenInGitHub,
|
||||||
onClose,
|
onClose,
|
||||||
onShowRevalidateConfirm,
|
onShowRevalidateConfirm,
|
||||||
|
onCreateFeature,
|
||||||
formatDate,
|
formatDate,
|
||||||
modelOverride,
|
modelOverride,
|
||||||
|
isMobile = false,
|
||||||
}: IssueDetailPanelProps) {
|
}: IssueDetailPanelProps) {
|
||||||
const isValidating = validatingIssues.has(issue.number);
|
const isValidating = validatingIssues.has(issue.number);
|
||||||
const cached = cachedValidations.get(issue.number);
|
const cached = cachedValidations.get(issue.number);
|
||||||
@@ -71,8 +75,13 @@ export function IssueDetailPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Detail Header */}
|
{/* Detail Header */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{isMobile && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose} className="shrink-0 -ml-1">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{issue.state === 'OPEN' ? (
|
{issue.state === 'OPEN' ? (
|
||||||
<Circle className="h-4 w-4 text-green-500 shrink-0" />
|
<Circle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -82,12 +91,12 @@ export function IssueDetailPanel({
|
|||||||
#{issue.number} {issue.title}
|
#{issue.number} {issue.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (isValidating) {
|
if (isValidating) {
|
||||||
return (
|
return (
|
||||||
<Button variant="default" size="sm" loading>
|
<Button variant="default" size="sm" loading>
|
||||||
Validating...
|
{isMobile ? '...' : 'Validating...'}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,7 +106,7 @@ export function IssueDetailPanel({
|
|||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
||||||
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
|
||||||
View Result
|
{!isMobile && 'View Result'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -116,7 +125,7 @@ export function IssueDetailPanel({
|
|||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
<Button variant="outline" size="sm" onClick={() => onViewCachedValidation(issue)}>
|
||||||
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
|
||||||
View (stale)
|
{!isMobile && 'View (stale)'}
|
||||||
</Button>
|
</Button>
|
||||||
<ModelOverrideTrigger
|
<ModelOverrideTrigger
|
||||||
currentModelEntry={modelOverride.effectiveModelEntry}
|
currentModelEntry={modelOverride.effectiveModelEntry}
|
||||||
@@ -133,7 +142,7 @@ export function IssueDetailPanel({
|
|||||||
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
<Wand2 className="h-4 w-4 mr-1" />
|
||||||
Re-validate
|
{!isMobile && 'Re-validate'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -156,23 +165,36 @@ export function IssueDetailPanel({
|
|||||||
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
onClick={() => onValidateIssue(issue, getValidationOptions())}
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
<Wand2 className="h-4 w-4 mr-1" />
|
||||||
Validate with AI
|
{!isMobile && 'Validate with AI'}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{!isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onCreateFeature(issue)}
|
||||||
|
title="Create a new feature to address this issue"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Create Feature
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
|
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
Open in GitHub
|
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Issue Detail Content */}
|
{/* Issue Detail Content */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
|
<h1 className="text-xl font-bold mb-2">{issue.title}</h1>
|
||||||
|
|
||||||
@@ -344,8 +366,25 @@ export function IssueDetailPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create Feature CTA - shown on mobile since it's hidden from the header */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="mt-6 p-4 rounded-lg bg-primary/5 border border-primary/20">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Plus className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium">Create Feature</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Create a new feature task to address this issue.
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" onClick={() => onCreateFeature(issue)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Open in GitHub CTA */}
|
{/* Open in GitHub CTA */}
|
||||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
View comments, add reactions, and more on GitHub.
|
View comments, add reactions, and more on GitHub.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ export interface IssueDetailPanelProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
|
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
|
||||||
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
|
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
|
||||||
|
/** Called when user wants to create a feature to address this issue */
|
||||||
|
onCreateFeature: (issue: GitHubIssue) => void;
|
||||||
formatDate: (date: string) => string;
|
formatDate: (date: string) => string;
|
||||||
/** Model override state */
|
/** Model override state */
|
||||||
modelOverride: {
|
modelOverride: {
|
||||||
@@ -146,4 +148,6 @@ export interface IssueDetailPanelProps {
|
|||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
setOverride: (entry: PhaseModelEntry | null) => void;
|
setOverride: (entry: PhaseModelEntry | null) => void;
|
||||||
};
|
};
|
||||||
|
/** Whether the view is in mobile mode - shows back button and full-screen detail */
|
||||||
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,41 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
|
import {
|
||||||
|
GitPullRequest,
|
||||||
|
RefreshCw,
|
||||||
|
ExternalLink,
|
||||||
|
GitMerge,
|
||||||
|
X,
|
||||||
|
MessageSquare,
|
||||||
|
MoreHorizontal,
|
||||||
|
Zap,
|
||||||
|
ArrowLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
import { getElectronAPI, type GitHubPR } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore, type Feature } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Markdown } from '@/components/ui/markdown';
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
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 { PRCommentResolutionDialog } from '@/components/dialogs/pr-comment-resolution-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
export function GitHubPRsView() {
|
export function GitHubPRsView() {
|
||||||
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null);
|
||||||
|
const [commentDialogPR, setCommentDialogPR] = useState<GitHubPR | null>(null);
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -38,6 +61,50 @@ export function GitHubPRsView() {
|
|||||||
api.openExternalLink(url);
|
api.openExternalLink(url);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const createFeature = useCreateFeature(currentProject?.path ?? '');
|
||||||
|
|
||||||
|
const handleAutoAddressComments = useCallback(
|
||||||
|
async (pr: GitHubPR) => {
|
||||||
|
if (!pr.number || !currentProject?.path) {
|
||||||
|
toast.error('Cannot address PR comments', {
|
||||||
|
description: 'No PR number or project available.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureId = `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const feature: Feature = {
|
||||||
|
id: featureId,
|
||||||
|
title: `Address PR #${pr.number} Review Comments`,
|
||||||
|
category: 'bug-fix',
|
||||||
|
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
|
||||||
|
steps: [],
|
||||||
|
status: 'in_progress',
|
||||||
|
model: 'opus',
|
||||||
|
thinkingLevel: 'none',
|
||||||
|
planningMode: 'skip',
|
||||||
|
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFeature.mutateAsync(feature);
|
||||||
|
|
||||||
|
// Start the feature immediately after creation
|
||||||
|
const api = getElectronAPI();
|
||||||
|
await api.features?.run(currentProject.path, featureId);
|
||||||
|
|
||||||
|
toast.success('Feature created and started', {
|
||||||
|
description: `Addressing review comments on PR #${pr.number}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to create feature', {
|
||||||
|
description: error instanceof Error ? error.message : 'An error occurred',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, createFeature]
|
||||||
|
);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
@@ -91,11 +158,12 @@ export function GitHubPRsView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* PR List */}
|
{/* PR List - hidden on mobile when a PR is selected */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col overflow-hidden border-r border-border',
|
'flex flex-col overflow-hidden border-r border-border',
|
||||||
selectedPR ? 'w-80' : 'flex-1'
|
selectedPR ? 'w-80' : 'flex-1',
|
||||||
|
isMobile && selectedPR && 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -140,6 +208,8 @@ export function GitHubPRsView() {
|
|||||||
isSelected={selectedPR?.number === pr.number}
|
isSelected={selectedPR?.number === pr.number}
|
||||||
onClick={() => setSelectedPR(pr)}
|
onClick={() => setSelectedPR(pr)}
|
||||||
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
||||||
|
onManageComments={() => setCommentDialogPR(pr)}
|
||||||
|
onAutoAddressComments={() => handleAutoAddressComments(pr)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
getReviewStatus={getReviewStatus}
|
getReviewStatus={getReviewStatus}
|
||||||
/>
|
/>
|
||||||
@@ -158,6 +228,8 @@ export function GitHubPRsView() {
|
|||||||
isSelected={selectedPR?.number === pr.number}
|
isSelected={selectedPR?.number === pr.number}
|
||||||
onClick={() => setSelectedPR(pr)}
|
onClick={() => setSelectedPR(pr)}
|
||||||
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
onOpenExternal={() => handleOpenInGitHub(pr.url)}
|
||||||
|
onManageComments={() => setCommentDialogPR(pr)}
|
||||||
|
onAutoAddressComments={() => handleAutoAddressComments(pr)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
getReviewStatus={getReviewStatus}
|
getReviewStatus={getReviewStatus}
|
||||||
/>
|
/>
|
||||||
@@ -173,8 +245,18 @@ export function GitHubPRsView() {
|
|||||||
{selectedPR && (
|
{selectedPR && (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Detail Header */}
|
{/* Detail Header */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedPR(null)}
|
||||||
|
className="shrink-0 -ml-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{selectedPR.state === 'MERGED' ? (
|
{selectedPR.state === 'MERGED' ? (
|
||||||
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
<GitMerge className="h-4 w-4 text-purple-500 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -189,23 +271,31 @@ export function GitHubPRsView() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleOpenInGitHub(selectedPR.url)}
|
onClick={() => handleOpenInGitHub(selectedPR.url)}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
Open in GitHub
|
{!isMobile && <span className="ml-1">Open in GitHub</span>}
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isMobile && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PR Detail Content */}
|
{/* PR Detail Content */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
|
<h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1>
|
||||||
|
|
||||||
@@ -276,8 +366,29 @@ export function GitHubPRsView() {
|
|||||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
<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 */}
|
{/* Open in GitHub CTA */}
|
||||||
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
|
<div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
View code changes, comments, and reviews on GitHub.
|
View code changes, comments, and reviews on GitHub.
|
||||||
</p>
|
</p>
|
||||||
@@ -289,6 +400,17 @@ export function GitHubPRsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PR Comment Resolution Dialog */}
|
||||||
|
{commentDialogPR && (
|
||||||
|
<PRCommentResolutionDialog
|
||||||
|
open={!!commentDialogPR}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setCommentDialogPR(null);
|
||||||
|
}}
|
||||||
|
pr={commentDialogPR}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -298,6 +420,8 @@ interface PRRowProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onOpenExternal: () => void;
|
onOpenExternal: () => void;
|
||||||
|
onManageComments: () => void;
|
||||||
|
onAutoAddressComments: () => void;
|
||||||
formatDate: (date: string) => string;
|
formatDate: (date: string) => string;
|
||||||
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
|
getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null;
|
||||||
}
|
}
|
||||||
@@ -307,6 +431,8 @@ function PRRow({
|
|||||||
isSelected,
|
isSelected,
|
||||||
onClick,
|
onClick,
|
||||||
onOpenExternal,
|
onOpenExternal,
|
||||||
|
onManageComments,
|
||||||
|
onAutoAddressComments,
|
||||||
formatDate,
|
formatDate,
|
||||||
getReviewStatus,
|
getReviewStatus,
|
||||||
}: PRRowProps) {
|
}: PRRowProps) {
|
||||||
@@ -378,17 +504,52 @@ function PRRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* Actions dropdown menu */}
|
||||||
variant="ghost"
|
<DropdownMenu>
|
||||||
size="sm"
|
<DropdownMenuTrigger asChild>
|
||||||
className="shrink-0 opacity-0 group-hover:opacity-100"
|
<Button
|
||||||
onClick={(e) => {
|
variant="ghost"
|
||||||
e.stopPropagation();
|
size="sm"
|
||||||
onOpenExternal();
|
className="shrink-0 h-7 w-7 p-0"
|
||||||
}}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onManageComments();
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Manage PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAutoAddressComments();
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-500 focus:text-blue-600"
|
||||||
|
>
|
||||||
|
<Zap className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Address PR Comments
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenExternal();
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Open in GitHub
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,594 @@
|
|||||||
|
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Terminal,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Info,
|
||||||
|
X,
|
||||||
|
Play,
|
||||||
|
FlaskConical,
|
||||||
|
ScrollText,
|
||||||
|
Plus,
|
||||||
|
GripVertical,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useProjectSettings } from '@/hooks/queries';
|
||||||
|
import { useUpdateProjectSettings } from '@/hooks/mutations';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
import { DEFAULT_TERMINAL_SCRIPTS } from './terminal-scripts-constants';
|
||||||
|
|
||||||
|
/** Preset dev server commands for quick selection */
|
||||||
|
const DEV_SERVER_PRESETS = [
|
||||||
|
{ label: 'npm run dev', command: 'npm run dev' },
|
||||||
|
{ label: 'yarn dev', command: 'yarn dev' },
|
||||||
|
{ label: 'pnpm dev', command: 'pnpm dev' },
|
||||||
|
{ label: 'bun dev', command: 'bun dev' },
|
||||||
|
{ label: 'npm start', command: 'npm start' },
|
||||||
|
{ label: 'cargo watch', command: 'cargo watch -x run' },
|
||||||
|
{ label: 'go run', command: 'go run .' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Preset test commands for quick selection */
|
||||||
|
const TEST_PRESETS = [
|
||||||
|
{ label: 'npm test', command: 'npm test' },
|
||||||
|
{ label: 'yarn test', command: 'yarn test' },
|
||||||
|
{ label: 'pnpm test', command: 'pnpm test' },
|
||||||
|
{ label: 'bun test', command: 'bun test' },
|
||||||
|
{ label: 'pytest', command: 'pytest' },
|
||||||
|
{ label: 'cargo test', command: 'cargo test' },
|
||||||
|
{ label: 'go test', command: 'go test ./...' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Preset scripts for quick addition */
|
||||||
|
const SCRIPT_PRESETS = [
|
||||||
|
{ name: 'Dev Server', command: 'npm run dev' },
|
||||||
|
{ name: 'Build', command: 'npm run build' },
|
||||||
|
{ name: 'Test', command: 'npm run test' },
|
||||||
|
{ name: 'Lint', command: 'npm run lint' },
|
||||||
|
{ name: 'Format', command: 'npm run format' },
|
||||||
|
{ name: 'Type Check', command: 'npm run typecheck' },
|
||||||
|
{ name: 'Start', command: 'npm start' },
|
||||||
|
{ name: 'Clean', command: 'npm run clean' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface ScriptEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandsAndScriptsSectionProps {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a unique ID for a new script */
|
||||||
|
function generateId(): string {
|
||||||
|
return `script-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandsAndScriptsSection({ project }: CommandsAndScriptsSectionProps) {
|
||||||
|
// Fetch project settings using TanStack Query
|
||||||
|
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
|
||||||
|
|
||||||
|
// Mutation hook for updating project settings
|
||||||
|
const updateSettingsMutation = useUpdateProjectSettings(project.path);
|
||||||
|
|
||||||
|
// ── Commands state ──
|
||||||
|
const [devCommand, setDevCommand] = useState('');
|
||||||
|
const [originalDevCommand, setOriginalDevCommand] = useState('');
|
||||||
|
const [testCommand, setTestCommand] = useState('');
|
||||||
|
const [originalTestCommand, setOriginalTestCommand] = useState('');
|
||||||
|
|
||||||
|
// ── Scripts state ──
|
||||||
|
const [scripts, setScripts] = useState<ScriptEntry[]>([]);
|
||||||
|
const [originalScripts, setOriginalScripts] = useState<ScriptEntry[]>([]);
|
||||||
|
|
||||||
|
// Dragging state for scripts
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Reset local state when project changes
|
||||||
|
useEffect(() => {
|
||||||
|
setDevCommand('');
|
||||||
|
setOriginalDevCommand('');
|
||||||
|
setTestCommand('');
|
||||||
|
setOriginalTestCommand('');
|
||||||
|
setScripts([]);
|
||||||
|
setOriginalScripts([]);
|
||||||
|
}, [project.path]);
|
||||||
|
|
||||||
|
// Sync commands state when project settings load
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectSettings) {
|
||||||
|
const dev = projectSettings.devCommand || '';
|
||||||
|
const test = projectSettings.testCommand || '';
|
||||||
|
setDevCommand(dev);
|
||||||
|
setOriginalDevCommand(dev);
|
||||||
|
setTestCommand(test);
|
||||||
|
setOriginalTestCommand(test);
|
||||||
|
}
|
||||||
|
}, [projectSettings]);
|
||||||
|
|
||||||
|
// Sync scripts state when project settings load
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectSettings) {
|
||||||
|
const configured = projectSettings.terminalScripts;
|
||||||
|
const scriptList =
|
||||||
|
configured && configured.length > 0
|
||||||
|
? configured.map((s) => ({ id: s.id, name: s.name, command: s.command }))
|
||||||
|
: DEFAULT_TERMINAL_SCRIPTS.map((s) => ({ ...s }));
|
||||||
|
setScripts(scriptList);
|
||||||
|
setOriginalScripts(JSON.parse(JSON.stringify(scriptList)));
|
||||||
|
}
|
||||||
|
}, [projectSettings, project.path]);
|
||||||
|
|
||||||
|
// ── Change detection ──
|
||||||
|
const hasDevChanges = devCommand !== originalDevCommand;
|
||||||
|
const hasTestChanges = testCommand !== originalTestCommand;
|
||||||
|
const hasCommandChanges = hasDevChanges || hasTestChanges;
|
||||||
|
const hasScriptChanges = JSON.stringify(scripts) !== JSON.stringify(originalScripts);
|
||||||
|
const hasChanges = hasCommandChanges || hasScriptChanges;
|
||||||
|
const isSaving = updateSettingsMutation.isPending;
|
||||||
|
|
||||||
|
// ── Save all (commands + scripts) ──
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
const normalizedDevCommand = devCommand.trim();
|
||||||
|
const normalizedTestCommand = testCommand.trim();
|
||||||
|
const validScripts = scripts.filter((s) => s.name.trim() && s.command.trim());
|
||||||
|
const normalizedScripts = validScripts.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name.trim(),
|
||||||
|
command: s.command.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateSettingsMutation.mutate(
|
||||||
|
{
|
||||||
|
devCommand: normalizedDevCommand || null,
|
||||||
|
testCommand: normalizedTestCommand || null,
|
||||||
|
terminalScripts: normalizedScripts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setDevCommand(normalizedDevCommand);
|
||||||
|
setOriginalDevCommand(normalizedDevCommand);
|
||||||
|
setTestCommand(normalizedTestCommand);
|
||||||
|
setOriginalTestCommand(normalizedTestCommand);
|
||||||
|
setScripts(normalizedScripts);
|
||||||
|
setOriginalScripts(JSON.parse(JSON.stringify(normalizedScripts)));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [devCommand, testCommand, scripts, updateSettingsMutation]);
|
||||||
|
|
||||||
|
// ── Reset all ──
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
setDevCommand(originalDevCommand);
|
||||||
|
setTestCommand(originalTestCommand);
|
||||||
|
setScripts(JSON.parse(JSON.stringify(originalScripts)));
|
||||||
|
}, [originalDevCommand, originalTestCommand, originalScripts]);
|
||||||
|
|
||||||
|
// ── Command handlers ──
|
||||||
|
const handleUseDevPreset = useCallback((command: string) => {
|
||||||
|
setDevCommand(command);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUseTestPreset = useCallback((command: string) => {
|
||||||
|
setTestCommand(command);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearDev = useCallback(() => {
|
||||||
|
setDevCommand('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearTest = useCallback(() => {
|
||||||
|
setTestCommand('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Script handlers ──
|
||||||
|
const handleAddScript = useCallback(() => {
|
||||||
|
setScripts((prev) => [...prev, { id: generateId(), name: '', command: '' }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddPreset = useCallback((preset: { name: string; command: string }) => {
|
||||||
|
setScripts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: generateId(), name: preset.name, command: preset.command },
|
||||||
|
]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveScript = useCallback((index: number) => {
|
||||||
|
setScripts((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateScript = useCallback(
|
||||||
|
(index: number, field: 'name' | 'command', value: string) => {
|
||||||
|
setScripts((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts (Enter to save)
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && hasChanges && !isSaving) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasChanges, isSaving, handleSave]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Drag and drop handlers for script reordering ──
|
||||||
|
const handleDragStart = useCallback((index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === index) return;
|
||||||
|
setDragOverIndex(index);
|
||||||
|
},
|
||||||
|
[draggedIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex !== null && dragOverIndex !== null && draggedIndex !== dragOverIndex) {
|
||||||
|
setScripts((prev) => {
|
||||||
|
const newScripts = [...prev];
|
||||||
|
const [removed] = newScripts.splice(draggedIndex, 1);
|
||||||
|
newScripts.splice(dragOverIndex, 0, removed);
|
||||||
|
return newScripts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
},
|
||||||
|
[draggedIndex, dragOverIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((_e: React.DragEvent) => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Commands Card ── */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
data-testid="commands-section"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<Terminal className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Project Commands
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure custom commands for development and testing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||||
|
Failed to load project settings. Please try again.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Dev Server Command Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Play className="w-4 h-4 text-brand-500" />
|
||||||
|
<h3 className="text-base font-medium text-foreground">Dev Server</h3>
|
||||||
|
{hasDevChanges && (
|
||||||
|
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pl-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="dev-command"
|
||||||
|
value={devCommand}
|
||||||
|
onChange={(e) => setDevCommand(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="e.g., npm run dev, yarn dev, cargo watch"
|
||||||
|
className="font-mono text-sm pr-8"
|
||||||
|
data-testid="dev-command-input"
|
||||||
|
/>
|
||||||
|
{devCommand && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearDev}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear dev command"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Leave empty to auto-detect based on your package manager.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Dev Presets */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{DEV_SERVER_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUseDevPreset(preset.command)}
|
||||||
|
className="text-xs font-mono h-7 px-2"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Test Command Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="w-4 h-4 text-brand-500" />
|
||||||
|
<h3 className="text-base font-medium text-foreground">Test Runner</h3>
|
||||||
|
{hasTestChanges && (
|
||||||
|
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pl-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="test-command"
|
||||||
|
value={testCommand}
|
||||||
|
onChange={(e) => setTestCommand(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="e.g., npm test, pytest, cargo test"
|
||||||
|
className="font-mono text-sm pr-8"
|
||||||
|
data-testid="test-command-input"
|
||||||
|
/>
|
||||||
|
{testCommand && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearTest}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear test command"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
Leave empty to auto-detect based on your project structure.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Test Presets */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{TEST_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUseTestPreset(preset.command)}
|
||||||
|
className="text-xs font-mono h-7 px-2"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-detection Info */}
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||||
|
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">Auto-detection</p>
|
||||||
|
<p>
|
||||||
|
When no custom command is set, the system automatically detects your package
|
||||||
|
manager and test framework based on project files (package.json, Cargo.toml,
|
||||||
|
go.mod, etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Terminal Quick Scripts Card ── */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
data-testid="scripts-section"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<ScrollText className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||||
|
Terminal Quick Scripts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure quick-access scripts that appear in the terminal header dropdown. Click any
|
||||||
|
script to run it instantly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||||
|
Failed to load project settings. Please try again.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Scripts List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{scripts.map((script, index) => (
|
||||||
|
<div
|
||||||
|
key={script.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 p-2 rounded-lg border border-border/30 bg-accent/10 transition-all',
|
||||||
|
draggedIndex === index && 'opacity-50',
|
||||||
|
dragOverIndex === index && 'border-brand-500/50 bg-brand-500/5'
|
||||||
|
)}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={(e) => handleDrop(e)}
|
||||||
|
onDragEnd={(e) => handleDragEnd(e)}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0 p-0.5"
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Script name */}
|
||||||
|
<Input
|
||||||
|
value={script.name}
|
||||||
|
onChange={(e) => handleUpdateScript(index, 'name', e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Script name"
|
||||||
|
className="h-8 text-sm flex-[0.4] min-w-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Script command */}
|
||||||
|
<Input
|
||||||
|
value={script.command}
|
||||||
|
onChange={(e) => handleUpdateScript(index, 'command', e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Command to run"
|
||||||
|
className="h-8 text-sm font-mono flex-[0.6] min-w-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Remove button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveScript(index)}
|
||||||
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
aria-label={`Remove ${script.name || 'script'}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{scripts.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||||
|
No scripts configured. Add some below or use a preset.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Script Button */}
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddScript} className="gap-1.5">
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
Add Script
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Quick Add Presets</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{SCRIPT_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.command}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddPreset(preset)}
|
||||||
|
className="text-xs font-mono h-7 px-2"
|
||||||
|
>
|
||||||
|
{preset.command}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
|
||||||
|
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">Terminal Quick Scripts</p>
|
||||||
|
<p>
|
||||||
|
These scripts appear in the terminal header as a dropdown menu (the{' '}
|
||||||
|
<ScrollText className="inline-block w-3 h-3 mx-0.5 align-middle" /> icon).
|
||||||
|
Clicking a script will type the command into the active terminal and press
|
||||||
|
Enter. Drag to reorder scripts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Shared Action Buttons ── */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
Database,
|
Database,
|
||||||
Terminal,
|
Terminal,
|
||||||
ScrollText,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
@@ -20,8 +19,7 @@ export interface ProjectNavigationItem {
|
|||||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||||
{ id: 'commands', label: 'Commands', icon: Terminal },
|
{ id: 'commands-scripts', label: 'Commands & Scripts', icon: Terminal },
|
||||||
{ id: 'scripts', label: 'Terminal Scripts', icon: ScrollText },
|
|
||||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||||
{ id: 'data', label: 'Data', icon: Database },
|
{ id: 'data', label: 'Data', icon: Database },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ProjectSettingsViewId =
|
|||||||
| 'worktrees'
|
| 'worktrees'
|
||||||
| 'commands'
|
| 'commands'
|
||||||
| 'scripts'
|
| 'scripts'
|
||||||
|
| 'commands-scripts'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
| 'data'
|
| 'data'
|
||||||
| 'danger';
|
| 'danger';
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export { ProjectSettingsView } from './project-settings-view';
|
|||||||
export { ProjectIdentitySection } from './project-identity-section';
|
export { ProjectIdentitySection } from './project-identity-section';
|
||||||
export { ProjectThemeSection } from './project-theme-section';
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
|
export { CommandsAndScriptsSection } from './commands-and-scripts-section';
|
||||||
|
// Legacy exports kept for backward compatibility
|
||||||
export { CommandsSection } from './commands-section';
|
export { CommandsSection } from './commands-section';
|
||||||
export { TerminalScriptsSection } from './terminal-scripts-section';
|
export { TerminalScriptsSection } from './terminal-scripts-section';
|
||||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectIdentitySection } from './project-identity-section';
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
import { CommandsSection } from './commands-section';
|
import { CommandsAndScriptsSection } from './commands-and-scripts-section';
|
||||||
import { TerminalScriptsSection } from './terminal-scripts-section';
|
|
||||||
import { ProjectModelsSection } from './project-models-section';
|
import { ProjectModelsSection } from './project-models-section';
|
||||||
import { DataManagementSection } from './data-management-section';
|
import { DataManagementSection } from './data-management-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
@@ -15,6 +14,8 @@ import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-fr
|
|||||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
import { useProjectSettingsView } from './hooks/use-project-settings-view';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
import type { Project as ElectronProject } from '@/lib/electron';
|
||||||
|
import { useSearch } from '@tanstack/react-router';
|
||||||
|
import type { ProjectSettingsViewId } from './hooks/use-project-settings-view';
|
||||||
|
|
||||||
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
|
||||||
const LG_BREAKPOINT = 1024;
|
const LG_BREAKPOINT = 1024;
|
||||||
@@ -34,8 +35,18 @@ export function ProjectSettingsView() {
|
|||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
|
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
|
||||||
|
|
||||||
|
// Read the optional section search param to support deep-linking to a specific section
|
||||||
|
const search = useSearch({ strict: false }) as { section?: ProjectSettingsViewId };
|
||||||
|
// Map legacy 'commands' and 'scripts' IDs to the combined 'commands-scripts' section
|
||||||
|
const resolvedSection: ProjectSettingsViewId | undefined =
|
||||||
|
search.section === 'commands' || search.section === 'scripts'
|
||||||
|
? 'commands-scripts'
|
||||||
|
: search.section;
|
||||||
|
|
||||||
// Use project settings view navigation hook
|
// Use project settings view navigation hook
|
||||||
const { activeView, navigateTo } = useProjectSettingsView();
|
const { activeView, navigateTo } = useProjectSettingsView({
|
||||||
|
initialView: resolvedSection ?? 'identity',
|
||||||
|
});
|
||||||
|
|
||||||
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
// Mobile navigation state - default to showing on desktop, hidden on mobile
|
||||||
const [showNavigation, setShowNavigation] = useState(() => {
|
const [showNavigation, setShowNavigation] = useState(() => {
|
||||||
@@ -91,9 +102,9 @@ export function ProjectSettingsView() {
|
|||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
case 'commands':
|
case 'commands':
|
||||||
return <CommandsSection project={currentProject} />;
|
|
||||||
case 'scripts':
|
case 'scripts':
|
||||||
return <TerminalScriptsSection project={currentProject} />;
|
case 'commands-scripts':
|
||||||
|
return <CommandsAndScriptsSection project={currentProject} />;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
case 'data':
|
case 'data':
|
||||||
|
|||||||
@@ -240,9 +240,17 @@ interface TerminalViewProps {
|
|||||||
initialMode?: 'tab' | 'split';
|
initialMode?: 'tab' | 'split';
|
||||||
/** Unique nonce to allow opening the same worktree multiple times */
|
/** Unique nonce to allow opening the same worktree multiple times */
|
||||||
nonce?: number;
|
nonce?: number;
|
||||||
|
/** Command to run automatically when the terminal is created (e.g., from scripts submenu) */
|
||||||
|
initialCommand?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: TerminalViewProps) {
|
export function TerminalView({
|
||||||
|
initialCwd,
|
||||||
|
initialBranch,
|
||||||
|
initialMode,
|
||||||
|
nonce,
|
||||||
|
initialCommand,
|
||||||
|
}: TerminalViewProps) {
|
||||||
const {
|
const {
|
||||||
terminalState,
|
terminalState,
|
||||||
setTerminalUnlocked,
|
setTerminalUnlocked,
|
||||||
@@ -288,6 +296,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
const isCreatingRef = useRef<boolean>(false);
|
const isCreatingRef = useRef<boolean>(false);
|
||||||
const restoringProjectPathRef = useRef<string | null>(null);
|
const restoringProjectPathRef = useRef<string | null>(null);
|
||||||
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
|
const [newSessionIds, setNewSessionIds] = useState<Set<string>>(new Set());
|
||||||
|
// Per-session command overrides (e.g., from scripts submenu), takes priority over defaultRunScript
|
||||||
|
const [sessionCommandOverrides, setSessionCommandOverrides] = useState<Map<string, string>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
const [serverSessionInfo, setServerSessionInfo] = useState<{
|
const [serverSessionInfo, setServerSessionInfo] = useState<{
|
||||||
current: number;
|
current: number;
|
||||||
max: number;
|
max: number;
|
||||||
@@ -618,8 +630,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark this session as new for running initial command
|
// Mark this session as new for running initial command
|
||||||
if (defaultRunScript) {
|
if (initialCommand || defaultRunScript) {
|
||||||
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
||||||
|
// Store per-session command override if an explicit command was provided
|
||||||
|
if (initialCommand) {
|
||||||
|
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, initialCommand));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success toast with branch name if provided
|
// Show success toast with branch name if provided
|
||||||
@@ -654,6 +670,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
initialCwd,
|
initialCwd,
|
||||||
initialBranch,
|
initialBranch,
|
||||||
initialMode,
|
initialMode,
|
||||||
|
initialCommand,
|
||||||
nonce,
|
nonce,
|
||||||
status?.enabled,
|
status?.enabled,
|
||||||
status?.passwordRequired,
|
status?.passwordRequired,
|
||||||
@@ -1059,7 +1076,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
// Create terminal in new tab
|
// Create terminal in new tab
|
||||||
// customCwd: optional working directory (e.g., a specific worktree path)
|
// customCwd: optional working directory (e.g., a specific worktree path)
|
||||||
// branchName: optional branch name to display in the terminal panel header
|
// branchName: optional branch name to display in the terminal panel header
|
||||||
const createTerminalInNewTab = async (customCwd?: string, branchName?: string) => {
|
// command: optional command to run when the terminal connects (e.g., from scripts menu)
|
||||||
|
const createTerminalInNewTab = async (
|
||||||
|
customCwd?: string,
|
||||||
|
branchName?: string,
|
||||||
|
command?: string
|
||||||
|
) => {
|
||||||
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
|
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1087,8 +1109,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
const { addTerminalToTab } = useAppStore.getState();
|
const { addTerminalToTab } = useAppStore.getState();
|
||||||
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
|
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
|
||||||
// Mark this session as new for running initial command
|
// Mark this session as new for running initial command
|
||||||
if (defaultRunScript) {
|
if (command || defaultRunScript) {
|
||||||
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
|
||||||
|
// Store per-session command override if an explicit command was provided
|
||||||
|
if (command) {
|
||||||
|
setSessionCommandOverrides((prev) => new Map(prev).set(data.data.id, command));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Refresh session count
|
// Refresh session count
|
||||||
fetchServerSettings();
|
fetchServerSettings();
|
||||||
@@ -1255,6 +1281,12 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
next.delete(sessionId);
|
next.delete(sessionId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Clean up any per-session command override
|
||||||
|
setSessionCommandOverrides((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Navigate between terminal panes with directional awareness
|
// Navigate between terminal panes with directional awareness
|
||||||
@@ -1387,6 +1419,9 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
|
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
|
||||||
// Only run command on new sessions (not restored ones)
|
// Only run command on new sessions (not restored ones)
|
||||||
const isNewSession = newSessionIds.has(content.sessionId);
|
const isNewSession = newSessionIds.has(content.sessionId);
|
||||||
|
// Per-session command override takes priority over defaultRunScript
|
||||||
|
const sessionCommand = sessionCommandOverrides.get(content.sessionId);
|
||||||
|
const runCommand = isNewSession ? sessionCommand || defaultRunScript : undefined;
|
||||||
return (
|
return (
|
||||||
<TerminalErrorBoundary
|
<TerminalErrorBoundary
|
||||||
key={`boundary-${content.sessionId}`}
|
key={`boundary-${content.sessionId}`}
|
||||||
@@ -1413,6 +1448,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
createTerminal('vertical', content.sessionId, cwd, branchName);
|
createTerminal('vertical', content.sessionId, cwd, branchName);
|
||||||
}}
|
}}
|
||||||
onNewTab={createTerminalInNewTab}
|
onNewTab={createTerminalInNewTab}
|
||||||
|
onRunCommandInNewTab={(command: string) => {
|
||||||
|
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
|
||||||
|
createTerminalInNewTab(cwd, branch, command);
|
||||||
|
}}
|
||||||
onNavigateUp={() => navigateToTerminal('up')}
|
onNavigateUp={() => navigateToTerminal('up')}
|
||||||
onNavigateDown={() => navigateToTerminal('down')}
|
onNavigateDown={() => navigateToTerminal('down')}
|
||||||
onNavigateLeft={() => navigateToTerminal('left')}
|
onNavigateLeft={() => navigateToTerminal('left')}
|
||||||
@@ -1427,7 +1466,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
||||||
runCommandOnConnect={isNewSession ? defaultRunScript : undefined}
|
runCommandOnConnect={runCommand}
|
||||||
onCommandRan={() => handleCommandRan(content.sessionId)}
|
onCommandRan={() => handleCommandRan(content.sessionId)}
|
||||||
isMaximized={terminalState.maximizedSessionId === content.sessionId}
|
isMaximized={terminalState.maximizedSessionId === content.sessionId}
|
||||||
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
onToggleMaximize={() => toggleTerminalMaximized(content.sessionId)}
|
||||||
@@ -1971,6 +2010,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
|
|||||||
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
|
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
|
||||||
}}
|
}}
|
||||||
onNewTab={createTerminalInNewTab}
|
onNewTab={createTerminalInNewTab}
|
||||||
|
onRunCommandInNewTab={(command: string) => {
|
||||||
|
const { cwd, branchName: branch } = getActiveSessionWorktreeInfo();
|
||||||
|
createTerminalInNewTab(cwd, branch, command);
|
||||||
|
}}
|
||||||
onSessionInvalid={() => {
|
onSessionInvalid={() => {
|
||||||
const sessionId = terminalState.maximizedSessionId!;
|
const sessionId = terminalState.maximizedSessionId!;
|
||||||
logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);
|
logger.info(`Maximized session ${sessionId} is invalid, removing from layout`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
@@ -90,6 +91,7 @@ interface TerminalPanelProps {
|
|||||||
onSplitHorizontal: () => void;
|
onSplitHorizontal: () => void;
|
||||||
onSplitVertical: () => void;
|
onSplitVertical: () => void;
|
||||||
onNewTab?: () => void;
|
onNewTab?: () => void;
|
||||||
|
onRunCommandInNewTab?: (command: string) => void; // Run a script command in a new terminal tab
|
||||||
onNavigateUp?: () => void; // Navigate to terminal pane above
|
onNavigateUp?: () => void; // Navigate to terminal pane above
|
||||||
onNavigateDown?: () => void; // Navigate to terminal pane below
|
onNavigateDown?: () => void; // Navigate to terminal pane below
|
||||||
onNavigateLeft?: () => void; // Navigate to terminal pane on the left
|
onNavigateLeft?: () => void; // Navigate to terminal pane on the left
|
||||||
@@ -120,6 +122,7 @@ export function TerminalPanel({
|
|||||||
onSplitHorizontal,
|
onSplitHorizontal,
|
||||||
onSplitVertical,
|
onSplitVertical,
|
||||||
onNewTab,
|
onNewTab,
|
||||||
|
onRunCommandInNewTab,
|
||||||
onNavigateUp,
|
onNavigateUp,
|
||||||
onNavigateDown,
|
onNavigateDown,
|
||||||
onNavigateLeft,
|
onNavigateLeft,
|
||||||
@@ -135,6 +138,7 @@ export function TerminalPanel({
|
|||||||
onToggleMaximize,
|
onToggleMaximize,
|
||||||
branchName,
|
branchName,
|
||||||
}: TerminalPanelProps) {
|
}: TerminalPanelProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const xtermRef = useRef<XTerminal | null>(null);
|
const xtermRef = useRef<XTerminal | null>(null);
|
||||||
@@ -2071,7 +2075,11 @@ export function TerminalPanel({
|
|||||||
{/* Quick scripts dropdown */}
|
{/* Quick scripts dropdown */}
|
||||||
<TerminalScriptsDropdown
|
<TerminalScriptsDropdown
|
||||||
onRunCommand={sendCommand}
|
onRunCommand={sendCommand}
|
||||||
|
onRunCommandInNewTab={onRunCommandInNewTab}
|
||||||
isConnected={connectionStatus === 'connected'}
|
isConnected={connectionStatus === 'connected'}
|
||||||
|
onOpenSettings={() =>
|
||||||
|
navigate({ to: '/project-settings', search: { section: 'commands-scripts' } })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Settings popover */}
|
{/* Settings popover */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { ScrollText, Play, Settings2 } from 'lucide-react';
|
import { ScrollText, Play, Settings2, SquareArrowOutUpRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -17,6 +17,8 @@ import { DEFAULT_TERMINAL_SCRIPTS } from '../project-settings-view/terminal-scri
|
|||||||
interface TerminalScriptsDropdownProps {
|
interface TerminalScriptsDropdownProps {
|
||||||
/** Callback to send a command + newline to the terminal */
|
/** Callback to send a command + newline to the terminal */
|
||||||
onRunCommand: (command: string) => void;
|
onRunCommand: (command: string) => void;
|
||||||
|
/** Callback to run a command in a new terminal tab */
|
||||||
|
onRunCommandInNewTab?: (command: string) => void;
|
||||||
/** Whether the terminal is connected and ready */
|
/** Whether the terminal is connected and ready */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/** Optional callback to navigate to project settings scripts section */
|
/** Optional callback to navigate to project settings scripts section */
|
||||||
@@ -25,11 +27,13 @@ interface TerminalScriptsDropdownProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dropdown menu in the terminal header bar that provides quick-access
|
* Dropdown menu in the terminal header bar that provides quick-access
|
||||||
* to user-configured project scripts. Clicking a script inserts the
|
* to user-configured project scripts. Each script is a split button:
|
||||||
* command into the terminal and presses Enter.
|
* clicking the left side runs the command in the current terminal,
|
||||||
|
* clicking the "new tab" icon on the right runs it in a new tab.
|
||||||
*/
|
*/
|
||||||
export function TerminalScriptsDropdown({
|
export function TerminalScriptsDropdown({
|
||||||
onRunCommand,
|
onRunCommand,
|
||||||
|
onRunCommandInNewTab,
|
||||||
isConnected,
|
isConnected,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
}: TerminalScriptsDropdownProps) {
|
}: TerminalScriptsDropdownProps) {
|
||||||
@@ -53,6 +57,14 @@ export function TerminalScriptsDropdown({
|
|||||||
[isConnected, onRunCommand]
|
[isConnected, onRunCommand]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRunScriptInNewTab = useCallback(
|
||||||
|
(command: string) => {
|
||||||
|
if (!isConnected || !onRunCommandInNewTab) return;
|
||||||
|
onRunCommandInNewTab(command);
|
||||||
|
},
|
||||||
|
[isConnected, onRunCommandInNewTab]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -82,7 +94,7 @@ export function TerminalScriptsDropdown({
|
|||||||
key={script.id}
|
key={script.id}
|
||||||
onClick={() => handleRunScript(script.command)}
|
onClick={() => handleRunScript(script.command)}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
className="gap-2"
|
className="gap-2 pr-1"
|
||||||
>
|
>
|
||||||
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
|
<Play className={cn('h-3.5 w-3.5 shrink-0 text-brand-500')} />
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
@@ -91,17 +103,43 @@ export function TerminalScriptsDropdown({
|
|||||||
{script.command}
|
{script.command}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{onRunCommandInNewTab && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 ml-1 p-1 rounded-sm border-l border-border',
|
||||||
|
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
|
||||||
|
'transition-colors',
|
||||||
|
!isConnected && 'pointer-events-none opacity-50'
|
||||||
|
)}
|
||||||
|
title="Run in new tab"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
handleRunScriptInNewTab(script.command);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
// Prevent the DropdownMenuItem from handling this pointer event
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SquareArrowOutUpRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
{onOpenSettings && (
|
<DropdownMenuSeparator />
|
||||||
<>
|
<DropdownMenuItem
|
||||||
<DropdownMenuSeparator />
|
onClick={onOpenSettings}
|
||||||
<DropdownMenuItem onClick={onOpenSettings} className="gap-2 text-muted-foreground">
|
className="gap-2 text-muted-foreground"
|
||||||
<Settings2 className="h-3.5 w-3.5 shrink-0" />
|
disabled={!onOpenSettings}
|
||||||
<span className="text-sm">Configure Scripts...</span>
|
>
|
||||||
</DropdownMenuItem>
|
<Settings2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
</>
|
<span className="text-sm">Edit Commands & Scripts</span>
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export {
|
|||||||
useValidateIssue,
|
useValidateIssue,
|
||||||
useMarkValidationViewed,
|
useMarkValidationViewed,
|
||||||
useGetValidationStatus,
|
useGetValidationStatus,
|
||||||
|
useResolveReviewThread,
|
||||||
} from './use-github-mutations';
|
} from './use-github-mutations';
|
||||||
|
|
||||||
// Ideation mutations
|
// Ideation mutations
|
||||||
|
|||||||
@@ -135,6 +135,55 @@ export function useMarkValidationViewed(projectPath: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve or unresolve a PR review thread
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param prNumber - PR number (for cache invalidation)
|
||||||
|
* @returns Mutation for resolving/unresolving a review thread
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const resolveThread = useResolveReviewThread(projectPath, prNumber);
|
||||||
|
* resolveThread.mutate({ threadId: comment.threadId, resolve: true });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useResolveReviewThread(projectPath: string, prNumber: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ threadId, resolve }: { threadId: string; resolve: boolean }) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.github?.resolveReviewThread) {
|
||||||
|
throw new Error('Resolve review thread API not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.github.resolveReviewThread(projectPath, threadId, resolve);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to resolve review thread');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isResolved: result.isResolved ?? resolve };
|
||||||
|
},
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
const action = variables.resolve ? 'resolved' : 'unresolved';
|
||||||
|
toast.success(`Comment ${action}`, {
|
||||||
|
description: `The review thread has been ${action} on GitHub`,
|
||||||
|
});
|
||||||
|
// Invalidate the PR review comments cache to reflect updated resolved status
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.github.prReviewComments(projectPath, prNumber),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to update comment', {
|
||||||
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get running validation status
|
* Get running validation status
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export {
|
|||||||
useGitHubValidations,
|
useGitHubValidations,
|
||||||
useGitHubRemote,
|
useGitHubRemote,
|
||||||
useGitHubIssueComments,
|
useGitHubIssueComments,
|
||||||
|
useGitHubPRReviewComments,
|
||||||
} from './use-github';
|
} from './use-github';
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { useQuery, useInfiniteQuery } 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';
|
||||||
import type { GitHubIssue, GitHubPR, GitHubComment, StoredValidation } from '@/lib/electron';
|
import type {
|
||||||
|
GitHubIssue,
|
||||||
|
GitHubPR,
|
||||||
|
GitHubComment,
|
||||||
|
PRReviewComment,
|
||||||
|
StoredValidation,
|
||||||
|
} from '@/lib/electron';
|
||||||
|
|
||||||
interface GitHubIssuesResult {
|
interface GitHubIssuesResult {
|
||||||
openIssues: GitHubIssue[];
|
openIssues: GitHubIssue[];
|
||||||
@@ -197,3 +203,45 @@ export function useGitHubIssueComments(
|
|||||||
staleTime: STALE_TIMES.GITHUB,
|
staleTime: STALE_TIMES.GITHUB,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch review comments for a GitHub PR
|
||||||
|
*
|
||||||
|
* Fetches both regular PR comments and inline code review comments
|
||||||
|
* with file path and line context for each.
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param prNumber - PR number
|
||||||
|
* @returns Query result with review comments
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { data, isLoading } = useGitHubPRReviewComments(projectPath, prNumber);
|
||||||
|
* const comments = data?.comments ?? [];
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useGitHubPRReviewComments(
|
||||||
|
projectPath: string | undefined,
|
||||||
|
prNumber: number | undefined
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.github.prReviewComments(projectPath ?? '', prNumber ?? 0),
|
||||||
|
queryFn: async (): Promise<{ comments: PRReviewComment[]; totalCount: number }> => {
|
||||||
|
if (!projectPath || !prNumber) throw new Error('Missing project path or PR number');
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.github) {
|
||||||
|
throw new Error('GitHub API not available');
|
||||||
|
}
|
||||||
|
const result = await api.github.getPRReviewComments(projectPath, prNumber);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to fetch PR review comments');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
comments: (result.comments ?? []) as PRReviewComment[],
|
||||||
|
totalCount: result.totalCount ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!projectPath && !!prNumber,
|
||||||
|
staleTime: STALE_TIMES.GITHUB,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -313,6 +313,34 @@ export interface GitHubRemoteStatus {
|
|||||||
repo: string | null;
|
repo: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A review comment on a pull request (inline code comment or general PR comment) */
|
||||||
|
export interface PRReviewComment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
body: string;
|
||||||
|
/** File path for inline review comments */
|
||||||
|
path?: string;
|
||||||
|
/** Line number for inline review comments */
|
||||||
|
line?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
/** Whether this is an inline code review comment (vs general PR comment) */
|
||||||
|
isReviewComment: boolean;
|
||||||
|
/** Whether this comment is outdated (code has changed since) */
|
||||||
|
isOutdated?: boolean;
|
||||||
|
/** Whether the review thread containing this comment has been resolved */
|
||||||
|
isResolved?: boolean;
|
||||||
|
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
|
||||||
|
threadId?: string;
|
||||||
|
/** The diff hunk context for the comment */
|
||||||
|
diffHunk?: string;
|
||||||
|
/** The side of the diff (LEFT or RIGHT) */
|
||||||
|
side?: string;
|
||||||
|
/** The commit ID the comment was made on */
|
||||||
|
commitId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitHubAPI {
|
export interface GitHubAPI {
|
||||||
checkRemote: (projectPath: string) => Promise<{
|
checkRemote: (projectPath: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -389,6 +417,26 @@ export interface GitHubAPI {
|
|||||||
endCursor?: string;
|
endCursor?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
/** Fetch review comments for a specific pull request */
|
||||||
|
getPRReviewComments: (
|
||||||
|
projectPath: string,
|
||||||
|
prNumber: number
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
comments?: PRReviewComment[];
|
||||||
|
totalCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
/** Resolve or unresolve a PR review thread */
|
||||||
|
resolveReviewThread: (
|
||||||
|
projectPath: string,
|
||||||
|
threadId: string,
|
||||||
|
resolve: boolean
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
isResolved?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spec Regeneration types
|
// Spec Regeneration types
|
||||||
@@ -3980,6 +4028,21 @@ function createMockGitHubAPI(): GitHubAPI {
|
|||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getPRReviewComments: async (projectPath: string, prNumber: number) => {
|
||||||
|
console.log('[Mock] Getting PR review comments:', { projectPath, prNumber });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
comments: [],
|
||||||
|
totalCount: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
resolveReviewThread: async (projectPath: string, threadId: string, resolve: boolean) => {
|
||||||
|
console.log('[Mock] Resolving review thread:', { projectPath, threadId, resolve });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
isResolved: resolve,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2474,6 +2474,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
|
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
|
||||||
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
|
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
|
||||||
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
|
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
|
||||||
|
getPRReviewComments: (projectPath: string, prNumber: number) =>
|
||||||
|
this.post('/api/github/pr-review-comments', { projectPath, prNumber }),
|
||||||
|
resolveReviewThread: (projectPath: string, threadId: string, resolve: boolean) =>
|
||||||
|
this.post('/api/github/resolve-pr-comment', { projectPath, threadId, resolve }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Workspace API
|
// Workspace API
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const queryKeys = {
|
|||||||
/** Issue comments */
|
/** Issue comments */
|
||||||
issueComments: (projectPath: string, issueNumber: number) =>
|
issueComments: (projectPath: string, issueNumber: number) =>
|
||||||
['github', 'issues', projectPath, issueNumber, 'comments'] as const,
|
['github', 'issues', projectPath, issueNumber, 'comments'] as const,
|
||||||
|
/** PR review comments */
|
||||||
|
prReviewComments: (projectPath: string, prNumber: number) =>
|
||||||
|
['github', 'prs', projectPath, prNumber, 'review-comments'] as const,
|
||||||
/** Remote info */
|
/** Remote info */
|
||||||
remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
|
remote: (projectPath: string) => ['github', 'remote', projectPath] as const,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { ProjectSettingsView } from '@/components/views/project-settings-view';
|
import { ProjectSettingsView } from '@/components/views/project-settings-view';
|
||||||
|
import type { ProjectSettingsViewId } from '@/components/views/project-settings-view/hooks/use-project-settings-view';
|
||||||
|
|
||||||
|
interface ProjectSettingsSearchParams {
|
||||||
|
section?: ProjectSettingsViewId;
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/project-settings')({
|
export const Route = createFileRoute('/project-settings')({
|
||||||
component: ProjectSettingsView,
|
component: ProjectSettingsView,
|
||||||
|
validateSearch: (search: Record<string, unknown>): ProjectSettingsSearchParams => {
|
||||||
|
return {
|
||||||
|
section: search.section as ProjectSettingsViewId | undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const Route = createLazyFileRoute('/terminal')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { cwd, branch, mode, nonce } = useSearch({ from: '/terminal' });
|
const { cwd, branch, mode, nonce, command } = useSearch({ from: '/terminal' });
|
||||||
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
|
return (
|
||||||
|
<TerminalView
|
||||||
|
initialCwd={cwd}
|
||||||
|
initialBranch={branch}
|
||||||
|
initialMode={mode}
|
||||||
|
nonce={nonce}
|
||||||
|
initialCommand={command}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const terminalSearchSchema = z.object({
|
|||||||
branch: z.string().optional(),
|
branch: z.string().optional(),
|
||||||
mode: z.enum(['tab', 'split']).optional(),
|
mode: z.enum(['tab', 'split']).optional(),
|
||||||
nonce: z.coerce.number().optional(),
|
nonce: z.coerce.number().optional(),
|
||||||
|
command: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Component is lazy-loaded via terminal.lazy.tsx for code splitting
|
// Component is lazy-loaded via terminal.lazy.tsx for code splitting
|
||||||
|
|||||||
Reference in New Issue
Block a user