From 0a29338f10bdb8066862d6cadf6bf3d3af3aee5d Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 13 Mar 2026 09:46:41 -0700 Subject: [PATCH 1/4] feat(scheduling): add Queue tab with provider lanes and execution analytics Adds a new 'Queue' tab to the Scheduling page that visualizes: - Queue overview (running, pending, avg wait, oldest pending) - Provider lanes chart showing jobs grouped by provider bucket - Provider bucket summary with running/pending counts - Recent runs chart with execution history This completes Phase 4 of the Provider-Aware Queue PRD. Co-Authored-By: Claude Opus 4.6 --- web/pages/Scheduling.tsx | 106 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/web/pages/Scheduling.tsx b/web/pages/Scheduling.tsx index c05bc9b..557268a 100644 --- a/web/pages/Scheduling.tsx +++ b/web/pages/Scheduling.tsx @@ -22,6 +22,9 @@ import Select from '../components/ui/Select'; import Switch from '../components/ui/Switch'; import Tabs from '../components/ui/Tabs'; import ScheduleTimeline from '../components/scheduling/ScheduleTimeline.js'; +import ProviderLanesChart from '../components/scheduling/ProviderLanesChart.js'; +import ProviderBucketSummary from '../components/scheduling/ProviderBucketSummary.js'; +import RecentRunsChart from '../components/scheduling/RecentRunsChart.js'; import { useStore } from '../store/useStore'; import type { INightWatchConfig, IQueueAnalytics, IQueueStatus, QueueMode } from '../api'; import { @@ -913,6 +916,109 @@ const Scheduling: React.FC = () => { ), }, + { + id: 'queue', + label: 'Queue', + content: ( +
+ {/* Queue Overview Card */} + +

Queue Overview

+
+
+
Running
+
+ {queueStatus?.running ? 1 : 0} +
+ {queueStatus?.running && ( +
+ {queueStatus.running.jobType} · {queueStatus.running.projectName} +
+ )} +
+
+
Pending
+
+ {queueStatus?.pending.total ?? 0} +
+
+
+
Avg Wait
+
+ {queueStatus?.averageWaitSeconds != null + ? `${Math.floor(queueStatus.averageWaitSeconds / 60)}m` + : '—'} +
+
+
+
Oldest Pending
+
+ {queueStatus?.oldestPendingAge != null + ? `${Math.floor(queueStatus.oldestPendingAge / 60)}m` + : '—'} +
+
+
+
+ + {/* Provider Lanes */} + +
+
+

Provider Lanes

+

+ Running and pending jobs grouped by provider bucket +

+
+
+ {queueStatus ? ( + + ) : ( +
Loading queue status...
+ )} +
+ + {/* Provider Bucket Summary */} + +
+
+

Provider Buckets

+

+ Running and pending counts per provider bucket +

+
+
+ {queueAnalytics ? ( + + ) : ( +
Loading analytics...
+ )} +
+ + {/* Recent Runs */} + +
+
+

Recent Runs

+

+ Last 24 hours of job executions +

+
+ {queueAnalytics?.averageWaitSeconds != null && ( +
+ Avg wait: {Math.floor(queueAnalytics.averageWaitSeconds / 60)}m +
+ )} +
+ {queueAnalytics ? ( + + ) : ( +
Loading analytics...
+ )} +
+
+ ), + }, ]; return ( From 87f9d643c64885b704c4bc253c4b3b8e9e95a2d3 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 29 Mar 2026 03:54:26 -0700 Subject: [PATCH 2/4] fix: resolve lint errors in review.ts and config.ts - Extract nested ternary into if-else chain in review.ts - Fix import sorting in config.ts Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/review.ts | 22 ++++++++++++++-------- packages/core/src/config.ts | 6 ++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index 840449e..d0027f4 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -166,8 +166,7 @@ export function postReadyForHumanReviewComment( finalScore: number | undefined, cwd: string, ): void { - const scoreNote = - finalScore !== undefined ? ` (score: ${finalScore}/100)` : ''; + const scoreNote = finalScore !== undefined ? ` (score: ${finalScore}/100)` : ''; const body = `## ✅ Ready for Human Review\n\n` + `Night Watch has reviewed this PR${scoreNote} and found no issues requiring automated fixes.\n\n` + @@ -520,12 +519,16 @@ export function reviewCommand(program: Command): void { const reviewedPrNumbers = parseReviewedPrNumbers(scriptResult?.data.prs); const noChangesPrNumbers = parseReviewedPrNumbers(scriptResult?.data.no_changes_prs); const fallbackPrNumber = fallbackPrDetails?.number; + let primaryPrNumbers: number[]; + if (reviewedPrNumbers.length > 0) { + primaryPrNumbers = reviewedPrNumbers; + } else if (fallbackPrNumber !== undefined) { + primaryPrNumbers = [fallbackPrNumber]; + } else { + primaryPrNumbers = []; + } const notificationTargets = buildReviewNotificationTargets( - reviewedPrNumbers.length > 0 - ? reviewedPrNumbers - : fallbackPrNumber !== undefined - ? [fallbackPrNumber] - : [], + primaryPrNumbers, noChangesPrNumbers, legacyNoChangesNeeded, ); @@ -567,7 +570,10 @@ export function reviewCommand(program: Command): void { event: reviewEvent, projectName: path.basename(projectDir), exitCode, - provider: formatProviderDisplay(envVars.NW_PROVIDER_CMD, envVars.NW_PROVIDER_LABEL), + provider: formatProviderDisplay( + envVars.NW_PROVIDER_CMD, + envVars.NW_PROVIDER_LABEL, + ), prUrl: prDetails?.url, prTitle: prDetails?.title, prBody: prDetails?.body, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a3e7fde..cf0329a 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -21,7 +21,6 @@ import { DEFAULT_ANALYTICS, DEFAULT_AUDIT, DEFAULT_AUTO_MERGE, - DEFAULT_MERGER, DEFAULT_AUTO_MERGE_METHOD, DEFAULT_BOARD_PROVIDER, DEFAULT_BRANCH_PATTERNS, @@ -36,6 +35,7 @@ import { DEFAULT_MAX_LOG_SIZE, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RUNTIME, + DEFAULT_MERGER, DEFAULT_MIN_REVIEW_SCORE, DEFAULT_NOTIFICATIONS, DEFAULT_PRD_DIR, @@ -220,7 +220,9 @@ function mergeConfigs( merged.merger = { ...merged.merger, enabled: true, - mergeMethod: (merged as unknown as Record).autoMergeMethod as IMergerConfig['mergeMethod'] ?? 'squash', + mergeMethod: + ((merged as unknown as Record) + .autoMergeMethod as IMergerConfig['mergeMethod']) ?? 'squash', }; } From 3d9d33c30d121798404f1acdf99b333307a42bb0 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 29 Mar 2026 09:57:38 -0700 Subject: [PATCH 3/4] refactor(scheduling): extract QueueTab component, add error handling, use useMemo - Extract Queue tab content to separate QueueTab.tsx component for better maintainability - Add error states for queueStatus and queueAnalytics with proper error UI - Wrap tabs array in useMemo to prevent unnecessary recreation on each render - Add tests for Queue tab rendering and error states Addresses review feedback on PR #86. Co-Authored-By: Claude Opus 4.6 --- web/components/scheduling/QueueTab.tsx | 145 +++++++++++++++++++++++ web/pages/Scheduling.tsx | 150 +++++++----------------- web/pages/__tests__/Scheduling.test.tsx | 45 +++++++ 3 files changed, 234 insertions(+), 106 deletions(-) create mode 100644 web/components/scheduling/QueueTab.tsx diff --git a/web/components/scheduling/QueueTab.tsx b/web/components/scheduling/QueueTab.tsx new file mode 100644 index 0000000..0794883 --- /dev/null +++ b/web/components/scheduling/QueueTab.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { AlertCircle } from 'lucide-react'; +import Card from '../ui/Card.js'; +import type { IQueueAnalytics, IQueueStatus } from '../../api.js'; +import ProviderLanesChart from './ProviderLanesChart.js'; +import ProviderBucketSummary from './ProviderBucketSummary.js'; +import RecentRunsChart from './RecentRunsChart.js'; + +interface IQueueTabProps { + queueStatus: IQueueStatus | null; + queueAnalytics: IQueueAnalytics | null; + queueStatusError: Error | null; + queueAnalyticsError: Error | null; +} + +const QueueTab: React.FC = ({ + queueStatus, + queueAnalytics, + queueStatusError, + queueAnalyticsError, +}) => { + return ( +
+ {/* Queue Overview Card */} + +

Queue Overview

+ {queueStatusError ? ( +
+ + Failed to load queue status +
+ ) : ( +
+
+
Running
+
+ {queueStatus?.running ? 1 : 0} +
+ {queueStatus?.running && ( +
+ {queueStatus.running.jobType} · {queueStatus.running.projectName} +
+ )} +
+
+
Pending
+
+ {queueStatus?.pending.total ?? 0} +
+
+
+
Avg Wait
+
+ {queueStatus?.averageWaitSeconds != null + ? `${Math.floor(queueStatus.averageWaitSeconds / 60)}m` + : '—'} +
+
+
+
Oldest Pending
+
+ {queueStatus?.oldestPendingAge != null + ? `${Math.floor(queueStatus.oldestPendingAge / 60)}m` + : '—'} +
+
+
+ )} +
+ + {/* Provider Lanes */} + +
+
+

Provider Lanes

+

+ Running and pending jobs grouped by provider bucket +

+
+
+ {queueStatusError ? ( +
+ + Failed to load queue status +
+ ) : queueStatus ? ( + + ) : ( +
Loading queue status...
+ )} +
+ + {/* Provider Bucket Summary */} + +
+
+

Provider Buckets

+

+ Running and pending counts per provider bucket +

+
+
+ {queueAnalyticsError ? ( +
+ + Failed to load analytics +
+ ) : queueAnalytics ? ( + + ) : ( +
Loading analytics...
+ )} +
+ + {/* Recent Runs */} + +
+
+

Recent Runs

+

+ Last 24 hours of job executions +

+
+ {queueAnalytics?.averageWaitSeconds != null && ( +
+ Avg wait: {Math.floor(queueAnalytics.averageWaitSeconds / 60)}m +
+ )} +
+ {queueAnalyticsError ? ( +
+ + Failed to load analytics +
+ ) : queueAnalytics ? ( + + ) : ( +
Loading analytics...
+ )} +
+
+ ); +}; + +export default QueueTab; diff --git a/web/pages/Scheduling.tsx b/web/pages/Scheduling.tsx index 557268a..f80a2d2 100644 --- a/web/pages/Scheduling.tsx +++ b/web/pages/Scheduling.tsx @@ -22,9 +22,7 @@ import Select from '../components/ui/Select'; import Switch from '../components/ui/Switch'; import Tabs from '../components/ui/Tabs'; import ScheduleTimeline from '../components/scheduling/ScheduleTimeline.js'; -import ProviderLanesChart from '../components/scheduling/ProviderLanesChart.js'; -import ProviderBucketSummary from '../components/scheduling/ProviderBucketSummary.js'; -import RecentRunsChart from '../components/scheduling/RecentRunsChart.js'; +import QueueTab from '../components/scheduling/QueueTab.js'; import { useStore } from '../store/useStore'; import type { INightWatchConfig, IQueueAnalytics, IQueueStatus, QueueMode } from '../api'; import { @@ -97,6 +95,8 @@ const Scheduling: React.FC = () => { const [allProjectConfigs, setAllProjectConfigs] = useState>([]); const [queueStatus, setQueueStatus] = useState(null); const [queueAnalytics, setQueueAnalytics] = useState(null); + const [queueStatusError, setQueueStatusError] = useState(null); + const [queueAnalyticsError, setQueueAnalyticsError] = useState(null); const [editState, setEditState] = useState({ isDirty: false, @@ -142,11 +142,21 @@ const Scheduling: React.FC = () => { if (globalModeLoading) return; const fetchDashboard = () => { fetchQueueStatus() - .then(setQueueStatus) - .catch(() => { /* silently ignore */ }); + .then((status) => { + setQueueStatus(status); + setQueueStatusError(null); + }) + .catch((err) => { + setQueueStatusError(err instanceof Error ? err : new Error(String(err))); + }); fetchQueueAnalytics(24) - .then(setQueueAnalytics) - .catch(() => { /* silently ignore */ }); + .then((analytics) => { + setQueueAnalytics(analytics); + setQueueAnalyticsError(null); + }) + .catch((err) => { + setQueueAnalyticsError(err instanceof Error ? err : new Error(String(err))); + }); }; fetchDashboard(); const interval = setInterval(fetchDashboard, 30000); @@ -463,7 +473,7 @@ const Scheduling: React.FC = () => { return `${activeTemplate.label} - ${activeTemplate.hints[job]}`; }; - const tabs = [ + const tabs = useMemo(() => [ { id: 'overview', label: 'Overview', @@ -920,106 +930,34 @@ const Scheduling: React.FC = () => { id: 'queue', label: 'Queue', content: ( -
- {/* Queue Overview Card */} - -

Queue Overview

-
-
-
Running
-
- {queueStatus?.running ? 1 : 0} -
- {queueStatus?.running && ( -
- {queueStatus.running.jobType} · {queueStatus.running.projectName} -
- )} -
-
-
Pending
-
- {queueStatus?.pending.total ?? 0} -
-
-
-
Avg Wait
-
- {queueStatus?.averageWaitSeconds != null - ? `${Math.floor(queueStatus.averageWaitSeconds / 60)}m` - : '—'} -
-
-
-
Oldest Pending
-
- {queueStatus?.oldestPendingAge != null - ? `${Math.floor(queueStatus.oldestPendingAge / 60)}m` - : '—'} -
-
-
-
- - {/* Provider Lanes */} - -
-
-

Provider Lanes

-

- Running and pending jobs grouped by provider bucket -

-
-
- {queueStatus ? ( - - ) : ( -
Loading queue status...
- )} -
- - {/* Provider Bucket Summary */} - -
-
-

Provider Buckets

-

- Running and pending counts per provider bucket -

-
-
- {queueAnalytics ? ( - - ) : ( -
Loading analytics...
- )} -
- - {/* Recent Runs */} - -
-
-

Recent Runs

-

- Last 24 hours of job executions -

-
- {queueAnalytics?.averageWaitSeconds != null && ( -
- Avg wait: {Math.floor(queueAnalytics.averageWaitSeconds / 60)}m -
- )} -
- {queueAnalytics ? ( - - ) : ( -
Loading analytics...
- )} -
-
+ ), }, - ]; + ], [ + agents, + statusColor, + statusText, + isPaused, + scheduleInfo, + config, + allProjectConfigs, + queueStatus, + queueAnalytics, + queueStatusError, + queueAnalyticsError, + toggling, + saving, + editState, + showAddBucket, + newBucketKey, + newBucketConcurrency, + activeTemplate, + ]); return (
diff --git a/web/pages/__tests__/Scheduling.test.tsx b/web/pages/__tests__/Scheduling.test.tsx index 7efbde2..c64f3a5 100644 --- a/web/pages/__tests__/Scheduling.test.tsx +++ b/web/pages/__tests__/Scheduling.test.tsx @@ -297,4 +297,49 @@ describe('Scheduling page', () => { expect(screen.getByRole('button', { name: 'Open Jobs' })).toBeInTheDocument(); expect(screen.queryByText('PRD Execution Schedule')).not.toBeInTheDocument(); }); + + it('renders Queue tab with queue status and analytics', async () => { + apiMocks.fetchQueueStatus.mockResolvedValue(makeQueueStatus({ + running: { + id: 1, + jobType: 'executor', + projectName: 'test-project', + providerKey: 'claude', + status: 'running', + createdAt: new Date().toISOString(), + priority: 50, + }, + pending: { total: 3, byType: { executor: 2, reviewer: 1 }, byProviderBucket: {} }, + })); + apiMocks.fetchQueueAnalytics.mockResolvedValue(makeQueueAnalytics({ + recentRuns: [ + { id: 1, jobType: 'executor', projectName: 'test-project', status: 'completed', createdAt: new Date().toISOString(), completedAt: new Date().toISOString() }, + ], + byProviderBucket: { claude: { running: 1, pending: 2, completed: 5 } }, + })); + + renderScheduling(); + + fireEvent.click(screen.getByRole('button', { name: 'Queue' })); + + await waitFor(() => { + expect(screen.getByText('Queue Overview')).toBeInTheDocument(); + }); + + expect(screen.getByText('Provider Lanes')).toBeInTheDocument(); + expect(screen.getByText('Provider Buckets')).toBeInTheDocument(); + expect(screen.getByText('Recent Runs')).toBeInTheDocument(); + }); + + it('shows error state when queue status fails to load', async () => { + apiMocks.fetchQueueStatus.mockRejectedValue(new Error('Network error')); + + renderScheduling(); + + fireEvent.click(screen.getByRole('button', { name: 'Queue' })); + + await waitFor(() => { + expect(screen.getByText('Failed to load queue status')).toBeInTheDocument(); + }); + }); }); From 6ac9973794b81a8d910445ec075de0c387e1afc3 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 30 Mar 2026 01:12:51 -0700 Subject: [PATCH 4/4] fix: resolve TypeScript and test errors in PR #86 - Fix TypeScript errors in Scheduling.test.tsx by using correct property names (enqueuedAt instead of createdAt, projectPath instead of projectName, removed completed from byProviderBucket) - Add fake claude binary to PATH in core-flow-smoke tests to fix exit code 127 errors in reviewer tests Co-Authored-By: Claude Opus 4.6 --- .../__tests__/scripts/core-flow-smoke.test.ts | 18 ++++++++++++++++++ web/pages/__tests__/Scheduling.test.tsx | 19 ++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts index 6fe7e71..e9f1c23 100644 --- a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts +++ b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts @@ -1539,6 +1539,12 @@ describe('core flow smoke tests (bash scripts)', () => { { encoding: 'utf-8', mode: 0o755 }, ); + fs.writeFileSync( + path.join(fakeBin, 'claude'), + '#!/usr/bin/env bash\nexit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + const result = runScript(reviewerScript, projectDir, { PATH: `${fakeBin}:${process.env.PATH}`, NW_PROVIDER_CMD: 'claude', @@ -1596,6 +1602,12 @@ describe('core flow smoke tests (bash scripts)', () => { { encoding: 'utf-8', mode: 0o755 }, ); + fs.writeFileSync( + path.join(fakeBin, 'claude'), + '#!/usr/bin/env bash\nexit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + const result = runScript(reviewerScript, projectDir, { PATH: `${fakeBin}:${process.env.PATH}`, NW_PROVIDER_CMD: 'claude', @@ -1748,6 +1760,12 @@ describe('core flow smoke tests (bash scripts)', () => { { encoding: 'utf-8', mode: 0o755 }, ); + fs.writeFileSync( + path.join(fakeBin, 'claude'), + '#!/usr/bin/env bash\nexit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + const result = runScript(reviewerScript, projectDir, { PATH: `${fakeBin}:${process.env.PATH}`, NW_PROVIDER_CMD: 'claude', diff --git a/web/pages/__tests__/Scheduling.test.tsx b/web/pages/__tests__/Scheduling.test.tsx index c64f3a5..6c1e3f4 100644 --- a/web/pages/__tests__/Scheduling.test.tsx +++ b/web/pages/__tests__/Scheduling.test.tsx @@ -303,19 +303,32 @@ describe('Scheduling page', () => { running: { id: 1, jobType: 'executor', + projectPath: '/path/to/test-project', projectName: 'test-project', providerKey: 'claude', status: 'running', - createdAt: new Date().toISOString(), + enqueuedAt: Date.now() / 1000, + dispatchedAt: null, priority: 50, }, pending: { total: 3, byType: { executor: 2, reviewer: 1 }, byProviderBucket: {} }, })); apiMocks.fetchQueueAnalytics.mockResolvedValue(makeQueueAnalytics({ recentRuns: [ - { id: 1, jobType: 'executor', projectName: 'test-project', status: 'completed', createdAt: new Date().toISOString(), completedAt: new Date().toISOString() }, + { + id: 1, + jobType: 'executor', + projectPath: '/path/to/test-project', + providerKey: 'claude', + status: 'completed', + startedAt: Date.now() / 1000, + finishedAt: Date.now() / 1000, + waitSeconds: 0, + durationSeconds: 100, + throttledCount: 0, + }, ], - byProviderBucket: { claude: { running: 1, pending: 2, completed: 5 } }, + byProviderBucket: { claude: { running: 1, pending: 2 } }, })); renderScheduling();