Skip to content

Commit 33fd225

Browse files
committed
feat: v1.3.2 DOM-state capture (Tier 2) — serialize iframe DOM to compact JSON, context injection callback, DOM State panel
1 parent a0ff5a8 commit 33fd225

7 files changed

Lines changed: 483 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ in-browser computer-use system.
626626
`sandbox="allow-scripts"` iframe → `html2canvas` screenshot → attach as
627627
image to next LLM call → iterate; debounced auto-refresh + manual ↻ Run;
628628
reuses the iframe infra planned for v0.31 Code Arena
629-
- [ ] **DOM-state capture (Tier 2)** — serialize active iframe's accessibility
629+
- [x] **DOM-state capture (Tier 2)** — serialize active iframe's accessibility
630630
tree (ARIA roles, labels, input states) to compact JSON injected into context
631631
before each model turn; depth + node-count budget to stay token-safe
632632
- [ ] **Vision mode (Tier 1/2)**`OffscreenCanvas` / `html2canvas` screenshot

src/App.css

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17629,6 +17629,66 @@ textarea:focus:not(:focus-visible),
1762917629
font-style: italic;
1763017630
}
1763117631

17632+
/* DOM State capture (Tier 2) */
17633+
.agent-loop-domstate-section {
17634+
border-top: 1px solid var(--border-color);
17635+
}
17636+
17637+
.agent-loop-domstate-header {
17638+
padding: 8px 20px;
17639+
background: var(--surface-secondary);
17640+
}
17641+
17642+
.agent-loop-domstate-toggle {
17643+
display: flex;
17644+
align-items: center;
17645+
gap: 6px;
17646+
width: 100%;
17647+
background: none;
17648+
border: none;
17649+
color: var(--text-secondary);
17650+
font-size: 13px;
17651+
font-weight: 600;
17652+
cursor: pointer;
17653+
padding: 0;
17654+
text-align: left;
17655+
}
17656+
17657+
.agent-loop-domstate-toggle:hover {
17658+
color: var(--text-primary);
17659+
}
17660+
17661+
.agent-loop-domstate-toggle.active {
17662+
color: var(--text-primary);
17663+
}
17664+
17665+
.agent-loop-domstate-badge {
17666+
font-size: 11px;
17667+
font-weight: 400;
17668+
color: var(--text-muted);
17669+
background: var(--surface-tertiary);
17670+
padding: 2px 8px;
17671+
border-radius: 10px;
17672+
margin-left: auto;
17673+
}
17674+
17675+
.agent-loop-domstate-source {
17676+
padding: 12px 20px;
17677+
margin: 0;
17678+
font-size: 11px;
17679+
color: var(--text-secondary);
17680+
background: var(--surface-tertiary);
17681+
overflow-x: auto;
17682+
max-height: 300px;
17683+
overflow-y: auto;
17684+
white-space: pre;
17685+
line-height: 1.5;
17686+
}
17687+
17688+
.agent-loop-domstate-source code {
17689+
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
17690+
}
17691+
1763217692
.agent-loop-screenshot-section {
1763317693
border-top: 1px solid var(--border-color);
1763417694
}
@@ -17873,6 +17933,16 @@ textarea:focus:not(:focus-visible),
1787317933
color: #374151;
1787417934
}
1787517935

17936+
[data-theme='light'] .agent-loop-domstate-source {
17937+
background: #f9fafb;
17938+
color: #374151;
17939+
}
17940+
17941+
[data-theme='light'] .agent-loop-domstate-badge {
17942+
background: #e5e7eb;
17943+
color: #6b7280;
17944+
}
17945+
1787617946
[data-theme='light'] .agent-loop-step-tool-args {
1787717947
background: #f3f4f6;
1787817948
}

src/components/AgentLoopPanel.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ interface Props {
2020
onScreenshotReady?: (dataUrl: string) => void
2121
/** When the model generates HTML, this callback receives it for rendering */
2222
onHtmlGenerated?: (html: string) => void
23+
/** When DOM state is captured, this callback receives the serialized DOM for context injection */
24+
onDomStateReady?: (domState: string) => void
2325
/** Current task goal from the agent engine */
2426
taskGoal?: string
2527
/** Steps from the agent engine */
@@ -65,6 +67,7 @@ export function AgentLoopPanel({
6567
onBack,
6668
onScreenshotReady,
6769
onHtmlGenerated,
70+
onDomStateReady,
6871
taskGoal,
6972
steps,
7073
agentRunning,
@@ -75,10 +78,12 @@ export function AgentLoopPanel({
7578
const [activeIteration, setActiveIteration] = useState<number | null>(null)
7679
const [iframeVisible, setIframeVisible] = useState(true)
7780
const [showHtml, setShowHtml] = useState(false)
81+
const [showDomState, setShowDomState] = useState(false)
7882
const [autoRefresh, setAutoRefresh] = useState(true)
83+
const [domStateNodeCount, setDomStateNodeCount] = useState(0)
7984
const iframeContainerRef = useRef<HTMLDivElement>(null)
8085

81-
const { state: loopState, actions: loopActions, iframeEl, screenshotRef } = useAgentLoop({
86+
const { state: loopState, actions: loopActions, iframeEl, currentDomState, screenshotRef } = useAgentLoop({
8287
onScreenshot: (dataUrl, iteration) => {
8388
setIterations((prev) => {
8489
const entry = {
@@ -103,6 +108,11 @@ export function AgentLoopPanel({
103108
})
104109
}
105110
},
111+
onDomState: (domState, iteration) => {
112+
const match = domState.match(/(\d+) nodes/)
113+
setDomStateNodeCount(match ? parseInt(match[1], 10) : 0)
114+
onDomStateReady?.(domState)
115+
},
106116
autoRefreshDelay: autoRefresh ? 800 : Infinity,
107117
})
108118

@@ -307,6 +317,26 @@ export function AgentLoopPanel({
307317
</div>
308318
)}
309319

320+
{/* DOM state capture (Tier 2) */}
321+
{currentDomState && (
322+
<div className="agent-loop-domstate-section">
323+
<div className="agent-loop-domstate-header">
324+
<button
325+
className={`agent-loop-domstate-toggle ${showDomState ? 'active' : ''}`}
326+
onClick={() => setShowDomState(!showDomState)}
327+
>
328+
{showDomState ? '▼' : '▶'} {t('agent.loopDomState') || 'DOM State'}
329+
<span className="agent-loop-domstate-badge">{domStateNodeCount} nodes</span>
330+
</button>
331+
</div>
332+
{showDomState && (
333+
<pre className="agent-loop-domstate-source">
334+
<code>{currentDomState}</code>
335+
</pre>
336+
)}
337+
</div>
338+
)}
339+
310340
{/* Screenshot preview */}
311341
{activeEntry?.screenshotDataUrl && (
312342
<div className="agent-loop-screenshot-section">

src/hooks/useAgentLoop.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { useState, useCallback, useRef, useEffect } from 'react'
1111
import { getOrCreateIframe, getSandboxIframe, removeSandboxIframe } from '../lib/browserUseTools'
1212
import { extractHTMLCode } from '../lib/htmlExtract'
13+
import { serializeIframeDomState, type SerializeOptions } from '../lib/domState'
1314

1415
export type AgentLoopStatus = 'idle' | 'running' | 'paused' | 'error'
1516

@@ -36,6 +37,10 @@ export interface UseAgentLoopOptions {
3637
onScreenshot?: (dataUrl: string, iteration: number) => void
3738
/** Called when iteration completes — receives iteration count and HTML */
3839
onIterationComplete?: (html: string | null, iteration: number) => void
40+
/** Called before each model turn — receives serialized DOM-state JSON for context injection */
41+
onDomState?: (domState: string, iteration: number) => void
42+
/** DOM-state serialization options (depth + node-count budget) */
43+
domStateOptions?: SerializeOptions
3944
/** Debounce delay in ms for auto-refresh (default: 500) */
4045
autoRefreshDelay?: number
4146
/** Iframe target ID (default: 'agent-loop') */
@@ -134,11 +139,14 @@ export function useAgentLoop(options: UseAgentLoopOptions = {}): {
134139
state: AgentLoopState
135140
actions: AgentLoopActions
136141
iframeEl: HTMLIFrameElement | null
142+
currentDomState: string | null
137143
screenshotRef: React.RefCallback<HTMLIFrameElement>
138144
} {
139145
const {
140146
onScreenshot,
141147
onIterationComplete,
148+
onDomState,
149+
domStateOptions,
142150
autoRefreshDelay = 500,
143151
iframeId = 'agent-loop',
144152
} = options
@@ -153,12 +161,15 @@ export function useAgentLoop(options: UseAgentLoopOptions = {}): {
153161
lastScreenshotAt: null,
154162
})
155163

164+
const [currentDomState, setCurrentDomState] = useState<string | null>(null)
165+
156166
const statusRef = useRef<AgentLoopStatus>('idle')
157167
const htmlRef = useRef<string | null>(null)
158168
const iframeRef = useRef<HTMLIFrameElement | null>(null)
159169
const abortRef = useRef(false)
160170
const autoRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
161171
const iterationRef = useRef(0)
172+
const domStateOptionsRef = useRef(domStateOptions)
162173

163174
// Create iframe on mount
164175
useEffect(() => {
@@ -180,6 +191,11 @@ export function useAgentLoop(options: UseAgentLoopOptions = {}): {
180191
}
181192
}, [])
182193

194+
// Update DOM-state options ref when they change
195+
useEffect(() => {
196+
domStateOptionsRef.current = domStateOptions
197+
}, [domStateOptions])
198+
183199
const start = useCallback(
184200
(goal: string) => {
185201
abortRef.current = false
@@ -247,14 +263,47 @@ export function useAgentLoop(options: UseAgentLoopOptions = {}): {
247263
}))
248264
}
249265

266+
// Capture DOM-state for context injection (Tier 2)
267+
const iframe = iframeRef.current
268+
if (iframe && iframe.contentDocument && onDomState) {
269+
const domJson = serializeIframeDomState(iframe, domStateOptionsRef.current)
270+
const lines: string[] = []
271+
lines.push('// DOM State Context')
272+
lines.push(`// ${domJson.nodeCount} nodes, depth ${domJson.budget.depthReached}/${domJson.budget.maxDepth}`)
273+
if (domJson.budget.nodesTruncated) lines.push('// ⚠️ Truncated — node budget exceeded')
274+
lines.push('')
275+
for (const node of domJson.tree) {
276+
const indent = ' '.repeat(node.depth)
277+
const attrs: string[] = []
278+
if (node.id) attrs.push(`id="${node.id}"`)
279+
if (node.classes) attrs.push(`class="${node.classes}"`)
280+
if (node.type) attrs.push(`type="${node.type}"`)
281+
if (node.name) attrs.push(`name="${node.name}"`)
282+
if (node.value !== undefined) attrs.push(`value="${node.value}"`)
283+
if (node.checked !== undefined) attrs.push(`checked=${node.checked}`)
284+
if (node.disabled !== undefined) attrs.push(`disabled=${node.disabled}`)
285+
if (node.selected !== undefined) attrs.push(`selected=${node.selected}`)
286+
if (node.focused) attrs.push('focused')
287+
if (node.interactive) attrs.push('interactive')
288+
if (node.ariaRole) attrs.push(`role="${node.ariaRole}"`)
289+
if (node.ariaLabel) attrs.push(`aria-label="${node.ariaLabel}"`)
290+
const attrStr = attrs.length > 0 ? ` [${attrs.join(', ')}]` : ''
291+
const text = node.text ? ` "${node.text.slice(0, 80)}"` : ''
292+
lines.push(`${indent}<${node.tag}${attrStr}>${text}</${node.tag}>`)
293+
}
294+
const domStateStr = lines.join('\n')
295+
setCurrentDomState(domStateStr)
296+
onDomState(domStateStr, iteration)
297+
}
298+
250299
onIterationComplete?.(htmlRef.current, iteration)
251300
} catch (err) {
252301
setState((prev) => ({
253302
...prev,
254303
error: err instanceof Error ? err.message : 'Screenshot failed',
255304
}))
256305
}
257-
}, [iframeId, onScreenshot, onIterationComplete])
306+
}, [iframeId, onScreenshot, onIterationComplete, onDomState])
258307

259308
const setHtml = useCallback(
260309
(html: string | null) => {
@@ -323,6 +372,7 @@ export function useAgentLoop(options: UseAgentLoopOptions = {}): {
323372
state,
324373
actions: { start, stop, refresh, setHtml, clear },
325374
iframeEl: iframeRef.current,
375+
currentDomState,
326376
screenshotRef,
327377
}
328378
}

src/lib/changelog.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,21 @@ export const CHANGELOG: ChangelogEntry[] = [
14141414
{ type: 'changed', text: 'Updated version to v1.3.0' },
14151415
],
14161416
},
1417+
{
1418+
version: '1.3.2',
1419+
date: '2026-05-11',
1420+
title: 'Browser-Use — DOM-State Capture (Tier 2)',
1421+
description:
1422+
'Serialize the active sandboxed iframe\'s DOM to a compact accessibility-tree JSON and inject it as context before each model turn. Includes depth + node-count budget limits to stay token-safe, plus a DOM State panel in the AgentLoop UI for inspection.',
1423+
changes: [
1424+
{ type: 'added', text: 'domState.ts — DOM-state serialization utility: serializeIframeDomState() with ARIA roles, labels, input states, interactive detection; serializeDomStateCompact() for LLM context injection; configurable maxDepth/maxNodes budget' },
1425+
{ type: 'added', text: 'useAgentLoop onDomState callback — captures serialized DOM state before each model turn via the refresh cycle; passes compact JSON to parent for context injection' },
1426+
{ type: 'added', text: 'AgentLoopPanel DOM State panel — collapsible DOM state viewer with node-count badge, syntax-highlighted tree output, light theme support' },
1427+
{ type: 'added', text: 'i18n — agent.loopDomState en/zh translation' },
1428+
{ type: 'added', text: 'CSS — dark terminal-style DOM State panel with node-count badge, light theme overrides' },
1429+
{ type: 'changed', text: 'Updated version to v1.3.2' },
1430+
],
1431+
},
14171432
]
14181433

14191434
// Sort descending by version (latest first)

0 commit comments

Comments
 (0)