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',
+ })}
+
+
+

+
+ ))}
+
+
+ )
+}
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 }))
}