diff --git a/ui/src/components/FeatureBrowserPreview.tsx b/ui/src/components/FeatureBrowserPreview.tsx new file mode 100644 index 000000000..c77db42e2 --- /dev/null +++ b/ui/src/components/FeatureBrowserPreview.tsx @@ -0,0 +1,107 @@ +/** + * Feature Browser Preview + * + * Shows the live (headless) browser of any agent currently working on or + * testing a feature, embedded in the feature detail modal. Agents always run + * Playwright headless, so this is how users watch tests without desktop + * browser windows popping up over their work. + */ + +import { useEffect } from 'react' +import { Monitor, Code, FlaskConical, ExternalLink } from 'lucide-react' +import { useAppContext } from '@/contexts/AppContext' +import { AGENT_MASCOTS } from '@/lib/types' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' + +interface FeatureBrowserPreviewProps { + featureId: number + onOpenBrowsersView: () => void +} + +export function FeatureBrowserPreview({ featureId, onOpenBrowsersView }: FeatureBrowserPreviewProps) { + const { wsState } = useAppContext() + const { subscribeBrowserView, unsubscribeBrowserView, browserScreenshots, activeAgents } = wsState + + // Stream screenshots only while the modal is open + useEffect(() => { + subscribeBrowserView() + return () => unsubscribeBrowserView() + }, [subscribeBrowserView, unsubscribeBrowserView]) + + // Batched agents report their primary feature in screenshots, so also match + // agents whose batch includes this feature + const workingAgents = activeAgents.filter(a => a.featureIds.includes(featureId)) + const screenshots = Array.from(browserScreenshots.values()).filter(s => + s.featureId === featureId || + workingAgents.some(a => a.agentIndex === s.agentIndex && a.agentType === s.agentType) + ) + + if (screenshots.length === 0) { + // Nothing to show unless an agent is actually on this feature + if (workingAgents.length === 0) return null + return ( +
+

+ + Live Browser +

+
+ + The browser view will appear here once the agent opens a browser. +
+
+ ) + } + + return ( +
+
+

+ + Live Browser +

+ +
+
+ {screenshots.map(screenshot => ( +
+
+ {screenshot.agentType === 'coding' ? ( + + ) : ( + + )} + + {AGENT_MASCOTS[screenshot.agentIndex % AGENT_MASCOTS.length]} + + + {screenshot.agentType} + + + {new Date(screenshot.timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + +
+ {`Live +
+ ))} +
+
+ ) +} diff --git a/ui/src/components/FeatureCard.tsx b/ui/src/components/FeatureCard.tsx index 3552763b1..8edb190d8 100644 --- a/ui/src/components/FeatureCard.tsx +++ b/ui/src/components/FeatureCard.tsx @@ -1,4 +1,4 @@ -import { CheckCircle2, Circle, Loader2, MessageCircle, UserCircle } from 'lucide-react' +import { CheckCircle2, Circle, Loader2, MessageCircle, Monitor, UserCircle } from 'lucide-react' import type { Feature, ActiveAgent } from '../lib/types' import { DependencyBadge } from './DependencyBadge' import { AgentAvatar } from './AgentAvatar' @@ -91,6 +91,13 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], )} + + + Live + )} diff --git a/ui/src/components/FeatureModal.tsx b/ui/src/components/FeatureModal.tsx index 6e3e04e36..915356ebc 100644 --- a/ui/src/components/FeatureModal.tsx +++ b/ui/src/components/FeatureModal.tsx @@ -3,6 +3,7 @@ import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pen import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects' import { EditFeatureForm } from './EditFeatureForm' import { HumanInputForm } from './HumanInputForm' +import { FeatureBrowserPreview } from './FeatureBrowserPreview' import type { Feature } from '../lib/types' import { Dialog, @@ -40,9 +41,10 @@ interface FeatureModalProps { feature: Feature projectName: string onClose: () => void + onOpenBrowsersView?: () => void } -export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) { +export function FeatureModal({ feature, projectName, onClose, onOpenBrowsersView }: FeatureModalProps) { const [error, setError] = useState(null) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showEdit, setShowEdit] = useState(false) @@ -149,6 +151,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp NEEDS YOUR INPUT + ) : feature.in_progress ? ( + <> + + IN PROGRESS + ) : ( <> @@ -160,6 +167,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp + {/* Live browser of any agent working on / testing this feature */} + {onOpenBrowsersView && ( + + )} + {/* Human Input Request */} {feature.needs_human_input && feature.human_input_request && ( setSelectedFeature(null)} + onOpenBrowsersView={() => { + setSelectedFeature(null) + setActiveView('browsers') + }} /> )} diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index cd891b9ba..566f28358 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -88,6 +88,9 @@ export function useProjectWebSocket(projectName: string | null) { const reconnectTimeoutRef = useRef(null) const reconnectAttempts = useRef(0) const lastMessageAtRef = useRef(Date.now()) + // Number of mounted consumers that want browser screenshots (Browsers view, debug tab, feature modal). + // The server tracks a single subscribed flag per socket, so we only send subscribe/unsubscribe on 0 <-> 1. + const browserViewSubscribersRef = useRef(0) const connect = useCallback(() => { if (!projectName) return @@ -127,6 +130,11 @@ export function useProjectWebSocket(projectName: string | null) { lastMessageAtRef.current = Date.now() setState(prev => ({ ...prev, isConnected: true })) reconnectAttempts.current = 0 + // Subscriptions are per-socket on the server, so restore them after (re)connecting + if (browserViewSubscribersRef.current > 0) { + ws.send(JSON.stringify({ type: 'browser_view_subscribe' })) + setState(prev => ({ ...prev, browserViewSubscribed: true })) + } } ws.onmessage = (event) => { @@ -223,7 +231,19 @@ export function useProjectWebSocket(projectName: string | null) { ) let newAgents: ActiveAgent[] + let newScreenshots = prev.browserScreenshots if (message.state === 'success' || message.state === 'error') { + // Drop the finished agent's last browser frame so it doesn't linger as "live" + const agentType = message.agentType || 'coding' + const stale = Array.from(prev.browserScreenshots.values()).filter(s => + s.agentType === agentType && + (message.agentIndex === -1 ? s.featureId === message.featureId : s.agentIndex === message.agentIndex) + ) + if (stale.length > 0) { + newScreenshots = new Map(prev.browserScreenshots) + stale.forEach(s => newScreenshots.delete(s.sessionName)) + } + // Remove agent from active list on completion (success or failure) // But keep the logs in agentLogs map for debugging if (message.agentIndex === -1) { @@ -310,6 +330,7 @@ export function useProjectWebSocket(projectName: string | null) { ...prev, activeAgents: newAgents, agentLogs: newAgentLogs, + browserScreenshots: newScreenshots, recentActivity: newActivity, celebrationQueue: newCelebrationQueue, celebration: newCelebration, @@ -565,17 +586,19 @@ export function useProjectWebSocket(projectName: string | null) { }) }, []) - // Subscribe to browser view screenshots + // Subscribe to browser view screenshots (ref-counted across consumers) const subscribeBrowserView = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { + browserViewSubscribersRef.current += 1 + if (browserViewSubscribersRef.current === 1 && wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'browser_view_subscribe' })) setState(prev => ({ ...prev, browserViewSubscribed: true })) } }, []) - // Unsubscribe from browser view screenshots + // Unsubscribe from browser view screenshots (only stops streaming when the last consumer leaves) const unsubscribeBrowserView = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { + browserViewSubscribersRef.current = Math.max(0, browserViewSubscribersRef.current - 1) + if (browserViewSubscribersRef.current === 0 && wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'browser_view_unsubscribe' })) setState(prev => ({ ...prev, browserViewSubscribed: false })) }