From 7c8787dc4f094d5ccad0834d629741b113534db4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 20:32:56 -0800 Subject: [PATCH 1/4] Changes from fix/board-crash-new-feat --- apps/server/src/services/spec-parser.ts | 7 ++- .../tests/unit/services/spec-parser.test.ts | 55 ++++++++++++++++++- apps/ui/src/components/views/board-view.tsx | 10 +++- .../components/list-view/row-actions.tsx | 39 +++++++++++++ .../board-view/hooks/use-board-features.ts | 18 +++--- .../views/board-view/kanban-board.tsx | 5 +- 6 files changed, 117 insertions(+), 17 deletions(-) diff --git a/apps/server/src/services/spec-parser.ts b/apps/server/src/services/spec-parser.ts index 1c9f527ed..295246fbf 100644 --- a/apps/server/src/services/spec-parser.ts +++ b/apps/server/src/services/spec-parser.ts @@ -214,10 +214,13 @@ 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); + // Use \n## [^#] to stop at same-level headers (## Foo) but NOT subsections (### Root Cause) + const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|\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/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..f0c927848 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1905,7 +1905,15 @@ 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); + const isBacklogLike = + feature.status === 'backlog' || + feature.status === 'merge_conflict' || + feature.status === 'ready' || + feature.status === 'interrupted'; + if (isBacklogLike && !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..173e014b9 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 @@ -431,6 +431,45 @@ export const RowActions = memo(function RowActions({ )} + {/* Running task with stale status (backlog/ready/interrupted but tracked as running). + These features are placed in the in_progress column by useBoardColumnFeatures + but their actual status hasn't updated yet, so no other menu block matches. */} + {!isCurrentAutoTask && + isRunningTask && + (feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted' || + feature.status === 'merge_conflict') && ( + <> + {handlers.onViewOutput && ( + + )} + + {handlers.onSpawnTask && ( + + )} + {handlers.onForceStop && ( + <> + + + + )} + + )} + {/* Backlog actions */} {!isCurrentAutoTask && !isRunningTask && 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({ ); -} +}); From ab95f45ecab0a896ee92c63f248185d47c9996da Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 20:45:23 -0800 Subject: [PATCH 2/4] refactor: extract shared isBacklogLikeStatus helper and improve comments Address PR #825 review feedback: - Extract duplicated backlog-like status check into shared helper in constants.ts - Improve spec-parser regex comment to clarify subsection preservation - Add file path reference in row-actions.tsx comment for traceability Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/spec-parser.ts | 3 +- apps/ui/src/components/views/board-view.tsx | 9 +-- .../components/list-view/row-actions.tsx | 68 +++++++++---------- .../components/views/board-view/constants.ts | 20 ++++++ 4 files changed, 57 insertions(+), 43 deletions(-) diff --git a/apps/server/src/services/spec-parser.ts b/apps/server/src/services/spec-parser.ts index 295246fbf..811ed9258 100644 --- a/apps/server/src/services/spec-parser.ts +++ b/apps/server/src/services/spec-parser.ts @@ -214,7 +214,8 @@ export function extractSummary(text: string): string | null { } // Check for ## Summary section (use last match) - // Use \n## [^#] to stop at same-level headers (## Foo) but NOT subsections (### Root Cause) + // 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## [^#]|\n\*\*|$)/gi); const sectionMatch = getLastMatch(sectionMatches); if (sectionMatch) { diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index f0c927848..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, @@ -1908,12 +1908,7 @@ export function BoardView({ initialFeatureId }: BoardViewProps) { // Running features should always show logs, even if status is // stale (still 'backlog'/'ready'/'interrupted' during race window) const isRunning = runningAutoTasksAllWorktrees.includes(feature.id); - const isBacklogLike = - feature.status === 'backlog' || - feature.status === 'merge_conflict' || - feature.status === 'ready' || - feature.status === 'interrupted'; - if (isBacklogLike && !isRunning) { + 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 173e014b9..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,44 +432,41 @@ export const RowActions = memo(function RowActions({ )} - {/* Running task with stale status (backlog/ready/interrupted but tracked as running). + {/* 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 - but their actual status hasn't updated yet, so no other menu block matches. */} - {!isCurrentAutoTask && - isRunningTask && - (feature.status === 'backlog' || - feature.status === 'ready' || - feature.status === 'interrupted' || - feature.status === 'merge_conflict') && ( - <> - {handlers.onViewOutput && ( - - )} - - {handlers.onSpawnTask && ( + (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 && ( + <> + - )} - {handlers.onForceStop && ( - <> - - - - )} - - )} + + )} + + )} {/* Backlog actions */} {!isCurrentAutoTask && 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 */ From 13f2a7a2b981cadbe514e50dc3d34f9dfb8bbdba Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 21:00:13 -0800 Subject: [PATCH 3/4] fix: Remove overly restrictive pattern from summary extraction regex --- apps/server/src/services/spec-parser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/services/spec-parser.ts b/apps/server/src/services/spec-parser.ts index 811ed9258..534c17e2f 100644 --- a/apps/server/src/services/spec-parser.ts +++ b/apps/server/src/services/spec-parser.ts @@ -216,7 +216,7 @@ export function extractSummary(text: string): string | null { // Check for ## Summary section (use last match) // 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## [^#]|\n\*\*|$)/gi); + const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi); const sectionMatch = getLastMatch(sectionMatches); if (sectionMatch) { const content = sectionMatch[1].trim(); From 0e4e9eb3491d86d596f72e385214c20e55d70358 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 2 Mar 2026 21:30:49 -0800 Subject: [PATCH 4/4] refactor: Simplify click URL resolution logic --- .../server/src/services/event-hook-service.ts | 41 +++++++++---------- .../unit/services/event-hook-service.test.ts | 11 +++-- 2 files changed, 26 insertions(+), 26 deletions(-) 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/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 () => {