fix(preview): add clear, reuse defaults, force refresh, local files (#379) (#380)

* fix(preview): add clear, reuse defaults, force refresh, local files (#379)

`ao preview` had four issues that made the desktop browser panel awkward
during sessions. This addresses all four:

1. No way to clear the panel. Adds `ao preview clear` (DELETE
   /sessions/{id}/preview) which empties the stored target; the panel
   loads about:blank and returns to its empty state.

2. Bare `ao preview` always autodetected index.html. It now reuses the
   session's existing preview target (so each agent/context keeps its own
   default), falling back to index.html only when nothing was previewed.

3. Re-running `ao preview <same-url>` never refreshed. The preview_url
   alone could not distinguish a real re-run from a CDC replay of an
   unrelated session update. A new monotonic preview_revision (bumped on
   every set, migration 0018, added to the sessions_cdc_update trigger)
   gives the renderer a per-command identity to key navigation on, so a
   re-run always re-navigates while unrelated updates are ignored.

4. Local files could not be previewed. `ao preview ./dist/index.html`
   (and other workspace-relative paths) now resolve server-side to the
   preview/files proxy URL when the file exists; non-file targets stay
   verbatim.

Backend, CLI, and renderer all covered by tests; OpenAPI spec and the
frontend schema are regenerated for the new DELETE route and field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(cdc): include previewRevision in sessions update event payload

The CDC trigger watched preview_revision changes but didn't include it
in the JSON payload, so the frontend couldn't detect same-URL preview
refreshes via SSE events. This broke the core purpose of the feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migration): renumber to 0019 to resolve conflict with main

Main branch now has migration 0018 (review_run_delivered_at), causing
a duplicate version conflict when CI merges the PR branch with main.
Renamed 0018_add_session_preview_revision.sql to 0019.

Also fixed the Down migration to properly restore the CDC trigger state
after migration 0017 (with previewUrl but without previewRevision).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vaibhaav <user@example.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-22 16:04:29 +05:30 committed by GitHub
parent f85b0d2ffe
commit 8fa403c480
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 730 additions and 68 deletions

View File

@ -22,6 +22,11 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
cmd := &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" +
"(e.g. ./dist/index.html) is served as a local file. Use `ao preview\n" +
"clear` to empty the panel.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var target string
@ -31,17 +36,41 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
return ctx.openPreview(cmd.Context(), target)
},
}
cmd.AddCommand(&cobra.Command{
Use: "clear",
Short: "Clear the desktop browser panel for the current session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return ctx.clearPreview(cmd.Context())
},
})
return cmd
}
func (c *commandContext) openPreview(ctx context.Context, target string) error {
sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID"))
if sessionID == "" {
return usageError{errors.New("ao preview must run inside an AO session (AO_SESSION_ID is not set)")}
path, err := sessionPreviewPath()
if err != nil {
return err
}
// PathEscape: session ids are already "-"/digit safe, but keep the URL
// well-formed regardless.
path := "sessions/" + url.PathEscape(sessionID) + "/preview"
return c.postJSON(ctx, path, previewAPIRequest{Url: target}, nil)
}
// clearPreview empties the desktop browser panel for the current session
// (`ao preview clear`) by deleting the session's stored preview target.
func (c *commandContext) clearPreview(ctx context.Context) error {
path, err := sessionPreviewPath()
if err != nil {
return err
}
return c.deleteJSON(ctx, path, nil)
}
func sessionPreviewPath() (string, error) {
sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID"))
if sessionID == "" {
return "", usageError{errors.New("ao preview must run inside an AO session (AO_SESSION_ID is not set)")}
}
// PathEscape: session ids are already "-"/digit safe, but keep the URL
// well-formed regardless.
return "sessions/" + url.PathEscape(sessionID) + "/preview", nil
}

View File

@ -14,16 +14,17 @@ import (
type previewCapture struct {
body string
path string
method string
called bool
}
// previewServer wires an httptest server expecting
// POST /api/v1/sessions/{id}/preview and captures what the CLI sent.
// previewServer wires an httptest server expecting POST or DELETE on
// /api/v1/sessions/{id}/preview and captures what the CLI sent.
func previewServer(t *testing.T, status int, respBody string) (*httptest.Server, *previewCapture) {
t.Helper()
capture := &previewCapture{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.NotFound(w, r)
return
}
@ -38,6 +39,7 @@ func previewServer(t *testing.T, status int, respBody string) (*httptest.Server,
capture.called = true
capture.body = string(body)
capture.path = r.URL.Path
capture.method = r.Method
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, respBody)
@ -89,6 +91,46 @@ func TestPreview_NoArgPostsEmptyURL(t *testing.T) {
}
}
func TestPreviewClear_DeletesSessionPreview(t *testing.T) {
t.Setenv("AO_SESSION_ID", "aa-47")
cfg := setConfigEnv(t)
srv, capture := previewServer(t, http.StatusOK, `{"ok":true}`)
writeRunFileFor(t, cfg, srv)
_, errOut, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "preview", "clear")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if capture.method != http.MethodDelete {
t.Errorf("method = %q, want DELETE", capture.method)
}
if capture.path != "/api/v1/sessions/aa-47/preview" {
t.Errorf("path = %q, want /api/v1/sessions/aa-47/preview", capture.path)
}
}
func TestPreviewClear_MissingSessionIDIsUsageError(t *testing.T) {
t.Setenv("AO_SESSION_ID", "")
cfg := setConfigEnv(t)
srv, capture := previewServer(t, http.StatusOK, `{"ok":true}`)
writeRunFileFor(t, cfg, srv)
_, _, err := executeCLI(t, Deps{
ProcessAlive: func(int) bool { return true },
}, "preview", "clear")
if err == nil {
t.Fatal("expected usage error when AO_SESSION_ID is unset")
}
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
if capture.called {
t.Fatal("daemon should not be contacted when AO_SESSION_ID is unset")
}
}
func TestPreview_MissingSessionIDIsUsageError(t *testing.T) {
t.Setenv("AO_SESSION_ID", "")
cfg := setConfigEnv(t)

View File

@ -34,6 +34,10 @@ type SessionMetadata struct {
// session. Set via `ao preview` (POST /sessions/{id}/preview); persisted so
// it survives a daemon restart. Empty means no preview has been requested.
PreviewURL string `json:"previewUrl,omitempty"`
// PreviewRevision is a monotonic counter bumped on every `ao preview` call,
// even when PreviewURL is unchanged. The desktop browser panel keys
// navigation on it so a repeated `ao preview <same-url>` still refreshes.
PreviewRevision int64 `json:"previewRevision,omitempty"`
}
// SessionRecord is the persistence shape. It intentionally stores only durable

View File

@ -939,6 +939,44 @@ paths:
tags:
- sessions
/api/v1/sessions/{sessionId}/preview:
delete:
operationId: clearSessionPreview
parameters:
- description: Session identifier, e.g. project-1.
in: path
name: sessionId
required: true
schema:
description: Session identifier, e.g. project-1.
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
description: OK
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Clear the browser preview URL for a session
tags:
- sessions
get:
operationId: getSessionPreview
parameters:
@ -1482,6 +1520,9 @@ components:
type: string
kind:
type: string
previewRevision:
format: int64
type: integer
previewUrl:
type: string
projectId:

View File

@ -506,6 +506,17 @@ func sessionOperations() []operation {
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodDelete, path: "/api/v1/sessions/{sessionId}/preview", id: "clearSessionPreview", tag: "sessions",
summary: "Clear the browser preview URL for a session",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.SessionResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodGet, path: "/api/v1/sessions/{sessionId}/preview/files/*", id: "getSessionPreviewFile", tag: "sessions",
summary: "Serve a static browser preview file from a session workspace",

View File

@ -124,6 +124,11 @@ type SessionView struct {
// session, set via POST /sessions/{sessionId}/preview. Empty (omitted) when
// no preview has been requested. Pulled from the json:"-" domain Metadata.
PreviewURL string `json:"previewUrl,omitempty"`
// PreviewRevision bumps on every `ao preview` call (even when previewUrl is
// unchanged) so the desktop browser panel can re-navigate / refresh on a
// repeated preview of the same target. Pulled from the json:"-" domain
// Metadata.
PreviewRevision int64 `json:"previewRevision,omitempty"`
PRs []SessionPRFacts `json:"prs"`
}

View File

@ -66,6 +66,7 @@ func (c *SessionsController) Register(r chi.Router) {
r.Get("/sessions/{sessionId}", c.get)
r.Get("/sessions/{sessionId}/preview", c.preview)
r.Post("/sessions/{sessionId}/preview", c.setPreview)
r.Delete("/sessions/{sessionId}/preview", c.clearPreview)
r.Get("/sessions/{sessionId}/preview/files/*", c.previewFile)
r.Get("/sessions/{sessionId}/pr", c.listPRs)
r.Post("/sessions/{sessionId}/pr/claim", c.claimPR)
@ -178,10 +179,19 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
}
// setPreview persists the browser preview URL the desktop app opens for a
// session. An explicit url is used verbatim; an empty url autodetects a static
// entry point in the session workspace (index.html and friends) and serves it
// through the preview/files route. The resolved URL is persisted and fans out a
// session_updated CDC event so the dashboard's browser panel reacts live.
// 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 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.
//
// Every call bumps the session's preview revision, so re-running `ao preview`
// with the same target still refreshes the panel.
func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/preview")
@ -193,7 +203,7 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
return
}
// Get first so a missing session is rejected with the normal 404 before any
// write, and so autodetect has the workspace path to probe.
// write, and so autodetect/local resolution has the workspace path to probe.
sess, err := c.Svc.Get(r.Context(), sessionID(r))
if err != nil {
envelope.WriteError(w, r, err)
@ -202,12 +212,16 @@ 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 == "" {
entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath)
if !ok {
if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
previewURL = existing
} else if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
previewURL = previewFileURL(r, sessionID(r), entry)
} else {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
return
}
previewURL = previewFileURL(r, sessionID(r), entry)
} else if resolved, ok := resolveLocalPreview(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL); ok {
previewURL = resolved
}
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL)
if err != nil {
@ -217,6 +231,24 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sessionView(updated)})
}
// clearPreview resets a session's browser preview to empty (`ao preview
// clear`). Unlike setPreview with an empty url it never autodetects: it persists
// an empty target so the desktop browser panel returns to its blank state. The
// write still bumps the preview revision, so the panel hears the change over
// CDC even though the url field is now empty.
func (c *SessionsController) clearPreview(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "DELETE", "/api/v1/sessions/{sessionId}/preview")
return
}
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), "")
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sessionView(updated)})
}
func (c *SessionsController) listPRs(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/pr")
@ -528,6 +560,47 @@ func discoverPreviewEntry(workspacePath string) (string, bool) {
return "", false
}
// resolveLocalPreview maps a workspace-local path (e.g. "index.html" or
// "./dist/index.html") to its preview/files proxy URL when the path resolves to
// a regular file inside the session workspace. It returns ok=false for anything
// that already looks like a URL (an http(s)/file scheme, or a host:port dev
// server) and for paths that escape the workspace or do not point at a file, so
// the caller keeps those targets verbatim.
func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || hasURLScheme(raw) {
return "", false
}
file, ok := confinedPreviewPath(workspacePath, raw)
if !ok {
return "", false
}
info, err := os.Stat(file)
if err != nil || info.IsDir() {
return "", false
}
entry := strings.TrimPrefix(path.Clean("/"+raw), "/")
return previewFileURL(r, id, entry), true
}
// 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
// on what counts as a URL versus a workspace-relative path.
func hasURLScheme(raw string) bool {
for i := 0; i < len(raw); i++ {
c := raw[i]
if c == ':' {
return i > 0
}
isSchemeChar := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '+' || c == '.' || c == '-'
if !isSchemeChar {
return false
}
}
return false
}
func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
root, err := filepath.Abs(workspacePath)
if err != nil || root == "" {
@ -567,7 +640,7 @@ func escapePath(raw string) string {
}
func sessionView(s domain.Session) SessionView {
return SessionView{Session: s, Branch: s.Metadata.Branch, PreviewURL: s.Metadata.PreviewURL, PRs: sessionPRFacts(s.PRs)}
return SessionView{Session: s, Branch: s.Metadata.Branch, PreviewURL: s.Metadata.PreviewURL, PreviewRevision: s.Metadata.PreviewRevision, PRs: sessionPRFacts(s.PRs)}
}
func sessionViews(sessions []domain.Session) []SessionView {

View File

@ -95,6 +95,9 @@ func (f *fakeSessionService) SetPreview(_ context.Context, id domain.SessionID,
return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
s.Metadata.PreviewURL = previewURL
// Mirror the store: every set bumps the revision, even when the URL is
// unchanged, so the controller's refresh contract can be exercised here.
s.Metadata.PreviewRevision++
f.sessions[id] = s
return s, nil
}
@ -427,6 +430,131 @@ func TestSessionsAPI_SetPreviewEmptyURLAutodetectsIndex(t *testing.T) {
}
}
func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(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.
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(`<html></html>`), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
s := svc.sessions["ao-1"]
s.Metadata = domain.SessionMetadata{WorkspacePath: workspace, 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)
}
var resp struct {
Session struct {
PreviewURL string `json:"previewUrl"`
} `json:"session"`
}
mustJSON(t, body, &resp)
if resp.Session.PreviewURL != "http://localhost:4321/docs" {
t.Fatalf("response previewUrl = %q, want reused existing target", resp.Session.PreviewURL)
}
}
func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T) {
svc := newFakeSessionService()
workspace := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, "dist"), 0o755); err != nil {
t.Fatalf("mkdir dist: %v", err)
}
if err := os.WriteFile(filepath.Join(workspace, "dist", "index.html"), []byte(`<html></html>`), 0o644); err != nil {
t.Fatalf("write dist index: %v", err)
}
s := svc.sessions["ao-1"]
s.Metadata = domain.SessionMetadata{WorkspacePath: workspace}
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":"./dist/index.html"}`)
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/dist/index.html") {
t.Fatalf("response previewUrl = %q, want dist/index.html files URL", resp.Session.PreviewURL)
}
if strings.Contains(resp.Session.PreviewURL, workspace) {
t.Fatalf("preview leaked workspace path: %s", resp.Session.PreviewURL)
}
// The resolved files URL actually serves the local file.
parsed, err := url.Parse(resp.Session.PreviewURL)
if err != nil {
t.Fatalf("parse preview URL: %v", err)
}
fileBody, fileStatus, _ := doRequest(t, srv, "GET", parsed.RequestURI(), "")
if fileStatus != http.StatusOK {
t.Fatalf("serve local file = %d, want 200; body=%s", fileStatus, fileBody)
}
}
func TestSessionsAPI_SetPreviewBumpsRevisionOnSameURL(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
readRevision := func() int64 {
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":"http://localhost:5173/"}`)
if status != http.StatusOK {
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
}
var resp struct {
Session struct {
PreviewRevision int64 `json:"previewRevision"`
} `json:"session"`
}
mustJSON(t, body, &resp)
return resp.Session.PreviewRevision
}
first := readRevision()
second := readRevision()
if second <= first {
t.Fatalf("revision did not advance on same-URL re-run: first=%d second=%d", first, second)
}
}
func TestSessionsAPI_ClearPreviewResetsURL(t *testing.T) {
svc := newFakeSessionService()
s := svc.sessions["ao-1"]
s.Metadata = domain.SessionMetadata{PreviewURL: "http://localhost:5173/"}
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "DELETE", "/api/v1/sessions/ao-1/preview", "")
if status != http.StatusOK {
t.Fatalf("clear preview = %d, want 200; body=%s", status, body)
}
var resp struct {
Session struct {
PreviewURL string `json:"previewUrl"`
} `json:"session"`
}
mustJSON(t, body, &resp)
if resp.Session.PreviewURL != "" {
t.Fatalf("response previewUrl = %q, want empty after clear", resp.Session.PreviewURL)
}
if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "" {
t.Fatalf("persisted previewUrl = %q, want empty after clear", got)
}
}
func TestSessionsAPI_ClearPreviewNotFound(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "DELETE", "/api/v1/sessions/missing-1/preview", "")
assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND")
}
func TestSessionsAPI_SetPreviewEmptyURLNoEntry(t *testing.T) {
svc := newFakeSessionService()
s := svc.sessions["ao-1"]

View File

@ -169,6 +169,7 @@ type Session struct {
DisplayName string
FirstSignalAt sql.NullTime
PreviewURL string
PreviewRevision int64
}
type SessionWorktree struct {

View File

@ -16,7 +16,7 @@ import (
const getSession = `-- name: GetSession :one
SELECT id, project_id, num, issue_id, kind, harness,
activity_state, activity_last_at, is_terminated, branch, workspace_path,
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url, preview_revision
FROM sessions WHERE id = ?
`
@ -43,6 +43,7 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session,
&i.DisplayName,
&i.FirstSignalAt,
&i.PreviewURL,
&i.PreviewRevision,
)
return i, err
}
@ -52,8 +53,8 @@ INSERT INTO sessions (
id, project_id, num, issue_id, kind, harness, display_name,
activity_state, activity_last_at, first_signal_at, is_terminated,
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
preview_url, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
preview_url, preview_revision, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
type InsertSessionParams struct {
@ -74,6 +75,7 @@ type InsertSessionParams struct {
AgentSessionID string
Prompt string
PreviewURL string
PreviewRevision int64
CreatedAt time.Time
UpdatedAt time.Time
}
@ -97,6 +99,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er
arg.AgentSessionID,
arg.Prompt,
arg.PreviewURL,
arg.PreviewRevision,
arg.CreatedAt,
arg.UpdatedAt,
)
@ -106,7 +109,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er
const listAllSessions = `-- name: ListAllSessions :many
SELECT id, project_id, num, issue_id, kind, harness,
activity_state, activity_last_at, is_terminated, branch, workspace_path,
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url, preview_revision
FROM sessions ORDER BY project_id, num
`
@ -139,6 +142,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) {
&i.DisplayName,
&i.FirstSignalAt,
&i.PreviewURL,
&i.PreviewRevision,
); err != nil {
return nil, err
}
@ -156,7 +160,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) {
const listSessionsByProject = `-- name: ListSessionsByProject :many
SELECT id, project_id, num, issue_id, kind, harness,
activity_state, activity_last_at, is_terminated, branch, workspace_path,
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url, preview_revision
FROM sessions WHERE project_id = ? ORDER BY num
`
@ -189,6 +193,7 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.Pr
&i.DisplayName,
&i.FirstSignalAt,
&i.PreviewURL,
&i.PreviewRevision,
); err != nil {
return nil, err
}
@ -257,7 +262,7 @@ func (q *Queries) SessionIsSeed(ctx context.Context, id domain.SessionID) (bool,
}
const setSessionPreviewURL = `-- name: SetSessionPreviewURL :execrows
UPDATE sessions SET preview_url = ?, updated_at = ? WHERE id = ?
UPDATE sessions SET preview_url = ?, preview_revision = preview_revision + 1, updated_at = ? WHERE id = ?
`
type SetSessionPreviewURLParams struct {
@ -266,6 +271,9 @@ type SetSessionPreviewURLParams struct {
ID domain.SessionID
}
// preview_revision is bumped on every call (even when preview_url is unchanged)
// so a repeated `ao preview <same-url>` still trips the sessions_cdc_update
// trigger and the desktop browser panel re-navigates / refreshes.
func (q *Queries) SetSessionPreviewURL(ctx context.Context, arg SetSessionPreviewURLParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setSessionPreviewURL, arg.PreviewURL, arg.UpdatedAt, arg.ID)
if err != nil {
@ -279,7 +287,7 @@ UPDATE sessions SET
issue_id = ?, kind = ?, harness = ?, display_name = ?,
activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?,
branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?,
preview_url = ?, updated_at = ?
preview_url = ?, preview_revision = ?, updated_at = ?
WHERE id = ?
`
@ -298,6 +306,7 @@ type UpdateSessionParams struct {
AgentSessionID string
Prompt string
PreviewURL string
PreviewRevision int64
UpdatedAt time.Time
ID domain.SessionID
}
@ -318,6 +327,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er
arg.AgentSessionID,
arg.Prompt,
arg.PreviewURL,
arg.PreviewRevision,
arg.UpdatedAt,
arg.ID,
)

View File

@ -0,0 +1,55 @@
-- +goose Up
-- preview_revision is a monotonic counter bumped on every `ao preview` call
-- (POST/DELETE /sessions/{id}/preview). The preview_url alone cannot tell a
-- repeated `ao preview <same-url>` from an unrelated session update replayed
-- over CDC, so the desktop browser panel could never refresh on a re-run. The
-- revision gives the renderer a per-command identity to key navigation on, so
-- re-running `ao preview` always re-navigates even when the URL is unchanged.
-- +goose StatementBegin
ALTER TABLE sessions ADD COLUMN preview_revision INTEGER NOT NULL DEFAULT 0;
-- +goose StatementEnd
-- Recreate the sessions update CDC trigger so a preview_revision bump also fans
-- out a session_updated event. Without this a same-URL `ao preview` re-run
-- would change only preview_revision/updated_at, which the prior trigger did
-- not watch, so the renderer never heard about the refresh.
-- +goose StatementBegin
DROP TRIGGER IF EXISTS sessions_cdc_update;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE TRIGGER sessions_cdc_update
AFTER UPDATE ON sessions
WHEN OLD.activity_state <> NEW.activity_state
OR OLD.is_terminated <> NEW.is_terminated
OR (OLD.first_signal_at IS NULL AND NEW.first_signal_at IS NOT NULL)
OR OLD.preview_url <> NEW.preview_url
OR OLD.preview_revision <> NEW.preview_revision
BEGIN
INSERT INTO change_log (project_id, session_id, event_type, payload, created_at)
VALUES (NEW.project_id, NEW.id, 'session_updated',
json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END), 'previewUrl', NEW.preview_url, 'previewRevision', NEW.preview_revision),
NEW.updated_at);
END;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TRIGGER IF EXISTS sessions_cdc_update;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE TRIGGER sessions_cdc_update
AFTER UPDATE ON sessions
WHEN OLD.activity_state <> NEW.activity_state
OR OLD.is_terminated <> NEW.is_terminated
OR (OLD.first_signal_at IS NULL AND NEW.first_signal_at IS NOT NULL)
OR OLD.preview_url <> NEW.preview_url
BEGIN
INSERT INTO change_log (project_id, session_id, event_type, payload, created_at)
VALUES (NEW.project_id, NEW.id, 'session_updated',
json_object('id', NEW.id, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END), 'previewUrl', NEW.preview_url),
NEW.updated_at);
END;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE sessions DROP COLUMN preview_revision;
-- +goose StatementEnd

View File

@ -6,33 +6,33 @@ INSERT INTO sessions (
id, project_id, num, issue_id, kind, harness, display_name,
activity_state, activity_last_at, first_signal_at, is_terminated,
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
preview_url, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
preview_url, preview_revision, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-- name: UpdateSession :exec
UPDATE sessions SET
issue_id = ?, kind = ?, harness = ?, display_name = ?,
activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?,
branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?,
preview_url = ?, updated_at = ?
preview_url = ?, preview_revision = ?, updated_at = ?
WHERE id = ?;
-- name: GetSession :one
SELECT id, project_id, num, issue_id, kind, harness,
activity_state, activity_last_at, is_terminated, branch, workspace_path,
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url, preview_revision
FROM sessions WHERE id = ?;
-- name: ListSessionsByProject :many
SELECT id, project_id, num, issue_id, kind, harness,
activity_state, activity_last_at, is_terminated, branch, workspace_path,
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url, preview_revision
FROM sessions WHERE project_id = ? ORDER BY num;
-- name: ListAllSessions :many
SELECT id, project_id, num, issue_id, kind, harness,
activity_state, activity_last_at, is_terminated, branch, workspace_path,
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url, preview_revision
FROM sessions ORDER BY project_id, num;
@ -40,7 +40,10 @@ FROM sessions ORDER BY project_id, num;
UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?;
-- name: SetSessionPreviewURL :execrows
UPDATE sessions SET preview_url = ?, updated_at = ? WHERE id = ?;
-- preview_revision is bumped on every call (even when preview_url is unchanged)
-- so a repeated `ao preview <same-url>` still trips the sessions_cdc_update
-- trigger and the desktop browser panel re-navigates / refreshes.
UPDATE sessions SET preview_url = ?, preview_revision = preview_revision + 1, updated_at = ? WHERE id = ?;
-- name: SessionIsSeed :one
-- SessionIsSeed reports whether the session id matches a row still in seed

View File

@ -207,6 +207,7 @@ func rowToRecord(row gen.Session) domain.SessionRecord {
AgentSessionID: row.AgentSessionID,
Prompt: row.Prompt,
PreviewURL: row.PreviewURL,
PreviewRevision: row.PreviewRevision,
},
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
@ -233,6 +234,7 @@ func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams
AgentSessionID: rec.Metadata.AgentSessionID,
Prompt: rec.Metadata.Prompt,
PreviewURL: rec.Metadata.PreviewURL,
PreviewRevision: rec.Metadata.PreviewRevision,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
}
@ -256,6 +258,7 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams {
AgentSessionID: rec.Metadata.AgentSessionID,
Prompt: rec.Metadata.Prompt,
PreviewURL: rec.Metadata.PreviewURL,
PreviewRevision: rec.Metadata.PreviewRevision,
UpdatedAt: rec.UpdatedAt,
}
}

View File

@ -660,6 +660,50 @@ func TestCDCTriggersPopulateChangeLog(t *testing.T) {
}
}
func TestSetSessionPreviewURLBumpsRevisionAndFiresCDCOnSameURL(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
seedProject(t, s, "mer")
r, _ := s.CreateSession(ctx, sampleRecord("mer"))
base, _ := s.LatestSeq(ctx)
now := time.Now().UTC()
for i := 0; i < 2; i++ {
ok, err := s.SetSessionPreviewURL(ctx, r.ID, "http://localhost:5173/", now.Add(time.Duration(i)*time.Second))
if err != nil || !ok {
t.Fatalf("set preview url (call %d): ok=%v err=%v", i, ok, err)
}
}
got, found, err := s.GetSession(ctx, r.ID)
if err != nil || !found {
t.Fatalf("get session: found=%v err=%v", found, err)
}
if got.Metadata.PreviewURL != "http://localhost:5173/" {
t.Fatalf("preview url = %q, want persisted target", got.Metadata.PreviewURL)
}
if got.Metadata.PreviewRevision != 2 {
t.Fatalf("preview revision = %d, want 2 after two sets", got.Metadata.PreviewRevision)
}
// Both sets fire session_updated even though the URL never changed — the
// revision bump is what trips the trigger, so a same-URL `ao preview` re-run
// still reaches the browser panel.
evs, err := s.EventsAfter(ctx, base, 100)
if err != nil {
t.Fatal(err)
}
updates := 0
for _, e := range evs {
if string(e.Type) == "session_updated" {
updates++
}
}
if updates != 2 {
t.Fatalf("session_updated events = %d, want 2 (one per same-URL set)", updates)
}
}
func TestConcurrentSessionCreateAssignsUniqueNums(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()

View File

@ -327,7 +327,8 @@ export interface paths {
put?: never;
/** Set (or autodetect) the browser preview URL for a session */
post: operations["setSessionPreview"];
delete?: never;
/** Clear the browser preview URL for a session */
delete: operations["clearSessionPreview"];
options?: never;
head?: never;
patch?: never;
@ -524,6 +525,8 @@ export interface components {
isTerminated: boolean;
issueId?: string;
kind: string;
/** Format: int64 */
previewRevision?: number;
previewUrl?: string;
projectId: string;
prs: components["schemas"]["SessionPRFacts"][];
@ -2150,6 +2153,56 @@ export interface operations {
};
};
};
clearSessionPreview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Session identifier, e.g. project-1. */
sessionId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @description Not Implemented */
501: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
};
};
getSessionPreviewFile: {
parameters: {
query?: never;

View File

@ -1,5 +1,64 @@
import { describe, expect, it } from "vitest";
import { clampBoundsToWindow, isAllowedBrowserURL, normalizeBrowserURL } from "./browser-view-host";
import { describe, expect, it, vi } from "vitest";
import {
type BrowserNavState,
clampBoundsToWindow,
createBrowserViewHost,
isAllowedBrowserURL,
normalizeBrowserURL,
} from "./browser-view-host";
type InvokeHandler = (event: unknown, ...args: unknown[]) => unknown;
function setupHost() {
let currentURL = "";
const webContents = {
canGoBack: () => false,
canGoForward: () => false,
getTitle: () => "",
getURL: () => currentURL,
goBack: () => undefined,
goForward: () => undefined,
isLoading: () => false,
loadURL: vi.fn(async (url: string) => {
currentURL = url;
}),
on: () => undefined,
reload: () => undefined,
send: () => undefined,
setWindowOpenHandler: () => undefined,
stop: () => undefined,
close: () => undefined,
};
const view = {
webContents,
setBounds: () => undefined,
setVisible: () => undefined,
};
const handlers = new Map<string, InvokeHandler>();
const sent: BrowserNavState[] = [];
const host = createBrowserViewHost({
mainWindow: {
contentView: { addChildView: () => undefined, removeChildView: () => undefined },
getContentBounds: () => ({ x: 0, y: 0, width: 800, height: 600 }),
webContents: { id: 1, send: (_channel: string, state: BrowserNavState) => sent.push(state) },
} as never,
ipcMain: {
handle: (channel: string, fn: InvokeHandler) => handlers.set(channel, fn),
on: () => undefined,
removeHandler: () => undefined,
off: () => undefined,
} as never,
shell: { openExternal: async () => undefined },
WebContentsView: function () {
return view;
} as never,
annotatePreloadPath: "/preload.js",
rendererOrigin: "http://localhost:5173",
});
const invoke = (channel: string, ...args: unknown[]) =>
handlers.get(channel)!({ sender: { id: 1 } }, ...args) as Promise<BrowserNavState>;
return { host, invoke, webContents };
}
describe("normalizeBrowserURL", () => {
it("defaults localhost-style inputs to http", () => {
@ -33,6 +92,19 @@ describe("isAllowedBrowserURL", () => {
});
});
describe("browser:clear", () => {
it("loads about:blank and reports it as an empty url (cleared state)", async () => {
const { invoke, webContents } = setupHost();
await invoke("browser:ensure", "sess-1");
await invoke("browser:navigate", { viewId: "1:sess-1", url: "http://localhost:3000/" });
const state = await invoke("browser:clear", "1:sess-1");
expect(webContents.loadURL).toHaveBeenLastCalledWith("about:blank");
expect(state.url).toBe("");
});
});
describe("clampBoundsToWindow", () => {
it("rounds and clamps bounds to the window content area", () => {
expect(

View File

@ -183,6 +183,16 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV
return pushNavState(options, entry);
};
// clear resets the view to a blank page (`ao preview clear`). about:blank is
// loaded directly, bypassing the URL allowlist — it carries no content and
// readNavState normalizes it back to an empty url so the panel shows its
// empty state.
const clear = async (viewId: string): Promise<BrowserNavState> => {
const entry = ensure(viewId);
await entry.view.webContents.loadURL("about:blank");
return pushNavState(options, entry);
};
const destroy = (viewId: string): void => {
const entry = entries.get(viewId);
if (!entry) return;
@ -215,6 +225,7 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV
handle("browser:ensure", (event, sessionId: string) => pushNavState(options, ensure(scopedViewId(event, sessionId))));
on("browser:setBounds", (_event, input: BrowserBoundsInput) => setBounds(input));
handle("browser:navigate", (_event, input: BrowserNavigateInput) => navigate(input));
handle("browser:clear", (_event, viewId: string) => clear(viewId));
handle("browser:goBack", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goBack()));
handle("browser:goForward", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goForward()));
handle("browser:reload", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.reload()));
@ -304,9 +315,13 @@ function pushNavState(options: BrowserViewHostOptions, entry: BrowserEntry): Bro
function readNavState(entry: BrowserEntry): BrowserNavState {
const { webContents } = entry.view;
const currentURL = webContents.getURL();
return {
viewId: entry.state.viewId,
url: webContents.getURL(),
// about:blank is the cleared/blank state — surface it as an empty url so
// the panel renders its "enter a URL" empty state and the address bar is
// blank rather than showing "about:blank".
url: currentURL === "about:blank" ? "" : currentURL,
title: webContents.getTitle(),
canGoBack: webContents.canGoBack(),
canGoForward: webContents.canGoForward(),

View File

@ -43,6 +43,7 @@ const api = {
setBounds: (input: BrowserBoundsInput) => ipcRenderer.send("browser:setBounds", input),
navigate: (input: BrowserNavigateInput) =>
ipcRenderer.invoke("browser:navigate", input) as Promise<BrowserNavState>,
clear: (viewId: string) => ipcRenderer.invoke("browser:clear", viewId) as Promise<BrowserNavState>,
goBack: (viewId: string) => ipcRenderer.invoke("browser:goBack", viewId) as Promise<BrowserNavState>,
goForward: (viewId: string) => ipcRenderer.invoke("browser:goForward", viewId) as Promise<BrowserNavState>,
reload: (viewId: string) => ipcRenderer.invoke("browser:reload", viewId) as Promise<BrowserNavState>,

View File

@ -18,6 +18,7 @@ export function BrowserPanel({ session, active, poppedOut, onTogglePopOut }: Bro
active,
poppedOut,
previewUrl: session.previewUrl,
previewRevision: session.previewRevision,
});
return (
<BrowserPanelView

View File

@ -54,12 +54,14 @@ export function SessionView({ sessionId }: SessionViewProps) {
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
const hasInspector = !isOrchestrator;
const previewUrl = session?.previewUrl?.trim() || undefined;
const revealedPreviewRef = useRef<string | null>(null);
const previewRevision = session?.previewRevision;
const revealedPreviewRef = useRef<number | null>(null);
const browserView = useBrowserView({
sessionId,
active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)),
poppedOut: browserPoppedOut,
previewUrl,
previewRevision,
});
useEffect(() => {
@ -71,14 +73,16 @@ export function SessionView({ sessionId }: SessionViewProps) {
// `ao preview` sets session.previewUrl (streamed over CDC); surface the result
// in the inspector rail's Browser tab (opening the rail if collapsed), not the
// center pane. Tracked per URL so re-revealing fires on a fresh target but a
// manual tab switch sticks while the URL is unchanged.
// center pane. Tracked per preview revision so re-revealing fires on every
// `ao preview` (even a re-run of the same target) while a manual tab switch
// sticks for a given revision. `ao preview clear` (empty url) does not reveal.
useEffect(() => {
if (!previewUrl || revealedPreviewRef.current === previewUrl) return;
revealedPreviewRef.current = previewUrl;
const revision = previewRevision ?? 0;
if (!previewUrl || revealedPreviewRef.current === revision) return;
revealedPreviewRef.current = revision;
setInspectorView("browser");
if (!useUiStore.getState().isInspectorOpen) toggleInspector();
}, [previewUrl, toggleInspector]);
}, [previewRevision, previewUrl, toggleInspector]);
// Computed when the inspector panel mounts and frozen while it stays
// mounted: rrp re-registers the panel (a layout effect keyed on defaultSize,

View File

@ -47,6 +47,7 @@ function setupBridge() {
),
setBounds: vi.fn(),
navigate: vi.fn(async ({ viewId }: { viewId: string }) => bridge.stateFor(viewId)),
clear: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
goBack: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
goForward: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
reload: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
@ -147,11 +148,12 @@ describe("useBrowserView", () => {
expect(result.current.navState.title).toBe("Local app");
});
it("navigates to a daemon-set preview URL and re-navigates only when it changes", async () => {
it("navigates on each preview revision, including a same-URL re-run, and ignores replays", async () => {
const bridge = setupBridge();
const { rerender } = renderHook(
({ previewUrl }) => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl }),
{ initialProps: { previewUrl: "http://localhost:5173/" } },
({ previewUrl, previewRevision }) =>
useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl, previewRevision }),
{ initialProps: { previewUrl: "http://localhost:5173/", previewRevision: 1 } },
);
await waitFor(() =>
@ -159,22 +161,44 @@ describe("useBrowserView", () => {
);
expect(bridge.navigate).toHaveBeenCalledTimes(1);
// Same URL replayed (CDC re-emits the session payload) must not re-navigate.
rerender({ previewUrl: "http://localhost:5173/" });
// CDC replays the session payload on an unrelated update (revision
// unchanged) — the panel must not reload.
rerender({ previewUrl: "http://localhost:5173/", previewRevision: 1 });
expect(bridge.navigate).toHaveBeenCalledTimes(1);
// A changed target re-navigates.
rerender({ previewUrl: "file:///tmp/preview/index.html" });
// Re-running `ao preview` with the SAME url bumps the revision and must
// re-navigate (refresh) — the regression this issue fixes.
rerender({ previewUrl: "http://localhost:5173/", previewRevision: 2 });
await waitFor(() => expect(bridge.navigate).toHaveBeenCalledTimes(2));
// A changed target with a fresh revision navigates to the new URL.
rerender({ previewUrl: "file:///tmp/preview/index.html", previewRevision: 3 });
await waitFor(() =>
expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "file:///tmp/preview/index.html" }),
);
expect(bridge.navigate).toHaveBeenCalledTimes(2);
expect(bridge.navigate).toHaveBeenCalledTimes(3);
});
it("does not navigate without a preview URL", async () => {
it("clears the view when the preview is reset (ao preview clear) and does not navigate", async () => {
const bridge = setupBridge();
const { rerender } = renderHook(
({ previewUrl, previewRevision }) =>
useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl, previewRevision }),
{ initialProps: { previewUrl: "http://localhost:5173/" as string | undefined, previewRevision: 1 } },
);
await waitFor(() => expect(bridge.navigate).toHaveBeenCalledTimes(1));
// `ao preview clear` empties previewUrl and bumps the revision.
rerender({ previewUrl: undefined, previewRevision: 2 });
await waitFor(() => expect(bridge.clear).toHaveBeenCalledWith("42:sess-1"));
expect(bridge.navigate).toHaveBeenCalledTimes(1);
});
it("does not navigate or clear without a preview URL at revision zero", async () => {
const bridge = setupBridge();
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
expect(bridge.navigate).not.toHaveBeenCalled();
expect(bridge.clear).not.toHaveBeenCalled();
});
});

View File

@ -9,9 +9,16 @@ type UseBrowserViewOptions = {
poppedOut: boolean;
/**
* Preview target driven by the daemon (via `ao preview`, streamed over CDC).
* When set, the view navigates here automatically; changing it re-navigates.
* When set, the view navigates here automatically; an empty value clears it.
*/
previewUrl?: string;
/**
* Monotonic counter the daemon bumps on every `ao preview` call, even when
* previewUrl is unchanged. The view re-navigates whenever it advances, so a
* repeated `ao preview <same-url>` still refreshes (and CDC replays of an
* unrelated session update, which leave it unchanged, are ignored).
*/
previewRevision?: number;
};
export type BrowserViewModel = {
@ -37,7 +44,13 @@ const EMPTY_NAV_STATE: BrowserNavState = {
const HIDDEN_RECT: BrowserRect = { x: 0, y: 0, width: 0, height: 0 };
export function useBrowserView({ sessionId, active, poppedOut, previewUrl }: UseBrowserViewOptions): BrowserViewModel {
export function useBrowserView({
sessionId,
active,
poppedOut,
previewUrl,
previewRevision,
}: UseBrowserViewOptions): BrowserViewModel {
const [viewId, setViewId] = useState("");
const [navState, setNavState] = useState<BrowserNavState>(EMPTY_NAV_STATE);
const slotNodeRef = useRef<HTMLDivElement | null>(null);
@ -45,7 +58,7 @@ export function useBrowserView({ sessionId, active, poppedOut, previewUrl }: Use
const activeRef = useRef(active);
const frameRef = useRef<number | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const previewNavRef = useRef<string | null>(null);
const previewRevisionRef = useRef<number | null>(null);
useEffect(() => {
activeRef.current = active;
@ -167,19 +180,26 @@ export function useBrowserView({ sessionId, active, poppedOut, previewUrl }: Use
[withView],
);
// Drive navigation from the daemon-set preview URL. Re-navigate only when the
// target actually changes; skip when it already matches what the view shows
// (the CDC stream replays the same session payload on unrelated updates).
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 <same-url>` 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.
useEffect(() => {
if (!viewId) return;
const revision = previewRevision ?? 0;
if (previewRevisionRef.current === revision) return;
previewRevisionRef.current = revision;
const target = previewUrl?.trim();
if (!target || !viewId) return;
if (previewNavRef.current === target || navState.url === target) {
previewNavRef.current = target;
return;
}
previewNavRef.current = target;
if (target) {
void navigate(target);
}, [navState.url, navigate, previewUrl, viewId]);
} else if (revision > 0) {
void clear();
}
}, [clear, navigate, previewRevision, previewUrl, viewId]);
const destroy = useCallback(() => {
const id = viewIdRef.current;

View File

@ -58,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
createdAt: session.createdAt,
updatedAt: session.updatedAt,
previewUrl: session.previewUrl,
previewRevision: session.previewRevision,
prs: (session.prs ?? []).map(toPullRequestFacts),
})),
}));

View File

@ -45,6 +45,14 @@ export const aoBridge: AoBridge =
canGoForward: false,
isLoading: false,
}),
clear: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
goBack: async (viewId: string) => ({
viewId,
url: "",

View File

@ -86,6 +86,14 @@ window.ao = {
canGoForward: false,
isLoading: false,
}),
clear: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
goBack: async (viewId: string) => ({
viewId,
url: "",

View File

@ -109,6 +109,12 @@ export type WorkspaceSession = {
* CDC. When non-empty, the browser panel opens and navigates here.
*/
previewUrl?: string;
/**
* Monotonic counter the daemon bumps on every `ao preview` call (even when
* previewUrl is unchanged), so the browser panel can re-navigate / refresh on
* a repeated preview of the same target.
*/
previewRevision?: number;
/** The session's git diff against its base, when known. */
changedFiles?: ChangedFile[];
/** Pre-filled commit subject for the Git rail, when known. */