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:
gsxdsm
2026-02-21 11:07:16 -08:00
committed by GitHub
parent 3ddf26f666
commit f3edfbf24e
14 changed files with 395 additions and 170 deletions

View File

@@ -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;
@@ -93,8 +100,62 @@ const logger = createLogger('PRReviewCommentsService');
// ── Service functions ── // ── Service functions ──
/**
* Execute a GraphQL query via the `gh` CLI and return the parsed response.
*/
async function executeGraphQL(projectPath: string, requestBody: string): Promise<GraphQLResponse> {
let timeoutId: NodeJS.Timeout | undefined;
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
gh.on('error', (err) => {
clearTimeout(timeoutId);
reject(err);
});
timeoutId = setTimeout(() => {
gh.kill();
reject(new Error('GitHub GraphQL API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(e);
}
});
gh.stdin.on('error', () => {
// Ignore stdin errors (e.g. when the child process is killed)
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
return response;
}
/** /**
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API. * 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 }. * Returns a map of comment ID (string) -> { isResolved, threadId }.
*/ */
export async function fetchReviewThreadResolvedStatus( export async function fetchReviewThreadResolvedStatus(
@@ -110,12 +171,14 @@ export async function fetchReviewThreadResolvedStatus(
$owner: String! $owner: String!
$repo: String! $repo: String!
$prNumber: Int! $prNumber: Int!
$cursor: String
) { ) {
repository(owner: $owner, name: $repo) { repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) { pullRequest(number: $prNumber) {
reviewThreads(first: 100) { reviewThreads(first: 100, after: $cursor) {
pageInfo { pageInfo {
hasNextPage hasNextPage
endCursor
} }
nodes { nodes {
id id
@@ -123,6 +186,7 @@ export async function fetchReviewThreadResolvedStatus(
comments(first: 100) { comments(first: 100) {
pageInfo { pageInfo {
hasNextPage hasNextPage
endCursor
} }
nodes { nodes {
databaseId databaseId
@@ -134,76 +198,48 @@ export async function fetchReviewThreadResolvedStatus(
} }
}`; }`;
const variables = { owner, repo, prNumber };
const requestBody = JSON.stringify({ query, variables });
try { try {
let timeoutId: NodeJS.Timeout | undefined; let cursor: string | null = null;
let pageCount = 0;
const response = await new Promise<GraphQLResponse>((resolve, reject) => { do {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { const variables = { owner, repo, prNumber, cursor };
cwd: projectPath, const requestBody = JSON.stringify({ query, variables });
env: execEnv, const response = await executeGraphQL(projectPath, requestBody);
});
gh.on('error', (err) => { const reviewThreads = response.data?.repository?.pullRequest?.reviewThreads;
clearTimeout(timeoutId); const threads = reviewThreads?.nodes ?? [];
reject(err);
});
timeoutId = setTimeout(() => { for (const thread of threads) {
gh.kill(); if (thread.comments.pageInfo?.hasNextPage) {
reject(new Error('GitHub GraphQL API request timed out')); logger.debug(
}, GITHUB_API_TIMEOUT_MS); `Review thread ${thread.id} in PR #${prNumber} has >100 comments — ` +
'some comments may be missing resolved status'
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 { const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
resolve(JSON.parse(stdout)); for (const comment of thread.comments.nodes) {
} catch (e) { resolvedMap.set(String(comment.databaseId), info);
reject(e);
} }
}); }
gh.stdin.write(requestBody); const pageInfo = reviewThreads?.pageInfo;
gh.stdin.end(); if (pageInfo?.hasNextPage && pageInfo.endCursor) {
}); cursor = pageInfo.endCursor;
pageCount++;
if (response.errors && response.errors.length > 0) { logger.debug(
throw new Error(response.errors[0].message); `Fetching next page of review threads for PR #${prNumber} (page ${pageCount + 1})`
}
// Check if reviewThreads data was truncated (more than 100 threads)
const pageInfo = response.data?.repository?.pullRequest?.reviewThreads?.pageInfo;
if (pageInfo?.hasNextPage) {
logger.warn(
`PR #${prNumber} in ${owner}/${repo} has more than 100 review threads — ` +
'results are truncated. Some comments may be missing resolved status.'
);
// TODO: Implement cursor-based pagination by iterating with
// reviewThreads.nodes pageInfo.endCursor across spawn calls.
}
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
for (const thread of threads) {
if (thread.comments.pageInfo?.hasNextPage) {
logger.warn(
`Review thread ${thread.id} in PR #${prNumber} has more than 100 comments — ` +
'comment list is truncated. Some comments may be missing resolved status.'
); );
} else {
cursor = null;
} }
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id }; } while (cursor && pageCount < MAX_PAGINATION_PAGES);
for (const comment of thread.comments.nodes) {
resolvedMap.set(String(comment.databaseId), info); 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
@@ -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) {

View File

@@ -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>
)} )}
{/* Expand detail button */} <div className="ml-auto shrink-0 flex items-center gap-1">
<button {/* Disclosure triangle - toggles expand/collapse */}
type="button" {needsExpansion ? (
onClick={handleExpandDetail} <button
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted" type="button"
title="View full comment details" onClick={handleExpandToggle}
> className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
<Maximize2 className="h-3.5 w-3.5" /> title={isExpanded ? 'Collapse comment' : 'Expand comment'}
</button> >
{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 */}
<button
type="button"
onClick={handleExpandDetail}
className="text-muted-foreground hover:text-foreground transition-colors p-0.5 rounded hover:bg-muted"
title="View full comment details"
>
<Maximize2 className="h-3.5 w-3.5" />
</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

View File

@@ -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 (

View File

@@ -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 */}

View File

@@ -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">
{projectName} <span>
{currentSessionId && !isConnected && ' - Connecting...'} {projectName}
</p> {currentSessionId && !isConnected && ' - Connecting...'}
</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>

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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 flex-1 pr-0 rounded-r-none text-destructive focus:text-destructive"
className="text-xs 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 />
</> </>
) : ( ) : (
<> <>
<DropdownMenuItem {/* Start Dev Server - split button: click main area to start, chevron for view logs */}
onClick={() => onStartDevServer(worktree)} <DropdownMenuSub>
disabled={isStartingDevServer} <div className="flex items-center">
className="text-xs" <DropdownMenuItem
> onClick={() => onStartDevServer(worktree)}
<Play className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')} /> disabled={isStartingDevServer}
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'} className="text-xs flex-1 pr-0 rounded-r-none"
</DropdownMenuItem> >
<Play
className={cn('w-3.5 h-3.5 mr-2', isStartingDevServer && 'animate-pulse')}
/>
{isStartingDevServer ? 'Starting...' : 'Start Dev Server'}
</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 />
</> </>
)} )}

View File

@@ -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);
} }

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;