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
107 changes: 107 additions & 0 deletions ui/src/components/FeatureBrowserPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<Monitor size={16} />
Live Browser
</h3>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md text-sm text-muted-foreground">
<Monitor size={16} />
The browser view will appear here once the agent opens a browser.
</div>
</div>
)
}

return (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground flex items-center gap-2">
<Monitor size={16} />
Live Browser
</h3>
<Button variant="ghost" size="sm" onClick={onOpenBrowsersView}>
<ExternalLink size={14} />
All browsers
</Button>
</div>
<div className="space-y-3">
{screenshots.map(screenshot => (
<div
key={screenshot.sessionName}
className="border-2 border-border rounded-lg overflow-hidden bg-card"
data-testid="feature-browser-preview"
>
<div className="flex items-center gap-2 px-2.5 py-1.5 bg-muted border-b border-border">
{screenshot.agentType === 'coding' ? (
<Code size={12} className="text-blue-500 shrink-0" />
) : (
<FlaskConical size={12} className="text-purple-500 shrink-0" />
)}
<span className="font-mono text-xs font-bold">
{AGENT_MASCOTS[screenshot.agentIndex % AGENT_MASCOTS.length]}
</span>
<Badge variant="outline" className="text-[9px] h-3.5 px-1">
{screenshot.agentType}
</Badge>
<span className="ml-auto text-[10px] text-muted-foreground font-mono">
{new Date(screenshot.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
</div>
<img
src={screenshot.imageDataUrl}
alt={`Live browser - ${screenshot.featureName}`}
className="w-full h-auto"
/>
</div>
))}
</div>
</div>
)
}
9 changes: 8 additions & 1 deletion ui/src/components/FeatureCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -91,6 +91,13 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
</div>
)}
</div>
<span
className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0"
title="Open to watch the agent's browser live"
>
<Monitor size={12} />
Live
</span>
</div>
)}

Expand Down
14 changes: 13 additions & 1 deletion ui/src/components/FeatureModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showEdit, setShowEdit] = useState(false)
Expand Down Expand Up @@ -149,6 +151,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
<UserCircle size={24} className="text-amber-500" />
<span className="font-semibold text-amber-500">NEEDS YOUR INPUT</span>
</>
) : feature.in_progress ? (
<>
<Loader2 size={24} className="text-primary animate-spin" />
<span className="font-semibold text-primary">IN PROGRESS</span>
</>
) : (
<>
<Circle size={24} className="text-muted-foreground" />
Expand All @@ -160,6 +167,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
</span>
</div>

{/* Live browser of any agent working on / testing this feature */}
{onOpenBrowsersView && (
<FeatureBrowserPreview featureId={feature.id} onOpenBrowsersView={onOpenBrowsersView} />
)}

{/* Human Input Request */}
{feature.needs_human_input && feature.human_input_request && (
<HumanInputForm
Expand Down
5 changes: 5 additions & 0 deletions ui/src/components/layout/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function Modals() {
const {
selectedProject,
selectedFeature, setSelectedFeature,
setActiveView,
showAddFeature, setShowAddFeature,
showExpandProject, setShowExpandProject,
showSpecChat, setShowSpecChat,
Expand Down Expand Up @@ -51,6 +52,10 @@ export function Modals() {
feature={selectedFeature}
projectName={selectedProject}
onClose={() => setSelectedFeature(null)}
onOpenBrowsersView={() => {
setSelectedFeature(null)
setActiveView('browsers')
}}
/>
)}

Expand Down
31 changes: 27 additions & 4 deletions ui/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export function useProjectWebSocket(projectName: string | null) {
const reconnectTimeoutRef = useRef<number | null>(null)
const reconnectAttempts = useRef(0)
const lastMessageAtRef = useRef<number>(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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -310,6 +330,7 @@ export function useProjectWebSocket(projectName: string | null) {
...prev,
activeAgents: newAgents,
agentLogs: newAgentLogs,
browserScreenshots: newScreenshots,
recentActivity: newActivity,
celebrationQueue: newCelebrationQueue,
celebration: newCelebration,
Expand Down Expand Up @@ -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 }))
}
Expand Down