* 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:
parent
f85b0d2ffe
commit
8fa403c480
|
|
@ -22,7 +22,12 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "preview [url]",
|
Use: "preview [url]",
|
||||||
Short: "Open a URL (or the workspace's index.html) in the desktop browser panel for the current session",
|
Short: "Open a URL (or the workspace's index.html) in the desktop browser panel for the current session",
|
||||||
Args: cobra.MaximumNArgs(1),
|
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 {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
var target string
|
var target string
|
||||||
if len(args) == 1 {
|
if len(args) == 1 {
|
||||||
|
|
@ -31,17 +36,41 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
|
||||||
return ctx.openPreview(cmd.Context(), target)
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *commandContext) openPreview(ctx context.Context, target string) error {
|
func (c *commandContext) openPreview(ctx context.Context, target string) error {
|
||||||
sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID"))
|
path, err := sessionPreviewPath()
|
||||||
if sessionID == "" {
|
if err != nil {
|
||||||
return usageError{errors.New("ao preview must run inside an AO session (AO_SESSION_ID is not set)")}
|
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)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,17 @@ import (
|
||||||
type previewCapture struct {
|
type previewCapture struct {
|
||||||
body string
|
body string
|
||||||
path string
|
path string
|
||||||
|
method string
|
||||||
called bool
|
called bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// previewServer wires an httptest server expecting
|
// previewServer wires an httptest server expecting POST or DELETE on
|
||||||
// POST /api/v1/sessions/{id}/preview and captures what the CLI sent.
|
// /api/v1/sessions/{id}/preview and captures what the CLI sent.
|
||||||
func previewServer(t *testing.T, status int, respBody string) (*httptest.Server, *previewCapture) {
|
func previewServer(t *testing.T, status int, respBody string) (*httptest.Server, *previewCapture) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
capture := &previewCapture{}
|
capture := &previewCapture{}
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +39,7 @@ func previewServer(t *testing.T, status int, respBody string) (*httptest.Server,
|
||||||
capture.called = true
|
capture.called = true
|
||||||
capture.body = string(body)
|
capture.body = string(body)
|
||||||
capture.path = r.URL.Path
|
capture.path = r.URL.Path
|
||||||
|
capture.method = r.Method
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_, _ = io.WriteString(w, respBody)
|
_, _ = 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) {
|
func TestPreview_MissingSessionIDIsUsageError(t *testing.T) {
|
||||||
t.Setenv("AO_SESSION_ID", "")
|
t.Setenv("AO_SESSION_ID", "")
|
||||||
cfg := setConfigEnv(t)
|
cfg := setConfigEnv(t)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ type SessionMetadata struct {
|
||||||
// session. Set via `ao preview` (POST /sessions/{id}/preview); persisted so
|
// session. Set via `ao preview` (POST /sessions/{id}/preview); persisted so
|
||||||
// it survives a daemon restart. Empty means no preview has been requested.
|
// it survives a daemon restart. Empty means no preview has been requested.
|
||||||
PreviewURL string `json:"previewUrl,omitempty"`
|
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
|
// SessionRecord is the persistence shape. It intentionally stores only durable
|
||||||
|
|
|
||||||
|
|
@ -939,6 +939,44 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- sessions
|
- sessions
|
||||||
/api/v1/sessions/{sessionId}/preview:
|
/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:
|
get:
|
||||||
operationId: getSessionPreview
|
operationId: getSessionPreview
|
||||||
parameters:
|
parameters:
|
||||||
|
|
@ -1482,6 +1520,9 @@ components:
|
||||||
type: string
|
type: string
|
||||||
kind:
|
kind:
|
||||||
type: string
|
type: string
|
||||||
|
previewRevision:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
previewUrl:
|
previewUrl:
|
||||||
type: string
|
type: string
|
||||||
projectId:
|
projectId:
|
||||||
|
|
|
||||||
|
|
@ -506,6 +506,17 @@ func sessionOperations() []operation {
|
||||||
{http.StatusNotImplemented, envelope.APIError{}},
|
{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",
|
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",
|
summary: "Serve a static browser preview file from a session workspace",
|
||||||
|
|
|
||||||
|
|
@ -123,8 +123,13 @@ type SessionView struct {
|
||||||
// PreviewURL is the browser preview target the desktop app opens for this
|
// PreviewURL is the browser preview target the desktop app opens for this
|
||||||
// session, set via POST /sessions/{sessionId}/preview. Empty (omitted) when
|
// session, set via POST /sessions/{sessionId}/preview. Empty (omitted) when
|
||||||
// no preview has been requested. Pulled from the json:"-" domain Metadata.
|
// no preview has been requested. Pulled from the json:"-" domain Metadata.
|
||||||
PreviewURL string `json:"previewUrl,omitempty"`
|
PreviewURL string `json:"previewUrl,omitempty"`
|
||||||
PRs []SessionPRFacts `json:"prs"`
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSessionsResponse is the body of GET /api/v1/sessions.
|
// ListSessionsResponse is the body of GET /api/v1/sessions.
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ func (c *SessionsController) Register(r chi.Router) {
|
||||||
r.Get("/sessions/{sessionId}", c.get)
|
r.Get("/sessions/{sessionId}", c.get)
|
||||||
r.Get("/sessions/{sessionId}/preview", c.preview)
|
r.Get("/sessions/{sessionId}/preview", c.preview)
|
||||||
r.Post("/sessions/{sessionId}/preview", c.setPreview)
|
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}/preview/files/*", c.previewFile)
|
||||||
r.Get("/sessions/{sessionId}/pr", c.listPRs)
|
r.Get("/sessions/{sessionId}/pr", c.listPRs)
|
||||||
r.Post("/sessions/{sessionId}/pr/claim", c.claimPR)
|
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
|
// 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
|
// session and fans out a session_updated CDC event so the dashboard's browser
|
||||||
// entry point in the session workspace (index.html and friends) and serves it
|
// panel reacts live. The target is resolved as follows:
|
||||||
// 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.
|
// - 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) {
|
func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
if c.Svc == nil {
|
if c.Svc == nil {
|
||||||
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/preview")
|
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
|
return
|
||||||
}
|
}
|
||||||
// Get first so a missing session is rejected with the normal 404 before any
|
// 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))
|
sess, err := c.Svc.Get(r.Context(), sessionID(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
envelope.WriteError(w, r, err)
|
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
|
// ponytail: no URL sanitization on preview target; agent-trusted for now
|
||||||
previewURL := strings.TrimSpace(in.URL)
|
previewURL := strings.TrimSpace(in.URL)
|
||||||
if previewURL == "" {
|
if previewURL == "" {
|
||||||
entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath)
|
if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
|
||||||
if !ok {
|
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)
|
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
|
||||||
return
|
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)
|
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL)
|
||||||
if err != nil {
|
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)})
|
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) {
|
func (c *SessionsController) listPRs(w http.ResponseWriter, r *http.Request) {
|
||||||
if c.Svc == nil {
|
if c.Svc == nil {
|
||||||
apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/pr")
|
apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/pr")
|
||||||
|
|
@ -528,6 +560,47 @@ func discoverPreviewEntry(workspacePath string) (string, bool) {
|
||||||
return "", false
|
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) {
|
func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
|
||||||
root, err := filepath.Abs(workspacePath)
|
root, err := filepath.Abs(workspacePath)
|
||||||
if err != nil || root == "" {
|
if err != nil || root == "" {
|
||||||
|
|
@ -567,7 +640,7 @@ func escapePath(raw string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionView(s domain.Session) SessionView {
|
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 {
|
func sessionViews(sessions []domain.Session) []SessionView {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ func (f *fakeSessionService) SetPreview(_ context.Context, id domain.SessionID,
|
||||||
return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
|
return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
|
||||||
}
|
}
|
||||||
s.Metadata.PreviewURL = previewURL
|
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
|
f.sessions[id] = s
|
||||||
return s, nil
|
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) {
|
func TestSessionsAPI_SetPreviewEmptyURLNoEntry(t *testing.T) {
|
||||||
svc := newFakeSessionService()
|
svc := newFakeSessionService()
|
||||||
s := svc.sessions["ao-1"]
|
s := svc.sessions["ao-1"]
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ type Session struct {
|
||||||
DisplayName string
|
DisplayName string
|
||||||
FirstSignalAt sql.NullTime
|
FirstSignalAt sql.NullTime
|
||||||
PreviewURL string
|
PreviewURL string
|
||||||
|
PreviewRevision int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionWorktree struct {
|
type SessionWorktree struct {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
const getSession = `-- name: GetSession :one
|
const getSession = `-- name: GetSession :one
|
||||||
SELECT id, project_id, num, issue_id, kind, harness,
|
SELECT id, project_id, num, issue_id, kind, harness,
|
||||||
activity_state, activity_last_at, is_terminated, branch, workspace_path,
|
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 = ?
|
FROM sessions WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -43,6 +43,7 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.FirstSignalAt,
|
&i.FirstSignalAt,
|
||||||
&i.PreviewURL,
|
&i.PreviewURL,
|
||||||
|
&i.PreviewRevision,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
@ -52,8 +53,8 @@ INSERT INTO sessions (
|
||||||
id, project_id, num, issue_id, kind, harness, display_name,
|
id, project_id, num, issue_id, kind, harness, display_name,
|
||||||
activity_state, activity_last_at, first_signal_at, is_terminated,
|
activity_state, activity_last_at, first_signal_at, is_terminated,
|
||||||
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
|
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
|
||||||
preview_url, created_at, updated_at
|
preview_url, preview_revision, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSessionParams struct {
|
type InsertSessionParams struct {
|
||||||
|
|
@ -74,6 +75,7 @@ type InsertSessionParams struct {
|
||||||
AgentSessionID string
|
AgentSessionID string
|
||||||
Prompt string
|
Prompt string
|
||||||
PreviewURL string
|
PreviewURL string
|
||||||
|
PreviewRevision int64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +99,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er
|
||||||
arg.AgentSessionID,
|
arg.AgentSessionID,
|
||||||
arg.Prompt,
|
arg.Prompt,
|
||||||
arg.PreviewURL,
|
arg.PreviewURL,
|
||||||
|
arg.PreviewRevision,
|
||||||
arg.CreatedAt,
|
arg.CreatedAt,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
@ -106,7 +109,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er
|
||||||
const listAllSessions = `-- name: ListAllSessions :many
|
const listAllSessions = `-- name: ListAllSessions :many
|
||||||
SELECT id, project_id, num, issue_id, kind, harness,
|
SELECT id, project_id, num, issue_id, kind, harness,
|
||||||
activity_state, activity_last_at, is_terminated, branch, workspace_path,
|
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
|
FROM sessions ORDER BY project_id, num
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -139,6 +142,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) {
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.FirstSignalAt,
|
&i.FirstSignalAt,
|
||||||
&i.PreviewURL,
|
&i.PreviewURL,
|
||||||
|
&i.PreviewRevision,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -156,7 +160,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) {
|
||||||
const listSessionsByProject = `-- name: ListSessionsByProject :many
|
const listSessionsByProject = `-- name: ListSessionsByProject :many
|
||||||
SELECT id, project_id, num, issue_id, kind, harness,
|
SELECT id, project_id, num, issue_id, kind, harness,
|
||||||
activity_state, activity_last_at, is_terminated, branch, workspace_path,
|
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
|
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.DisplayName,
|
||||||
&i.FirstSignalAt,
|
&i.FirstSignalAt,
|
||||||
&i.PreviewURL,
|
&i.PreviewURL,
|
||||||
|
&i.PreviewRevision,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +262,7 @@ func (q *Queries) SessionIsSeed(ctx context.Context, id domain.SessionID) (bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const setSessionPreviewURL = `-- name: SetSessionPreviewURL :execrows
|
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 {
|
type SetSessionPreviewURLParams struct {
|
||||||
|
|
@ -266,6 +271,9 @@ type SetSessionPreviewURLParams struct {
|
||||||
ID domain.SessionID
|
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) {
|
func (q *Queries) SetSessionPreviewURL(ctx context.Context, arg SetSessionPreviewURLParams) (int64, error) {
|
||||||
result, err := q.db.ExecContext(ctx, setSessionPreviewURL, arg.PreviewURL, arg.UpdatedAt, arg.ID)
|
result, err := q.db.ExecContext(ctx, setSessionPreviewURL, arg.PreviewURL, arg.UpdatedAt, arg.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -279,7 +287,7 @@ UPDATE sessions SET
|
||||||
issue_id = ?, kind = ?, harness = ?, display_name = ?,
|
issue_id = ?, kind = ?, harness = ?, display_name = ?,
|
||||||
activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?,
|
activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?,
|
||||||
branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?,
|
branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?,
|
||||||
preview_url = ?, updated_at = ?
|
preview_url = ?, preview_revision = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -298,6 +306,7 @@ type UpdateSessionParams struct {
|
||||||
AgentSessionID string
|
AgentSessionID string
|
||||||
Prompt string
|
Prompt string
|
||||||
PreviewURL string
|
PreviewURL string
|
||||||
|
PreviewRevision int64
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
ID domain.SessionID
|
ID domain.SessionID
|
||||||
}
|
}
|
||||||
|
|
@ -318,6 +327,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er
|
||||||
arg.AgentSessionID,
|
arg.AgentSessionID,
|
||||||
arg.Prompt,
|
arg.Prompt,
|
||||||
arg.PreviewURL,
|
arg.PreviewURL,
|
||||||
|
arg.PreviewRevision,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -6,33 +6,33 @@ INSERT INTO sessions (
|
||||||
id, project_id, num, issue_id, kind, harness, display_name,
|
id, project_id, num, issue_id, kind, harness, display_name,
|
||||||
activity_state, activity_last_at, first_signal_at, is_terminated,
|
activity_state, activity_last_at, first_signal_at, is_terminated,
|
||||||
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
|
branch, workspace_path, runtime_handle_id, agent_session_id, prompt,
|
||||||
preview_url, created_at, updated_at
|
preview_url, preview_revision, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
|
|
||||||
-- name: UpdateSession :exec
|
-- name: UpdateSession :exec
|
||||||
UPDATE sessions SET
|
UPDATE sessions SET
|
||||||
issue_id = ?, kind = ?, harness = ?, display_name = ?,
|
issue_id = ?, kind = ?, harness = ?, display_name = ?,
|
||||||
activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?,
|
activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?,
|
||||||
branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?,
|
branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?,
|
||||||
preview_url = ?, updated_at = ?
|
preview_url = ?, preview_revision = ?, updated_at = ?
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: GetSession :one
|
-- name: GetSession :one
|
||||||
SELECT id, project_id, num, issue_id, kind, harness,
|
SELECT id, project_id, num, issue_id, kind, harness,
|
||||||
activity_state, activity_last_at, is_terminated, branch, workspace_path,
|
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 = ?;
|
FROM sessions WHERE id = ?;
|
||||||
|
|
||||||
-- name: ListSessionsByProject :many
|
-- name: ListSessionsByProject :many
|
||||||
SELECT id, project_id, num, issue_id, kind, harness,
|
SELECT id, project_id, num, issue_id, kind, harness,
|
||||||
activity_state, activity_last_at, is_terminated, branch, workspace_path,
|
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;
|
FROM sessions WHERE project_id = ? ORDER BY num;
|
||||||
|
|
||||||
-- name: ListAllSessions :many
|
-- name: ListAllSessions :many
|
||||||
SELECT id, project_id, num, issue_id, kind, harness,
|
SELECT id, project_id, num, issue_id, kind, harness,
|
||||||
activity_state, activity_last_at, is_terminated, branch, workspace_path,
|
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;
|
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 = ?;
|
UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: SetSessionPreviewURL :execrows
|
-- 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
|
-- name: SessionIsSeed :one
|
||||||
-- SessionIsSeed reports whether the session id matches a row still in seed
|
-- SessionIsSeed reports whether the session id matches a row still in seed
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ func rowToRecord(row gen.Session) domain.SessionRecord {
|
||||||
AgentSessionID: row.AgentSessionID,
|
AgentSessionID: row.AgentSessionID,
|
||||||
Prompt: row.Prompt,
|
Prompt: row.Prompt,
|
||||||
PreviewURL: row.PreviewURL,
|
PreviewURL: row.PreviewURL,
|
||||||
|
PreviewRevision: row.PreviewRevision,
|
||||||
},
|
},
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
|
@ -233,6 +234,7 @@ func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams
|
||||||
AgentSessionID: rec.Metadata.AgentSessionID,
|
AgentSessionID: rec.Metadata.AgentSessionID,
|
||||||
Prompt: rec.Metadata.Prompt,
|
Prompt: rec.Metadata.Prompt,
|
||||||
PreviewURL: rec.Metadata.PreviewURL,
|
PreviewURL: rec.Metadata.PreviewURL,
|
||||||
|
PreviewRevision: rec.Metadata.PreviewRevision,
|
||||||
CreatedAt: rec.CreatedAt,
|
CreatedAt: rec.CreatedAt,
|
||||||
UpdatedAt: rec.UpdatedAt,
|
UpdatedAt: rec.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +258,7 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams {
|
||||||
AgentSessionID: rec.Metadata.AgentSessionID,
|
AgentSessionID: rec.Metadata.AgentSessionID,
|
||||||
Prompt: rec.Metadata.Prompt,
|
Prompt: rec.Metadata.Prompt,
|
||||||
PreviewURL: rec.Metadata.PreviewURL,
|
PreviewURL: rec.Metadata.PreviewURL,
|
||||||
|
PreviewRevision: rec.Metadata.PreviewRevision,
|
||||||
UpdatedAt: rec.UpdatedAt,
|
UpdatedAt: rec.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestConcurrentSessionCreateAssignsUniqueNums(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,8 @@ export interface paths {
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Set (or autodetect) the browser preview URL for a session */
|
/** Set (or autodetect) the browser preview URL for a session */
|
||||||
post: operations["setSessionPreview"];
|
post: operations["setSessionPreview"];
|
||||||
delete?: never;
|
/** Clear the browser preview URL for a session */
|
||||||
|
delete: operations["clearSessionPreview"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
patch?: never;
|
patch?: never;
|
||||||
|
|
@ -524,6 +525,8 @@ export interface components {
|
||||||
isTerminated: boolean;
|
isTerminated: boolean;
|
||||||
issueId?: string;
|
issueId?: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
previewRevision?: number;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
prs: components["schemas"]["SessionPRFacts"][];
|
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: {
|
getSessionPreviewFile: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,64 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { clampBoundsToWindow, isAllowedBrowserURL, normalizeBrowserURL } from "./browser-view-host";
|
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", () => {
|
describe("normalizeBrowserURL", () => {
|
||||||
it("defaults localhost-style inputs to http", () => {
|
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", () => {
|
describe("clampBoundsToWindow", () => {
|
||||||
it("rounds and clamps bounds to the window content area", () => {
|
it("rounds and clamps bounds to the window content area", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,16 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV
|
||||||
return pushNavState(options, entry);
|
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 destroy = (viewId: string): void => {
|
||||||
const entry = entries.get(viewId);
|
const entry = entries.get(viewId);
|
||||||
if (!entry) return;
|
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))));
|
handle("browser:ensure", (event, sessionId: string) => pushNavState(options, ensure(scopedViewId(event, sessionId))));
|
||||||
on("browser:setBounds", (_event, input: BrowserBoundsInput) => setBounds(input));
|
on("browser:setBounds", (_event, input: BrowserBoundsInput) => setBounds(input));
|
||||||
handle("browser:navigate", (_event, input: BrowserNavigateInput) => navigate(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:goBack", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goBack()));
|
||||||
handle("browser:goForward", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goForward()));
|
handle("browser:goForward", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goForward()));
|
||||||
handle("browser:reload", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.reload()));
|
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 {
|
function readNavState(entry: BrowserEntry): BrowserNavState {
|
||||||
const { webContents } = entry.view;
|
const { webContents } = entry.view;
|
||||||
|
const currentURL = webContents.getURL();
|
||||||
return {
|
return {
|
||||||
viewId: entry.state.viewId,
|
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(),
|
title: webContents.getTitle(),
|
||||||
canGoBack: webContents.canGoBack(),
|
canGoBack: webContents.canGoBack(),
|
||||||
canGoForward: webContents.canGoForward(),
|
canGoForward: webContents.canGoForward(),
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ const api = {
|
||||||
setBounds: (input: BrowserBoundsInput) => ipcRenderer.send("browser:setBounds", input),
|
setBounds: (input: BrowserBoundsInput) => ipcRenderer.send("browser:setBounds", input),
|
||||||
navigate: (input: BrowserNavigateInput) =>
|
navigate: (input: BrowserNavigateInput) =>
|
||||||
ipcRenderer.invoke("browser:navigate", input) as Promise<BrowserNavState>,
|
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>,
|
goBack: (viewId: string) => ipcRenderer.invoke("browser:goBack", viewId) as Promise<BrowserNavState>,
|
||||||
goForward: (viewId: string) => ipcRenderer.invoke("browser:goForward", 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>,
|
reload: (viewId: string) => ipcRenderer.invoke("browser:reload", viewId) as Promise<BrowserNavState>,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export function BrowserPanel({ session, active, poppedOut, onTogglePopOut }: Bro
|
||||||
active,
|
active,
|
||||||
poppedOut,
|
poppedOut,
|
||||||
previewUrl: session.previewUrl,
|
previewUrl: session.previewUrl,
|
||||||
|
previewRevision: session.previewRevision,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<BrowserPanelView
|
<BrowserPanelView
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,14 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
||||||
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
|
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
|
||||||
const hasInspector = !isOrchestrator;
|
const hasInspector = !isOrchestrator;
|
||||||
const previewUrl = session?.previewUrl?.trim() || undefined;
|
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({
|
const browserView = useBrowserView({
|
||||||
sessionId,
|
sessionId,
|
||||||
active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)),
|
active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)),
|
||||||
poppedOut: browserPoppedOut,
|
poppedOut: browserPoppedOut,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
|
previewRevision,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -71,14 +73,16 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
||||||
|
|
||||||
// `ao preview` sets session.previewUrl (streamed over CDC); surface the result
|
// `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
|
// 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
|
// center pane. Tracked per preview revision so re-revealing fires on every
|
||||||
// manual tab switch sticks while the URL is unchanged.
|
// `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(() => {
|
useEffect(() => {
|
||||||
if (!previewUrl || revealedPreviewRef.current === previewUrl) return;
|
const revision = previewRevision ?? 0;
|
||||||
revealedPreviewRef.current = previewUrl;
|
if (!previewUrl || revealedPreviewRef.current === revision) return;
|
||||||
|
revealedPreviewRef.current = revision;
|
||||||
setInspectorView("browser");
|
setInspectorView("browser");
|
||||||
if (!useUiStore.getState().isInspectorOpen) toggleInspector();
|
if (!useUiStore.getState().isInspectorOpen) toggleInspector();
|
||||||
}, [previewUrl, toggleInspector]);
|
}, [previewRevision, previewUrl, toggleInspector]);
|
||||||
|
|
||||||
// Computed when the inspector panel mounts and frozen while it stays
|
// Computed when the inspector panel mounts and frozen while it stays
|
||||||
// mounted: rrp re-registers the panel (a layout effect keyed on defaultSize,
|
// mounted: rrp re-registers the panel (a layout effect keyed on defaultSize,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ function setupBridge() {
|
||||||
),
|
),
|
||||||
setBounds: vi.fn(),
|
setBounds: vi.fn(),
|
||||||
navigate: vi.fn(async ({ viewId }: { viewId: string }) => bridge.stateFor(viewId)),
|
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)),
|
goBack: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
||||||
goForward: 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)),
|
reload: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
||||||
|
|
@ -147,11 +148,12 @@ describe("useBrowserView", () => {
|
||||||
expect(result.current.navState.title).toBe("Local app");
|
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 bridge = setupBridge();
|
||||||
const { rerender } = renderHook(
|
const { rerender } = renderHook(
|
||||||
({ previewUrl }) => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl }),
|
({ previewUrl, previewRevision }) =>
|
||||||
{ initialProps: { previewUrl: "http://localhost:5173/" } },
|
useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl, previewRevision }),
|
||||||
|
{ initialProps: { previewUrl: "http://localhost:5173/", previewRevision: 1 } },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|
@ -159,22 +161,44 @@ describe("useBrowserView", () => {
|
||||||
);
|
);
|
||||||
expect(bridge.navigate).toHaveBeenCalledTimes(1);
|
expect(bridge.navigate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Same URL replayed (CDC re-emits the session payload) must not re-navigate.
|
// CDC replays the session payload on an unrelated update (revision
|
||||||
rerender({ previewUrl: "http://localhost:5173/" });
|
// unchanged) — the panel must not reload.
|
||||||
|
rerender({ previewUrl: "http://localhost:5173/", previewRevision: 1 });
|
||||||
expect(bridge.navigate).toHaveBeenCalledTimes(1);
|
expect(bridge.navigate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// A changed target re-navigates.
|
// Re-running `ao preview` with the SAME url bumps the revision and must
|
||||||
rerender({ previewUrl: "file:///tmp/preview/index.html" });
|
// 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(() =>
|
await waitFor(() =>
|
||||||
expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "file:///tmp/preview/index.html" }),
|
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 bridge = setupBridge();
|
||||||
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
|
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
|
||||||
await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
|
await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
|
||||||
expect(bridge.navigate).not.toHaveBeenCalled();
|
expect(bridge.navigate).not.toHaveBeenCalled();
|
||||||
|
expect(bridge.clear).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,16 @@ type UseBrowserViewOptions = {
|
||||||
poppedOut: boolean;
|
poppedOut: boolean;
|
||||||
/**
|
/**
|
||||||
* Preview target driven by the daemon (via `ao preview`, streamed over CDC).
|
* 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;
|
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 = {
|
export type BrowserViewModel = {
|
||||||
|
|
@ -37,7 +44,13 @@ const EMPTY_NAV_STATE: BrowserNavState = {
|
||||||
|
|
||||||
const HIDDEN_RECT: BrowserRect = { x: 0, y: 0, width: 0, height: 0 };
|
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 [viewId, setViewId] = useState("");
|
||||||
const [navState, setNavState] = useState<BrowserNavState>(EMPTY_NAV_STATE);
|
const [navState, setNavState] = useState<BrowserNavState>(EMPTY_NAV_STATE);
|
||||||
const slotNodeRef = useRef<HTMLDivElement | null>(null);
|
const slotNodeRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -45,7 +58,7 @@ export function useBrowserView({ sessionId, active, poppedOut, previewUrl }: Use
|
||||||
const activeRef = useRef(active);
|
const activeRef = useRef(active);
|
||||||
const frameRef = useRef<number | null>(null);
|
const frameRef = useRef<number | null>(null);
|
||||||
const observerRef = useRef<ResizeObserver | null>(null);
|
const observerRef = useRef<ResizeObserver | null>(null);
|
||||||
const previewNavRef = useRef<string | null>(null);
|
const previewRevisionRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeRef.current = active;
|
activeRef.current = active;
|
||||||
|
|
@ -167,19 +180,26 @@ export function useBrowserView({ sessionId, active, poppedOut, previewUrl }: Use
|
||||||
[withView],
|
[withView],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drive navigation from the daemon-set preview URL. Re-navigate only when the
|
const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]);
|
||||||
// target actually changes; skip when it already matches what the view shows
|
|
||||||
// (the CDC stream replays the same session payload on unrelated updates).
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
if (!viewId) return;
|
||||||
|
const revision = previewRevision ?? 0;
|
||||||
|
if (previewRevisionRef.current === revision) return;
|
||||||
|
previewRevisionRef.current = revision;
|
||||||
const target = previewUrl?.trim();
|
const target = previewUrl?.trim();
|
||||||
if (!target || !viewId) return;
|
if (target) {
|
||||||
if (previewNavRef.current === target || navState.url === target) {
|
void navigate(target);
|
||||||
previewNavRef.current = target;
|
} else if (revision > 0) {
|
||||||
return;
|
void clear();
|
||||||
}
|
}
|
||||||
previewNavRef.current = target;
|
}, [clear, navigate, previewRevision, previewUrl, viewId]);
|
||||||
void navigate(target);
|
|
||||||
}, [navState.url, navigate, previewUrl, viewId]);
|
|
||||||
|
|
||||||
const destroy = useCallback(() => {
|
const destroy = useCallback(() => {
|
||||||
const id = viewIdRef.current;
|
const id = viewIdRef.current;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
||||||
createdAt: session.createdAt,
|
createdAt: session.createdAt,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
previewUrl: session.previewUrl,
|
previewUrl: session.previewUrl,
|
||||||
|
previewRevision: session.previewRevision,
|
||||||
prs: (session.prs ?? []).map(toPullRequestFacts),
|
prs: (session.prs ?? []).map(toPullRequestFacts),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ export const aoBridge: AoBridge =
|
||||||
canGoForward: false,
|
canGoForward: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
|
clear: async (viewId: string) => ({
|
||||||
|
viewId,
|
||||||
|
url: "",
|
||||||
|
title: "",
|
||||||
|
canGoBack: false,
|
||||||
|
canGoForward: false,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
goBack: async (viewId: string) => ({
|
goBack: async (viewId: string) => ({
|
||||||
viewId,
|
viewId,
|
||||||
url: "",
|
url: "",
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ window.ao = {
|
||||||
canGoForward: false,
|
canGoForward: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
|
clear: async (viewId: string) => ({
|
||||||
|
viewId,
|
||||||
|
url: "",
|
||||||
|
title: "",
|
||||||
|
canGoBack: false,
|
||||||
|
canGoForward: false,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
goBack: async (viewId: string) => ({
|
goBack: async (viewId: string) => ({
|
||||||
viewId,
|
viewId,
|
||||||
url: "",
|
url: "",
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ export type WorkspaceSession = {
|
||||||
* CDC. When non-empty, the browser panel opens and navigates here.
|
* CDC. When non-empty, the browser panel opens and navigates here.
|
||||||
*/
|
*/
|
||||||
previewUrl?: string;
|
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. */
|
/** The session's git diff against its base, when known. */
|
||||||
changedFiles?: ChangedFile[];
|
changedFiles?: ChangedFile[];
|
||||||
/** Pre-filled commit subject for the Git rail, when known. */
|
/** Pre-filled commit subject for the Git rail, when known. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue