Skip to content

Commit cd25248

Browse files
GiniGini
authored andcommitted
feat: replay preserved visual task evidence
1 parent d7398c5 commit cd25248

4 files changed

Lines changed: 32 additions & 9 deletions

File tree

docs/MANUS-PARITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ This is the implementation gate, not a marketing checklist. **I** means behavior
1717
9. **I** Structured waiting state — runtime parks durably, UI renders the focused request, and an answer resumes the same execution.
1818
10. **I** Terminal-input request state — native Claude can invoke the ONEVibe input MCP tool and receive the user's answer as its tool result.
1919
11. **I** Persistent routes — `/tasks/:taskId` and the Skills, Library, and Scheduled surfaces survive reload and browser navigation through explicit view URLs.
20-
12. **P** Concurrent workspace plus conversation and Computer timeline — server-classified, run-bound, evidence-backed task events render as a scrub-able terminal, visual-frame, artifact, diff, preview, deck, and approval record beside the conversation. The rail supports explicit live follow/pause, keyboard step/scrub, grouped tool/result cards, virtualized mixed-media replay, causal inline thumbnails, and browser-tool-labelled X11 frames. Browser navigation evidence is URL-redacted before projection. Browser/desktop-frame playback, native browser metadata, and production microVM evidence remain P0 in [`SIDE-ARTIFACT-RAIL.md`](SIDE-ARTIFACT-RAIL.md).
20+
12. **P** Concurrent workspace plus conversation and Computer timeline — server-classified, run-bound, evidence-backed task events render as a scrub-able terminal, visual-frame, artifact, diff, preview, deck, and approval record beside the conversation. The rail supports explicit live follow/pause, keyboard step/scrub, grouped tool/result cards, virtualized mixed-media replay, causal inline thumbnails, browser-tool-labelled X11 frames, and pauseable chronological replay of preserved visual evidence. Browser navigation evidence is URL-redacted before projection. Native browser metadata, cross-run comparison, and production microVM evidence remain P0 in [`SIDE-ARTIFACT-RAIL.md`](SIDE-ARTIFACT-RAIL.md).
2121
13. **I** Agent-mode entry point — primary ONEVibe surface.
2222
14. **I** Task history surface — durable turn-based chat history, timestamps/status, cursor pagination, full-text search, reload persistence, and evidence export.
2323
15. **P** Reusable project context — a project now retains a governed brief plus up to twelve bounded, path-confined knowledge files. Text-like files are attached server-side as untrusted context with immutable metadata-only evidence. Folder sync, fine-grained project permissions, connected drives, and deletion/version management remain pending.

docs/SIDE-ARTIFACT-RAIL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ The default view is chronological and auto-follows new activity. Pausing preserv
3434

3535
1. **P0a — rail cards:** Replace the history-list + single-stage mental model with a virtualized chronological card rail. Group request/result events by `toolUseId`; retain standalone control/policy cards.
3636
2. **P0b — visual correlation:** Persist causality for X11 and future browser captures; show safe inline thumbnails directly after the producing tool card and provide an expanded inspect view.
37-
3. **P0c — replay:** Preserve selection, scroll anchor, filters, and deep links across reload/reconnect. Add explicit run chapters and compare a prior run without mutating evidence.
37+
3. **P0c — replay (in progress):** Selection, scroll anchor, filters, and deep links survive reload/reconnect; the rail can play preserved visual evidence in chronological order, then pause for manual immutable inspection. Add explicit run chapters, prior-run comparison, and synchronized production browser/desktop playback without mutating evidence.
3838
4. **P0d — browser evidence (in progress):** Website, App, and Game outputs now add an explicit sandbox-local `file://` Chromium screenshot after artifact extraction, with hostname resolution blocked. Allowlisted browser-tool starts/results now produce explicitly labelled X11 checkpoints tied to the tool-use ID; next, add browser-native checkpoint metadata (URL/title with secret-aware redaction) without exposing CDP or control credentials.
3939
5. **P0e — scale and accessibility:** Progressive media loading, keyboard traversal, screen-reader labels, reduced-motion behavior, and performance proof at 10,000 events.
4040
6. **P0f — real runtime proof:** Exercise the rail against an attested ONEComputer X11/browser runtime—not demo fixtures—with redaction and retention evidence.

src/components/ComputerTimeline.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArrowLeft, ArrowRight, CheckCircle2, Eye, FileCode2, Presentation, Radio, ShieldCheck, TerminalSquare, Wrench } from 'lucide-react'
1+
import { ArrowLeft, ArrowRight, CheckCircle2, Eye, FileCode2, Pause, Play, Presentation, Radio, ShieldCheck, TerminalSquare, Wrench } from 'lucide-react'
22
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
33
import type { PresentationPanel, TaskSnapshot } from '../types'
44
import { artifactRailItems, causalVisualItemsFor, evidenceItemId, formatDuration, formatInspectable, matchesRailQuery, presentationItems, terminalActivityFor, virtualRailRange, type ComputerItem } from './computer-timeline-activity'
@@ -22,6 +22,7 @@ export const ComputerTimeline = ({ task }: { task: TaskSnapshot }) => {
2222
const [selectedId, setSelectedId] = useState<string>()
2323
const [follow, setFollow] = useState(true)
2424
const [newActivity, setNewActivity] = useState(0)
25+
const [replaying, setReplaying] = useState(false)
2526
const [frame, setFrame] = useState(Date.now())
2627
const [railScrollTop, setRailScrollTop] = useState(0)
2728
const [railViewportHeight, setRailViewportHeight] = useState(360)
@@ -31,8 +32,9 @@ export const ComputerTimeline = ({ task }: { task: TaskSnapshot }) => {
3132
const itemKey = items.map((item) => item.id).join('|')
3233
const lastItemId = items.at(-1)?.id
3334
const selected = Math.max(0, items.findIndex((item) => item.id === selectedId))
35+
const replayFrames = useMemo(() => railItems.filter((item) => item.kind === 'screenshot' && !item.live), [railItems])
3436
const visibleRange = virtualRailRange(items.length, railScrollTop, railViewportHeight, RAIL_ROW_HEIGHT)
35-
useEffect(() => { setFollow(true); setNewActivity(0); setSelectedId(undefined); setFilter('all'); setRailQuery('') }, [task.id])
37+
useEffect(() => { setFollow(true); setNewActivity(0); setReplaying(false); setSelectedId(undefined); setFilter('all'); setRailQuery('') }, [task.id])
3638
useEffect(() => {
3739
if (previousFilter.current !== filter) { setFollow(false); setNewActivity(0); previousFilter.current = filter }
3840
if (follow && items.length) setSelectedId(items.at(-1)?.id)
@@ -87,11 +89,32 @@ export const ComputerTimeline = ({ task }: { task: TaskSnapshot }) => {
8789
else url.searchParams.set('rail', persistedFilter)
8890
window.history.replaceState(window.history.state, '', url)
8991
}
90-
const move = (next: number) => { const index = Math.max(0, Math.min(items.length - 1, next)); setFollow(false); setSelectedId(items[index]?.id); persistEvidenceReference(items[index]) }
91-
const resumeLive = () => { const index = items.length - 1; setFollow(true); setNewActivity(0); setSelectedId(items[index]?.id); persistEvidenceReference(items[index]) }
92+
useEffect(() => {
93+
if (!replaying || filter !== 'screenshot' || items.length < 2) return
94+
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
95+
const timer = window.setTimeout(() => {
96+
const next = selected >= items.length - 1 ? 0 : selected + 1
97+
setSelectedId(items[next]?.id)
98+
persistEvidenceReference(items[next], 'screenshot')
99+
}, reducedMotion ? 2_400 : 1_250)
100+
return () => window.clearTimeout(timer)
101+
}, [filter, items, replaying, selected])
102+
const move = (next: number) => { const index = Math.max(0, Math.min(items.length - 1, next)); setReplaying(false); setFollow(false); setSelectedId(items[index]?.id); persistEvidenceReference(items[index]) }
103+
const resumeLive = () => { const index = items.length - 1; setReplaying(false); setFollow(true); setNewActivity(0); setSelectedId(items[index]?.id); persistEvidenceReference(items[index]) }
104+
const toggleReplay = () => {
105+
if (replaying) { setReplaying(false); return }
106+
if (replayFrames.length < 2) return
107+
setFilter('screenshot')
108+
setRailQuery('')
109+
setFollow(false)
110+
setNewActivity(0)
111+
setSelectedId(replayFrames[0]?.id)
112+
setReplaying(true)
113+
}
92114
const inspectVisual = (eventId: string) => {
93115
const index = railItems.findIndex((item) => item.id === eventId)
94116
if (index < 0) return
117+
setReplaying(false)
95118
setFilter('all')
96119
setFollow(false)
97120
setNewActivity(0)
@@ -106,8 +129,8 @@ export const ComputerTimeline = ({ task }: { task: TaskSnapshot }) => {
106129
}
107130
if (!allItems.length) return <div className="workspace-placeholder"><Wrench size={20} /><strong>No computer activity yet</strong><span>Commands, screenshots, files, and previews will appear here as the agent works.</span></div>
108131
return <div className="computer-timeline" onKeyDown={onTimelineKeyDown} tabIndex={0} aria-label="Agent computer artifact timeline">
109-
<aside className="computer-history"><div className="computer-history-heading"><span>Artifact rail</span><button className={follow ? 'active' : ''} onClick={resumeLive} aria-label="Resume live follow"><Radio size={10} /> {follow ? 'Live' : newActivity ? `${newActivity} new` : 'Resume'}</button></div><div className="computer-filters">{(['all', 'terminal', 'screenshot', 'file', 'preview', 'slide', 'approval', 'diff'] as const).filter((kind) => kind === 'all' || railItems.some((item) => item.kind === kind)).map((kind) => <button key={kind} className={filter === kind ? 'active' : ''} onClick={() => setFilter(kind)}>{kind === 'all' ? 'All' : kind}</button>)}</div><label className="computer-rail-search"><span>Find evidence</span><input value={railQuery} onChange={(event) => setRailQuery(event.target.value)} placeholder="Command, artifact, run…" aria-label="Find projected task evidence" /></label><div className="computer-rail-scroll" ref={railScrollRef} onScroll={(event) => setRailScrollTop(event.currentTarget.scrollTop)}>{items.length ? <div className="computer-rail-virtual" style={{ height: items.length * RAIL_ROW_HEIGHT }}><div style={{ transform: `translateY(${visibleRange.start * RAIL_ROW_HEIGHT}px)` }}>{items.slice(visibleRange.start, visibleRange.end).map((item, offset) => { const index = visibleRange.start + offset; return <ArtifactRailEntry key={item.id} item={item} previousRunId={items[index - 1]?.runId} index={index} selected={index === selected} total={items.length} events={task.events} onMove={move} /> })}</div></div> : <p className="computer-rail-empty">No projected evidence matches this search.</p>}</div></aside>
110-
<section className="computer-stage"><header><button disabled={selected === 0} onClick={() => move(selected - 1)} aria-label="Previous timeline event"><ArrowLeft size={13} /></button><button disabled={selected >= items.length - 1} onClick={() => move(selected + 1)} aria-label="Next timeline event"><ArrowRight size={13} /></button><div><strong>{active?.title}</strong><span>{active?.detail}</span></div><em>{active?.runId ? `run ${active.runId.slice(-6)} · ` : ''}{active?.sequence ? `#${active.sequence} · ` : ''}{selected + 1} / {items.length}{filter !== 'all' && ` · ${filter}`}{active?.eventHash && <code title="Immutable evidence hash">{active.eventHash.slice(0, 8)}</code>}{!follow && <b>paused</b>}</em></header>
132+
<aside className="computer-history"><div className="computer-history-heading"><span>Artifact rail</span><div>{replayFrames.length > 1 && <button className={replaying ? 'active' : ''} onClick={toggleReplay} aria-label={replaying ? 'Pause visual evidence replay' : 'Replay visual evidence'} title={replaying ? 'Pause replay' : 'Replay visual evidence'}>{replaying ? <Pause size={10} /> : <Play size={10} />} {replaying ? 'Pause' : 'Replay'}</button>}<button className={follow ? 'active' : ''} onClick={resumeLive} aria-label="Resume live follow"><Radio size={10} /> {follow ? 'Live' : newActivity ? `${newActivity} new` : 'Resume'}</button></div></div><div className="computer-filters">{(['all', 'terminal', 'screenshot', 'file', 'preview', 'slide', 'approval', 'diff'] as const).filter((kind) => kind === 'all' || railItems.some((item) => item.kind === kind)).map((kind) => <button key={kind} className={filter === kind ? 'active' : ''} onClick={() => { setReplaying(false); setFilter(kind) }}>{kind === 'all' ? 'All' : kind}</button>)}</div><label className="computer-rail-search"><span>Find evidence</span><input value={railQuery} onChange={(event) => { setReplaying(false); setRailQuery(event.target.value) }} placeholder="Command, artifact, run…" aria-label="Find projected task evidence" /></label><div className="computer-rail-scroll" ref={railScrollRef} onScroll={(event) => setRailScrollTop(event.currentTarget.scrollTop)}>{items.length ? <div className="computer-rail-virtual" style={{ height: items.length * RAIL_ROW_HEIGHT }}><div style={{ transform: `translateY(${visibleRange.start * RAIL_ROW_HEIGHT}px)` }}>{items.slice(visibleRange.start, visibleRange.end).map((item, offset) => { const index = visibleRange.start + offset; return <ArtifactRailEntry key={item.id} item={item} previousRunId={items[index - 1]?.runId} index={index} selected={index === selected} total={items.length} events={task.events} onMove={move} /> })}</div></div> : <p className="computer-rail-empty">No projected evidence matches this search.</p>}</div></aside>
133+
<section className="computer-stage"><header><button disabled={selected === 0} onClick={() => move(selected - 1)} aria-label="Previous timeline event"><ArrowLeft size={13} /></button><button disabled={selected >= items.length - 1} onClick={() => move(selected + 1)} aria-label="Next timeline event"><ArrowRight size={13} /></button><div><strong>{active?.title}</strong><span>{active?.detail}</span></div><em>{active?.runId ? `run ${active.runId.slice(-6)} · ` : ''}{active?.sequence ? `#${active.sequence} · ` : ''}{selected + 1} / {items.length}{filter !== 'all' && ` · ${filter}`}{active?.eventHash && <code title="Immutable evidence hash">{active.eventHash.slice(0, 8)}</code>}{replaying ? <b>replaying</b> : !follow && <b>paused</b>}</em></header>
111134
{active?.kind === 'screenshot' && active.uri && <div className="computer-visual"><img src={withCacheBust(active.uri, frame)} alt={active.title} /></div>}
112135
{active?.kind === 'preview' && active.uri && <iframe title={active.title} sandbox="allow-scripts" src={active.uri} />}
113136
{active?.kind === 'slide' && <div className="computer-file"><Presentation size={28} /><strong>{active.detail ?? active.title}</strong><span>Deck evidence is preserved. Open the Files tab to download the PPTX or inspect the rendered viewer.</span></div>}

0 commit comments

Comments
 (0)