mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Fix: Delete Worktree Crash + PR Comments + Dev Server UX Improvements (#792)
* Changes from fix/delete-worktree-hotifx * fix: Improve bot detection and prevent UI overflow issues - Include GitHub app-initiated comments in bot detection - Wrap handleQuickCreateSession with useCallback to fix dependency issues - Truncate long branch names in agent header to prevent layout overflow * feat: Support GitHub App comments in PR review and fix session filtering * feat: Return invalidation result from delete session handler
This commit is contained in:
@@ -37,6 +37,8 @@ export interface PRReviewComment {
|
|||||||
side?: string;
|
side?: string;
|
||||||
/** The commit ID the comment was made on */
|
/** The commit ID the comment was made on */
|
||||||
commitId?: string;
|
commitId?: string;
|
||||||
|
/** Whether the comment author is a bot/app account */
|
||||||
|
isBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListPRReviewCommentsResult {
|
export interface ListPRReviewCommentsResult {
|
||||||
@@ -51,6 +53,9 @@ export interface ListPRReviewCommentsResult {
|
|||||||
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
/** Timeout for GitHub GraphQL API requests in milliseconds */
|
||||||
const GITHUB_API_TIMEOUT_MS = 30000;
|
const GITHUB_API_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
/** Maximum number of pagination pages to prevent infinite loops */
|
||||||
|
const MAX_PAGINATION_PAGES = 20;
|
||||||
|
|
||||||
interface GraphQLReviewThreadComment {
|
interface GraphQLReviewThreadComment {
|
||||||
databaseId: number;
|
databaseId: number;
|
||||||
}
|
}
|
||||||
@@ -61,6 +66,7 @@ interface GraphQLReviewThread {
|
|||||||
comments: {
|
comments: {
|
||||||
pageInfo?: {
|
pageInfo?: {
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
|
endCursor?: string | null;
|
||||||
};
|
};
|
||||||
nodes: GraphQLReviewThreadComment[];
|
nodes: GraphQLReviewThreadComment[];
|
||||||
};
|
};
|
||||||
@@ -74,6 +80,7 @@ interface GraphQLResponse {
|
|||||||
nodes: GraphQLReviewThread[];
|
nodes: GraphQLReviewThread[];
|
||||||
pageInfo?: {
|
pageInfo?: {
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
|
endCursor?: string | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
} | null;
|
} | null;
|
||||||
@@ -94,50 +101,9 @@ const logger = createLogger('PRReviewCommentsService');
|
|||||||
// ── Service functions ──
|
// ── Service functions ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
|
* Execute a GraphQL query via the `gh` CLI and return the parsed response.
|
||||||
* Returns a map of comment ID (string) -> { isResolved, threadId }.
|
|
||||||
*/
|
*/
|
||||||
export async function fetchReviewThreadResolvedStatus(
|
async function executeGraphQL(projectPath: string, requestBody: string): Promise<GraphQLResponse> {
|
||||||
projectPath: string,
|
|
||||||
owner: string,
|
|
||||||
repo: string,
|
|
||||||
prNumber: number
|
|
||||||
): Promise<Map<string, ReviewThreadInfo>> {
|
|
||||||
const resolvedMap = new Map<string, ReviewThreadInfo>();
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query GetPRReviewThreads(
|
|
||||||
$owner: String!
|
|
||||||
$repo: String!
|
|
||||||
$prNumber: Int!
|
|
||||||
) {
|
|
||||||
repository(owner: $owner, name: $repo) {
|
|
||||||
pullRequest(number: $prNumber) {
|
|
||||||
reviewThreads(first: 100) {
|
|
||||||
pageInfo {
|
|
||||||
hasNextPage
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
id
|
|
||||||
isResolved
|
|
||||||
comments(first: 100) {
|
|
||||||
pageInfo {
|
|
||||||
hasNextPage
|
|
||||||
}
|
|
||||||
nodes {
|
|
||||||
databaseId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const variables = { owner, repo, prNumber };
|
|
||||||
const requestBody = JSON.stringify({ query, variables });
|
|
||||||
|
|
||||||
try {
|
|
||||||
let timeoutId: NodeJS.Timeout | undefined;
|
let timeoutId: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
|
||||||
@@ -173,6 +139,9 @@ export async function fetchReviewThreadResolvedStatus(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
gh.stdin.on('error', () => {
|
||||||
|
// Ignore stdin errors (e.g. when the child process is killed)
|
||||||
|
});
|
||||||
gh.stdin.write(requestBody);
|
gh.stdin.write(requestBody);
|
||||||
gh.stdin.end();
|
gh.stdin.end();
|
||||||
});
|
});
|
||||||
@@ -181,23 +150,71 @@ export async function fetchReviewThreadResolvedStatus(
|
|||||||
throw new Error(response.errors[0].message);
|
throw new Error(response.errors[0].message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if reviewThreads data was truncated (more than 100 threads)
|
return response;
|
||||||
const pageInfo = response.data?.repository?.pullRequest?.reviewThreads?.pageInfo;
|
|
||||||
if (pageInfo?.hasNextPage) {
|
|
||||||
logger.warn(
|
|
||||||
`PR #${prNumber} in ${owner}/${repo} has more than 100 review threads — ` +
|
|
||||||
'results are truncated. Some comments may be missing resolved status.'
|
|
||||||
);
|
|
||||||
// TODO: Implement cursor-based pagination by iterating with
|
|
||||||
// reviewThreads.nodes pageInfo.endCursor across spawn calls.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
/**
|
||||||
|
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
|
||||||
|
* Uses cursor-based pagination to handle PRs with more than 100 review threads.
|
||||||
|
* Returns a map of comment ID (string) -> { isResolved, threadId }.
|
||||||
|
*/
|
||||||
|
export async function fetchReviewThreadResolvedStatus(
|
||||||
|
projectPath: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
prNumber: number
|
||||||
|
): Promise<Map<string, ReviewThreadInfo>> {
|
||||||
|
const resolvedMap = new Map<string, ReviewThreadInfo>();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetPRReviewThreads(
|
||||||
|
$owner: String!
|
||||||
|
$repo: String!
|
||||||
|
$prNumber: Int!
|
||||||
|
$cursor: String
|
||||||
|
) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $prNumber) {
|
||||||
|
reviewThreads(first: 100, after: $cursor) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
isResolved
|
||||||
|
comments(first: 100) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
nodes {
|
||||||
|
databaseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const variables = { owner, repo, prNumber, cursor };
|
||||||
|
const requestBody = JSON.stringify({ query, variables });
|
||||||
|
const response = await executeGraphQL(projectPath, requestBody);
|
||||||
|
|
||||||
|
const reviewThreads = response.data?.repository?.pullRequest?.reviewThreads;
|
||||||
|
const threads = reviewThreads?.nodes ?? [];
|
||||||
|
|
||||||
for (const thread of threads) {
|
for (const thread of threads) {
|
||||||
if (thread.comments.pageInfo?.hasNextPage) {
|
if (thread.comments.pageInfo?.hasNextPage) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`Review thread ${thread.id} in PR #${prNumber} has more than 100 comments — ` +
|
`Review thread ${thread.id} in PR #${prNumber} has >100 comments — ` +
|
||||||
'comment list is truncated. Some comments may be missing resolved status.'
|
'some comments may be missing resolved status'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
|
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
|
||||||
@@ -205,6 +222,25 @@ export async function fetchReviewThreadResolvedStatus(
|
|||||||
resolvedMap.set(String(comment.databaseId), info);
|
resolvedMap.set(String(comment.databaseId), info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageInfo = reviewThreads?.pageInfo;
|
||||||
|
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
|
||||||
|
cursor = pageInfo.endCursor;
|
||||||
|
pageCount++;
|
||||||
|
logger.debug(
|
||||||
|
`Fetching next page of review threads for PR #${prNumber} (page ${pageCount + 1})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
cursor = null;
|
||||||
|
}
|
||||||
|
} while (cursor && pageCount < MAX_PAGINATION_PAGES);
|
||||||
|
|
||||||
|
if (pageCount >= MAX_PAGINATION_PAGES) {
|
||||||
|
logger.warn(
|
||||||
|
`PR #${prNumber} in ${owner}/${repo} has more than ${MAX_PAGINATION_PAGES * 100} review threads — ` +
|
||||||
|
'pagination limit reached. Some comments may be missing resolved status.'
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log but don't fail — resolved status is best-effort
|
// Log but don't fail — resolved status is best-effort
|
||||||
logError(error, 'Failed to fetch PR review thread resolved status');
|
logError(error, 'Failed to fetch PR review thread resolved status');
|
||||||
@@ -214,7 +250,7 @@ export async function fetchReviewThreadResolvedStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all comments for a PR (both regular and inline review comments)
|
* Fetch all comments for a PR (regular, inline review, and review body comments)
|
||||||
*/
|
*/
|
||||||
export async function fetchPRReviewComments(
|
export async function fetchPRReviewComments(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -228,10 +264,14 @@ export async function fetchPRReviewComments(
|
|||||||
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
|
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
|
||||||
|
|
||||||
// 1. Fetch regular PR comments (issue-level comments)
|
// 1. Fetch regular PR comments (issue-level comments)
|
||||||
|
// Uses the REST API issues endpoint instead of `gh pr view --json comments`
|
||||||
|
// because the latter uses GraphQL internally where bot/app authors can return
|
||||||
|
// null, causing bot comments to be silently dropped or display as "unknown".
|
||||||
try {
|
try {
|
||||||
|
const issueCommentsEndpoint = `repos/${owner}/${repo}/issues/${prNumber}/comments`;
|
||||||
const { stdout: commentsOutput } = await execFileAsync(
|
const { stdout: commentsOutput } = await execFileAsync(
|
||||||
'gh',
|
'gh',
|
||||||
['pr', 'view', String(prNumber), '-R', `${owner}/${repo}`, '--json', 'comments'],
|
['api', issueCommentsEndpoint, '--paginate'],
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
@@ -241,22 +281,24 @@ export async function fetchPRReviewComments(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const commentsData = JSON.parse(commentsOutput);
|
const commentsData = JSON.parse(commentsOutput);
|
||||||
const regularComments = (commentsData.comments || []).map(
|
const regularComments = (Array.isArray(commentsData) ? commentsData : []).map(
|
||||||
(c: {
|
(c: {
|
||||||
id: string;
|
id: number;
|
||||||
author: { login: string; avatarUrl?: string };
|
user: { login: string; avatar_url?: string; type?: string } | null;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: string;
|
created_at: string;
|
||||||
updatedAt?: string;
|
updated_at?: string;
|
||||||
|
performed_via_github_app?: { slug: string } | null;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: String(c.id),
|
id: String(c.id),
|
||||||
author: c.author?.login || 'unknown',
|
author: c.user?.login || c.performed_via_github_app?.slug || 'unknown',
|
||||||
avatarUrl: c.author?.avatarUrl,
|
avatarUrl: c.user?.avatar_url,
|
||||||
body: c.body,
|
body: c.body,
|
||||||
createdAt: c.createdAt,
|
createdAt: c.created_at,
|
||||||
updatedAt: c.updatedAt,
|
updatedAt: c.updated_at,
|
||||||
isReviewComment: false,
|
isReviewComment: false,
|
||||||
isOutdated: false,
|
isOutdated: false,
|
||||||
|
isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app,
|
||||||
// Regular PR comments are not part of review threads, so not resolvable
|
// Regular PR comments are not part of review threads, so not resolvable
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
})
|
})
|
||||||
@@ -272,7 +314,7 @@ export async function fetchPRReviewComments(
|
|||||||
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
||||||
const { stdout: reviewsOutput } = await execFileAsync(
|
const { stdout: reviewsOutput } = await execFileAsync(
|
||||||
'gh',
|
'gh',
|
||||||
['api', reviewsEndpoint, '--paginate', '--slurp', '--jq', 'add // []'],
|
['api', reviewsEndpoint, '--paginate'],
|
||||||
{
|
{
|
||||||
cwd: projectPath,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
@@ -285,7 +327,7 @@ export async function fetchPRReviewComments(
|
|||||||
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
|
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
|
||||||
(c: {
|
(c: {
|
||||||
id: number;
|
id: number;
|
||||||
user: { login: string; avatar_url?: string };
|
user: { login: string; avatar_url?: string; type?: string } | null;
|
||||||
body: string;
|
body: string;
|
||||||
path: string;
|
path: string;
|
||||||
line?: number;
|
line?: number;
|
||||||
@@ -296,9 +338,10 @@ export async function fetchPRReviewComments(
|
|||||||
side?: string;
|
side?: string;
|
||||||
commit_id?: string;
|
commit_id?: string;
|
||||||
position?: number | null;
|
position?: number | null;
|
||||||
|
performed_via_github_app?: { slug: string } | null;
|
||||||
}) => ({
|
}) => ({
|
||||||
id: String(c.id),
|
id: String(c.id),
|
||||||
author: c.user?.login || 'unknown',
|
author: c.user?.login || c.performed_via_github_app?.slug || 'unknown',
|
||||||
avatarUrl: c.user?.avatar_url,
|
avatarUrl: c.user?.avatar_url,
|
||||||
body: c.body,
|
body: c.body,
|
||||||
path: c.path,
|
path: c.path,
|
||||||
@@ -310,6 +353,7 @@ export async function fetchPRReviewComments(
|
|||||||
isOutdated: c.position === null,
|
isOutdated: c.position === null,
|
||||||
// isResolved will be filled in below from GraphQL data
|
// isResolved will be filled in below from GraphQL data
|
||||||
isResolved: false,
|
isResolved: false,
|
||||||
|
isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app,
|
||||||
diffHunk: c.diff_hunk,
|
diffHunk: c.diff_hunk,
|
||||||
side: c.side,
|
side: c.side,
|
||||||
commitId: c.commit_id,
|
commitId: c.commit_id,
|
||||||
@@ -321,6 +365,55 @@ export async function fetchPRReviewComments(
|
|||||||
logError(error, 'Failed to fetch inline review comments');
|
logError(error, 'Failed to fetch inline review comments');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Fetch review body comments (summary text submitted with each review)
|
||||||
|
// These are the top-level comments written when submitting a review
|
||||||
|
// (Approve, Request Changes, Comment). They are separate from inline code comments
|
||||||
|
// and issue-level comments. Only include reviews that have a non-empty body.
|
||||||
|
try {
|
||||||
|
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/reviews`;
|
||||||
|
const { stdout: reviewBodiesOutput } = await execFileAsync(
|
||||||
|
'gh',
|
||||||
|
['api', reviewsEndpoint, '--paginate'],
|
||||||
|
{
|
||||||
|
cwd: projectPath,
|
||||||
|
env: execEnv,
|
||||||
|
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs
|
||||||
|
timeout: GITHUB_API_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewBodiesData = JSON.parse(reviewBodiesOutput);
|
||||||
|
const reviewBodyComments = (Array.isArray(reviewBodiesData) ? reviewBodiesData : [])
|
||||||
|
.filter(
|
||||||
|
(r: { body?: string; state?: string }) =>
|
||||||
|
r.body && r.body.trim().length > 0 && r.state !== 'PENDING'
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(r: {
|
||||||
|
id: number;
|
||||||
|
user: { login: string; avatar_url?: string; type?: string } | null;
|
||||||
|
body: string;
|
||||||
|
state: string;
|
||||||
|
submitted_at: string;
|
||||||
|
performed_via_github_app?: { slug: string } | null;
|
||||||
|
}) => ({
|
||||||
|
id: `review-${r.id}`,
|
||||||
|
author: r.user?.login || r.performed_via_github_app?.slug || 'unknown',
|
||||||
|
avatarUrl: r.user?.avatar_url,
|
||||||
|
body: r.body,
|
||||||
|
createdAt: r.submitted_at,
|
||||||
|
isReviewComment: false,
|
||||||
|
isOutdated: false,
|
||||||
|
isResolved: false,
|
||||||
|
isBot: r.user?.type === 'Bot' || !!r.performed_via_github_app,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
allComments.push(...reviewBodyComments);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Failed to fetch review body comments');
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for resolved status and apply to inline review comments
|
// Wait for resolved status and apply to inline review comments
|
||||||
const resolvedMap = await resolvedStatusPromise;
|
const resolvedMap = await resolvedStatusPromise;
|
||||||
for (const comment of allComments) {
|
for (const comment of allComments) {
|
||||||
|
|||||||
@@ -248,39 +248,22 @@ function CommentRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-3 p-3 rounded-lg border border-border transition-colors cursor-pointer',
|
'flex items-start gap-3 p-3 rounded-lg border border-border transition-colors',
|
||||||
|
needsExpansion ? 'cursor-pointer' : 'cursor-default',
|
||||||
isSelected ? 'bg-accent/50 border-primary/30' : 'hover:bg-accent/30'
|
isSelected ? 'bg-accent/50 border-primary/30' : 'hover:bg-accent/30'
|
||||||
)}
|
)}
|
||||||
onClick={onToggle}
|
onClick={needsExpansion ? () => setIsExpanded((prev) => !prev) : undefined}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={() => onToggle()}
|
onCheckedChange={() => onToggle()}
|
||||||
className="mt-0.5"
|
className="mt-0.5 shrink-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Header: disclosure triangle + author + file location + tags */}
|
{/* Header: disclosure triangle + author + file location + tags */}
|
||||||
<div className="flex items-start gap-1.5 flex-wrap mb-1">
|
<div className="flex items-start gap-1.5 flex-wrap mb-1">
|
||||||
{/* Disclosure triangle - always shown, toggles expand/collapse */}
|
|
||||||
{needsExpansion ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleExpandToggle}
|
|
||||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title={isExpanded ? 'Collapse comment' : 'Expand comment'}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 -rotate-90" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="mt-0.5 shrink-0 w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{comment.avatarUrl ? (
|
{comment.avatarUrl ? (
|
||||||
@@ -304,6 +287,12 @@ function CommentRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{comment.isBot && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-purple-500/10 text-purple-500">
|
||||||
|
Bot
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{comment.isOutdated && (
|
{comment.isOutdated && (
|
||||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-yellow-500/10 text-yellow-500">
|
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-yellow-500/10 text-yellow-500">
|
||||||
Outdated
|
Outdated
|
||||||
@@ -347,27 +336,47 @@ function CommentRow({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto shrink-0 flex items-center gap-1">
|
||||||
|
{/* Disclosure triangle - toggles expand/collapse */}
|
||||||
|
{needsExpansion ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExpandToggle}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
|
||||||
|
title={isExpanded ? 'Collapse comment' : 'Expand comment'}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 -rotate-90" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Expand detail button */}
|
{/* Expand detail button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleExpandDetail}
|
onClick={handleExpandDetail}
|
||||||
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
|
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
|
||||||
title="View full comment details"
|
title="View full comment details"
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Comment body - collapsible, rendered as markdown */}
|
{/* Comment body - collapsible, rendered as markdown */}
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<div className="pl-5" onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground">
|
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground">
|
||||||
{comment.body}
|
{comment.body}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="pl-5 line-clamp-2">
|
<div className="line-clamp-2">
|
||||||
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground [&_p]:my-0 [&_ul]:my-0 [&_ol]:my-0 [&_h1]:text-sm [&_h2]:text-sm [&_h3]:text-sm [&_h4]:text-sm [&_h1]:my-0 [&_h2]:my-0 [&_h3]:my-0 [&_h4]:my-0 [&_pre]:my-0 [&_blockquote]:my-0">
|
<Markdown className="text-sm [&_p]:text-muted-foreground [&_li]:text-muted-foreground [&_p]:my-0 [&_ul]:my-0 [&_ol]:my-0 [&_h1]:text-sm [&_h2]:text-sm [&_h3]:text-sm [&_h4]:text-sm [&_h1]:my-0 [&_h2]:my-0 [&_h3]:my-0 [&_h4]:my-0 [&_pre]:my-0 [&_blockquote]:my-0">
|
||||||
{comment.body}
|
{comment.body}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
@@ -375,7 +384,7 @@ function CommentRow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date row */}
|
{/* Date row */}
|
||||||
<div className="flex items-center mt-1 pl-5">
|
<div className="flex items-center mt-1">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="text-xs text-muted-foreground">{formatDate(comment.createdAt)}</div>
|
<div className="text-xs text-muted-foreground">{formatDate(comment.createdAt)}</div>
|
||||||
<div className="text-xs text-muted-foreground/70">{formatTime(comment.createdAt)}</div>
|
<div className="text-xs text-muted-foreground/70">{formatTime(comment.createdAt)}</div>
|
||||||
@@ -440,6 +449,11 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo
|
|||||||
|
|
||||||
{/* Badges */}
|
{/* Badges */}
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto">
|
||||||
|
{comment.isBot && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-purple-500/10 text-purple-500">
|
||||||
|
Bot
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{comment.isOutdated && (
|
{comment.isOutdated && (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-yellow-500/10 text-yellow-500">
|
<span className="px-2 py-0.5 text-xs font-medium rounded bg-yellow-500/10 text-yellow-500">
|
||||||
Outdated
|
Outdated
|
||||||
@@ -850,7 +864,7 @@ export function PRCommentResolutionDialog({
|
|||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
|
<DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between pr-10">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<MessageSquare className="h-5 w-5 text-blue-500" />
|
<MessageSquare className="h-5 w-5 text-blue-500" />
|
||||||
Manage PR Review Comments
|
Manage PR Review Comments
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, pathsEqual } from '@/lib/utils';
|
||||||
import type { SessionListItem } from '@/types/electron';
|
import type { SessionListItem } from '@/types/electron';
|
||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
@@ -93,6 +93,7 @@ interface SessionManagerProps {
|
|||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
onSelectSession: (sessionId: string | null) => void;
|
onSelectSession: (sessionId: string | null) => void;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
workingDirectory?: string; // Current worktree path for scoping sessions
|
||||||
isCurrentSessionThinking?: boolean;
|
isCurrentSessionThinking?: boolean;
|
||||||
onQuickCreateRef?: React.MutableRefObject<(() => Promise<void>) | null>;
|
onQuickCreateRef?: React.MutableRefObject<(() => Promise<void>) | null>;
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,7 @@ export function SessionManager({
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
workingDirectory,
|
||||||
isCurrentSessionThinking = false,
|
isCurrentSessionThinking = false,
|
||||||
onQuickCreateRef,
|
onQuickCreateRef,
|
||||||
}: SessionManagerProps) {
|
}: SessionManagerProps) {
|
||||||
@@ -153,6 +155,7 @@ export function SessionManager({
|
|||||||
if (result.data) {
|
if (result.data) {
|
||||||
await checkRunningSessions(result.data);
|
await checkRunningSessions(result.data);
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}, [queryClient, refetchSessions, checkRunningSessions]);
|
}, [queryClient, refetchSessions, checkRunningSessions]);
|
||||||
|
|
||||||
// Check running state on initial load (runs only once when sessions first load)
|
// Check running state on initial load (runs only once when sessions first load)
|
||||||
@@ -177,6 +180,9 @@ export function SessionManager({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
||||||
|
|
||||||
|
// Effective working directory for session creation (worktree path or project path)
|
||||||
|
const effectiveWorkingDirectory = workingDirectory || projectPath;
|
||||||
|
|
||||||
// Create new session with random name
|
// Create new session with random name
|
||||||
const handleCreateSession = async () => {
|
const handleCreateSession = async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -184,7 +190,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||||
|
|
||||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
setNewSessionName('');
|
setNewSessionName('');
|
||||||
@@ -195,19 +201,19 @@ export function SessionManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create new session directly with a random name (one-click)
|
// Create new session directly with a random name (one-click)
|
||||||
const handleQuickCreateSession = async () => {
|
const handleQuickCreateSession = useCallback(async () => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.sessions) return;
|
if (!api?.sessions) return;
|
||||||
|
|
||||||
const sessionName = generateRandomSessionName();
|
const sessionName = generateRandomSessionName();
|
||||||
|
|
||||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
await invalidateSessions();
|
await invalidateSessions();
|
||||||
onSelectSession(result.session.id);
|
onSelectSession(result.session.id);
|
||||||
}
|
}
|
||||||
};
|
}, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]);
|
||||||
|
|
||||||
// Expose the quick create function via ref for keyboard shortcuts
|
// Expose the quick create function via ref for keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -219,7 +225,7 @@ export function SessionManager({
|
|||||||
onQuickCreateRef.current = null;
|
onQuickCreateRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [onQuickCreateRef, projectPath]);
|
}, [onQuickCreateRef, handleQuickCreateSession]);
|
||||||
|
|
||||||
// Rename session
|
// Rename session
|
||||||
const handleRenameSession = async (sessionId: string) => {
|
const handleRenameSession = async (sessionId: string) => {
|
||||||
@@ -292,10 +298,11 @@ export function SessionManager({
|
|||||||
|
|
||||||
const result = await api.sessions.delete(sessionId);
|
const result = await api.sessions.delete(sessionId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await invalidateSessions();
|
const refetchResult = await invalidateSessions();
|
||||||
if (currentSessionId === sessionId) {
|
if (currentSessionId === sessionId) {
|
||||||
// Switch to another session or create a new one
|
// Switch to another session using fresh data, excluding the deleted session
|
||||||
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
const freshSessions = refetchResult?.data ?? [];
|
||||||
|
const activeSessionsList = freshSessions.filter((s) => !s.isArchived && s.id !== sessionId);
|
||||||
if (activeSessionsList.length > 0) {
|
if (activeSessionsList.length > 0) {
|
||||||
onSelectSession(activeSessionsList[0].id);
|
onSelectSession(activeSessionsList[0].id);
|
||||||
}
|
}
|
||||||
@@ -318,8 +325,16 @@ export function SessionManager({
|
|||||||
setIsDeleteAllArchivedDialogOpen(false);
|
setIsDeleteAllArchivedDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
// Filter sessions by current working directory (worktree scoping)
|
||||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
const scopedSessions = sessions.filter((s) => {
|
||||||
|
const sessionDir = s.workingDirectory || s.projectPath;
|
||||||
|
// Match sessions whose workingDirectory matches the current effective directory
|
||||||
|
// Use pathsEqual for cross-platform path normalization (trailing slashes, separators)
|
||||||
|
return pathsEqual(sessionDir, effectiveWorkingDirectory);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSessions = scopedSessions.filter((s) => !s.isArchived);
|
||||||
|
const archivedSessions = scopedSessions.filter((s) => s.isArchived);
|
||||||
const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
|
const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ import { AgentInputArea } from './agent-view/input-area';
|
|||||||
const LG_BREAKPOINT = 1024;
|
const LG_BREAKPOINT = 1024;
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject, getCurrentWorktree } = useAppStore();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Get the current worktree to scope sessions and agent working directory
|
||||||
|
const currentWorktree = currentProject ? getCurrentWorktree(currentProject.path) : null;
|
||||||
|
const effectiveWorkingDirectory = currentWorktree?.path || currentProject?.path;
|
||||||
// Initialize session manager state - starts as true to match SSR
|
// Initialize session manager state - starts as true to match SSR
|
||||||
// Then updates on mount based on actual screen size to prevent hydration mismatch
|
// Then updates on mount based on actual screen size to prevent hydration mismatch
|
||||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||||
@@ -52,9 +56,10 @@ export function AgentView() {
|
|||||||
// Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState
|
// Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState
|
||||||
const createSessionInFlightRef = useRef(false);
|
const createSessionInFlightRef = useRef(false);
|
||||||
|
|
||||||
// Session management hook
|
// Session management hook - scoped to current worktree
|
||||||
const { currentSessionId, handleSelectSession } = useAgentSession({
|
const { currentSessionId, handleSelectSession } = useAgentSession({
|
||||||
projectPath: currentProject?.path,
|
projectPath: currentProject?.path,
|
||||||
|
workingDirectory: effectiveWorkingDirectory,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the Electron agent hook (only if we have a session)
|
// Use the Electron agent hook (only if we have a session)
|
||||||
@@ -71,7 +76,7 @@ export function AgentView() {
|
|||||||
clearServerQueue,
|
clearServerQueue,
|
||||||
} = useElectronAgent({
|
} = useElectronAgent({
|
||||||
sessionId: currentSessionId || '',
|
sessionId: currentSessionId || '',
|
||||||
workingDirectory: currentProject?.path,
|
workingDirectory: effectiveWorkingDirectory,
|
||||||
model: modelSelection.model,
|
model: modelSelection.model,
|
||||||
thinkingLevel: modelSelection.thinkingLevel,
|
thinkingLevel: modelSelection.thinkingLevel,
|
||||||
onToolUse: (toolName) => {
|
onToolUse: (toolName) => {
|
||||||
@@ -229,6 +234,7 @@ export function AgentView() {
|
|||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
projectPath={currentProject.path}
|
projectPath={currentProject.path}
|
||||||
|
workingDirectory={effectiveWorkingDirectory}
|
||||||
isCurrentSessionThinking={isProcessing}
|
isCurrentSessionThinking={isProcessing}
|
||||||
onQuickCreateRef={quickCreateSessionRef}
|
onQuickCreateRef={quickCreateSessionRef}
|
||||||
/>
|
/>
|
||||||
@@ -248,6 +254,7 @@ export function AgentView() {
|
|||||||
showSessionManager={showSessionManager}
|
showSessionManager={showSessionManager}
|
||||||
onToggleSessionManager={() => setShowSessionManager(!showSessionManager)}
|
onToggleSessionManager={() => setShowSessionManager(!showSessionManager)}
|
||||||
onClearChat={handleClearChat}
|
onClearChat={handleClearChat}
|
||||||
|
worktreeBranch={currentWorktree?.branch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2 } from 'lucide-react';
|
import { Bot, PanelLeftClose, PanelLeft, Wrench, Trash2, GitBranch } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface AgentHeaderProps {
|
interface AgentHeaderProps {
|
||||||
@@ -11,6 +11,7 @@ interface AgentHeaderProps {
|
|||||||
showSessionManager: boolean;
|
showSessionManager: boolean;
|
||||||
onToggleSessionManager: () => void;
|
onToggleSessionManager: () => void;
|
||||||
onClearChat: () => void;
|
onClearChat: () => void;
|
||||||
|
worktreeBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentHeader({
|
export function AgentHeader({
|
||||||
@@ -23,6 +24,7 @@ export function AgentHeader({
|
|||||||
showSessionManager,
|
showSessionManager,
|
||||||
onToggleSessionManager,
|
onToggleSessionManager,
|
||||||
onClearChat,
|
onClearChat,
|
||||||
|
worktreeBranch,
|
||||||
}: AgentHeaderProps) {
|
}: AgentHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||||
@@ -32,10 +34,18 @@ export function AgentHeader({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
{projectName}
|
{projectName}
|
||||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||||
</p>
|
</span>
|
||||||
|
{worktreeBranch && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs bg-muted/50 px-2 py-0.5 rounded-full border border-border">
|
||||||
|
<GitBranch className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="max-w-[180px] truncate">{worktreeBranch}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const logger = createLogger('AgentSession');
|
|||||||
|
|
||||||
interface UseAgentSessionOptions {
|
interface UseAgentSessionOptions {
|
||||||
projectPath: string | undefined;
|
projectPath: string | undefined;
|
||||||
|
workingDirectory?: string; // Current worktree path for per-worktree session persistence
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAgentSessionResult {
|
interface UseAgentSessionResult {
|
||||||
@@ -13,49 +14,56 @@ interface UseAgentSessionResult {
|
|||||||
handleSelectSession: (sessionId: string | null) => void;
|
handleSelectSession: (sessionId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult {
|
export function useAgentSession({
|
||||||
|
projectPath,
|
||||||
|
workingDirectory,
|
||||||
|
}: UseAgentSessionOptions): UseAgentSessionResult {
|
||||||
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Track if initial session has been loaded
|
// Track if initial session has been loaded
|
||||||
const initialSessionLoadedRef = useRef(false);
|
const initialSessionLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
// Use workingDirectory as the persistence key so sessions are scoped per worktree
|
||||||
|
const persistenceKey = workingDirectory || projectPath;
|
||||||
|
|
||||||
// Handle session selection with persistence
|
// Handle session selection with persistence
|
||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(sessionId: string | null) => {
|
(sessionId: string | null) => {
|
||||||
setCurrentSessionId(sessionId);
|
setCurrentSessionId(sessionId);
|
||||||
// Persist the selection for this project
|
// Persist the selection for this worktree/project
|
||||||
if (projectPath) {
|
if (persistenceKey) {
|
||||||
setLastSelectedSession(projectPath, sessionId);
|
setLastSelectedSession(persistenceKey, sessionId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectPath, setLastSelectedSession]
|
[persistenceKey, setLastSelectedSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restore last selected session when switching to Agent view or when project changes
|
// Restore last selected session when switching to Agent view or when worktree changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectPath) {
|
if (!persistenceKey) {
|
||||||
// No project, reset
|
// No project, reset
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
initialSessionLoadedRef.current = false;
|
initialSessionLoadedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only restore once per project
|
// Only restore once per persistence key
|
||||||
if (initialSessionLoadedRef.current) return;
|
if (initialSessionLoadedRef.current) return;
|
||||||
initialSessionLoadedRef.current = true;
|
initialSessionLoadedRef.current = true;
|
||||||
|
|
||||||
const lastSessionId = getLastSelectedSession(projectPath);
|
const lastSessionId = getLastSelectedSession(persistenceKey);
|
||||||
if (lastSessionId) {
|
if (lastSessionId) {
|
||||||
logger.info('Restoring last selected session:', lastSessionId);
|
logger.info('Restoring last selected session:', lastSessionId);
|
||||||
setCurrentSessionId(lastSessionId);
|
setCurrentSessionId(lastSessionId);
|
||||||
}
|
}
|
||||||
}, [projectPath, getLastSelectedSession]);
|
}, [persistenceKey, getLastSelectedSession]);
|
||||||
|
|
||||||
// Reset initialSessionLoadedRef when project changes
|
// Reset when worktree/project changes - clear current session and allow restore
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialSessionLoadedRef.current = false;
|
initialSessionLoadedRef.current = false;
|
||||||
}, [projectPath]);
|
setCurrentSessionId(null);
|
||||||
|
}, [persistenceKey]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
|||||||
@@ -486,6 +486,11 @@ export function BoardView() {
|
|||||||
} else {
|
} else {
|
||||||
// Specific worktree selected - find it by path
|
// Specific worktree selected - find it by path
|
||||||
found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
|
||||||
|
// If the selected worktree no longer exists (e.g. just deleted),
|
||||||
|
// fall back to main to prevent rendering with undefined worktree
|
||||||
|
if (!found) {
|
||||||
|
found = worktrees.find((w) => w.isMain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!found) return undefined;
|
if (!found) return undefined;
|
||||||
// Ensure all required WorktreeInfo fields are present
|
// Ensure all required WorktreeInfo fields are present
|
||||||
@@ -1953,6 +1958,16 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
|
||||||
onDeleted={(deletedWorktree, _deletedBranch) => {
|
onDeleted={(deletedWorktree, _deletedBranch) => {
|
||||||
|
// If the deleted worktree was currently selected, immediately reset to main
|
||||||
|
// to prevent the UI from trying to render a non-existent worktree view
|
||||||
|
if (
|
||||||
|
currentWorktreePath !== null &&
|
||||||
|
pathsEqual(currentWorktreePath, deletedWorktree.path)
|
||||||
|
) {
|
||||||
|
const mainBranch = worktrees.find((w) => w.isMain)?.branch || 'main';
|
||||||
|
setCurrentWorktree(currentProject.path, null, mainBranch);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset features that were assigned to the deleted worktree (by branch)
|
// Reset features that were assigned to the deleted worktree (by branch)
|
||||||
hookFeatures.forEach((feature) => {
|
hookFeatures.forEach((feature) => {
|
||||||
// Match by branch name since worktreePath is no longer stored
|
// Match by branch name since worktreePath is no longer stored
|
||||||
|
|||||||
@@ -217,9 +217,14 @@ export function useBoardActions({
|
|||||||
const needsTitleGeneration =
|
const needsTitleGeneration =
|
||||||
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
||||||
|
|
||||||
const initialStatus = featureData.initialStatus || 'backlog';
|
const {
|
||||||
|
initialStatus: requestedStatus,
|
||||||
|
workMode: _workMode,
|
||||||
|
...restFeatureData
|
||||||
|
} = featureData;
|
||||||
|
const initialStatus = requestedStatus || 'backlog';
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...restFeatureData,
|
||||||
title: titleWasGenerated ? titleForBranch : featureData.title,
|
title: titleWasGenerated ? titleForBranch : featureData.title,
|
||||||
titleGenerating: needsTitleGeneration,
|
titleGenerating: needsTitleGeneration,
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
@@ -1161,10 +1166,15 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const handleDuplicateFeature = useCallback(
|
const handleDuplicateFeature = useCallback(
|
||||||
async (feature: Feature, asChild: boolean = false) => {
|
async (feature: Feature, asChild: boolean = false) => {
|
||||||
// Copy all feature data, stripping id, status (handled by create), and runtime/state fields
|
// Copy all feature data, stripping id, status (handled by create), and runtime/state fields.
|
||||||
|
// Also strip initialStatus and workMode which are transient creation parameters that
|
||||||
|
// should not carry over to duplicates (initialStatus: 'in_progress' would cause
|
||||||
|
// the duplicate to immediately appear in "In Progress" instead of "Backlog").
|
||||||
const {
|
const {
|
||||||
id: _id,
|
id: _id,
|
||||||
status: _status,
|
status: _status,
|
||||||
|
initialStatus: _initialStatus,
|
||||||
|
workMode: _workMode,
|
||||||
startedAt: _startedAt,
|
startedAt: _startedAt,
|
||||||
error: _error,
|
error: _error,
|
||||||
summary: _summary,
|
summary: _summary,
|
||||||
@@ -1212,6 +1222,8 @@ export function useBoardActions({
|
|||||||
const {
|
const {
|
||||||
id: _id,
|
id: _id,
|
||||||
status: _status,
|
status: _status,
|
||||||
|
initialStatus: _initialStatus,
|
||||||
|
workMode: _workMode,
|
||||||
startedAt: _startedAt,
|
startedAt: _startedAt,
|
||||||
error: _error,
|
error: _error,
|
||||||
summary: _summary,
|
summary: _summary,
|
||||||
|
|||||||
@@ -399,29 +399,57 @@ export function WorktreeActionsDropdown({
|
|||||||
Open in Browser
|
Open in Browser
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
{/* Stop Dev Server - split button: click main area to stop, chevron for view logs */}
|
||||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
<DropdownMenuSub>
|
||||||
View Logs
|
<div className="flex items-center">
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onStopDevServer(worktree)}
|
onClick={() => onStopDevServer(worktree)}
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
className="text-xs flex-1 pr-0 rounded-r-none text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Square className="w-3.5 h-3.5 mr-2" />
|
<Square className="w-3.5 h-3.5 mr-2" />
|
||||||
Stop Dev Server
|
Stop Dev Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||||
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Dev Server Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Start Dev Server - split button: click main area to start, chevron for view logs */}
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<div className="flex items-center">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onStartDevServer(worktree)}
|
onClick={() => onStartDevServer(worktree)}
|
||||||
disabled={isStartingDevServer}
|
disabled={isStartingDevServer}
|
||||||
className="text-xs"
|
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||||
>
|
>
|
||||||
<Play className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')} />
|
<Play
|
||||||
|
className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')}
|
||||||
|
/>
|
||||||
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
|
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
|
||||||
|
isStartingDevServer && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
disabled={isStartingDevServer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSubContent>
|
||||||
|
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||||
|
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||||
|
View Dev Server Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -456,13 +456,20 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
});
|
});
|
||||||
// Start port detection timeout
|
// Start port detection timeout
|
||||||
startPortDetectionTimer(key);
|
startPortDetectionTimer(key);
|
||||||
toast.success('Dev server started, detecting port...');
|
toast.success('Dev server started, detecting port...', {
|
||||||
|
description: 'Logs are now visible in the dev server panel.',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || 'Failed to start dev server');
|
toast.error(result.error || 'Failed to start dev server', {
|
||||||
|
description: 'Check the dev server logs panel for details.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Start dev server failed:', error);
|
logger.error('Start dev server failed:', error);
|
||||||
toast.error('Failed to start dev server');
|
toast.error('Failed to start dev server', {
|
||||||
|
description:
|
||||||
|
error instanceof Error ? error.message : 'Check the dev server logs panel for details.',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsStartingDevServer(false);
|
setIsStartingDevServer(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,6 +659,18 @@ export function WorktreePanel({
|
|||||||
// Keep logPanelWorktree set for smooth close animation
|
// Keep logPanelWorktree set for smooth close animation
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Wrap handleStartDevServer to auto-open the logs panel so the user
|
||||||
|
// can see output immediately (including failure reasons)
|
||||||
|
const handleStartDevServerAndShowLogs = useCallback(
|
||||||
|
async (worktree: WorktreeInfo) => {
|
||||||
|
// Open logs panel immediately so output is visible from the start
|
||||||
|
setLogPanelWorktree(worktree);
|
||||||
|
setLogPanelOpen(true);
|
||||||
|
await handleStartDevServer(worktree);
|
||||||
|
},
|
||||||
|
[handleStartDevServer]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle opening the push to remote dialog
|
// Handle opening the push to remote dialog
|
||||||
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
|
||||||
setPushToRemoteWorktree(worktree);
|
setPushToRemoteWorktree(worktree);
|
||||||
@@ -937,7 +949,7 @@ export function WorktreePanel({
|
|||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
@@ -1181,7 +1193,7 @@ export function WorktreePanel({
|
|||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
@@ -1288,7 +1300,7 @@ export function WorktreePanel({
|
|||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
@@ -1375,7 +1387,7 @@ export function WorktreePanel({
|
|||||||
onResolveConflicts={onResolveConflicts}
|
onResolveConflicts={onResolveConflicts}
|
||||||
onMerge={handleMerge}
|
onMerge={handleMerge}
|
||||||
onDeleteWorktree={onDeleteWorktree}
|
onDeleteWorktree={onDeleteWorktree}
|
||||||
onStartDevServer={handleStartDevServer}
|
onStartDevServer={handleStartDevServerAndShowLogs}
|
||||||
onStopDevServer={handleStopDevServer}
|
onStopDevServer={handleStopDevServer}
|
||||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||||
onViewDevServerLogs={handleViewDevServerLogs}
|
onViewDevServerLogs={handleViewDevServerLogs}
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ export interface PRReviewComment {
|
|||||||
side?: string;
|
side?: string;
|
||||||
/** The commit ID the comment was made on */
|
/** The commit ID the comment was made on */
|
||||||
commitId?: string;
|
commitId?: string;
|
||||||
|
/** Whether the comment author is a bot/app account */
|
||||||
|
isBot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubAPI {
|
export interface GitHubAPI {
|
||||||
|
|||||||
1
apps/ui/src/types/electron.d.ts
vendored
1
apps/ui/src/types/electron.d.ts
vendored
@@ -69,6 +69,7 @@ export interface SessionListItem {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
workingDirectory?: string; // The worktree/directory this session runs in
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface AgentSession {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
workingDirectory?: string; // The worktree/directory this session runs in
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user