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({
);
-}
+});