Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 19 additions & 22 deletions apps/server/src/services/event-hook-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
);
}
}

Expand Down
8 changes: 6 additions & 2 deletions apps/server/src/services/spec-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 7 additions & 4 deletions apps/server/tests/unit/services/event-hook-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
55 changes: 53 additions & 2 deletions apps/server/tests/unit/services/spec-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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

Expand All @@ -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.');
});
});

Expand Down
7 changes: 5 additions & 2 deletions apps/ui/src/components/views/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onForceStop && (
<>
<DropdownMenuSeparator />
<MenuItem
icon={StopCircle}
label="Force Stop"
onClick={withClose(handlers.onForceStop)}
variant="destructive"
/>
</>
)}
</>
)}

{/* Backlog actions */}
{!isCurrentAutoTask &&
!isRunningTask &&
Expand Down
20 changes: 20 additions & 0 deletions apps/ui/src/components/views/board-view/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +118 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment explains why auto_mode_feature_start is not handled here, but it could be improved by mentioning the specific code or hook that handles it (useAutoModeQueryInvalidation) to make it easier to trace the logic.


if (event.type === 'auto_mode_feature_complete') {
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
if (!muteDoneSound) {
Expand Down
5 changes: 3 additions & 2 deletions apps/ui/src/components/views/board-view/kanban-board.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
memo,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -280,7 +281,7 @@ function VirtualizedList<Item extends VirtualListItem>({
);
}

export function KanbanBoard({
export const KanbanBoard = memo(function KanbanBoard({
activeFeature,
getColumnFeatures,
backgroundImageStyle,
Expand Down Expand Up @@ -719,4 +720,4 @@ export function KanbanBoard({
</DragOverlay>
</div>
);
}
});
Loading