diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 649735656..005e7e547 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -588,29 +588,26 @@ export class EventHookService { eventType: context.eventType, }; - // Build click URL with deep-link if project context is available - let clickUrl = action.clickUrl; - if (!clickUrl && endpoint.defaultClickUrl) { - clickUrl = endpoint.defaultClickUrl; - // If we have a project path and the click URL looks like the server URL, - // append deep-link path - if (context.projectPath && clickUrl) { - try { - const url = new URL(clickUrl); - // Add featureId as query param for deep linking to board with feature output modal - if (context.featureId) { - url.pathname = '/board'; - url.searchParams.set('featureId', context.featureId); - } else if (context.projectPath) { - url.pathname = '/board'; - } - clickUrl = url.toString(); - } catch (error) { - // If URL parsing fails, log warning and use as-is - logger.warn( - `Failed to parse defaultClickUrl "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}` - ); + // Resolve click URL: action-level overrides endpoint default + let clickUrl = action.clickUrl || endpoint.defaultClickUrl; + + // Apply deep-link parameters to the resolved click URL + if (clickUrl && context.projectPath) { + try { + const url = new URL(clickUrl); + // Add featureId as query param for deep linking to board with feature output modal + if (context.featureId) { + url.pathname = '/board'; + url.searchParams.set('featureId', context.featureId); + } else { + url.pathname = '/board'; } + clickUrl = url.toString(); + } catch (error) { + // If URL parsing fails, log warning and use as-is + logger.warn( + `Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}` + ); } } diff --git a/apps/server/src/services/spec-parser.ts b/apps/server/src/services/spec-parser.ts index 1c9f527ed..534c17e2f 100644 --- a/apps/server/src/services/spec-parser.ts +++ b/apps/server/src/services/spec-parser.ts @@ -214,10 +214,14 @@ export function extractSummary(text: string): string | null { } // Check for ## Summary section (use last match) - const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi); + // Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections + // (like "### Root Cause", "### Fix Applied") that belong to the summary content. + const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi); const sectionMatch = getLastMatch(sectionMatches); if (sectionMatch) { - return truncate(sectionMatch[1].trim(), 500); + const content = sectionMatch[1].trim(); + // Keep full content (including ### subsections) up to max length + return content.length > 500 ? `${content.substring(0, 500)}...` : content; } // Check for **Goal**: section (lite mode, use last match) diff --git a/apps/server/tests/unit/services/event-hook-service.test.ts b/apps/server/tests/unit/services/event-hook-service.test.ts index 900bb3b36..fba67664f 100644 --- a/apps/server/tests/unit/services/event-hook-service.test.ts +++ b/apps/server/tests/unit/services/event-hook-service.test.ts @@ -1246,7 +1246,9 @@ describe('EventHookService', () => { const options = mockFetch.mock.calls[0][1]; // Hook values should override endpoint defaults expect(options.headers['Tags']).toBe('override-emoji,override-tag'); - expect(options.headers['Click']).toBe('https://override.example.com'); + // Click URL uses hook-specific base URL with deep link params applied + expect(options.headers['Click']).toContain('https://override.example.com/board'); + expect(options.headers['Click']).toContain('featureId=feat-1'); expect(options.headers['Priority']).toBe('5'); }); @@ -1359,7 +1361,7 @@ describe('EventHookService', () => { expect(clickUrl).not.toContain('featureId='); }); - it('should use hook-specific click URL overriding default with featureId', async () => { + it('should apply deep link params to hook-specific click URL', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -1409,8 +1411,9 @@ describe('EventHookService', () => { const options = mockFetch.mock.calls[0][1]; const clickUrl = options.headers['Click']; - // Should use the hook-specific click URL (not modified with featureId since it's a custom URL) - expect(clickUrl).toBe('https://custom.example.com/custom-page'); + // Should use the hook-specific click URL with deep link params applied + expect(clickUrl).toContain('https://custom.example.com/board'); + expect(clickUrl).toContain('featureId=feat-789'); }); it('should preserve existing query params when adding featureId', async () => { diff --git a/apps/server/tests/unit/services/spec-parser.test.ts b/apps/server/tests/unit/services/spec-parser.test.ts index 411c92909..205e92c3b 100644 --- a/apps/server/tests/unit/services/spec-parser.test.ts +++ b/apps/server/tests/unit/services/spec-parser.test.ts @@ -573,6 +573,55 @@ Implementation details. `; expect(extractSummary(text)).toBe('Summary content here.'); }); + + it('should include ### subsections within the summary (not cut off at ### Root Cause)', () => { + const text = ` +## Summary + +Overview of changes. + +### Root Cause +The bug was caused by X. + +### Fix Applied +Changed Y to Z. + +## Other Section +More content. +`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result).toContain('Overview of changes.'); + expect(result).toContain('### Root Cause'); + expect(result).toContain('The bug was caused by X.'); + expect(result).toContain('### Fix Applied'); + expect(result).toContain('Changed Y to Z.'); + expect(result).not.toContain('## Other Section'); + }); + + it('should include ### subsections and stop at next ## header', () => { + const text = ` +## Summary + +Brief intro. + +### Changes +- File A modified +- File B added + +### Notes +Important context. + +## Implementation +Details here. +`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result).toContain('Brief intro.'); + expect(result).toContain('### Changes'); + expect(result).toContain('### Notes'); + expect(result).not.toContain('## Implementation'); + }); }); describe('**Goal**: section (lite planning mode)', () => { @@ -692,7 +741,7 @@ Summary section content. expect(extractSummary('Random text without any summary patterns')).toBeNull(); }); - it('should handle multiple paragraph summaries (return first paragraph)', () => { + it('should include all paragraphs in ## Summary section', () => { const text = ` ## Summary @@ -702,7 +751,9 @@ Second paragraph of summary. ## Other `; - expect(extractSummary(text)).toBe('First paragraph of summary.'); + const result = extractSummary(text); + expect(result).toContain('First paragraph of summary.'); + expect(result).toContain('Second paragraph of summary.'); }); }); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 9b525edb5..ecbbe8a7d 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -83,7 +83,7 @@ import type { StashApplyConflictInfo, } from './board-view/worktree-panel/types'; import { BoardErrorBoundary } from './board-view/board-error-boundary'; -import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; +import { COLUMNS, getColumnsWithPipeline, isBacklogLikeStatus } from './board-view/constants'; import { useBoardFeatures, useBoardDragDrop, @@ -1905,7 +1905,10 @@ export function BoardView({ initialFeatureId }: BoardViewProps) { selectedFeatureIds={selectedFeatureIds} onToggleFeatureSelection={toggleFeatureSelection} onRowClick={(feature) => { - if (feature.status === 'backlog') { + // Running features should always show logs, even if status is + // stale (still 'backlog'/'ready'/'interrupted' during race window) + const isRunning = runningAutoTasksAllWorktrees.includes(feature.id); + if (isBacklogLikeStatus(feature.status) && !isRunning) { setEditingFeature(feature); } else { handleViewOutput(feature); diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index fcf938d13..755ed017a 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -30,6 +30,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import type { Feature } from '@/store/app-store'; +import { isBacklogLikeStatus } from '../../constants'; /** * Action handler types for row actions @@ -431,6 +432,42 @@ export const RowActions = memo(function RowActions({ )} + {/* Running task with stale status - the feature is tracked as running but its + persisted status hasn't caught up yet during WebSocket/cache sync delays. + These features are placed in the in_progress column by useBoardColumnFeatures + (hooks/use-board-column-features.ts) but no other menu block matches their + stale status, so we provide running-appropriate actions here. */} + {!isCurrentAutoTask && isRunningTask && isBacklogLikeStatus(feature.status) && ( + <> + {handlers.onViewOutput && ( + + )} + + {handlers.onSpawnTask && ( + + )} + {handlers.onForceStop && ( + <> + + + + )} + + )} + {/* Backlog actions */} {!isCurrentAutoTask && !isRunningTask && diff --git a/apps/ui/src/components/views/board-view/constants.ts b/apps/ui/src/components/views/board-view/constants.ts index fda19ebfb..8ec071d85 100644 --- a/apps/ui/src/components/views/board-view/constants.ts +++ b/apps/ui/src/components/views/board-view/constants.ts @@ -136,6 +136,26 @@ export function getPipelineInsertIndex(): number { return BASE_COLUMNS.length; } +/** + * Statuses that display in the backlog column because they don't have dedicated columns: + * - 'backlog': Default state for new features + * - 'ready': Feature has an approved plan, waiting for execution + * - 'interrupted': Feature execution was aborted (user stopped it, server restart) + * - 'merge_conflict': Automatic merge failed, user must resolve conflicts + * + * Used to determine row click behavior and menu actions when a feature is running + * but its status hasn't updated yet (race condition during WebSocket/cache sync). + * See use-board-column-features.ts for the column assignment logic. + */ +export function isBacklogLikeStatus(status: string): boolean { + return ( + status === 'backlog' || + status === 'ready' || + status === 'interrupted' || + status === 'merge_conflict' + ); +} + /** * Check if a status is a pipeline status */ diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts index 40fb30bef..9068af89a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-features.ts @@ -115,16 +115,14 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { // Board view only reacts to events for the currently selected project const eventProjectId = ('projectId' in event && event.projectId) || projectId; - if (event.type === 'auto_mode_feature_start') { - // Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected - logger.info( - `[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...` - ); - loadFeatures(); - } else if (event.type === 'auto_mode_feature_complete') { - // Reload features when a feature is completed - logger.info('Feature completed, reloading features...'); - loadFeatures(); + // NOTE: auto_mode_feature_start and auto_mode_feature_complete are NOT handled here + // for feature list reloading. That is handled by useAutoModeQueryInvalidation which + // invalidates the features.all query on those events. Duplicate invalidation here + // caused a re-render cascade through DndContext that triggered React error #185 + // (maximum update depth exceeded), crashing the board view with an infinite spinner + // when a new feature was added and moved to in_progress. + + if (event.type === 'auto_mode_feature_complete') { // Play ding sound when feature is done (unless muted) const { muteDoneSound } = useAppStore.getState(); if (!muteDoneSound) { diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 0d053ea80..446ac08ff 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -1,4 +1,5 @@ import { + memo, useMemo, useRef, useState, @@ -280,7 +281,7 @@ function VirtualizedList({ ); } -export function KanbanBoard({ +export const KanbanBoard = memo(function KanbanBoard({ activeFeature, getColumnFeatures, backgroundImageStyle, @@ -719,4 +720,4 @@ export function KanbanBoard({ ); -} +});