From cc7754e176873b3134e1b18dff451a0352d8e0ff Mon Sep 17 00:00:00 2001
From: Apoorv Singh <68725597+aprv10@users.noreply.github.com>
Date: Thu, 9 Jul 2026 03:55:06 +0530
Subject: [PATCH] feat(preview): agent driven browser previews + Markdown
rendering (#2455)
* feat(preview): render Markdown and let agents drive the browser panel via ao preview
* fix go linting errors
* fix linting errors
* refactor(preview): fix the auto open bug
---
backend/go.mod | 1 +
backend/go.sum | 2 +
.../internal/httpd/controllers/sessions.go | 23 ++++
backend/internal/preview/entry.go | 98 +++++++++++++-
backend/internal/preview/entry_test.go | 122 ++++++++++++++++++
backend/internal/preview/markdown.go | 103 +++++++++++++++
backend/internal/preview/markdown_test.go | 43 ++++++
7 files changed, 389 insertions(+), 3 deletions(-)
create mode 100644 backend/internal/preview/entry_test.go
create mode 100644 backend/internal/preview/markdown.go
create mode 100644 backend/internal/preview/markdown_test.go
diff --git a/backend/go.mod b/backend/go.mod
index 55657904d..5c7f80fb6 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -14,6 +14,7 @@ require (
github.com/spf13/pflag v1.0.9
github.com/swaggest/jsonschema-go v0.3.79
github.com/swaggest/openapi-go v0.2.61
+ github.com/yuin/goldmark v1.8.2
golang.org/x/sys v0.44.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.51.0
diff --git a/backend/go.sum b/backend/go.sum
index a7e9d8331..475afdf5f 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -68,6 +68,8 @@ github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCO
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go
index 348f65ff1..8b61fe391 100644
--- a/backend/internal/httpd/controllers/sessions.go
+++ b/backend/internal/httpd/controllers/sessions.go
@@ -189,9 +189,32 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
+ if previewutil.IsMarkdownPath(file) {
+ c.servePreviewMarkdown(w, r, file)
+ return
+ }
http.ServeFile(w, r, file)
}
+// servePreviewMarkdown renders a workspace Markdown file to a self-contained
+// HTML document so the browser panel displays formatted content instead of raw
+// source.
+func (c *SessionsController) servePreviewMarkdown(w http.ResponseWriter, r *http.Request, file string) {
+ source, err := os.ReadFile(file)
+ if err != nil {
+ envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
+ return
+ }
+ rendered, err := previewutil.RenderMarkdown(source, filepath.Base(file))
+ if err != nil {
+ envelope.WriteError(w, r, err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+
+ _, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted
+}
+
// setPreview persists the browser preview URL the desktop app opens for a
// session and fans out a session_updated CDC event so the dashboard's browser
// panel reacts live. The target is resolved as follows:
diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go
index 2cc3a60c6..0b2a2bbf5 100644
--- a/backend/internal/preview/entry.go
+++ b/backend/internal/preview/entry.go
@@ -1,6 +1,7 @@
package preview
import (
+ "io/fs"
"net/url"
"os"
"path"
@@ -13,6 +14,19 @@ import (
var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"}
+// previewableExts are the file extensions the browser panel can render: HTML
+// verbatim and Markdown converted to HTML by the preview/files route.
+var previewableExts = map[string]struct{}{
+ ".html": {},
+ ".htm": {},
+ ".md": {},
+ ".markdown": {},
+}
+
+// maxPreviewWalkFiles bounds the most-recent fallback scan so a pathological
+// workspace cannot stall the preview poller.
+const maxPreviewWalkFiles = 5000
+
// Entry is a workspace-local static frontend entrypoint.
type Entry struct {
Path string
@@ -21,8 +35,11 @@ type Entry struct {
Size int64
}
-// DiscoverEntry returns the first supported HTML entrypoint that exists inside
-// the workspace.
+// DiscoverEntry returns the entry the browser panel should preview for a
+// workspace. A conventional index.html (or its public/dist/build variants)
+// always wins; when none exists it falls back to the most-recently-modified
+// previewable file (.html/.htm/.md/.markdown) anywhere in the workspace, so a
+// freshly generated report or document shows up automatically.
func DiscoverEntry(workspacePath string) (Entry, bool) {
if strings.TrimSpace(workspacePath) == "" {
return Entry{}, false
@@ -37,7 +54,82 @@ func DiscoverEntry(workspacePath string) (Entry, bool) {
return Entry{Path: candidate, AbsPath: file, ModTime: info.ModTime(), Size: info.Size()}, true
}
}
- return Entry{}, false
+ return mostRecentPreviewable(workspacePath)
+}
+
+// mostRecentPreviewable walks the workspace and returns the newest previewable
+// file. Ties (equal mod times) break on the slash path so the result is
+// deterministic. Hidden directories and node_modules are skipped, and the scan
+// is bounded by maxPreviewWalkFiles.
+func mostRecentPreviewable(workspacePath string) (Entry, bool) {
+ root, err := filepath.Abs(workspacePath)
+ if err != nil {
+ return Entry{}, false
+ }
+ var best Entry
+ found := false
+ seen := 0
+ _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ //nolint:nilerr // skip unreadable entries rather than aborting the whole scan
+ return nil
+ }
+ if d.IsDir() {
+ if p != root && skipPreviewDir(d.Name()) {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if _, ok := previewableExts[strings.ToLower(filepath.Ext(d.Name()))]; !ok {
+ return nil
+ }
+ seen++
+ if seen > maxPreviewWalkFiles {
+ return filepath.SkipAll
+ }
+ info, err := d.Info()
+ if err != nil {
+ //nolint:nilerr // skip this file, keep scanning the rest of the workspace
+ return nil
+ }
+ rel, err := filepath.Rel(root, p)
+ if err != nil {
+ //nolint:nilerr // skip this file, keep scanning the rest of the workspace
+ return nil
+ }
+ relSlash := filepath.ToSlash(rel)
+ if !found || newerPreviewable(info, relSlash, best) {
+ best = Entry{Path: relSlash, AbsPath: p, ModTime: info.ModTime(), Size: info.Size()}
+ found = true
+ }
+ return nil
+ })
+ return best, found
+}
+
+func newerPreviewable(info fs.FileInfo, relSlash string, best Entry) bool {
+ mod := info.ModTime()
+ if mod.After(best.ModTime) {
+ return true
+ }
+ if mod.Equal(best.ModTime) {
+ return relSlash < best.Path
+ }
+ return false
+}
+
+func skipPreviewDir(name string) bool {
+ return strings.HasPrefix(name, ".") || name == "node_modules"
+}
+
+// IsMarkdownPath reports whether p names a Markdown file the preview/files
+// route should render to HTML rather than serve verbatim.
+func IsMarkdownPath(p string) bool {
+ switch strings.ToLower(filepath.Ext(p)) {
+ case ".md", ".markdown":
+ return true
+ }
+ return false
}
// ConfinedPath maps an asset path into workspacePath and rejects paths that
diff --git a/backend/internal/preview/entry_test.go b/backend/internal/preview/entry_test.go
new file mode 100644
index 000000000..705f6f169
--- /dev/null
+++ b/backend/internal/preview/entry_test.go
@@ -0,0 +1,122 @@
+package preview
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func writeEntryFile(t *testing.T, path, contents string, mod time.Time) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+ if !mod.IsZero() {
+ if err := os.Chtimes(path, mod, mod); err != nil {
+ t.Fatalf("chtimes %s: %v", path, err)
+ }
+ }
+}
+
+func TestDiscoverEntryPrefersIndexOverNewerFile(t *testing.T) {
+ ws := t.TempDir()
+ base := time.Now()
+ writeEntryFile(t, filepath.Join(ws, "index.html"), "