diff --git a/internal/trace/writer.go b/internal/trace/writer.go
index 4dfad4a..0aa4ff0 100644
--- a/internal/trace/writer.go
+++ b/internal/trace/writer.go
@@ -13,16 +13,17 @@ import (
)
type Step struct {
- Index int `json:"step"`
- Timestamp time.Time `json:"timestamp"`
- Screen string `json:"screen,omitempty"`
- Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
- Action *Action `json:"action,omitempty"`
- Exceptions []Exception `json:"exceptions,omitempty"`
- Violations []string `json:"violations,omitempty"`
- Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"`
- Residuals map[string]json.RawMessage `json:"residuals,omitempty"`
- Metrics *Metrics `json:"metrics,omitempty"`
+ Index int `json:"step"`
+ Timestamp time.Time `json:"timestamp"`
+ Screen string `json:"screen,omitempty"`
+ Snapshots map[string]json.RawMessage `json:"snapshots,omitempty"`
+ Action *Action `json:"action,omitempty"`
+ Exceptions []Exception `json:"exceptions,omitempty"`
+ Violations []string `json:"violations,omitempty"`
+ Hierarchy *hierarchy.Tree `json:"hierarchy,omitempty"`
+ Residuals map[string]json.RawMessage `json:"residuals,omitempty"`
+ Metrics *Metrics `json:"metrics,omitempty"`
+ HTMLAvailable bool `json:"html_available,omitempty"`
}
type Metrics struct {
@@ -143,6 +144,30 @@ func (w *Writer) writePNG(name string, png []byte) error {
return os.WriteFile(filepath.Join(directory, name), png, 0o644)
}
+// WriteHTML stores the page HTML for `stepIndex` under `
/html/`. Empty
+// payloads are skipped so the directory only exists when there is real
+// content to render in the inspect UI.
+func (w *Writer) WriteHTML(stepIndex int, html []byte) error {
+ return w.writeHTML(fmt.Sprintf("step-%05d.html", stepIndex), html)
+}
+
+// WriteHTMLAfter writes the post-action HTML, paired with the post-action
+// screenshot.
+func (w *Writer) WriteHTMLAfter(stepIndex int, html []byte) error {
+ return w.writeHTML(fmt.Sprintf("step-%05d-after.html", stepIndex), html)
+}
+
+func (w *Writer) writeHTML(name string, html []byte) error {
+ if len(html) == 0 {
+ return nil
+ }
+ directory := filepath.Join(w.directory, "html")
+ if err := os.MkdirAll(directory, 0o755); err != nil {
+ return fmt.Errorf("mkdir html: %w", err)
+ }
+ return os.WriteFile(filepath.Join(directory, name), html, 0o644)
+}
+
func (w *Writer) Close() error {
w.mutex.Lock()
defer w.mutex.Unlock()
diff --git a/internal/trace/writer_test.go b/internal/trace/writer_test.go
index 454656c..ad39e07 100644
--- a/internal/trace/writer_test.go
+++ b/internal/trace/writer_test.go
@@ -269,6 +269,65 @@ func TestWriteAfterClose_Errors(t *testing.T) {
}
}
+func TestWriteHTML_WritesToHTMLDir(t *testing.T) {
+ directory := t.TempDir()
+ writer, _ := NewWriter(directory)
+ defer writer.Close()
+
+ html := []byte("hi")
+ if err := writer.WriteHTML(3, html); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.WriteHTMLAfter(3, html); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := os.ReadFile(filepath.Join(directory, "html", "step-00003.html"))
+ if err != nil {
+ t.Fatalf("step-00003 missing: %v", err)
+ }
+ if string(got) != string(html) {
+ t.Errorf("html bytes wrong: %q", got)
+ }
+ if _, err := os.Stat(filepath.Join(directory, "html", "step-00003-after.html")); err != nil {
+ t.Errorf("step-00003-after missing: %v", err)
+ }
+}
+
+func TestWriteHTML_EmptyByteSliceIsNoop(t *testing.T) {
+ directory := t.TempDir()
+ writer, _ := NewWriter(directory)
+ defer writer.Close()
+
+ if err := writer.WriteHTML(1, nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(directory, "html")); !os.IsNotExist(err) {
+ t.Errorf("html dir should not exist after empty write")
+ }
+}
+
+func TestStep_HTMLAvailableRoundTrip(t *testing.T) {
+ directory := t.TempDir()
+ writer, _ := NewWriter(directory)
+ defer writer.Close()
+
+ if err := writer.WriteStep(Step{Index: 1, HTMLAvailable: true}); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.WriteStep(Step{Index: 2}); err != nil {
+ t.Fatal(err)
+ }
+ body, _ := os.ReadFile(filepath.Join(directory, "trace.jsonl"))
+ lines := strings.Split(strings.TrimSpace(string(body)), "\n")
+ if !strings.Contains(lines[0], `"html_available":true`) {
+ t.Errorf("html_available missing in step 1: %s", lines[0])
+ }
+ if strings.Contains(lines[1], "html_available") {
+ t.Errorf("html_available should be omitted when false: %s", lines[1])
+ }
+}
+
func TestNewWriter_CreatesNestedDirectory(t *testing.T) {
parent := t.TempDir()
target := filepath.Join(parent, "runs", "2026-04-17T22-30-00")