diff --git a/apps/server/src/services/pr-review-comments.service.ts b/apps/server/src/services/pr-review-comments.service.ts index 68d801dc1..d4bc1388a 100644 --- a/apps/server/src/services/pr-review-comments.service.ts +++ b/apps/server/src/services/pr-review-comments.service.ts @@ -37,6 +37,8 @@ export interface PRReviewComment { side?: string; /** The commit ID the comment was made on */ commitId?: string; + /** Whether the comment author is a bot/app account */ + isBot?: boolean; } export interface ListPRReviewCommentsResult { @@ -51,6 +53,9 @@ export interface ListPRReviewCommentsResult { /** Timeout for GitHub GraphQL API requests in milliseconds */ const GITHUB_API_TIMEOUT_MS = 30000; +/** Maximum number of pagination pages to prevent infinite loops */ +const MAX_PAGINATION_PAGES = 20; + interface GraphQLReviewThreadComment { databaseId: number; } @@ -61,6 +66,7 @@ interface GraphQLReviewThread { comments: { pageInfo?: { hasNextPage: boolean; + endCursor?: string | null; }; nodes: GraphQLReviewThreadComment[]; }; @@ -74,6 +80,7 @@ interface GraphQLResponse { nodes: GraphQLReviewThread[]; pageInfo?: { hasNextPage: boolean; + endCursor?: string | null; }; }; } | null; @@ -93,8 +100,62 @@ const logger = createLogger('PRReviewCommentsService'); // ── Service functions ── +/** + * Execute a GraphQL query via the `gh` CLI and return the parsed response. + */ +async function executeGraphQL(projectPath: string, requestBody: string): Promise { + let timeoutId: NodeJS.Timeout | undefined; + + const response = await new Promise((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. + * 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( @@ -110,12 +171,14 @@ export async function fetchReviewThreadResolvedStatus( $owner: String! $repo: String! $prNumber: Int! + $cursor: String ) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { - reviewThreads(first: 100) { + reviewThreads(first: 100, after: $cursor) { pageInfo { hasNextPage + endCursor } nodes { id @@ -123,6 +186,7 @@ export async function fetchReviewThreadResolvedStatus( comments(first: 100) { pageInfo { hasNextPage + endCursor } nodes { databaseId @@ -134,76 +198,48 @@ export async function fetchReviewThreadResolvedStatus( } }`; - const variables = { owner, repo, prNumber }; - const requestBody = JSON.stringify({ query, variables }); - try { - let timeoutId: NodeJS.Timeout | undefined; - - const response = await new Promise((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}`)); + 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) { + if (thread.comments.pageInfo?.hasNextPage) { + logger.debug( + `Review thread ${thread.id} in PR #${prNumber} has >100 comments — ` + + 'some comments may be missing resolved status' + ); } - try { - resolve(JSON.parse(stdout)); - } catch (e) { - reject(e); + const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id }; + for (const comment of thread.comments.nodes) { + resolvedMap.set(String(comment.databaseId), info); } - }); - - gh.stdin.write(requestBody); - gh.stdin.end(); - }); + } - if (response.errors && response.errors.length > 0) { - throw new Error(response.errors[0].message); - } + 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); - // Check if reviewThreads data was truncated (more than 100 threads) - const pageInfo = response.data?.repository?.pullRequest?.reviewThreads?.pageInfo; - if (pageInfo?.hasNextPage) { + if (pageCount >= MAX_PAGINATION_PAGES) { logger.warn( - `PR #${prNumber} in ${owner}/${repo} has more than 100 review threads — ` + - 'results are truncated. Some comments may be missing resolved status.' + `PR #${prNumber} in ${owner}/${repo} has more than ${MAX_PAGINATION_PAGES * 100} review threads — ` + + 'pagination limit reached. Some comments may be missing resolved status.' ); - // TODO: Implement cursor-based pagination by iterating with - // reviewThreads.nodes pageInfo.endCursor across spawn calls. - } - - const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []; - for (const thread of threads) { - if (thread.comments.pageInfo?.hasNextPage) { - logger.warn( - `Review thread ${thread.id} in PR #${prNumber} has more than 100 comments — ` + - 'comment list is truncated. Some comments may be missing resolved status.' - ); - } - const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id }; - for (const comment of thread.comments.nodes) { - resolvedMap.set(String(comment.databaseId), info); - } } } catch (error) { // Log but don't fail — resolved status is best-effort @@ -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( projectPath: string, @@ -228,10 +264,14 @@ export async function fetchPRReviewComments( const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber); // 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 { + const issueCommentsEndpoint = `repos/${owner}/${repo}/issues/${prNumber}/comments`; const { stdout: commentsOutput } = await execFileAsync( 'gh', - ['pr', 'view', String(prNumber), '-R', `${owner}/${repo}`, '--json', 'comments'], + ['api', issueCommentsEndpoint, '--paginate'], { cwd: projectPath, env: execEnv, @@ -241,22 +281,24 @@ export async function fetchPRReviewComments( ); const commentsData = JSON.parse(commentsOutput); - const regularComments = (commentsData.comments || []).map( + const regularComments = (Array.isArray(commentsData) ? commentsData : []).map( (c: { - id: string; - author: { login: string; avatarUrl?: string }; + id: number; + user: { login: string; avatar_url?: string; type?: string } | null; body: string; - createdAt: string; - updatedAt?: string; + created_at: string; + updated_at?: string; + performed_via_github_app?: { slug: string } | null; }) => ({ id: String(c.id), - author: c.author?.login || 'unknown', - avatarUrl: c.author?.avatarUrl, + author: c.user?.login || c.performed_via_github_app?.slug || 'unknown', + avatarUrl: c.user?.avatar_url, body: c.body, - createdAt: c.createdAt, - updatedAt: c.updatedAt, + createdAt: c.created_at, + updatedAt: c.updated_at, isReviewComment: 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 isResolved: false, }) @@ -272,7 +314,7 @@ export async function fetchPRReviewComments( const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`; const { stdout: reviewsOutput } = await execFileAsync( 'gh', - ['api', reviewsEndpoint, '--paginate', '--slurp', '--jq', 'add // []'], + ['api', reviewsEndpoint, '--paginate'], { cwd: projectPath, env: execEnv, @@ -285,7 +327,7 @@ export async function fetchPRReviewComments( const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map( (c: { id: number; - user: { login: string; avatar_url?: string }; + user: { login: string; avatar_url?: string; type?: string } | null; body: string; path: string; line?: number; @@ -296,9 +338,10 @@ export async function fetchPRReviewComments( side?: string; commit_id?: string; position?: number | null; + performed_via_github_app?: { slug: string } | null; }) => ({ 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, body: c.body, path: c.path, @@ -310,6 +353,7 @@ export async function fetchPRReviewComments( isOutdated: c.position === null, // isResolved will be filled in below from GraphQL data isResolved: false, + isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app, diffHunk: c.diff_hunk, side: c.side, commitId: c.commit_id, @@ -321,6 +365,55 @@ export async function fetchPRReviewComments( 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 const resolvedMap = await resolvedStatusPromise; for (const comment of allComments) { diff --git a/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx index 313c985ad..fa54ffcdc 100644 --- a/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx +++ b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx @@ -248,39 +248,22 @@ function CommentRow({ return (
setIsExpanded((prev) => !prev) : undefined} > onToggle()} - className="mt-0.5" + className="mt-0.5 shrink-0" onClick={(e) => e.stopPropagation()} />
{/* Header: disclosure triangle + author + file location + tags */}
- {/* Disclosure triangle - always shown, toggles expand/collapse */} - {needsExpansion ? ( - - ) : ( - - )} -
{comment.avatarUrl ? ( @@ -304,6 +287,12 @@ function CommentRow({
)} + {comment.isBot && ( + + Bot + + )} + {comment.isOutdated && ( Outdated @@ -347,27 +336,47 @@ function CommentRow({ )} - {/* Expand detail button */} - +
+ {/* Disclosure triangle - toggles expand/collapse */} + {needsExpansion ? ( + + ) : ( + + )} + + {/* Expand detail button */} + +
{/* Comment body - collapsible, rendered as markdown */} {isExpanded ? ( -
e.stopPropagation()}> +
e.stopPropagation()}> {comment.body}
) : ( -
+
{comment.body} @@ -375,7 +384,7 @@ function CommentRow({ )} {/* Date row */} -
+
{formatDate(comment.createdAt)}
{formatTime(comment.createdAt)}
@@ -440,6 +449,11 @@ function CommentDetailDialog({ comment, open, onOpenChange }: CommentDetailDialo {/* Badges */}
+ {comment.isBot && ( + + Bot + + )} {comment.isOutdated && ( Outdated @@ -850,7 +864,7 @@ export function PRCommentResolutionDialog({ -
+
Manage PR Review Comments diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index b539eb847..43a8f010b 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -19,7 +19,7 @@ import { ArchiveRestore, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; -import { cn } from '@/lib/utils'; +import { cn, pathsEqual } from '@/lib/utils'; import type { SessionListItem } from '@/types/electron'; import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { getElectronAPI } from '@/lib/electron'; @@ -93,6 +93,7 @@ interface SessionManagerProps { currentSessionId: string | null; onSelectSession: (sessionId: string | null) => void; projectPath: string; + workingDirectory?: string; // Current worktree path for scoping sessions isCurrentSessionThinking?: boolean; onQuickCreateRef?: React.MutableRefObject<(() => Promise) | null>; } @@ -101,6 +102,7 @@ export function SessionManager({ currentSessionId, onSelectSession, projectPath, + workingDirectory, isCurrentSessionThinking = false, onQuickCreateRef, }: SessionManagerProps) { @@ -153,6 +155,7 @@ export function SessionManager({ if (result.data) { await checkRunningSessions(result.data); } + return result; }, [queryClient, refetchSessions, checkRunningSessions]); // Check running state on initial load (runs only once when sessions first load) @@ -177,6 +180,9 @@ export function SessionManager({ return () => clearInterval(interval); }, [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 const handleCreateSession = async () => { const api = getElectronAPI(); @@ -184,7 +190,7 @@ export function SessionManager({ 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) { setNewSessionName(''); @@ -195,19 +201,19 @@ export function SessionManager({ }; // Create new session directly with a random name (one-click) - const handleQuickCreateSession = async () => { + const handleQuickCreateSession = useCallback(async () => { const api = getElectronAPI(); if (!api?.sessions) return; 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) { await invalidateSessions(); onSelectSession(result.session.id); } - }; + }, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]); // Expose the quick create function via ref for keyboard shortcuts useEffect(() => { @@ -219,7 +225,7 @@ export function SessionManager({ onQuickCreateRef.current = null; } }; - }, [onQuickCreateRef, projectPath]); + }, [onQuickCreateRef, handleQuickCreateSession]); // Rename session const handleRenameSession = async (sessionId: string) => { @@ -292,10 +298,11 @@ export function SessionManager({ const result = await api.sessions.delete(sessionId); if (result.success) { - await invalidateSessions(); + const refetchResult = await invalidateSessions(); if (currentSessionId === sessionId) { - // Switch to another session or create a new one - const activeSessionsList = sessions.filter((s) => !s.isArchived); + // Switch to another session using fresh data, excluding the deleted session + const freshSessions = refetchResult?.data ?? []; + const activeSessionsList = freshSessions.filter((s) => !s.isArchived && s.id !== sessionId); if (activeSessionsList.length > 0) { onSelectSession(activeSessionsList[0].id); } @@ -318,8 +325,16 @@ export function SessionManager({ setIsDeleteAllArchivedDialogOpen(false); }; - const activeSessions = sessions.filter((s) => !s.isArchived); - const archivedSessions = sessions.filter((s) => s.isArchived); + // Filter sessions by current working directory (worktree scoping) + 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; return ( diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 1a0da4450..51c8f7fbb 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -20,9 +20,13 @@ import { AgentInputArea } from './agent-view/input-area'; const LG_BREAKPOINT = 1024; export function AgentView() { - const { currentProject } = useAppStore(); + const { currentProject, getCurrentWorktree } = useAppStore(); const [input, setInput] = useState(''); const [currentTool, setCurrentTool] = useState(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 // Then updates on mount based on actual screen size to prevent hydration mismatch const [showSessionManager, setShowSessionManager] = useState(true); @@ -52,9 +56,10 @@ export function AgentView() { // Guard to prevent concurrent invocations of handleCreateSessionFromEmptyState const createSessionInFlightRef = useRef(false); - // Session management hook + // Session management hook - scoped to current worktree const { currentSessionId, handleSelectSession } = useAgentSession({ projectPath: currentProject?.path, + workingDirectory: effectiveWorkingDirectory, }); // Use the Electron agent hook (only if we have a session) @@ -71,7 +76,7 @@ export function AgentView() { clearServerQueue, } = useElectronAgent({ sessionId: currentSessionId || '', - workingDirectory: currentProject?.path, + workingDirectory: effectiveWorkingDirectory, model: modelSelection.model, thinkingLevel: modelSelection.thinkingLevel, onToolUse: (toolName) => { @@ -229,6 +234,7 @@ export function AgentView() { currentSessionId={currentSessionId} onSelectSession={handleSelectSession} projectPath={currentProject.path} + workingDirectory={effectiveWorkingDirectory} isCurrentSessionThinking={isProcessing} onQuickCreateRef={quickCreateSessionRef} /> @@ -248,6 +254,7 @@ export function AgentView() { showSessionManager={showSessionManager} onToggleSessionManager={() => setShowSessionManager(!showSessionManager)} onClearChat={handleClearChat} + worktreeBranch={currentWorktree?.branch} /> {/* Messages */} diff --git a/apps/ui/src/components/views/agent-view/components/agent-header.tsx b/apps/ui/src/components/views/agent-view/components/agent-header.tsx index a594c39e0..cfb8cd0ae 100644 --- a/apps/ui/src/components/views/agent-view/components/agent-header.tsx +++ b/apps/ui/src/components/views/agent-view/components/agent-header.tsx @@ -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'; interface AgentHeaderProps { @@ -11,6 +11,7 @@ interface AgentHeaderProps { showSessionManager: boolean; onToggleSessionManager: () => void; onClearChat: () => void; + worktreeBranch?: string; } export function AgentHeader({ @@ -23,6 +24,7 @@ export function AgentHeader({ showSessionManager, onToggleSessionManager, onClearChat, + worktreeBranch, }: AgentHeaderProps) { return (
@@ -32,10 +34,18 @@ export function AgentHeader({

AI Agent

-

- {projectName} - {currentSessionId && !isConnected && ' - Connecting...'} -

+
+ + {projectName} + {currentSessionId && !isConnected && ' - Connecting...'} + + {worktreeBranch && ( + + + {worktreeBranch} + + )} +
diff --git a/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts b/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts index fbf773e32..a159cee4a 100644 --- a/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts +++ b/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts @@ -6,6 +6,7 @@ const logger = createLogger('AgentSession'); interface UseAgentSessionOptions { projectPath: string | undefined; + workingDirectory?: string; // Current worktree path for per-worktree session persistence } interface UseAgentSessionResult { @@ -13,49 +14,56 @@ interface UseAgentSessionResult { handleSelectSession: (sessionId: string | null) => void; } -export function useAgentSession({ projectPath }: UseAgentSessionOptions): UseAgentSessionResult { +export function useAgentSession({ + projectPath, + workingDirectory, +}: UseAgentSessionOptions): UseAgentSessionResult { const { setLastSelectedSession, getLastSelectedSession } = useAppStore(); const [currentSessionId, setCurrentSessionId] = useState(null); // Track if initial session has been loaded 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 const handleSelectSession = useCallback( (sessionId: string | null) => { setCurrentSessionId(sessionId); - // Persist the selection for this project - if (projectPath) { - setLastSelectedSession(projectPath, sessionId); + // Persist the selection for this worktree/project + if (persistenceKey) { + 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(() => { - if (!projectPath) { + if (!persistenceKey) { // No project, reset setCurrentSessionId(null); initialSessionLoadedRef.current = false; return; } - // Only restore once per project + // Only restore once per persistence key if (initialSessionLoadedRef.current) return; initialSessionLoadedRef.current = true; - const lastSessionId = getLastSelectedSession(projectPath); + const lastSessionId = getLastSelectedSession(persistenceKey); if (lastSessionId) { logger.info('Restoring last selected session:', lastSessionId); setCurrentSessionId(lastSessionId); } - }, [projectPath, getLastSelectedSession]); + }, [persistenceKey, getLastSelectedSession]); - // Reset initialSessionLoadedRef when project changes + // Reset when worktree/project changes - clear current session and allow restore useEffect(() => { initialSessionLoadedRef.current = false; - }, [projectPath]); + setCurrentSessionId(null); + }, [persistenceKey]); return { currentSessionId, diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 61cfd5701..0a936cff1 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -486,6 +486,11 @@ export function BoardView() { } else { // Specific worktree selected - find it by path 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; // Ensure all required WorktreeInfo fields are present @@ -1953,6 +1958,16 @@ export function BoardView() { } defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)} 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) hookFeatures.forEach((feature) => { // Match by branch name since worktreePath is no longer stored diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 8c39b5daf..e631a2075 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -217,9 +217,14 @@ export function useBoardActions({ const needsTitleGeneration = !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 = { - ...featureData, + ...restFeatureData, title: titleWasGenerated ? titleForBranch : featureData.title, titleGenerating: needsTitleGeneration, status: initialStatus, @@ -1161,10 +1166,15 @@ export function useBoardActions({ const handleDuplicateFeature = useCallback( 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 { id: _id, status: _status, + initialStatus: _initialStatus, + workMode: _workMode, startedAt: _startedAt, error: _error, summary: _summary, @@ -1212,6 +1222,8 @@ export function useBoardActions({ const { id: _id, status: _status, + initialStatus: _initialStatus, + workMode: _workMode, startedAt: _startedAt, error: _error, summary: _summary, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 164729f21..560ff807e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -399,29 +399,57 @@ export function WorktreeActionsDropdown({ Open in Browser )} - onViewDevServerLogs(worktree)} className="text-xs"> - - View Logs - - onStopDevServer(worktree)} - className="text-xs text-destructive focus:text-destructive" - > - - Stop Dev Server - + {/* Stop Dev Server - split button: click main area to stop, chevron for view logs */} + +
+ onStopDevServer(worktree)} + className="text-xs flex-1 pr-0 rounded-r-none text-destructive focus:text-destructive" + > + + Stop Dev Server + + +
+ + onViewDevServerLogs(worktree)} className="text-xs"> + + View Dev Server Logs + + +
) : ( <> - onStartDevServer(worktree)} - disabled={isStartingDevServer} - className="text-xs" - > - - {isStartingDevServer ? 'Starting...' : 'Start Dev Server'} - + {/* Start Dev Server - split button: click main area to start, chevron for view logs */} + +
+ onStartDevServer(worktree)} + disabled={isStartingDevServer} + className="text-xs flex-1 pr-0 rounded-r-none" + > + + {isStartingDevServer ? 'Starting...' : 'Start Dev Server'} + + +
+ + onViewDevServerLogs(worktree)} className="text-xs"> + + View Dev Server Logs + + +
)} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts index 3d55c4344..6ee05a650 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-dev-servers.ts @@ -456,13 +456,20 @@ export function useDevServers({ projectPath }: UseDevServersOptions) { }); // Start port detection timeout 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 { - 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) { 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 { setIsStartingDevServer(false); } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index ee217c197..a85ace575 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -659,6 +659,18 @@ export function WorktreePanel({ // 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 const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => { setPushToRemoteWorktree(worktree); @@ -937,7 +949,7 @@ export function WorktreePanel({ onResolveConflicts={onResolveConflicts} onMerge={handleMerge} onDeleteWorktree={onDeleteWorktree} - onStartDevServer={handleStartDevServer} + onStartDevServer={handleStartDevServerAndShowLogs} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} @@ -1181,7 +1193,7 @@ export function WorktreePanel({ onResolveConflicts={onResolveConflicts} onMerge={handleMerge} onDeleteWorktree={onDeleteWorktree} - onStartDevServer={handleStartDevServer} + onStartDevServer={handleStartDevServerAndShowLogs} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} @@ -1288,7 +1300,7 @@ export function WorktreePanel({ onResolveConflicts={onResolveConflicts} onMerge={handleMerge} onDeleteWorktree={onDeleteWorktree} - onStartDevServer={handleStartDevServer} + onStartDevServer={handleStartDevServerAndShowLogs} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} @@ -1375,7 +1387,7 @@ export function WorktreePanel({ onResolveConflicts={onResolveConflicts} onMerge={handleMerge} onDeleteWorktree={onDeleteWorktree} - onStartDevServer={handleStartDevServer} + onStartDevServer={handleStartDevServerAndShowLogs} onStopDevServer={handleStopDevServer} onOpenDevServerUrl={handleOpenDevServerUrl} onViewDevServerLogs={handleViewDevServerLogs} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index a32dbf208..cecaf3bf1 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -339,6 +339,8 @@ export interface PRReviewComment { side?: string; /** The commit ID the comment was made on */ commitId?: string; + /** Whether the comment author is a bot/app account */ + isBot?: boolean; } export interface GitHubAPI { diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 44e8d2523..b2a346008 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -69,6 +69,7 @@ export interface SessionListItem { id: string; name: string; projectPath: string; + workingDirectory?: string; // The worktree/directory this session runs in createdAt: string; updatedAt: string; messageCount: number; diff --git a/libs/types/src/session.ts b/libs/types/src/session.ts index a4fea93c2..2c81825c6 100644 --- a/libs/types/src/session.ts +++ b/libs/types/src/session.ts @@ -6,6 +6,7 @@ export interface AgentSession { id: string; name: string; projectPath: string; + workingDirectory?: string; // The worktree/directory this session runs in createdAt: string; updatedAt: string; messageCount: number;