Skip to content
Open
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
15 changes: 15 additions & 0 deletions packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,11 @@ 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',
Expand Down Expand Up @@ -1973,6 +1978,11 @@ 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',
Expand Down Expand Up @@ -2176,6 +2186,11 @@ 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',
Expand Down
22 changes: 14 additions & 8 deletions packages/cli/src/commands/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` +
Expand Down Expand Up @@ -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 prsToNotify: number[];
if (reviewedPrNumbers.length > 0) {
prsToNotify = reviewedPrNumbers;
} else if (fallbackPrNumber !== undefined) {
prsToNotify = [fallbackPrNumber];
} else {
prsToNotify = [];
}
const notificationTargets = buildReviewNotificationTargets(
reviewedPrNumbers.length > 0
? reviewedPrNumbers
: fallbackPrNumber !== undefined
? [fallbackPrNumber]
: [],
prsToNotify,
noChangesPrNumbers,
legacyNoChangesNeeded,
);
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 18 additions & 12 deletions web/App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import React from 'react';
import { Navigate, Route, HashRouter as Router, Routes } from 'react-router-dom';
import Sidebar from './components/Sidebar';
import TopBar from './components/TopBar';
import { ToastContainer } from './components/ui/Toast';
import { useGlobalMode } from './hooks/useGlobalMode';
import { useStatusSync } from './hooks/useStatusSync';
import Sidebar from './components/Sidebar.js';
import TopBar from './components/TopBar.js';
import CommandPalette from './components/CommandPalette.js';
import ActivityCenter from './components/ActivityCenter.js';
import { ToastContainer } from './components/ui/Toast.js';
import { useGlobalMode } from './hooks/useGlobalMode.js';
import { useStatusSync } from './hooks/useStatusSync.js';
import { useCommandPalette } from './hooks/useCommandPalette.js';
// import Agents from './pages/Agents';
import Board from './pages/Board';
import Dashboard from './pages/Dashboard';
import Logs from './pages/Logs';
import PRs from './pages/PRs';
import Roadmap from './pages/Roadmap';
import Scheduling from './pages/Scheduling';
import Settings from './pages/Settings';
import Board from './pages/Board.js';
import Dashboard from './pages/Dashboard.js';
import Logs from './pages/Logs.js';
import PRs from './pages/PRs.js';
import Roadmap from './pages/Roadmap.js';
import Scheduling from './pages/Scheduling.js';
import Settings from './pages/Settings.js';

const App: React.FC = () => {
useGlobalMode();
useStatusSync();
useCommandPalette();

return (
<Router>
Expand Down Expand Up @@ -47,6 +51,8 @@ const App: React.FC = () => {
</div>

<ToastContainer />
<CommandPalette />
<ActivityCenter />
</div>
</Router>
);
Expand Down
182 changes: 182 additions & 0 deletions web/components/ActivityCenter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import React, { useEffect, useRef } from 'react';
import { X, CheckCircle, AlertCircle, Pause, Play, GitPullRequest, Clock } from 'lucide-react';
import { useStore } from '../store/useStore.js';
import { useActivityFeed } from '../hooks/useActivityFeed.js';
import type { IActivityEvent } from '../hooks/useActivityFeed.js';

function getEventIcon(type: IActivityEvent['type']): React.ReactNode {
switch (type) {
case 'agent_completed':
return <CheckCircle className="h-4 w-4 text-emerald-400" />;
case 'agent_failed':
return <AlertCircle className="h-4 w-4 text-red-400" />;
case 'automation_paused':
return <Pause className="h-4 w-4 text-amber-400" />;
case 'automation_resumed':
return <Play className="h-4 w-4 text-emerald-400" />;
case 'pr_opened':
return <GitPullRequest className="h-4 w-4 text-indigo-400" />;
case 'schedule_fired':
return <Clock className="h-4 w-4 text-slate-400" />;
default:
return <CheckCircle className="h-4 w-4 text-slate-400" />;
}
}

function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);

if (diffSeconds < 60) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}

function getEventDescription(event: IActivityEvent): string {
switch (event.type) {
case 'agent_completed':
return `${event.agent} completed${event.prd ? ` PRD-${event.prd}` : ''}${event.duration ? ` (${event.duration})` : ''}`;
case 'agent_failed':
return `${event.agent} failed${event.error ? `: ${event.error.substring(0, 50)}` : ''}`;
case 'automation_paused':
return 'Automation paused';
case 'automation_resumed':
return 'Automation resumed';
case 'pr_opened':
return `PR #${event.prNumber} opened${event.prTitle ? `: ${event.prTitle.substring(0, 40)}${event.prTitle.length > 40 ? '...' : ''}` : ''}`;
case 'schedule_fired':
return `${event.agent} scheduled run triggered`;
default:
return 'Unknown event';
}
}

const ActivityCenter: React.FC = () => {
const { activityCenterOpen, setActivityCenterOpen } = useStore();
const { groupedEvents, markAsRead } = useActivityFeed();
const panelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (activityCenterOpen) {
markAsRead();
}
}, [activityCenterOpen, markAsRead]);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
panelRef.current &&
!panelRef.current.contains(event.target as Node) &&
activityCenterOpen
) {
setActivityCenterOpen(false);
}
};

if (activityCenterOpen) {
document.addEventListener('mousedown', handleClickOutside);
}

return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [activityCenterOpen, setActivityCenterOpen]);

const handleClose = () => {
setActivityCenterOpen(false);
};

return (
<>
{/* Backdrop overlay */}
<div
className={`fixed inset-0 bg-black/50 transition-opacity duration-300 z-40 ${
activityCenterOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={handleClose}
/>

{/* Slide-out panel */}
<div
ref={panelRef}
className={`fixed right-0 top-0 h-full w-[360px] bg-slate-900 border-l border-slate-800 shadow-2xl z-50 transform transition-transform duration-300 ease-out ${
activityCenterOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-800">
<h2 className="text-lg font-semibold text-white">Activity</h2>
<button
onClick={handleClose}
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>

{/* Content */}
<div className="flex-1 overflow-y-auto">
{groupedEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-slate-500">
<Clock className="h-8 w-8 mb-2" />
<p className="text-sm">No recent activity</p>
<p className="text-xs text-slate-600 mt-1">Events will appear here as they occur</p>
</div>
) : (
<div className="p-2">
{groupedEvents.map((group) => (
<div key={group.label} className="mb-4">
{/* Day header */}
<div className="px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider">
{group.label}
</div>

{/* Events */}
<div className="space-y-1">
{group.events.map((event) => (
<div
key={event.id}
className="flex items-start gap-3 px-3 py-2.5 rounded-lg hover:bg-slate-800/50 transition-colors"
>
{/* Icon */}
<div className="flex-shrink-0 mt-0.5">
{getEventIcon(event.type)}
</div>

{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-200 leading-snug">
{getEventDescription(event)}
</p>
</div>

{/* Time */}
<div className="flex-shrink-0">
<span className="text-xs text-slate-500">
{formatRelativeTime(event.ts)}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>

{/* Footer */}
<div className="px-4 py-3 border-t border-slate-800 text-xs text-slate-500">
<p>Showing {groupedEvents.reduce((acc, g) => acc + g.events.length, 0)} events</p>
</div>
</div>
</>
);
};

export default ActivityCenter;
Loading
Loading