diff --git a/backend/internal/cli/preview.go b/backend/internal/cli/preview.go
index 527efb30..b57dd48a 100644
--- a/backend/internal/cli/preview.go
+++ b/backend/internal/cli/preview.go
@@ -23,8 +23,7 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
Use: "preview [url]",
Short: "Open a URL (or the workspace's index.html) in the desktop browser panel for the current session",
Long: "Open a URL in the desktop browser panel for the current session.\n\n" +
- "With no argument it re-opens whatever this session last previewed,\n" +
- "falling back to the workspace's index.html. A workspace-relative path\n" +
+ "With no argument it opens the workspace's index.html. A workspace-relative path\n" +
"(e.g. ./dist/index.html) is served as a local file. Use `ao preview\n" +
"clear` to empty the panel.",
Args: cobra.MaximumNArgs(1),
diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go
index 7ea50c3a..59791c86 100644
--- a/backend/internal/daemon/daemon.go
+++ b/backend/internal/daemon/daemon.go
@@ -18,6 +18,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/notify"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
+ "github.com/aoagents/agent-orchestrator/backend/internal/preview"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
@@ -127,6 +128,7 @@ func Run() error {
}
return fmt.Errorf("wire session service: %w", err)
}
+ previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}),
@@ -141,6 +143,7 @@ func Run() error {
})
if err != nil {
stop()
+ <-previewDone
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
@@ -155,6 +158,7 @@ func Run() error {
// via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel
// runs before the cancel — which would hang any non-signal exit path.
stop()
+ <-previewDone
lcStack.Stop()
if err := cdcPipe.Stop(); err != nil {
log.Error("cdc pipeline shutdown", "err", err)
diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go
index 31d899ad..d10e72d4 100644
--- a/backend/internal/httpd/controllers/sessions.go
+++ b/backend/internal/httpd/controllers/sessions.go
@@ -17,6 +17,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
+ previewutil "github.com/aoagents/agent-orchestrator/backend/internal/preview"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
)
@@ -25,6 +26,8 @@ const (
maxMessageLen = 4096
)
+var errPreviewFileNotFound = errors.New("preview file not found")
+
// SessionService is the controller-facing session service contract.
type SessionService interface {
List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error)
@@ -182,10 +185,9 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
// session and fans out a session_updated CDC event so the dashboard's browser
// panel reacts live. The target is resolved as follows:
//
-// - An empty url reuses the session's existing preview target (so a bare
-// `ao preview` re-opens whatever this agent/context last previewed),
-// falling back to autodetecting a static entry point (index.html and
-// friends) only when nothing has been previewed yet.
+// - An empty url opens the workspace's static entry point (index.html and
+// friends), falling back to the session's existing preview target only
+// when no entry point exists.
// - An explicit workspace-local path (e.g. `index.html`, `./dist/index.html`)
// is served through the preview/files route so local files load.
// - Anything else (http(s)/file URLs, host:port dev servers) is kept verbatim.
@@ -212,16 +214,26 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
// ponytail: no URL sanitization on preview target; agent-trusted for now
previewURL := strings.TrimSpace(in.URL)
if previewURL == "" {
- if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
- previewURL = existing
- } else if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
+ if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
previewURL = previewFileURL(r, sessionID(r), entry)
+ } else if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
+ var resolveErr error
+ previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, existing)
+ if resolveErr != nil {
+ writePreviewResolveError(w, r, resolveErr)
+ return
+ }
} else {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
return
}
- } else if resolved, ok := resolveLocalPreview(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL); ok {
- previewURL = resolved
+ } else {
+ var resolveErr error
+ previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL)
+ if resolveErr != nil {
+ writePreviewResolveError(w, r, resolveErr)
+ return
+ }
}
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL)
if err != nil {
@@ -544,20 +556,8 @@ func writeSessionPRError(w http.ResponseWriter, r *http.Request, err error) {
}
func discoverPreviewEntry(workspacePath string) (string, bool) {
- if strings.TrimSpace(workspacePath) == "" {
- return "", false
- }
- for _, candidate := range []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} {
- file, ok := confinedPreviewPath(workspacePath, candidate)
- if !ok {
- continue
- }
- info, err := os.Stat(file)
- if err == nil && !info.IsDir() {
- return candidate, true
- }
- }
- return "", false
+ entry, ok := previewutil.DiscoverEntry(workspacePath)
+ return entry.Path, ok
}
// resolveLocalPreview maps a workspace-local path (e.g. "index.html" or
@@ -583,6 +583,49 @@ func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, ra
return previewFileURL(r, id, entry), true
}
+func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if isAbsolutePreviewPath(raw) {
+ return absolutePreviewFileURL(raw)
+ }
+ if resolved, ok := resolveLocalPreview(r, id, workspacePath, raw); ok {
+ return resolved, nil
+ }
+ return raw, nil
+}
+
+func isAbsolutePreviewPath(raw string) bool {
+ return filepath.IsAbs(raw) || isWindowsAbsolutePath(raw)
+}
+
+func isWindowsAbsolutePath(raw string) bool {
+ return len(raw) >= 3 && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z')) && raw[1] == ':' && (raw[2] == '\\' || raw[2] == '/')
+}
+
+func absolutePreviewFileURL(raw string) (string, error) {
+ file, err := filepath.Abs(raw)
+ if err != nil {
+ return "", errPreviewFileNotFound
+ }
+ info, err := os.Stat(file)
+ if err != nil || info.IsDir() {
+ return "", errPreviewFileNotFound
+ }
+ filePath := filepath.ToSlash(file)
+ if filepath.VolumeName(file) != "" || isWindowsAbsolutePath(filePath) {
+ filePath = "/" + filePath
+ }
+ return (&url.URL{Scheme: "file", Path: filePath}).String(), nil
+}
+
+func writePreviewResolveError(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, errPreviewFileNotFound) {
+ envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
+ return
+ }
+ envelope.WriteError(w, r, err)
+}
+
// hasURLScheme reports whether raw begins with an RFC-3986 "scheme:" prefix
// (http:, https:, file:, or a host:port like localhost:5173). It mirrors the
// renderer's withDefaultScheme heuristic so the daemon and browser panel agree
@@ -602,41 +645,11 @@ func hasURLScheme(raw string) bool {
}
func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
- root, err := filepath.Abs(workspacePath)
- if err != nil || root == "" {
- return "", false
- }
- clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/")
- if clean == "" || clean == "." {
- clean = "index.html"
- }
- file := filepath.Join(root, filepath.FromSlash(clean))
- absFile, err := filepath.Abs(file)
- if err != nil {
- return "", false
- }
- rel, err := filepath.Rel(root, absFile)
- if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
- return "", false
- }
- return absFile, true
+ return previewutil.ConfinedPath(workspacePath, assetPath)
}
func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
- u := url.URL{
- Scheme: "http",
- Host: r.Host,
- Path: "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry),
- }
- return u.String()
-}
-
-func escapePath(raw string) string {
- parts := strings.Split(raw, "/")
- for i, part := range parts {
- parts[i] = url.PathEscape(part)
- }
- return strings.Join(parts, "/")
+ return previewutil.FileURL("http://"+r.Host, id, entry)
}
func sessionView(s domain.Session) SessionView {
diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go
index 1cafa64f..ee27405b 100644
--- a/backend/internal/httpd/controllers/sessions_test.go
+++ b/backend/internal/httpd/controllers/sessions_test.go
@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
"time"
@@ -430,11 +431,11 @@ func TestSessionsAPI_SetPreviewEmptyURLAutodetectsIndex(t *testing.T) {
}
}
-func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(t *testing.T) {
+func TestSessionsAPI_SetPreviewEmptyURLPrefersWorkspaceEntryOverExistingTarget(t *testing.T) {
svc := newFakeSessionService()
workspace := t.TempDir()
- // An index.html exists, but the session already has a preview target — the
- // bare `ao preview` must reuse that target rather than autodetecting index.
+ // An index.html exists, so bare `ao preview` returns to the workspace entry
+ // instead of sticking to the last explicit target.
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
@@ -443,6 +444,57 @@ func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(t *testing.T) {
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)
+ body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
+ if status != http.StatusOK {
+ t.Fatalf("set preview = %d, want 200; body=%s", status, body)
+ }
+ var resp struct {
+ Session struct {
+ PreviewURL string `json:"previewUrl"`
+ } `json:"session"`
+ }
+ mustJSON(t, body, &resp)
+ if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") {
+ t.Fatalf("response previewUrl = %q, want workspace index files URL", resp.Session.PreviewURL)
+ }
+}
+
+func TestSessionsAPI_SetPreviewEmptyURLNormalizesExistingRelativeTarget(t *testing.T) {
+ svc := newFakeSessionService()
+ workspace := t.TempDir()
+ if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil {
+ t.Fatalf("write index: %v", err)
+ }
+ s := svc.sessions["ao-1"]
+ s.Metadata = domain.SessionMetadata{WorkspacePath: workspace, PreviewURL: "index.html"}
+ svc.sessions["ao-1"] = s
+ srv := newSessionTestServer(t, svc)
+
+ body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
+ if status != http.StatusOK {
+ t.Fatalf("set preview = %d, want 200; body=%s", status, body)
+ }
+ var resp struct {
+ Session struct {
+ PreviewURL string `json:"previewUrl"`
+ } `json:"session"`
+ }
+ mustJSON(t, body, &resp)
+ if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") {
+ t.Fatalf("response previewUrl = %q, want index.html files URL", resp.Session.PreviewURL)
+ }
+ if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != resp.Session.PreviewURL {
+ t.Fatalf("persisted previewUrl = %q, want normalized response URL %q", got, resp.Session.PreviewURL)
+ }
+}
+
+func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTargetWhenNoEntryExists(t *testing.T) {
+ svc := newFakeSessionService()
+ s := svc.sessions["ao-1"]
+ s.Metadata = domain.SessionMetadata{WorkspacePath: t.TempDir(), PreviewURL: "http://localhost:4321/docs"}
+ svc.sessions["ao-1"] = s
+ srv := newSessionTestServer(t, svc)
+
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
if status != http.StatusOK {
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
@@ -499,6 +551,50 @@ func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T)
}
}
+func TestSessionsAPI_SetPreviewAbsoluteFilePathPersistsFileURL(t *testing.T) {
+ svc := newFakeSessionService()
+ file := filepath.Join(t.TempDir(), "implementation_plan.html")
+ if err := os.WriteFile(file, []byte(``), 0o644); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ srv := newSessionTestServer(t, svc)
+
+ body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(file)+`}`)
+ if status != http.StatusOK {
+ t.Fatalf("set preview = %d, want 200; body=%s", status, body)
+ }
+ var resp struct {
+ Session struct {
+ PreviewURL string `json:"previewUrl"`
+ } `json:"session"`
+ }
+ mustJSON(t, body, &resp)
+ parsed, err := url.Parse(resp.Session.PreviewURL)
+ if err != nil {
+ t.Fatalf("parse preview url: %v", err)
+ }
+ if parsed.Scheme != "file" {
+ t.Fatalf("previewUrl = %q, want file URL", resp.Session.PreviewURL)
+ }
+}
+
+func TestSessionsAPI_SetPreviewMissingAbsoluteFilePathFailsWithoutOverwriting(t *testing.T) {
+ svc := newFakeSessionService()
+ missing := filepath.Join(t.TempDir(), "implmentation_plan.html")
+ s := svc.sessions["ao-1"]
+ s.Metadata = domain.SessionMetadata{PreviewURL: "http://localhost:4321/docs"}
+ svc.sessions["ao-1"] = s
+ srv := newSessionTestServer(t, svc)
+
+ body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(missing)+`}`)
+ if status != http.StatusNotFound {
+ t.Fatalf("set missing absolute preview = %d, want 404; body=%s", status, body)
+ }
+ if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "http://localhost:4321/docs" {
+ t.Fatalf("persisted previewUrl = %q, want existing target preserved", got)
+ }
+}
+
func TestSessionsAPI_SetPreviewBumpsRevisionOnSameURL(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go
new file mode 100644
index 00000000..2cc3a60c
--- /dev/null
+++ b/backend/internal/preview/entry.go
@@ -0,0 +1,96 @@
+package preview
+
+import (
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/domain"
+)
+
+var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"}
+
+// Entry is a workspace-local static frontend entrypoint.
+type Entry struct {
+ Path string
+ AbsPath string
+ ModTime time.Time
+ Size int64
+}
+
+// DiscoverEntry returns the first supported HTML entrypoint that exists inside
+// the workspace.
+func DiscoverEntry(workspacePath string) (Entry, bool) {
+ if strings.TrimSpace(workspacePath) == "" {
+ return Entry{}, false
+ }
+ for _, candidate := range entryCandidates {
+ file, ok := ConfinedPath(workspacePath, candidate)
+ if !ok {
+ continue
+ }
+ info, err := os.Stat(file)
+ if err == nil && !info.IsDir() {
+ return Entry{Path: candidate, AbsPath: file, ModTime: info.ModTime(), Size: info.Size()}, true
+ }
+ }
+ return Entry{}, false
+}
+
+// ConfinedPath maps an asset path into workspacePath and rejects paths that
+// escape the workspace root.
+func ConfinedPath(workspacePath, assetPath string) (string, bool) {
+ root, err := filepath.Abs(workspacePath)
+ if err != nil || root == "" {
+ return "", false
+ }
+ clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/")
+ if clean == "" || clean == "." {
+ clean = "index.html"
+ }
+ file := filepath.Join(root, filepath.FromSlash(clean))
+ absFile, err := filepath.Abs(file)
+ if err != nil {
+ return "", false
+ }
+ rel, err := filepath.Rel(root, absFile)
+ if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
+ return "", false
+ }
+ return absFile, true
+}
+
+// FileURL builds the daemon preview/files URL for a workspace-local entry.
+func FileURL(baseURL string, id domain.SessionID, entry string) string {
+ u := normalizedBaseURL(baseURL)
+ u.Path = "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry)
+ u.RawQuery = ""
+ u.Fragment = ""
+ return u.String()
+}
+
+func normalizedBaseURL(raw string) url.URL {
+ raw = strings.TrimRight(strings.TrimSpace(raw), "/")
+ if raw == "" {
+ raw = "http://127.0.0.1:3001"
+ }
+ if !strings.Contains(raw, "://") {
+ raw = "http://" + raw
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" {
+ return url.URL{Scheme: "http", Host: raw}
+ }
+ return *u
+}
+
+func escapePath(raw string) string {
+ parts := strings.Split(raw, "/")
+ for i, part := range parts {
+ parts[i] = url.PathEscape(part)
+ }
+ return strings.Join(parts, "/")
+}
diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go
new file mode 100644
index 00000000..6825840e
--- /dev/null
+++ b/backend/internal/preview/poller.go
@@ -0,0 +1,178 @@
+package preview
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/domain"
+)
+
+// DefaultPollInterval is the preview poller's scan interval when none is configured.
+const DefaultPollInterval = 250 * time.Millisecond
+
+type sessionPreviewSource interface {
+ ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
+}
+
+type previewSetter interface {
+ SetPreview(ctx context.Context, id domain.SessionID, previewURL string) (domain.Session, error)
+}
+
+// PollerConfig configures preview poller timing and logging.
+type PollerConfig struct {
+ Interval time.Duration
+ Logger *slog.Logger
+}
+
+// Poller watches active worker workspaces for static frontend entrypoints and
+// persists preview URL refreshes through the normal session service path.
+type Poller struct {
+ source sessionPreviewSource
+ setter previewSetter
+ baseURL string
+ interval time.Duration
+ logger *slog.Logger
+ seen map[domain.SessionID]entryState
+}
+
+type entryState struct {
+ path string
+ modUnix int64
+ size int64
+}
+
+// NewPoller constructs a preview poller over the supplied session source and setter.
+func NewPoller(source sessionPreviewSource, setter previewSetter, baseURL string, cfg PollerConfig) *Poller {
+ p := &Poller{
+ source: source,
+ setter: setter,
+ baseURL: baseURL,
+ interval: cfg.Interval,
+ logger: cfg.Logger,
+ seen: map[domain.SessionID]entryState{},
+ }
+ if p.interval <= 0 {
+ p.interval = DefaultPollInterval
+ }
+ if p.logger == nil {
+ p.logger = slog.Default()
+ }
+ return p
+}
+
+// Start runs an immediate poll followed by interval polling until ctx is
+// cancelled. The returned channel closes after the goroutine exits.
+func (p *Poller) Start(ctx context.Context) <-chan struct{} {
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ p.pollAndLog(ctx)
+ ticker := time.NewTicker(p.interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ p.pollAndLog(ctx)
+ }
+ }
+ }()
+ return done
+}
+
+func (p *Poller) pollAndLog(ctx context.Context) {
+ if err := p.Poll(ctx); err != nil {
+ p.logger.Error("preview poller: poll failed", "err", err)
+ }
+}
+
+// Poll performs one deterministic scan of active worker sessions.
+func (p *Poller) Poll(ctx context.Context) error {
+ if p.source == nil || p.setter == nil {
+ return nil
+ }
+ sessions, err := p.source.ListAllSessions(ctx)
+ if err != nil {
+ return fmt.Errorf("preview poller list sessions: %w", err)
+ }
+ activeIDs := make(map[domain.SessionID]struct{}, len(sessions))
+ for _, sess := range sessions {
+ if sess.IsTerminated {
+ continue
+ }
+ activeIDs[sess.ID] = struct{}{}
+ if sess.Kind != domain.KindWorker {
+ continue
+ }
+ entry, ok := DiscoverEntry(sess.Metadata.WorkspacePath)
+ if !ok {
+ continue
+ }
+ state := stateFor(entry)
+ previous, seenBefore := p.seen[sess.ID]
+ if seenBefore && previous == state {
+ continue
+ }
+ target := FileURL(p.baseURL, sess.ID, entry.Path)
+ if !p.shouldRefresh(sess, target, seenBefore) {
+ p.seen[sess.ID] = state
+ continue
+ }
+ if _, err := p.setter.SetPreview(ctx, sess.ID, target); err != nil {
+ return fmt.Errorf("preview poller set preview %s: %w", sess.ID, err)
+ }
+ p.seen[sess.ID] = state
+ }
+ for id := range p.seen {
+ if _, ok := activeIDs[id]; !ok {
+ delete(p.seen, id)
+ }
+ }
+ return nil
+}
+
+func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore bool) bool {
+ current := strings.TrimSpace(sess.Metadata.PreviewURL)
+ if current == "" {
+ return !seenBefore && sess.Metadata.PreviewRevision == 0
+ }
+ if current == target || isWorkspacePreviewURL(current, sess.ID) {
+ return true
+ }
+ return isStaleWorkspacePath(current)
+}
+
+func stateFor(entry Entry) entryState {
+ return entryState{path: entry.Path, modUnix: entry.ModTime.UnixNano(), size: entry.Size}
+}
+
+func isWorkspacePreviewURL(raw string, id domain.SessionID) bool {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil {
+ return false
+ }
+ previewPath := parsed.Path
+ if previewPath == "" {
+ previewPath = raw
+ }
+ prefix := "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/"
+ return strings.HasPrefix(previewPath, prefix)
+}
+
+func isStaleWorkspacePath(raw string) bool {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || strings.Contains(raw, "://") || filepath.IsAbs(raw) || isWindowsAbs(raw) {
+ return false
+ }
+ return !strings.Contains(raw, ":")
+}
+
+func isWindowsAbs(raw string) bool {
+ return len(raw) >= 3 && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z')) && raw[1] == ':' && (raw[2] == '\\' || raw[2] == '/')
+}
diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go
new file mode 100644
index 00000000..c9427eaf
--- /dev/null
+++ b/backend/internal/preview/poller_test.go
@@ -0,0 +1,209 @@
+package preview
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/domain"
+)
+
+type fakePreviewSessions struct {
+ sessions []domain.SessionRecord
+ sets []previewSet
+}
+
+type previewSet struct {
+ id domain.SessionID
+ url string
+}
+
+func (f *fakePreviewSessions) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) {
+ return append([]domain.SessionRecord(nil), f.sessions...), nil
+}
+
+func (f *fakePreviewSessions) SetPreview(_ context.Context, id domain.SessionID, previewURL string) (domain.Session, error) {
+ f.sets = append(f.sets, previewSet{id: id, url: previewURL})
+ for i, sess := range f.sessions {
+ if sess.ID == id {
+ sess.Metadata.PreviewURL = previewURL
+ f.sessions[i] = sess
+ return domain.Session{SessionRecord: sess}, nil
+ }
+ }
+ return domain.Session{}, nil
+}
+
+func TestPollerSetsPreviewWhenActiveWorkerEntryAppears(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "index.html"), "hello")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ assertSets(t, svc.sets, previewSet{
+ id: "ao-1",
+ url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html",
+ })
+}
+
+func TestPollerUsesFirstExistingEntrypoint(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "dist", "index.html"), "dist")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ assertSets(t, svc.sets, previewSet{
+ id: "ao-1",
+ url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/dist/index.html",
+ })
+}
+
+func TestPollerPreservesEntrypointPriority(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "public", "index.html"), "public")
+ writeFile(t, filepath.Join(workspace, "dist", "index.html"), "dist")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ assertSets(t, svc.sets, previewSet{
+ id: "ao-1",
+ url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/public/index.html",
+ })
+}
+
+func TestPollerRefreshesOnlyWhenEntrypointChanges(t *testing.T) {
+ workspace := t.TempDir()
+ entry := filepath.Join(workspace, "index.html")
+ writeFile(t, entry, "v1")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("first Poll: %v", err)
+ }
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("second Poll: %v", err)
+ }
+ if len(svc.sets) != 1 {
+ t.Fatalf("sets after unchanged entry = %#v, want one set", svc.sets)
+ }
+
+ writeFile(t, entry, "v2 changed")
+ nextMod := time.Now().Add(2 * time.Second)
+ if err := os.Chtimes(entry, nextMod, nextMod); err != nil {
+ t.Fatalf("chtimes entry: %v", err)
+ }
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("third Poll: %v", err)
+ }
+
+ if len(svc.sets) != 2 {
+ t.Fatalf("sets after changed entry = %#v, want refresh set", svc.sets)
+ }
+}
+
+func TestPollerDoesNotRestoreClearedPreviewAfterRestart(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "index.html"), "hello")
+ sess := workerSession("ao-1", workspace, "")
+ sess.Metadata.PreviewRevision = 2
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{sess}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ if len(svc.sets) != 0 {
+ t.Fatalf("sets = %#v, want cleared preview to remain empty after restart", svc.sets)
+ }
+}
+
+func TestPollerDoesNotOverrideExplicitPreviewTarget(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "index.html"), "hello")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "file:///C:/tmp/other.html")}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ if len(svc.sets) != 0 {
+ t.Fatalf("sets = %#v, want no automatic override", svc.sets)
+ }
+}
+
+func TestPollerSkipsNonWorkerSessions(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "index.html"), "hello")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{{
+ ID: "ao-orch",
+ Kind: domain.KindOrchestrator,
+ Metadata: domain.SessionMetadata{
+ WorkspacePath: workspace,
+ },
+ }}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ if len(svc.sets) != 0 {
+ t.Fatalf("sets = %#v, want no preview updates for orchestrator sessions", svc.sets)
+ }
+}
+
+func workerSession(id domain.SessionID, workspace, previewURL string) domain.SessionRecord {
+ return domain.SessionRecord{
+ ID: id,
+ Kind: domain.KindWorker,
+ Metadata: domain.SessionMetadata{
+ WorkspacePath: workspace,
+ PreviewURL: previewURL,
+ },
+ }
+}
+
+func writeFile(t *testing.T, path string, contents string) {
+ 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)
+ }
+}
+
+func assertSets(t *testing.T, got []previewSet, want ...previewSet) {
+ t.Helper()
+ if len(got) != len(want) {
+ t.Fatalf("sets = %#v, want %#v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("sets[%d] = %#v, want %#v", i, got[i], want[i])
+ }
+ }
+}
+
+func discardLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(io.Discard, nil))
+}
diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts
index d1e70db2..2eacf168 100644
--- a/frontend/src/main/browser-view-host.test.ts
+++ b/frontend/src/main/browser-view-host.test.ts
@@ -76,6 +76,14 @@ describe("normalizeBrowserURL", () => {
expect(normalizeBrowserURL("file:///C:/tmp/index.html").protocol).toBe("file:");
});
+ it("converts absolute local file paths to file URLs", () => {
+ expect(normalizeBrowserURL("C:\\Users\\Lenovo\\Downloads\\sm5\\paper_explainer.html").href).toBe(
+ "file:///C:/Users/Lenovo/Downloads/sm5/paper_explainer.html",
+ );
+ expect(normalizeBrowserURL("C:/Users/Lenovo/My File.html").href).toBe("file:///C:/Users/Lenovo/My%20File.html");
+ expect(normalizeBrowserURL("/tmp/preview/index.html").href).toBe("file:///tmp/preview/index.html");
+ });
+
it("rejects privileged or unsupported schemes", () => {
expect(() => normalizeBrowserURL("app://renderer/index.html")).toThrow(/unsupported/i);
expect(() => normalizeBrowserURL("javascript:alert(1)")).toThrow(/unsupported/i);
diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts
index e6c373f7..79653038 100644
--- a/frontend/src/main/browser-view-host.ts
+++ b/frontend/src/main/browser-view-host.ts
@@ -249,12 +249,33 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV
}
function withDefaultScheme(raw: string): string {
+ if (isWindowsAbsolutePath(raw) || isPosixAbsolutePath(raw)) return localPathToFileURL(raw);
if (/^https?:\/\//i.test(raw)) return raw;
if (isLocalhostLike(raw)) return `http://${raw}`;
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw)) return raw;
return `https://${raw}`;
}
+function isWindowsAbsolutePath(raw: string): boolean {
+ return /^[a-zA-Z]:[\\/]/.test(raw);
+}
+
+function isPosixAbsolutePath(raw: string): boolean {
+ return raw.startsWith("/");
+}
+
+function localPathToFileURL(raw: string): string {
+ if (isWindowsAbsolutePath(raw)) {
+ const normalized = raw.replace(/\\/g, "/");
+ return `file:///${encodePathSegments(normalized).replace(/^([A-Za-z])%3A(?=\/)/, "$1:")}`;
+ }
+ return `file://${encodePathSegments(raw)}`;
+}
+
+function encodePathSegments(pathname: string): string {
+ return pathname.split("/").map(encodeURIComponent).join("/");
+}
+
function isLocalhostLike(raw: string): boolean {
return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(raw);
}
diff --git a/frontend/src/renderer/hooks/useBrowserView.test.tsx b/frontend/src/renderer/hooks/useBrowserView.test.tsx
index ef0e8c9e..cc6d2455 100644
--- a/frontend/src/renderer/hooks/useBrowserView.test.tsx
+++ b/frontend/src/renderer/hooks/useBrowserView.test.tsx
@@ -179,6 +179,34 @@ describe("useBrowserView", () => {
expect(bridge.navigate).toHaveBeenCalledTimes(3);
});
+ it("navigates legacy preview URLs when the daemon omits preview revisions", async () => {
+ const bridge = setupBridge();
+ const { result, rerender } = renderHook(
+ ({ previewUrl }) => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl }),
+ { initialProps: { previewUrl: undefined as string | undefined } },
+ );
+ await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
+ expect(bridge.navigate).not.toHaveBeenCalled();
+
+ rerender({ previewUrl: "http://localhost:5173/" });
+ await waitFor(() =>
+ expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "http://localhost:5173/" }),
+ );
+ expect(bridge.navigate).toHaveBeenCalledTimes(1);
+
+ rerender({ previewUrl: "http://localhost:5173/" });
+ expect(bridge.navigate).toHaveBeenCalledTimes(1);
+
+ rerender({ previewUrl: "C:\\Users\\Lenovo\\Downloads\\sm5\\paper_explainer.html" });
+ await waitFor(() =>
+ expect(bridge.navigate).toHaveBeenCalledWith({
+ viewId: "42:sess-1",
+ url: "C:\\Users\\Lenovo\\Downloads\\sm5\\paper_explainer.html",
+ }),
+ );
+ expect(bridge.navigate).toHaveBeenCalledTimes(2);
+ });
+
it("clears the view when the preview is reset (ao preview clear) and does not navigate", async () => {
const bridge = setupBridge();
const { rerender } = renderHook(
diff --git a/frontend/src/renderer/hooks/useBrowserView.ts b/frontend/src/renderer/hooks/useBrowserView.ts
index 0788c6c3..a97c111f 100644
--- a/frontend/src/renderer/hooks/useBrowserView.ts
+++ b/frontend/src/renderer/hooks/useBrowserView.ts
@@ -58,7 +58,7 @@ export function useBrowserView({
const activeRef = useRef(active);
const frameRef = useRef(null);
const observerRef = useRef(null);
- const previewRevisionRef = useRef(null);
+ const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null);
useEffect(() => {
activeRef.current = active;
@@ -182,21 +182,20 @@ export function useBrowserView({
const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]);
- // Drive the view from the daemon-set preview target, keyed on the preview
- // revision (bumped on every `ao preview` call). Acting on the revision rather
- // than the URL means a repeated `ao preview ` still refreshes, an
- // `ao preview clear` (empty URL) blanks the view, and CDC replays of
- // unrelated session updates (revision unchanged) are ignored — so the panel
- // never reloads on an unrelated activity flip.
+ // Drive the view from the daemon-set preview target. Current daemons key
+ // this on previewRevision (bumped on every `ao preview` call); older daemons
+ // did not send it, so fall back to URL changes for compatibility.
useEffect(() => {
if (!viewId) return;
- const revision = previewRevision ?? 0;
- if (previewRevisionRef.current === revision) return;
- previewRevisionRef.current = revision;
- const target = previewUrl?.trim();
+ const target = previewUrl?.trim() ?? "";
+ const revision = typeof previewRevision === "number" ? previewRevision : null;
+ const previous = previewTriggerRef.current;
+ if (previous?.revision === revision && previous.target === target) return;
+ if (revision !== null && previous?.revision === revision) return;
+ previewTriggerRef.current = { revision, target };
if (target) {
void navigate(target);
- } else if (revision > 0) {
+ } else if ((revision !== null && revision > 0) || previous?.target) {
void clear();
}
}, [clear, navigate, previewRevision, previewUrl, viewId]);