feat(frontend): add live browser panel (#375)

* feat(frontend): add live browser panel

* chore: format with prettier [skip ci]

* feat: preserve and auto-open browser previews

* fix: retry browser preview after session updates

* fix: wait for browser view before preview navigation

* fix: reopen preview after session switches

* fix: preserve browser views across session switches

* chore: format with prettier [skip ci]

* feat: add `ao preview` command to drive the session browser panel

Replaces the browser panel's auto-detect with an explicit, session-scoped
command. `ao preview [url]` runs inside a session (derives the target from
AO_SESSION_ID; rejects when unset or when the session is unknown):
- with a url, opens it verbatim (file://, http, https; no sanitization for now)
- with no url, autodetects index.html in the workspace as before

The resolved target is persisted as a new `previewUrl` session field and fans
out over the existing CDC /events stream (the sessions update trigger now fires
on preview_url and carries previewUrl in its payload). The desktop browser panel
reflects session.previewUrl: it opens, switches the center pane to the browser,
and navigates, re-navigating only when the target changes.

ponytail: file:// preview targets are accepted unsanitized; agent-trusted for now.

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

* docs(cli): document the `ao preview` command

Add `ao preview` to the CLI command tables in README.md and docs/cli/README.md,
noting it resolves its session from AO_SESSION_ID and its no-arg autodetect vs
explicit-URL behavior.

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

* chore: format with prettier [skip ci]

* fix(frontend): reveal `ao preview` in the inspector Browser tab, not the center pane

`ao preview` set session.previewUrl, and SessionView surfaced it by
popping the browser into the center pane, replacing the terminal. Reveal
it in the inspector rail's Browser tab instead (opening the rail if it is
collapsed); the manual pop-out button still expands it to the center.

Lifts the inspector's active tab to an optional controlled prop so
SessionView can drive it, and adds a regression test asserting the center
pane keeps the terminal while the rail switches to Browser.

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

* docs: instruct agents to `ao preview` when showing frontend changes

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

---------

Co-authored-by: Vaibhaav <user@example.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Harshit Singh Bhandari <dev@theharshitsingh.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vaibhaav Tiwari 2026-06-22 04:14:18 +05:30 committed by GitHub
parent 7ba860741c
commit c6d9692d37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2538 additions and 69 deletions

View File

@ -41,6 +41,8 @@ npm run typecheck
npm run build npm run build
``` ```
When showing or demoing frontend changes, run `ao preview [url]` from inside the session so the change renders in the desktop browser panel (the inspector rail's Browser tab); do not just describe it.
## Where to look first ## Where to look first
- `README.md` — current run/config/test quickstart. - `README.md` — current run/config/test quickstart.

View File

@ -24,3 +24,7 @@ older "match emdash" framing** in DESIGN.md (per explicit user decision 2026-06-
Build new UI from shadcn primitives (`components/ui/*`) where a component fits. Do not Build new UI from shadcn primitives (`components/ui/*`) where a component fits. Do not
deviate without explicit user approval. In QA/review, flag any renderer code that deviate without explicit user approval. In QA/review, flag any renderer code that
diverges from **agent-orchestrator** — do **not** re-flag emdash mismatches. diverges from **agent-orchestrator** — do **not** re-flag emdash mismatches.
When showing or demoing frontend changes, run `ao preview [url]` from inside the
session so the change renders in the desktop browser panel (the inspector rail's
Browser tab); do not just describe it.

View File

@ -129,7 +129,7 @@ route. Run `ao <command> --help` for the authoritative flag shape; the table
below groups what's on `main` today. below groups what's on `main` today.
| Lane | Command | Purpose | | Lane | Command | Purpose |
| ------------ | ------------------------------------ | ------------------------------------------------------------ | | ------------ | ------------------------------------ | ---------------------------------------------------------------------------------- |
| Daemon | `ao start` | Start the daemon in the background and wait for `/readyz`. | | Daemon | `ao start` | Start the daemon in the background and wait for `/readyz`. |
| Daemon | `ao stop` | Graceful shutdown via loopback `POST /shutdown`. | | Daemon | `ao stop` | Graceful shutdown via loopback `POST /shutdown`. |
| Daemon | `ao status` | Report PID/port/health/readiness from `running.json`. | | Daemon | `ao status` | Report PID/port/health/readiness from `running.json`. |
@ -149,6 +149,7 @@ below groups what's on `main` today.
| Session | `ao session claim-pr <session> <pr>` | Attach an existing PR to a session. | | Session | `ao session claim-pr <session> <pr>` | Attach an existing PR to a session. |
| Orchestrator | `ao orchestrator ls` | List orchestrator sessions. | | Orchestrator | `ao orchestrator ls` | List orchestrator sessions. |
| Messaging | `ao send` | Send a message to a running agent session. | | Messaging | `ao send` | Send a message to a running agent session. |
| Preview | `ao preview [url]` | Open a URL (or the workspace `index.html`) in the session's desktop browser panel. |
| Utility | `ao doctor` | Local health checks (config, data dir, DB, `git`, `zellij`). | | Utility | `ao doctor` | Local health checks (config, data dir, DB, `git`, `zellij`). |
| Utility | `ao completion <shell>` | Generate bash/zsh/fish/powershell completions. | | Utility | `ao completion <shell>` | Generate bash/zsh/fish/powershell completions. |
| Utility | `ao version` | Print build metadata. | | Utility | `ao version` | Print build metadata. |

View File

@ -90,6 +90,10 @@ func (f *fakeSessionService) Rename(context.Context, domain.SessionID, string) e
return nil return nil
} }
func (f *fakeSessionService) SetPreview(context.Context, domain.SessionID, string) (domain.Session, error) {
return domain.Session{}, nil
}
func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) error { func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) error {
return nil return nil
} }

View File

@ -0,0 +1,47 @@
package cli
import (
"context"
"errors"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
)
// previewAPIRequest mirrors the daemon's body for
// POST /api/v1/sessions/{id}/preview. An empty Url asks the daemon to
// autodetect an index.html in the workspace. The CLI keeps its own copy so it
// need not import httpd.
type previewAPIRequest struct {
Url string `json:"url"`
}
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",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var target string
if len(args) == 1 {
target = args[0]
}
return ctx.openPreview(cmd.Context(), target)
},
}
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)")}
}
// 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)
}

View File

@ -0,0 +1,133 @@
package cli
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// previewCapture records the request body and path the CLI hit, plus whether
// the daemon was contacted at all.
type previewCapture struct {
body string
path string
called bool
}
// previewServer wires an httptest server expecting
// POST /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 {
http.NotFound(w, r)
return
}
if !strings.HasPrefix(r.URL.Path, "/api/v1/sessions/") || !strings.HasSuffix(r.URL.Path, "/preview") {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
capture.called = true
capture.body = string(body)
capture.path = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, respBody)
}))
t.Cleanup(srv.Close)
return srv, capture
}
func TestPreview_WithURLArg(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", "http://localhost:5173")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if capture.path != "/api/v1/sessions/aa-47/preview" {
t.Errorf("path = %q, want /api/v1/sessions/aa-47/preview", capture.path)
}
var req struct {
Url string `json:"url"`
}
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v\nbody=%s", err, capture.body)
}
if req.Url != "http://localhost:5173" {
t.Errorf("captured url = %q, want %q", req.Url, "http://localhost:5173")
}
}
func TestPreview_NoArgPostsEmptyURL(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")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if capture.body != `{"url":""}` {
t.Errorf("captured body = %q, want %q", capture.body, `{"url":""}`)
}
}
func TestPreview_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", "http://localhost:5173")
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 !strings.Contains(err.Error(), "AO_SESSION_ID is not set") {
t.Fatalf("error missing usage message: %v", err)
}
if capture.called {
t.Fatal("daemon should not be contacted when AO_SESSION_ID is unset")
}
}
func TestPreview_BlankSessionIDIsUsageError(t *testing.T) {
t.Setenv("AO_SESSION_ID", " \t ")
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")
if err == nil {
t.Fatal("expected usage error when AO_SESSION_ID is blank")
}
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 blank")
}
}

View File

@ -185,6 +185,7 @@ func NewRootCommand(deps Deps) *cobra.Command {
root.AddCommand(newDoctorCommand(ctx)) root.AddCommand(newDoctorCommand(ctx))
root.AddCommand(newSpawnCommand(ctx)) root.AddCommand(newSpawnCommand(ctx))
root.AddCommand(newSendCommand(ctx)) root.AddCommand(newSendCommand(ctx))
root.AddCommand(newPreviewCommand(ctx))
root.AddCommand(newHooksCommand(ctx)) root.AddCommand(newHooksCommand(ctx))
root.AddCommand(newLaunchCommand(ctx)) root.AddCommand(newLaunchCommand(ctx))
root.AddCommand(newImportCommand(ctx)) root.AddCommand(newImportCommand(ctx))

View File

@ -30,6 +30,10 @@ type SessionMetadata struct {
RuntimeHandleID string `json:"runtimeHandleId,omitempty"` RuntimeHandleID string `json:"runtimeHandleId,omitempty"`
AgentSessionID string `json:"agentSessionId,omitempty"` AgentSessionID string `json:"agentSessionId,omitempty"`
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
// PreviewURL is the browser preview target the desktop app opens for this
// 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"`
} }
// SessionRecord is the persistence shape. It intentionally stores only durable // SessionRecord is the persistence shape. It intentionally stores only durable

View File

@ -938,6 +938,134 @@ paths:
summary: Claim an existing pull request for a session summary: Claim an existing pull request for a session
tags: tags:
- sessions - sessions
/api/v1/sessions/{sessionId}/preview:
get:
operationId: getSessionPreview
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/SessionPreviewResponse'
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: Discover a browser preview URL for a session workspace
tags:
- sessions
post:
operationId: setSessionPreview
parameters:
- description: Session identifier, e.g. project-1.
in: path
name: sessionId
required: true
schema:
description: Session identifier, e.g. project-1.
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SetSessionPreviewRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Bad Request
"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: Set (or autodetect) the browser preview URL for a session
tags:
- sessions
/api/v1/sessions/{sessionId}/preview/files/*:
get:
operationId: getSessionPreviewFile
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:
text/html:
schema:
type: string
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: Serve a static browser preview file from a session workspace
tags:
- sessions
/api/v1/sessions/{sessionId}/restore: /api/v1/sessions/{sessionId}/restore:
post: post:
operationId: restoreSession operationId: restoreSession
@ -1354,6 +1482,8 @@ components:
type: string type: string
kind: kind:
type: string type: string
previewUrl:
type: string
projectId: projectId:
type: string type: string
prs: prs:
@ -2088,6 +2218,17 @@ components:
- count - count
- links - links
type: object type: object
SessionPreviewResponse:
properties:
entry:
type: string
previewUrl:
type: string
sessionId:
type: string
required:
- sessionId
type: object
SessionResponse: SessionResponse:
properties: properties:
session: session:
@ -2128,6 +2269,13 @@ components:
required: required:
- config - config
type: object type: object
SetSessionPreviewRequest:
properties:
url:
description: Preview target URL. When empty, the daemon autodetects a static
entry point in the session workspace.
type: string
type: object
SpawnOrchestratorRequest: SpawnOrchestratorRequest:
properties: properties:
clean: clean:

View File

@ -139,6 +139,8 @@ var schemaNames = map[string]string{
"ControllersListSessionsResponse": "ListSessionsResponse", "ControllersListSessionsResponse": "ListSessionsResponse",
"ControllersSpawnSessionRequest": "SpawnSessionRequest", "ControllersSpawnSessionRequest": "SpawnSessionRequest",
"ControllersSessionResponse": "SessionResponse", "ControllersSessionResponse": "SessionResponse",
"ControllersSessionPreviewResponse": "SessionPreviewResponse",
"ControllersSetSessionPreviewRequest": "SetSessionPreviewRequest",
"ControllersRenameSessionRequest": "RenameSessionRequest", "ControllersRenameSessionRequest": "RenameSessionRequest",
"ControllersRenameSessionResponse": "RenameSessionResponse", "ControllersRenameSessionResponse": "RenameSessionResponse",
"ControllersRestoreSessionResponse": "RestoreSessionResponse", "ControllersRestoreSessionResponse": "RestoreSessionResponse",
@ -480,6 +482,42 @@ func sessionOperations() []operation {
{http.StatusInternalServerError, envelope.APIError{}}, {http.StatusInternalServerError, envelope.APIError{}},
}, },
}, },
{
method: http.MethodGet, path: "/api/v1/sessions/{sessionId}/preview", id: "getSessionPreview", tag: "sessions",
summary: "Discover a browser preview URL for a session workspace",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.SessionPreviewResponse{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/preview", id: "setSessionPreview", tag: "sessions",
summary: "Set (or autodetect) the browser preview URL for a session",
pathParams: []any{controllers.SessionIDParam{}},
reqBody: controllers.SetSessionPreviewRequest{},
resps: []respUnit{
{http.StatusOK, controllers.SessionResponse{}},
{http.StatusBadRequest, envelope.APIError{}},
{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",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, ""},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
contentTypes: map[int]string{http.StatusOK: "text/html"},
},
{ {
method: http.MethodGet, path: "/api/v1/sessions/{sessionId}/pr", id: "listSessionPRs", tag: "sessions", method: http.MethodGet, path: "/api/v1/sessions/{sessionId}/pr", id: "listSessionPRs", tag: "sessions",
summary: "List pull requests owned by a session", summary: "List pull requests owned by a session",

View File

@ -120,6 +120,10 @@ type CleanupSessionsQuery struct {
type SessionView struct { type SessionView struct {
domain.Session domain.Session
Branch string `json:"branch,omitempty"` Branch string `json:"branch,omitempty"`
// PreviewURL is the browser preview target the desktop app opens for this
// 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"`
PRs []SessionPRFacts `json:"prs"` PRs []SessionPRFacts `json:"prs"`
} }
@ -143,11 +147,25 @@ type SessionResponse struct {
Session SessionView `json:"session"` Session SessionView `json:"session"`
} }
// SessionPreviewResponse is the body of GET /api/v1/sessions/{sessionId}/preview.
type SessionPreviewResponse struct {
SessionID domain.SessionID `json:"sessionId"`
PreviewURL string `json:"previewUrl,omitempty"`
Entry string `json:"entry,omitempty"`
}
// RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}. // RenameSessionRequest is the body of PATCH /api/v1/sessions/{sessionId}.
type RenameSessionRequest struct { type RenameSessionRequest struct {
DisplayName string `json:"displayName" minLength:"1"` DisplayName string `json:"displayName" minLength:"1"`
} }
// SetSessionPreviewRequest is the body of POST /api/v1/sessions/{sessionId}/preview.
// An empty url asks the daemon to autodetect a static entry point in the
// session workspace; a non-empty url is used verbatim as the preview target.
type SetSessionPreviewRequest struct {
URL string `json:"url,omitempty" description:"Preview target URL. When empty, the daemon autodetects a static entry point in the session workspace."`
}
// RenameSessionResponse is the body of PATCH /api/v1/sessions/{sessionId}. // RenameSessionResponse is the body of PATCH /api/v1/sessions/{sessionId}.
type RenameSessionResponse struct { type RenameSessionResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`

View File

@ -4,6 +4,10 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@ -32,6 +36,7 @@ type SessionService interface {
RollbackSpawn(ctx context.Context, id domain.SessionID) (sessionsvc.RollbackOutcome, error) RollbackSpawn(ctx context.Context, id domain.SessionID) (sessionsvc.RollbackOutcome, error)
Cleanup(ctx context.Context, project domain.ProjectID) (sessionsvc.CleanupOutcome, error) Cleanup(ctx context.Context, project domain.ProjectID) (sessionsvc.CleanupOutcome, error)
Rename(ctx context.Context, id domain.SessionID, displayName string) error Rename(ctx context.Context, id domain.SessionID, displayName string) error
SetPreview(ctx context.Context, id domain.SessionID, previewURL string) (domain.Session, error)
Send(ctx context.Context, id domain.SessionID, message string) error Send(ctx context.Context, id domain.SessionID, message string) error
ListPRSummaries(ctx context.Context, id domain.SessionID) ([]sessionsvc.PRSummary, error) ListPRSummaries(ctx context.Context, id domain.SessionID) ([]sessionsvc.PRSummary, error)
ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) ClaimPR(ctx context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error)
@ -59,6 +64,9 @@ func (c *SessionsController) Register(r chi.Router) {
r.Post("/sessions", c.spawn) r.Post("/sessions", c.spawn)
r.Post("/sessions/cleanup", c.cleanup) r.Post("/sessions/cleanup", c.cleanup)
r.Get("/sessions/{sessionId}", c.get) r.Get("/sessions/{sessionId}", c.get)
r.Get("/sessions/{sessionId}/preview", c.preview)
r.Post("/sessions/{sessionId}/preview", c.setPreview)
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)
r.Patch("/sessions/{sessionId}", c.rename) r.Patch("/sessions/{sessionId}", c.rename)
@ -132,6 +140,83 @@ func (c *SessionsController) get(w http.ResponseWriter, r *http.Request) {
envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sessionView(sess)}) envelope.WriteJSON(w, http.StatusOK, SessionResponse{Session: sessionView(sess)})
} }
func (c *SessionsController) preview(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/preview")
return
}
sess, err := c.Svc.Get(r.Context(), sessionID(r))
if err != nil {
envelope.WriteError(w, r, err)
return
}
entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath)
res := SessionPreviewResponse{SessionID: sessionID(r)}
if ok {
res.Entry = entry
res.PreviewURL = previewFileURL(r, sessionID(r), entry)
}
envelope.WriteJSON(w, http.StatusOK, res)
}
func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/sessions/{sessionId}/preview/files/*")
return
}
sess, err := c.Svc.Get(r.Context(), sessionID(r))
if err != nil {
envelope.WriteError(w, r, err)
return
}
file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, chi.URLParam(r, "*"))
if !ok {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
http.ServeFile(w, r, file)
}
// 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.
func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/preview")
return
}
var in SetSessionPreviewRequest
if err := decodeJSON(r, &in); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
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.
sess, err := c.Svc.Get(r.Context(), sessionID(r))
if err != nil {
envelope.WriteError(w, r, err)
return
}
// 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 {
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)
}
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL)
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")
@ -426,8 +511,63 @@ func writeSessionPRError(w http.ResponseWriter, r *http.Request, err error) {
} }
} }
func discoverPreviewEntry(workspacePath string) (string, bool) {
if strings.TrimSpace(workspacePath) == "" {
return "", false
}
for _, candidate := range []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} {
file, ok := confinedPreviewPath(workspacePath, candidate)
if !ok {
continue
}
info, err := os.Stat(file)
if err == nil && !info.IsDir() {
return candidate, true
}
}
return "", false
}
func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
root, err := filepath.Abs(workspacePath)
if err != nil || root == "" {
return "", false
}
clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/")
if clean == "" || clean == "." {
clean = "index.html"
}
file := filepath.Join(root, filepath.FromSlash(clean))
absFile, err := filepath.Abs(file)
if err != nil {
return "", false
}
rel, err := filepath.Rel(root, absFile)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return "", false
}
return absFile, true
}
func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
u := url.URL{
Scheme: "http",
Host: r.Host,
Path: "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry),
}
return u.String()
}
func escapePath(raw string) string {
parts := strings.Split(raw, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func sessionView(s domain.Session) SessionView { func sessionView(s domain.Session) SessionView {
return SessionView{Session: s, Branch: s.Metadata.Branch, PRs: sessionPRFacts(s.PRs)} return SessionView{Session: s, Branch: s.Metadata.Branch, PreviewURL: s.Metadata.PreviewURL, PRs: sessionPRFacts(s.PRs)}
} }
func sessionViews(sessions []domain.Session) []SessionView { func sessionViews(sessions []domain.Session) []SessionView {

View File

@ -6,6 +6,10 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing" "testing"
"time" "time"
@ -78,7 +82,21 @@ func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID do
} }
func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) { func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) {
return f.sessions[id], nil s, ok := f.sessions[id]
if !ok {
return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
return s, nil
}
func (f *fakeSessionService) SetPreview(_ context.Context, id domain.SessionID, previewURL string) (domain.Session, error) {
s, ok := f.sessions[id]
if !ok {
return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
s.Metadata.PreviewURL = previewURL
f.sessions[id] = s
return s, nil
} }
func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) { func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) {
@ -309,6 +327,124 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
} }
} }
func TestSessionsAPI_PreviewDiscoversAndServesStaticIndex(t *testing.T) {
svc := newFakeSessionService()
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(`<link rel="stylesheet" href="styles.css"><script src="app.js"></script>`), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
if err := os.WriteFile(filepath.Join(workspace, "styles.css"), []byte(`body { color: red; }`), 0o644); err != nil {
t.Fatalf("write css: %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, "GET", "/api/v1/sessions/ao-1/preview", "")
if status != http.StatusOK {
t.Fatalf("preview = %d, want 200; body=%s", status, body)
}
var preview struct {
SessionID string `json:"sessionId"`
PreviewURL string `json:"previewUrl"`
Entry string `json:"entry"`
}
mustJSON(t, body, &preview)
if preview.SessionID != "ao-1" || preview.Entry != "index.html" || preview.PreviewURL == "" {
t.Fatalf("preview response = %#v", preview)
}
if strings.Contains(preview.PreviewURL, workspace) {
t.Fatalf("preview leaked workspace path: %s", preview.PreviewURL)
}
if !strings.Contains(preview.PreviewURL, "/index.html") {
t.Fatalf("preview URL = %q, want index.html asset path", preview.PreviewURL)
}
parsed, err := url.Parse(preview.PreviewURL)
if err != nil {
t.Fatalf("parse preview URL: %v", err)
}
body, status, headers := doRequest(t, srv, "GET", parsed.RequestURI(), "")
if status != http.StatusOK {
t.Fatalf("preview file = %d, want 200; body=%s", status, body)
}
if !strings.Contains(headers.Get("Content-Type"), "text/html") {
t.Fatalf("content type = %q, want text/html", headers.Get("Content-Type"))
}
if !strings.Contains(string(body), "styles.css") {
t.Fatalf("preview body did not serve index: %s", body)
}
}
func TestSessionsAPI_SetPreviewExplicitURLPersists(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
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 {
PreviewURL string `json:"previewUrl"`
} `json:"session"`
}
mustJSON(t, body, &resp)
if resp.Session.PreviewURL != "http://localhost:5173/" {
t.Fatalf("response previewUrl = %q, want explicit url", resp.Session.PreviewURL)
}
if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "http://localhost:5173/" {
t.Fatalf("persisted previewUrl = %q, want explicit url", got)
}
}
func TestSessionsAPI_SetPreviewEmptyURLAutodetectsIndex(t *testing.T) {
svc := newFakeSessionService()
workspace := t.TempDir()
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}
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
if status != http.StatusOK {
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
}
var resp struct {
Session struct {
PreviewURL string `json:"previewUrl"`
} `json:"session"`
}
mustJSON(t, body, &resp)
if !strings.Contains(resp.Session.PreviewURL, "/index.html") {
t.Fatalf("response previewUrl = %q, want autodetected index.html URL", resp.Session.PreviewURL)
}
if strings.Contains(resp.Session.PreviewURL, workspace) {
t.Fatalf("preview leaked workspace path: %s", resp.Session.PreviewURL)
}
}
func TestSessionsAPI_SetPreviewEmptyURLNoEntry(t *testing.T) {
svc := newFakeSessionService()
s := svc.sessions["ao-1"]
s.Metadata = domain.SessionMetadata{WorkspacePath: t.TempDir()}
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
assertErrorCode(t, body, status, http.StatusNotFound, "NO_PREVIEW_ENTRY")
}
func TestSessionsAPI_SetPreviewNotFound(t *testing.T) {
srv := newSessionTestServer(t, newFakeSessionService())
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/missing-1/preview", `{"url":"http://x"}`)
assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND")
}
func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) { func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) {
svc := newFakeSessionService() svc := newFakeSessionService()
svc.spawnErr = apierr.Invalid("BRANCH_NOT_FETCHED", `workspace: branch is not fetched: "feature/missing"`, nil) svc.spawnErr = apierr.Invalid("BRANCH_NOT_FETCHED", `workspace: branch is not fetched: "feature/missing"`, nil)

View File

@ -20,6 +20,7 @@ type Store interface {
ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error)
ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error)
RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error)
SetSessionPreviewURL(ctx context.Context, id domain.SessionID, previewURL string, updatedAt time.Time) (bool, error)
GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error)
ListPRFactsForSession(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) ListPRFactsForSession(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error)
ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error)
@ -323,6 +324,23 @@ func (s *Service) Rename(ctx context.Context, id domain.SessionID, displayName s
return nil return nil
} }
// SetPreview persists the browser preview URL for a session and returns the
// refreshed read model. The URL is taken verbatim from the caller (the
// controller resolves it, either an explicit target or an autodetected entry).
// Persisting it via the store fans out a session_updated CDC event through the
// sessions_cdc_update trigger, mirroring how other session mutations surface on
// the live event stream.
func (s *Service) SetPreview(ctx context.Context, id domain.SessionID, previewURL string) (domain.Session, error) {
updated, err := s.store.SetSessionPreviewURL(ctx, id, previewURL, time.Now().UTC())
if err != nil {
return domain.Session{}, fmt.Errorf("set preview url %s: %w", id, err)
}
if !updated {
return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session")
}
return s.Get(ctx, id)
}
// Cleanup delegates terminal workspace cleanup to the internal manager and // Cleanup delegates terminal workspace cleanup to the internal manager and
// reports both reclaimed and preserved (skipped) workspaces. // reports both reclaimed and preserved (skipped) workspaces.
func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) (CleanupOutcome, error) { func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) (CleanupOutcome, error) {

View File

@ -82,6 +82,17 @@ func (f *fakeStore) RenameSession(_ context.Context, id domain.SessionID, displa
return true, nil return true, nil
} }
func (f *fakeStore) SetSessionPreviewURL(_ context.Context, id domain.SessionID, previewURL string, updatedAt time.Time) (bool, error) {
r, ok := f.sessions[id]
if !ok {
return false, nil
}
r.Metadata.PreviewURL = previewURL
r.UpdatedAt = updatedAt
f.sessions[id] = r
return true, nil
}
func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) {
pr, ok := f.pr[id] pr, ok := f.pr[id]
return pr, ok, nil return pr, ok, nil
@ -147,6 +158,29 @@ func TestSessionRenameUpdatesDisplayName(t *testing.T) {
} }
} }
func TestSessionSetPreviewPersistsURL(t *testing.T) {
st := newFakeStore()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"}
sess, err := (&Service{store: st, clock: time.Now}).SetPreview(context.Background(), "mer-1", "file:///tmp/index.html")
if err != nil {
t.Fatal(err)
}
if sess.Metadata.PreviewURL != "file:///tmp/index.html" {
t.Fatalf("returned preview url = %q, want set value", sess.Metadata.PreviewURL)
}
if got := st.sessions["mer-1"].Metadata.PreviewURL; got != "file:///tmp/index.html" {
t.Fatalf("persisted preview url = %q, want set value", got)
}
}
func TestSessionSetPreviewUnknownSession(t *testing.T) {
st := newFakeStore()
if _, err := (&Service{store: st}).SetPreview(context.Background(), "ghost-1", "http://x"); err == nil {
t.Fatal("want error for unknown session")
}
}
func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) {
st := newFakeStore() st := newFakeStore()

View File

@ -167,6 +167,7 @@ type Session struct {
UpdatedAt time.Time UpdatedAt time.Time
DisplayName string DisplayName string
FirstSignalAt sql.NullTime FirstSignalAt sql.NullTime
PreviewURL string
} }
type SessionWorktree struct { type SessionWorktree struct {

View File

@ -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 runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
FROM sessions WHERE id = ? FROM sessions WHERE id = ?
` `
@ -42,6 +42,7 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session,
&i.UpdatedAt, &i.UpdatedAt,
&i.DisplayName, &i.DisplayName,
&i.FirstSignalAt, &i.FirstSignalAt,
&i.PreviewURL,
) )
return i, err return i, err
} }
@ -51,8 +52,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,
created_at, updated_at preview_url, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
type InsertSessionParams struct { type InsertSessionParams struct {
@ -72,6 +73,7 @@ type InsertSessionParams struct {
RuntimeHandleID string RuntimeHandleID string
AgentSessionID string AgentSessionID string
Prompt string Prompt string
PreviewURL string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@ -94,6 +96,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er
arg.RuntimeHandleID, arg.RuntimeHandleID,
arg.AgentSessionID, arg.AgentSessionID,
arg.Prompt, arg.Prompt,
arg.PreviewURL,
arg.CreatedAt, arg.CreatedAt,
arg.UpdatedAt, arg.UpdatedAt,
) )
@ -103,7 +106,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 runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
FROM sessions ORDER BY project_id, num FROM sessions ORDER BY project_id, num
` `
@ -135,6 +138,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) {
&i.UpdatedAt, &i.UpdatedAt,
&i.DisplayName, &i.DisplayName,
&i.FirstSignalAt, &i.FirstSignalAt,
&i.PreviewURL,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -152,7 +156,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 runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
FROM sessions WHERE project_id = ? ORDER BY num FROM sessions WHERE project_id = ? ORDER BY num
` `
@ -184,6 +188,7 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.Pr
&i.UpdatedAt, &i.UpdatedAt,
&i.DisplayName, &i.DisplayName,
&i.FirstSignalAt, &i.FirstSignalAt,
&i.PreviewURL,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -251,12 +256,30 @@ func (q *Queries) SessionIsSeed(ctx context.Context, id domain.SessionID) (bool,
return is_seed, err return is_seed, err
} }
const setSessionPreviewURL = `-- name: SetSessionPreviewURL :execrows
UPDATE sessions SET preview_url = ?, updated_at = ? WHERE id = ?
`
type SetSessionPreviewURLParams struct {
PreviewURL string
UpdatedAt time.Time
ID domain.SessionID
}
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 {
return 0, err
}
return result.RowsAffected()
}
const updateSession = `-- name: UpdateSession :exec const updateSession = `-- 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 = ?,
updated_at = ? preview_url = ?, updated_at = ?
WHERE id = ? WHERE id = ?
` `
@ -274,6 +297,7 @@ type UpdateSessionParams struct {
RuntimeHandleID string RuntimeHandleID string
AgentSessionID string AgentSessionID string
Prompt string Prompt string
PreviewURL string
UpdatedAt time.Time UpdatedAt time.Time
ID domain.SessionID ID domain.SessionID
} }
@ -293,6 +317,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er
arg.RuntimeHandleID, arg.RuntimeHandleID,
arg.AgentSessionID, arg.AgentSessionID,
arg.Prompt, arg.Prompt,
arg.PreviewURL,
arg.UpdatedAt, arg.UpdatedAt,
arg.ID, arg.ID,
) )

View File

@ -0,0 +1,52 @@
-- +goose Up
-- preview_url is the browser preview target the desktop app opens for a
-- session, set via `ao preview` (POST /sessions/{id}/preview). It is durable
-- so a daemon restart keeps the requested preview. Empty means no preview has
-- been requested. Defaulting to '' keeps existing rows valid without backfill.
-- +goose StatementBegin
ALTER TABLE sessions ADD COLUMN preview_url TEXT NOT NULL DEFAULT '';
-- +goose StatementEnd
-- Recreate the sessions update CDC trigger so a preview_url change also fans
-- out a session_updated event: the dashboard's browser panel subscribes to the
-- /events SSE stream and must react when the preview target moves. The payload
-- gains previewUrl so the renderer can read the new target straight from the
-- event without a follow-up GET.
-- +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 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)
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)),
NEW.updated_at);
END;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE sessions DROP COLUMN preview_url;
-- +goose StatementEnd

View File

@ -6,39 +6,42 @@ 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,
created_at, updated_at preview_url, 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 = ?,
updated_at = ? preview_url = ?, 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 runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
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 runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
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 runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url
FROM sessions ORDER BY project_id, num; FROM sessions ORDER BY project_id, num;
-- name: RenameSession :execrows -- name: RenameSession :execrows
UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?; UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?;
-- name: SetSessionPreviewURL :execrows
UPDATE sessions SET preview_url = ?, 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
-- state (see DeleteSeedSession for the conditions). Callers probe with this -- state (see DeleteSeedSession for the conditions). Callers probe with this

View File

@ -56,6 +56,24 @@ func (s *Store) RenameSession(ctx context.Context, id domain.SessionID, displayN
return rows > 0, nil return rows > 0, nil
} }
// SetSessionPreviewURL updates only the browser preview URL for an existing
// session. It returns ok=false when the session id does not exist. The
// sessions_cdc_update trigger fans out a session_updated CDC event when the
// preview URL actually changes.
func (s *Store) SetSessionPreviewURL(ctx context.Context, id domain.SessionID, previewURL string, updatedAt time.Time) (bool, error) {
s.writeMu.Lock()
defer s.writeMu.Unlock()
rows, err := s.qw.SetSessionPreviewURL(ctx, gen.SetSessionPreviewURLParams{
ID: id,
PreviewURL: previewURL,
UpdatedAt: updatedAt,
})
if err != nil {
return false, fmt.Errorf("set preview url for session %s: %w", id, err)
}
return rows > 0, nil
}
// DeleteSession removes a session row, but only if it is still in seed state // DeleteSession removes a session row, but only if it is still in seed state
// (no workspace, no runtime handle, no agent session id, no prompt, and not // (no workspace, no runtime handle, no agent session id, no prompt, and not
// already terminated). Rows that have observable spawn output are immutable // already terminated). Rows that have observable spawn output are immutable
@ -188,6 +206,7 @@ func rowToRecord(row gen.Session) domain.SessionRecord {
RuntimeHandleID: row.RuntimeHandleID, RuntimeHandleID: row.RuntimeHandleID,
AgentSessionID: row.AgentSessionID, AgentSessionID: row.AgentSessionID,
Prompt: row.Prompt, Prompt: row.Prompt,
PreviewURL: row.PreviewURL,
}, },
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
@ -213,6 +232,7 @@ func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams
RuntimeHandleID: rec.Metadata.RuntimeHandleID, RuntimeHandleID: rec.Metadata.RuntimeHandleID,
AgentSessionID: rec.Metadata.AgentSessionID, AgentSessionID: rec.Metadata.AgentSessionID,
Prompt: rec.Metadata.Prompt, Prompt: rec.Metadata.Prompt,
PreviewURL: rec.Metadata.PreviewURL,
CreatedAt: rec.CreatedAt, CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt, UpdatedAt: rec.UpdatedAt,
} }
@ -235,6 +255,7 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams {
RuntimeHandleID: rec.Metadata.RuntimeHandleID, RuntimeHandleID: rec.Metadata.RuntimeHandleID,
AgentSessionID: rec.Metadata.AgentSessionID, AgentSessionID: rec.Metadata.AgentSessionID,
Prompt: rec.Metadata.Prompt, Prompt: rec.Metadata.Prompt,
PreviewURL: rec.Metadata.PreviewURL,
UpdatedAt: rec.UpdatedAt, UpdatedAt: rec.UpdatedAt,
} }
} }

View File

@ -41,8 +41,14 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
| `ao session claim-pr <id> <pr-ref>` | `POST /api/v1/sessions/{id}/pr/claim` | | `ao session claim-pr <id> <pr-ref>` | `POST /api/v1/sessions/{id}/pr/claim` |
| `ao orchestrator ls` | `GET /api/v1/orchestrators` | | `ao orchestrator ls` | `GET /api/v1/orchestrators` |
| `ao send` | `POST /api/v1/sessions/{id}/send` | | `ao send` | `POST /api/v1/sessions/{id}/send` |
| `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` |
| `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) | | `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) |
`ao preview` resolves its session from the `AO_SESSION_ID` environment variable
(it is meant to run inside a session), not a flag. With no argument it
autodetects an `index.html` in the session workspace; with a URL argument it
opens that URL verbatim (`file://`, `http`, `https`).
`go run .` in `backend/` remains a compatibility wrapper around the daemon. `go run .` in `backend/` remains a compatibility wrapper around the daemon.
PR and review actions (merge, resolve-comments, review execute/send) are PR and review actions (merge, resolve-comments, review execute/send) are

View File

@ -75,6 +75,7 @@ const config: ForgeConfig = {
build: [ build: [
{ entry: "src/main.ts", config: "vite.main.config.ts", target: "main" }, { entry: "src/main.ts", config: "vite.main.config.ts", target: "main" },
{ entry: "src/preload.ts", config: "vite.preload.config.ts", target: "preload" }, { entry: "src/preload.ts", config: "vite.preload.config.ts", target: "preload" },
{ entry: "src/annotate-preload.ts", config: "vite.preload.config.ts", target: "preload" },
], ],
renderer: [{ name: "main_window", config: "vite.renderer.config.ts" }], renderer: [{ name: "main_window", config: "vite.renderer.config.ts" }],
}), }),

View File

@ -0,0 +1 @@
export {};

View File

@ -315,6 +315,41 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/sessions/{sessionId}/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Discover a browser preview URL for a session workspace */
get: operations["getSessionPreview"];
put?: never;
/** Set (or autodetect) the browser preview URL for a session */
post: operations["setSessionPreview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/sessions/{sessionId}/preview/files/*": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Serve a static browser preview file from a session workspace */
get: operations["getSessionPreviewFile"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/sessions/{sessionId}/restore": { "/api/v1/sessions/{sessionId}/restore": {
parameters: { parameters: {
query?: never; query?: never;
@ -489,6 +524,7 @@ export interface components {
isTerminated: boolean; isTerminated: boolean;
issueId?: string; issueId?: string;
kind: string; kind: string;
previewUrl?: string;
projectId: string; projectId: string;
prs: components["schemas"]["SessionPRFacts"][]; prs: components["schemas"]["SessionPRFacts"][];
/** @enum {string} */ /** @enum {string} */
@ -759,6 +795,11 @@ export interface components {
links: components["schemas"]["SessionPRReviewCommentLink"][]; links: components["schemas"]["SessionPRReviewCommentLink"][];
reviewerId: string; reviewerId: string;
}; };
SessionPreviewResponse: {
entry?: string;
previewUrl?: string;
sessionId: string;
};
SessionResponse: { SessionResponse: {
session: components["schemas"]["ControllersSessionView"]; session: components["schemas"]["ControllersSessionView"];
}; };
@ -777,6 +818,10 @@ export interface components {
SetProjectConfigInput: { SetProjectConfigInput: {
config: components["schemas"]["ProjectConfig"]; config: components["schemas"]["ProjectConfig"];
}; };
SetSessionPreviewRequest: {
/** @description Preview target URL. When empty, the daemon autodetects a static entry point in the session workspace. */
url?: string;
};
SpawnOrchestratorRequest: { SpawnOrchestratorRequest: {
clean?: boolean; clean?: boolean;
projectId: string; projectId: string;
@ -1990,6 +2035,169 @@ export interface operations {
}; };
}; };
}; };
getSessionPreview: {
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"]["SessionPreviewResponse"];
};
};
/** @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"];
};
};
};
};
setSessionPreview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Session identifier, e.g. project-1. */
sessionId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SetSessionPreviewRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["APIError"];
};
};
/** @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;
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: {
"text/html": string;
};
};
/** @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"];
};
};
};
};
restoreSession: { restoreSession: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -8,6 +8,7 @@ import {
Notification as ElectronNotification, Notification as ElectronNotification,
protocol, protocol,
shell, shell,
WebContentsView,
type OpenDialogOptions, type OpenDialogOptions,
} from "electron"; } from "electron";
import { updateElectronApp } from "update-electron-app"; import { updateElectronApp } from "update-electron-app";
@ -22,6 +23,7 @@ import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./sha
import type { DaemonStatus } from "./shared/daemon-status"; import type { DaemonStatus } from "./shared/daemon-status";
import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config";
import { buildTelemetryBootstrap } from "./shared/telemetry"; import { buildTelemetryBootstrap } from "./shared/telemetry";
import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host";
// Globals injected at compile time by @electron-forge/plugin-vite. // Globals injected at compile time by @electron-forge/plugin-vite.
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
@ -44,6 +46,7 @@ let daemonStoppingProcess: ChildProcessWithoutNullStreams | null = null;
let daemonStartPromise: Promise<DaemonStatus> | null = null; let daemonStartPromise: Promise<DaemonStatus> | null = null;
let daemonStartEpoch = 0; let daemonStartEpoch = 0;
let daemonStatus: DaemonStatus = { state: "stopped" }; let daemonStatus: DaemonStatus = { state: "stopped" };
let browserViewHost: BrowserViewHost | null = null;
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
@ -100,6 +103,10 @@ function preloadPath(): string {
return path.join(__dirname, "preload.js"); return path.join(__dirname, "preload.js");
} }
function annotatePreloadPath(): string {
return path.join(__dirname, "annotate-preload.js");
}
// Runtime window/taskbar icon for Linux and Windows. macOS ignores this and // Runtime window/taskbar icon for Linux and Windows. macOS ignores this and
// uses the .app bundle's .icns instead. Packaged: shipped via extraResource to // uses the .app bundle's .icns instead. Packaged: shipped via extraResource to
// resources/icon.png; dev: the source asset under frontend/assets. // resources/icon.png; dev: the source asset under frontend/assets.
@ -116,6 +123,8 @@ function setDaemonStatus(nextStatus: DaemonStatus): void {
} }
function createWindow(): void { function createWindow(): void {
browserViewHost?.dispose();
browserViewHost = null;
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1320, width: 1320,
height: 860, height: 860,
@ -154,6 +163,15 @@ function createWindow(): void {
} }
}); });
browserViewHost = createBrowserViewHost({
mainWindow,
ipcMain,
shell,
WebContentsView,
annotatePreloadPath: annotatePreloadPath(),
rendererOrigin: RENDERER_ORIGIN,
});
void mainWindow.loadURL(rendererUrl()); void mainWindow.loadURL(rendererUrl());
if (isDev && process.env.AO_OPEN_DEVTOOLS === "1") { if (isDev && process.env.AO_OPEN_DEVTOOLS === "1") {
@ -163,6 +181,8 @@ function createWindow(): void {
} }
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
browserViewHost?.dispose();
browserViewHost = null;
mainWindow = null; mainWindow = null;
}); });
} }
@ -598,6 +618,8 @@ app.whenReady().then(() => {
}); });
app.on("before-quit", () => { app.on("before-quit", () => {
browserViewHost?.dispose();
browserViewHost = null;
if (daemonProcess) { if (daemonProcess) {
killDaemon(daemonProcess); killDaemon(daemonProcess);
} }

View File

@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { clampBoundsToWindow, isAllowedBrowserURL, normalizeBrowserURL } from "./browser-view-host";
describe("normalizeBrowserURL", () => {
it("defaults localhost-style inputs to http", () => {
expect(normalizeBrowserURL("localhost:5173").href).toBe("http://localhost:5173/");
expect(normalizeBrowserURL("127.0.0.1:3000").href).toBe("http://127.0.0.1:3000/");
expect(normalizeBrowserURL("[::1]:4173").href).toBe("http://[::1]:4173/");
});
it("defaults ordinary bare hosts to https", () => {
expect(normalizeBrowserURL("example.com").href).toBe("https://example.com/");
});
it("allows file:// preview targets without mangling the scheme", () => {
expect(normalizeBrowserURL("file:///tmp/preview/index.html").href).toBe("file:///tmp/preview/index.html");
expect(normalizeBrowserURL("file:///C:/tmp/index.html").protocol).toBe("file:");
});
it("rejects privileged or unsupported schemes", () => {
expect(() => normalizeBrowserURL("app://renderer/index.html")).toThrow(/unsupported/i);
expect(() => normalizeBrowserURL("javascript:alert(1)")).toThrow(/unsupported/i);
});
});
describe("isAllowedBrowserURL", () => {
it("allows file:// even when a renderer origin is set", () => {
expect(isAllowedBrowserURL("file:///tmp/preview/index.html", "http://localhost:5173")).toBe(true);
});
it("still blocks the renderer's own http origin", () => {
expect(isAllowedBrowserURL("http://localhost:5173/", "http://localhost:5173")).toBe(false);
});
});
describe("clampBoundsToWindow", () => {
it("rounds and clamps bounds to the window content area", () => {
expect(
clampBoundsToWindow({ x: -10.4, y: 20.6, width: 900.2, height: 700.8 }, { width: 800, height: 600 }),
).toEqual({ x: 0, y: 21, width: 800, height: 579 });
});
it("returns a zero-sized rectangle when the slot is outside the window", () => {
expect(clampBoundsToWindow({ x: 900, y: 10, width: 100, height: 100 }, { width: 800, height: 600 })).toEqual({
x: 800,
y: 10,
width: 0,
height: 100,
});
});
});

View File

@ -0,0 +1,315 @@
import type { IpcMain, IpcMainEvent, IpcMainInvokeEvent, Rectangle, View, WebContents } from "electron";
export type BrowserRect = Pick<Rectangle, "x" | "y" | "width" | "height">;
export type BrowserNavState = {
viewId: string;
url: string;
title: string;
canGoBack: boolean;
canGoForward: boolean;
isLoading: boolean;
error?: string;
};
type BrowserBoundsInput = {
viewId: string;
rect: BrowserRect;
visible: boolean;
};
type BrowserNavigateInput = {
viewId: string;
url: string;
};
type BrowserWebContents = Pick<
WebContents,
| "canGoBack"
| "canGoForward"
| "getTitle"
| "getURL"
| "goBack"
| "goForward"
| "isLoading"
| "loadURL"
| "on"
| "reload"
| "send"
| "setWindowOpenHandler"
| "stop"
> & {
close?: () => void;
};
type BrowserViewLike = View & {
webContents: BrowserWebContents;
setBounds: (bounds: BrowserRect) => void;
setVisible?: (visible: boolean) => void;
};
type BrowserWindowLike = {
contentView: {
addChildView: (view: BrowserViewLike) => void;
removeChildView?: (view: BrowserViewLike) => void;
};
getContentBounds: () => BrowserRect;
webContents: Pick<WebContents, "id" | "send">;
};
type ShellLike = {
openExternal: (url: string) => Promise<void>;
};
type WebContentsViewConstructor = new (options: { webPreferences: Electron.WebPreferences }) => BrowserViewLike;
export type BrowserViewHostOptions = {
mainWindow: BrowserWindowLike;
ipcMain: Pick<IpcMain, "handle" | "on" | "removeHandler" | "off">;
shell: ShellLike;
WebContentsView: WebContentsViewConstructor;
annotatePreloadPath: string;
rendererOrigin: string;
};
export type BrowserViewHost = {
dispose: () => void;
destroy: (viewId: string) => void;
destroyAll: () => void;
};
type BrowserEntry = {
view: BrowserViewLike;
state: BrowserNavState;
};
const OFFSCREEN_BOUNDS: BrowserRect = { x: -10_000, y: -10_000, width: 0, height: 0 };
// ponytail: file:// allowed unsanitized; preview targets are agent-trusted for now
const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:"]);
export function normalizeBrowserURL(input: string): URL {
const raw = input.trim();
if (raw === "") {
throw new Error("URL is required");
}
const candidate = withDefaultScheme(raw);
const url = new URL(candidate);
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
throw new Error(`Unsupported browser URL scheme: ${url.protocol}`);
}
return url;
}
export function isAllowedBrowserURL(input: string, rendererOrigin?: string): boolean {
try {
const url = normalizeBrowserURL(input);
if (rendererOrigin && url.origin === rendererOrigin) return false;
return true;
} catch {
return false;
}
}
export function clampBoundsToWindow(
rect: BrowserRect,
windowBounds: Pick<BrowserRect, "width" | "height">,
): BrowserRect {
const rounded = {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.max(0, Math.round(rect.width)),
height: Math.max(0, Math.round(rect.height)),
};
const maxX = Math.max(0, Math.round(windowBounds.width));
const maxY = Math.max(0, Math.round(windowBounds.height));
const x = Math.min(Math.max(rounded.x, 0), maxX);
const y = Math.min(Math.max(rounded.y, 0), maxY);
return {
x,
y,
width: Math.min(rounded.width, Math.max(0, maxX - x)),
height: Math.min(rounded.height, Math.max(0, maxY - y)),
};
}
export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserViewHost {
const entries = new Map<string, BrowserEntry>();
const ipcDisposers: Array<() => void> = [];
const ensure = (viewId: string): BrowserEntry => {
const existing = entries.get(viewId);
if (existing) return existing;
const view = new options.WebContentsView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: options.annotatePreloadPath,
sandbox: true,
},
});
view.setBounds(OFFSCREEN_BOUNDS);
view.setVisible?.(false);
options.mainWindow.contentView.addChildView(view);
const state: BrowserNavState = emptyNavState(viewId);
const entry = { view, state };
entries.set(viewId, entry);
hardenWebContents(view.webContents, options, entry);
wireNavEvents(view.webContents, options, entry);
return entry;
};
const setBounds = ({ viewId, rect, visible }: BrowserBoundsInput): void => {
const entry = entries.get(viewId);
if (!entry) return;
if (!visible) {
entry.view.setVisible?.(false);
entry.view.setBounds(OFFSCREEN_BOUNDS);
return;
}
const bounds = clampBoundsToWindow(rect, options.mainWindow.getContentBounds());
entry.view.setBounds(bounds);
entry.view.setVisible?.(bounds.width > 0 && bounds.height > 0);
};
const navigate = async ({ viewId, url }: BrowserNavigateInput): Promise<BrowserNavState> => {
const entry = ensure(viewId);
const normalized = normalizeBrowserURL(url);
if (!isAllowedBrowserURL(normalized.href, options.rendererOrigin)) {
throw new Error("Unsupported browser URL");
}
await entry.view.webContents.loadURL(normalized.href);
return pushNavState(options, entry);
};
const destroy = (viewId: string): void => {
const entry = entries.get(viewId);
if (!entry) return;
entries.delete(viewId);
entry.view.setVisible?.(false);
entry.view.setBounds(OFFSCREEN_BOUNDS);
options.mainWindow.contentView.removeChildView?.(entry.view);
entry.view.webContents.close?.();
};
const invokeNav = (viewId: string, action: (contents: BrowserWebContents) => void): BrowserNavState => {
const entry = entries.get(viewId);
if (!entry) return emptyNavState(viewId);
action(entry.view.webContents);
return pushNavState(options, entry);
};
const handle = <Args extends unknown[], Result>(
channel: string,
fn: (event: IpcMainInvokeEvent, ...args: Args) => Result,
): void => {
options.ipcMain.handle(channel, fn);
ipcDisposers.push(() => options.ipcMain.removeHandler(channel));
};
const on = <Args extends unknown[]>(channel: string, fn: (event: IpcMainEvent, ...args: Args) => void): void => {
options.ipcMain.on(channel, fn);
ipcDisposers.push(() => options.ipcMain.off(channel, fn));
};
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: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()));
handle("browser:stop", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.stop()));
on("browser:destroy", (_event, viewId: string) => destroy(viewId));
return {
dispose: () => {
ipcDisposers.splice(0).forEach((dispose) => dispose());
for (const viewId of [...entries.keys()]) {
destroy(viewId);
}
},
destroy,
destroyAll: () => {
for (const viewId of [...entries.keys()]) {
destroy(viewId);
}
},
};
}
function withDefaultScheme(raw: string): string {
if (/^https?:\/\//i.test(raw)) return raw;
if (isLocalhostLike(raw)) return `http://${raw}`;
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw)) return raw;
return `https://${raw}`;
}
function isLocalhostLike(raw: string): boolean {
return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(raw);
}
function emptyNavState(viewId: string): BrowserNavState {
return {
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
};
}
function scopedViewId(event: IpcMainInvokeEvent, sessionId: string): string {
return `${event.sender.id}:${sessionId}`;
}
function hardenWebContents(contents: BrowserWebContents, options: BrowserViewHostOptions, entry: BrowserEntry): void {
contents.setWindowOpenHandler(({ url }) => {
if (isAllowedBrowserURL(url, options.rendererOrigin)) {
void options.shell.openExternal(url);
}
return { action: "deny" };
});
const blockUnsafeNavigation = (event: Electron.Event, url: string) => {
if (!isAllowedBrowserURL(url, options.rendererOrigin)) {
event.preventDefault();
entry.state = { ...entry.state, error: "Unsupported browser URL" };
options.mainWindow.webContents.send("browser:navState", entry.state);
}
};
contents.on("will-navigate", blockUnsafeNavigation);
contents.on("will-redirect", blockUnsafeNavigation);
}
function wireNavEvents(contents: BrowserWebContents, options: BrowserViewHostOptions, entry: BrowserEntry): void {
const update = () => {
pushNavState(options, entry);
};
contents.on("did-navigate", update);
contents.on("did-navigate-in-page", update);
contents.on("page-title-updated", update);
contents.on("did-start-loading", update);
contents.on("did-stop-loading", update);
contents.on("did-fail-load", (_event, _errorCode, errorDescription) => {
entry.state = { ...readNavState(entry), error: String(errorDescription || "Unable to load page") };
options.mainWindow.webContents.send("browser:navState", entry.state);
});
}
function pushNavState(options: BrowserViewHostOptions, entry: BrowserEntry): BrowserNavState {
entry.state = readNavState(entry);
options.mainWindow.webContents.send("browser:navState", entry.state);
return entry.state;
}
function readNavState(entry: BrowserEntry): BrowserNavState {
const { webContents } = entry.view;
return {
viewId: entry.state.viewId,
url: webContents.getURL(),
title: webContents.getTitle(),
canGoBack: webContents.canGoBack(),
canGoForward: webContents.canGoForward(),
isLoading: webContents.isLoading(),
};
}

View File

@ -1,7 +1,19 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import type { BrowserNavState, BrowserRect } from "./main/browser-view-host";
import type { DaemonStatus } from "./shared/daemon-status"; import type { DaemonStatus } from "./shared/daemon-status";
import type { TelemetryBootstrap } from "./shared/telemetry"; import type { TelemetryBootstrap } from "./shared/telemetry";
export type BrowserBoundsInput = {
viewId: string;
rect: BrowserRect;
visible: boolean;
};
export type BrowserNavigateInput = {
viewId: string;
url: string;
};
const api = { const api = {
app: { app: {
getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise<string>, getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise<string>,
@ -26,6 +38,24 @@ const api = {
telemetry: { telemetry: {
getBootstrap: () => ipcRenderer.invoke("telemetry:getBootstrap") as Promise<TelemetryBootstrap | null>, getBootstrap: () => ipcRenderer.invoke("telemetry:getBootstrap") as Promise<TelemetryBootstrap | null>,
}, },
browser: {
ensure: (sessionId: string) => ipcRenderer.invoke("browser:ensure", sessionId) as Promise<BrowserNavState>,
setBounds: (input: BrowserBoundsInput) => ipcRenderer.send("browser:setBounds", input),
navigate: (input: BrowserNavigateInput) =>
ipcRenderer.invoke("browser:navigate", input) 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>,
stop: (viewId: string) => ipcRenderer.invoke("browser:stop", viewId) as Promise<BrowserNavState>,
destroy: (viewId: string) => ipcRenderer.send("browser:destroy", viewId),
onNavState: (listener: (state: BrowserNavState) => void) => {
const wrapped = (_event: Electron.IpcRendererEvent, state: BrowserNavState) => listener(state);
ipcRenderer.on("browser:navState", wrapped);
return () => {
ipcRenderer.off("browser:navState", wrapped);
};
},
},
notifications: { notifications: {
show: (notification: { id: string; title: string; body?: string }) => show: (notification: { id: string; title: string; body?: string }) =>
ipcRenderer.invoke("notifications:show", notification) as Promise<void>, ipcRenderer.invoke("notifications:show", notification) as Promise<void>,

View File

@ -0,0 +1,130 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BrowserPanel } from "./BrowserPanel";
import type { BrowserNavState } from "../hooks/useBrowserView";
import type { WorkspaceSession } from "../types/workspace";
const hookState = vi.hoisted(() => ({
navigate: vi.fn(),
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
stop: vi.fn(),
previewUrl: undefined as string | undefined,
navState: {
viewId: "42:sess-1",
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
} as BrowserNavState,
}));
vi.mock("../hooks/useBrowserView", () => ({
useBrowserView: (options: { previewUrl?: string }) => {
hookState.previewUrl = options.previewUrl;
return {
viewId: "42:sess-1",
navState: hookState.navState,
slotRef: vi.fn(),
navigate: hookState.navigate,
goBack: hookState.goBack,
goForward: hookState.goForward,
reload: hookState.reload,
stop: hookState.stop,
};
},
}));
const session: WorkspaceSession = {
id: "sess-1",
workspaceId: "ws-1",
workspaceName: "my-app",
title: "do the thing",
provider: "claude-code",
kind: "worker",
branch: "feat/ns",
status: "working",
updatedAt: "2026-06-15T00:00:00Z",
prs: [],
};
describe("BrowserPanel", () => {
beforeEach(() => {
hookState.navigate.mockReset();
hookState.goBack.mockReset();
hookState.goForward.mockReset();
hookState.reload.mockReset();
hookState.stop.mockReset();
hookState.previewUrl = undefined;
hookState.navState = {
viewId: "42:sess-1",
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
};
});
it("navigates to the entered URL on submit", async () => {
render(<BrowserPanel active onTogglePopOut={() => undefined} poppedOut={false} session={session} />);
const input = screen.getByRole("textbox", { name: /browser url/i });
await userEvent.clear(input);
await userEvent.type(input, "localhost:5173{Enter}");
expect(hookState.navigate).toHaveBeenCalledWith("localhost:5173");
});
it("threads the session preview URL into the browser view (which drives navigation)", () => {
render(
<BrowserPanel
active
onTogglePopOut={() => undefined}
poppedOut={false}
session={{ ...session, previewUrl: "file:///tmp/preview/index.html" }}
/>,
);
expect(hookState.previewUrl).toBe("file:///tmp/preview/index.html");
});
it("binds navigation controls to nav state", async () => {
hookState.navState = {
viewId: "42:sess-1",
url: "http://localhost:5173/",
title: "Local app",
canGoBack: true,
canGoForward: false,
isLoading: true,
};
render(<BrowserPanel active onTogglePopOut={() => undefined} poppedOut={false} session={session} />);
await userEvent.click(screen.getByRole("button", { name: /back/i }));
await userEvent.click(screen.getByRole("button", { name: /stop/i }));
expect(hookState.goBack).toHaveBeenCalled();
expect(screen.getByRole("button", { name: /forward/i })).toBeDisabled();
expect(hookState.stop).toHaveBeenCalled();
});
it("shows empty and error states", () => {
hookState.navState = { ...hookState.navState, error: "Connection refused" };
render(<BrowserPanel active onTogglePopOut={() => undefined} poppedOut={false} session={session} />);
expect(screen.getByText("Enter a dev-server URL to preview it here.")).toBeInTheDocument();
expect(screen.getByText("Connection refused")).toBeInTheDocument();
});
it("toggles pop-out mode", async () => {
const onTogglePopOut = vi.fn();
render(<BrowserPanel active onTogglePopOut={onTogglePopOut} poppedOut={false} session={session} />);
await userEvent.click(screen.getByRole("button", { name: /pop out/i }));
expect(onTogglePopOut).toHaveBeenCalledWith(true);
});
});

View File

@ -0,0 +1,122 @@
import { useEffect, useState, type FormEvent } from "react";
import { ArrowLeft, ArrowRight, Globe2, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
import { useBrowserView, type BrowserViewModel } from "../hooks/useBrowserView";
import type { WorkspaceSession } from "../types/workspace";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
type BrowserPanelProps = {
session: WorkspaceSession;
active: boolean;
poppedOut: boolean;
onTogglePopOut: (next: boolean) => void;
};
export function BrowserPanel({ session, active, poppedOut, onTogglePopOut }: BrowserPanelProps) {
const browserView = useBrowserView({
sessionId: session.id,
active,
poppedOut,
previewUrl: session.previewUrl,
});
return (
<BrowserPanelView
active={active}
browserView={browserView}
onTogglePopOut={onTogglePopOut}
poppedOut={poppedOut}
session={session}
/>
);
}
export function BrowserPanelView({
poppedOut,
onTogglePopOut,
browserView,
}: BrowserPanelProps & { browserView: BrowserViewModel }) {
const { navState, slotRef, navigate, goBack, goForward, reload, stop } = browserView;
const [urlInput, setUrlInput] = useState(navState.url);
useEffect(() => {
setUrlInput(navState.url);
}, [navState.url]);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const nextURL = urlInput.trim();
if (nextURL) void navigate(nextURL);
};
return (
<div className="browser-panel" role="tabpanel">
<form className="browser-panel__toolbar" onSubmit={submit}>
<Button
aria-label="Back"
disabled={!navState.canGoBack}
onClick={() => void goBack()}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
</Button>
<Button
aria-label="Forward"
disabled={!navState.canGoForward}
onClick={() => void goForward()}
size="icon-sm"
type="button"
variant="ghost"
>
<ArrowRight aria-hidden="true" className="h-4 w-4" />
</Button>
<Button
aria-label={navState.isLoading ? "Stop" : "Reload"}
onClick={() => void (navState.isLoading ? stop() : reload())}
size="icon-sm"
type="button"
variant="ghost"
>
{navState.isLoading ? (
<X aria-hidden="true" className="h-4 w-4" />
) : (
<RefreshCw aria-hidden="true" className="h-4 w-4" />
)}
</Button>
<div className="browser-panel__url">
<Globe2 aria-hidden="true" className="browser-panel__url-icon" />
<Input
aria-label="Browser URL"
className="browser-panel__url-input"
onChange={(event) => setUrlInput(event.target.value)}
placeholder="localhost:5173"
value={urlInput}
/>
</div>
<Button
aria-label={poppedOut ? "Return to panel" : "Pop out"}
onClick={() => onTogglePopOut(!poppedOut)}
size="icon-sm"
type="button"
variant="ghost"
>
{poppedOut ? (
<Minimize2 aria-hidden="true" className="h-4 w-4" />
) : (
<Maximize2 aria-hidden="true" className="h-4 w-4" />
)}
</Button>
</form>
<div className="browser-panel__content">
<div className="browser-panel__slot" ref={slotRef} />
{navState.url === "" ? (
<div className="browser-panel__overlay">
<p>Enter a dev-server URL to preview it here.</p>
</div>
) : null}
{navState.error ? <p className="browser-panel__error">{navState.error}</p> : null}
</div>
</div>
);
}

View File

@ -136,7 +136,7 @@ describe("ProjectSettingsForm", () => {
}, },
}); });
expect(await screen.findByText("Saved.")).toBeInTheDocument(); expect(await screen.findByText("Saved.")).toBeInTheDocument();
}, 10_000); }, 20_000);
it("shows the daemon validation message when save fails", async () => { it("shows the daemon validation message when save fails", async () => {
getMock.mockResolvedValue({ getMock.mockResolvedValue({

View File

@ -18,7 +18,10 @@ import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSession
import { prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display"; import { prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display";
import type { SessionStatus, WorkspaceSession } from "../types/workspace"; import type { SessionStatus, WorkspaceSession } from "../types/workspace";
import { sortedPRs, workerDisplayStatus } from "../types/workspace"; import { sortedPRs, workerDisplayStatus } from "../types/workspace";
import { BrowserPanelView } from "./BrowserPanel";
import type { BrowserViewModel } from "../hooks/useBrowserView";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay"; import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay";
@ -27,7 +30,7 @@ type ReviewRun = components["schemas"]["ReviewRun"];
type ReviewsResponse = components["schemas"]["ListReviewsResponse"]; type ReviewsResponse = components["schemas"]["ListReviewsResponse"];
type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void; type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void;
type InspectorView = "summary" | "reviews" | "browser"; export type InspectorView = "summary" | "reviews" | "browser";
const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
{ {
@ -79,11 +82,29 @@ const prStateTone: Record<SessionPRSummary["state"], string> = {
export function SessionInspector({ export function SessionInspector({
session, session,
onOpenReviewerTerminal, onOpenReviewerTerminal,
browserPoppedOut = false,
isInspectorVisible = true,
onToggleBrowserPopOut,
browserView,
view: viewProp,
onViewChange,
}: { }: {
session?: WorkspaceSession; session?: WorkspaceSession;
onOpenReviewerTerminal?: OpenReviewerTerminal; onOpenReviewerTerminal?: OpenReviewerTerminal;
browserPoppedOut?: boolean;
isInspectorVisible?: boolean;
onToggleBrowserPopOut?: (next: boolean) => void;
browserView?: BrowserViewModel;
/** Controlled active tab. Omit to let the inspector own its own selection. */
view?: InspectorView;
onViewChange?: (view: InspectorView) => void;
}) { }) {
const [view, setView] = useState<InspectorView>("summary"); const [internalView, setInternalView] = useState<InspectorView>("summary");
const view = viewProp ?? internalView;
const setView = (next: InspectorView) => {
setInternalView(next);
onViewChange?.(next);
};
if (!session) { if (!session) {
return ( return (
@ -116,7 +137,15 @@ export function SessionInspector({
<div className="session-inspector__body"> <div className="session-inspector__body">
{view === "summary" ? <SummaryView session={session} /> : null} {view === "summary" ? <SummaryView session={session} /> : null}
{view === "reviews" ? <ReviewsView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null} {view === "reviews" ? <ReviewsView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
{view === "browser" ? <BrowserView /> : null} {view === "browser" ? (
<BrowserView
browserPoppedOut={browserPoppedOut}
browserView={browserView}
isActive={isInspectorVisible && !browserPoppedOut}
onTogglePopOut={onToggleBrowserPopOut}
session={session}
/>
) : null}
</div> </div>
</aside> </aside>
); );
@ -563,22 +592,47 @@ function reviewStatus(review?: ReviewRun): {
return { label: "Complete", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> }; return { label: "Complete", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
} }
function BrowserView() { function BrowserView({
session,
isActive,
browserPoppedOut,
onTogglePopOut,
browserView,
}: {
session: WorkspaceSession;
isActive: boolean;
browserPoppedOut: boolean;
onTogglePopOut?: (next: boolean) => void;
browserView?: BrowserViewModel;
}) {
if (browserPoppedOut) {
return ( return (
<div role="tabpanel"> <div role="tabpanel">
<div className="inspector-empty inspector-empty--browser"> <div className="inspector-empty inspector-empty--browser">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> <p>Browser preview is in the center pane.</p>
<circle cx="12" cy="12" r="9" /> <Button onClick={() => onTogglePopOut?.(false)} size="sm" type="button" variant="outline">
<line x1="3" y1="12" x2="21" y2="12" /> Return to panel
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" /> </Button>
</svg>
<p>No live browser preview.</p>
<span>A browser plugin will render what the agent is viewing here.</span>
</div> </div>
</div> </div>
); );
} }
if (!browserView) {
return null;
}
return (
<BrowserPanelView
active={isActive}
browserView={browserView}
onTogglePopOut={(next) => onTogglePopOut?.(next)}
poppedOut={false}
session={session}
/>
);
}
function Row({ k, v, mono }: { k: string; v: string; mono?: boolean }) { function Row({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
return ( return (
<div className="inspector-kv__row"> <div className="inspector-kv__row">

View File

@ -45,8 +45,48 @@ const { workspaces, panels } = vi.hoisted(() => {
// The terminal and inspector body pull in xterm/SSE machinery irrelevant to // The terminal and inspector body pull in xterm/SSE machinery irrelevant to
// the split under test. (The topbar is shell-owned — see ShellTopbar.) // the split under test. (The topbar is shell-owned — see ShellTopbar.)
vi.mock("./CenterPane", () => ({ CenterPane: () => <div /> })); vi.mock("./CenterPane", () => ({ CenterPane: () => <div>terminal center</div> }));
vi.mock("./SessionInspector", () => ({ SessionInspector: () => <div /> })); vi.mock("./BrowserPanel", () => ({
BrowserPanelView: ({
poppedOut,
onTogglePopOut,
}: {
poppedOut: boolean;
onTogglePopOut: (next: boolean) => void;
}) => (
<button type="button" onClick={() => onTogglePopOut(!poppedOut)}>
{poppedOut ? "browser center" : "browser rail"}
</button>
),
}));
const browserDestroy = vi.hoisted(() => vi.fn());
vi.mock("../hooks/useBrowserView", () => ({
useBrowserView: () => ({
viewId: "browser:sess-1",
navState: {
viewId: "browser:sess-1",
url: "http://127.0.0.1:4173/",
title: "Calculator",
canGoBack: false,
canGoForward: false,
isLoading: false,
},
slotRef: vi.fn(),
navigate: vi.fn(),
goBack: vi.fn(),
goForward: vi.fn(),
reload: vi.fn(),
stop: vi.fn(),
destroy: browserDestroy,
}),
}));
vi.mock("./SessionInspector", () => ({
SessionInspector: ({ onToggleBrowserPopOut, view }: { onToggleBrowserPopOut?: () => void; view?: string }) => (
<button type="button" data-view={view} onClick={onToggleBrowserPopOut}>
pop browser
</button>
),
}));
vi.mock("../lib/shell-context", () => ({ vi.mock("../lib/shell-context", () => ({
useShell: () => ({ daemonStatus: { state: "ready" } }), useShell: () => ({ daemonStatus: { state: "ready" } }),
})); }));
@ -129,6 +169,7 @@ describe("SessionView", () => {
window.localStorage.clear(); window.localStorage.clear();
useUiStore.setState({ isInspectorOpen: true }); useUiStore.setState({ isInspectorOpen: true });
panels.clear(); panels.clear();
browserDestroy.mockReset();
}); });
// Regression: react-resizable-panels v4 treats bare numeric sizes as PIXELS // Regression: react-resizable-panels v4 treats bare numeric sizes as PIXELS
@ -259,4 +300,36 @@ describe("SessionView", () => {
fireEvent.keyDown(window, { key: "B", metaKey: true, shiftKey: true }); fireEvent.keyDown(window, { key: "B", metaKey: true, shiftKey: true });
expect(useUiStore.getState().isInspectorOpen).toBe(true); expect(useUiStore.getState().isInspectorOpen).toBe(true);
}); });
it("lets the browser take over the center pane and return to the rail", () => {
render(<SessionView sessionId="sess-1" />);
expect(screen.getByText("terminal center")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "pop browser" }));
expect(screen.queryByText("terminal center")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "browser center" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "browser center" }));
expect(screen.getByText("terminal center")).toBeInTheDocument();
expect(browserDestroy).not.toHaveBeenCalled();
});
it("reveals an `ao preview` URL in the inspector Browser tab, not the center pane", () => {
const worker = workspaces[0].sessions[0];
worker.previewUrl = "http://localhost:5173/";
try {
useUiStore.setState({ isInspectorOpen: false });
render(<SessionView sessionId="sess-1" />);
// Center pane keeps the terminal — the preview must not pop out over it.
expect(screen.getByText("terminal center")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "browser center" })).not.toBeInTheDocument();
// Rail opened and switched to the Browser tab.
expect(useUiStore.getState().isInspectorOpen).toBe(true);
expect(screen.getByRole("button", { name: "pop browser" })).toHaveAttribute("data-view", "browser");
} finally {
delete worker.previewUrl;
}
});
}); });

View File

@ -1,10 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels"; import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels";
import { BrowserPanelView } from "./BrowserPanel";
import { CenterPane } from "./CenterPane"; import { CenterPane } from "./CenterPane";
import { SessionInspector } from "./SessionInspector"; import { SessionInspector, type InspectorView } from "./SessionInspector";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
import { useUiStore } from "../stores/ui-store"; import { useUiStore } from "../stores/ui-store";
import { useShell } from "../lib/shell-context"; import { useShell } from "../lib/shell-context";
import { useBrowserView } from "../hooks/useBrowserView";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery"; import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
import { isOrchestratorSession } from "../types/workspace"; import { isOrchestratorSession } from "../types/workspace";
import type { TerminalTarget } from "../types/terminal"; import type { TerminalTarget } from "../types/terminal";
@ -44,16 +46,40 @@ export function SessionView({ sessionId }: SessionViewProps) {
const inspectorRef = useRef<PanelImperativeHandle | null>(null); const inspectorRef = useRef<PanelImperativeHandle | null>(null);
const inspectorSeparatorRef = useRef<HTMLDivElement | null>(null); const inspectorSeparatorRef = useRef<HTMLDivElement | null>(null);
const [terminalTarget, setTerminalTarget] = useState<TerminalTarget>({ kind: "worker" }); const [terminalTarget, setTerminalTarget] = useState<TerminalTarget>({ kind: "worker" });
const [browserPoppedOut, setBrowserPoppedOut] = useState(false);
const [inspectorView, setInspectorView] = useState<InspectorView>("summary");
const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId); const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId);
const isOrchestrator = session ? isOrchestratorSession(session) : false; const isOrchestrator = session ? isOrchestratorSession(session) : false;
// 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 browserView = useBrowserView({
sessionId,
active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)),
poppedOut: browserPoppedOut,
previewUrl,
});
useEffect(() => { useEffect(() => {
setTerminalTarget({ kind: "worker" }); setTerminalTarget({ kind: "worker" });
setBrowserPoppedOut(false);
setInspectorView("summary");
revealedPreviewRef.current = null;
}, [sessionId]); }, [sessionId]);
// Orchestrator sessions are terminal-only; only worker sessions have the rail. // `ao preview` sets session.previewUrl (streamed over CDC); surface the result
const hasInspector = !isOrchestrator; // 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.
useEffect(() => {
if (!previewUrl || revealedPreviewRef.current === previewUrl) return;
revealedPreviewRef.current = previewUrl;
setInspectorView("browser");
if (!useUiStore.getState().isInspectorOpen) toggleInspector();
}, [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,
// among others) whenever this prop's identity changes, and the imperative // among others) whenever this prop's identity changes, and the imperative
@ -151,6 +177,15 @@ export function SessionView({ sessionId }: SessionViewProps) {
{/* react-resizable-panels v4: bare numbers are PIXELS; percentages must {/* react-resizable-panels v4: bare numbers are PIXELS; percentages must
be strings. Numeric sizes here once clamped the inspector to 45px. */} be strings. Numeric sizes here once clamped the inspector to 45px. */}
<ResizablePanel defaultSize="72%" id="terminal" minSize="45%"> <ResizablePanel defaultSize="72%" id="terminal" minSize="45%">
{browserPoppedOut && session ? (
<BrowserPanelView
active
browserView={browserView}
onTogglePopOut={setBrowserPoppedOut}
poppedOut
session={session}
/>
) : (
<CenterPane <CenterPane
daemonReady={daemonStatus.state === "ready"} daemonReady={daemonStatus.state === "ready"}
onSelectWorkerTerminal={() => setTerminalTarget({ kind: "worker" })} onSelectWorkerTerminal={() => setTerminalTarget({ kind: "worker" })}
@ -158,6 +193,7 @@ export function SessionView({ sessionId }: SessionViewProps) {
terminalTarget={terminalTarget} terminalTarget={terminalTarget}
theme={theme} theme={theme}
/> />
)}
</ResizablePanel> </ResizablePanel>
{hasInspector ? ( {hasInspector ? (
<> <>
@ -181,9 +217,15 @@ export function SessionView({ sessionId }: SessionViewProps) {
the pane clips instead of reflowing the inspector mid-collapse. */} the pane clips instead of reflowing the inspector mid-collapse. */}
<div className="h-full min-w-[280px]"> <div className="h-full min-w-[280px]">
<SessionInspector <SessionInspector
browserPoppedOut={browserPoppedOut}
isInspectorVisible={isInspectorOpen}
onOpenReviewerTerminal={({ handleId, harness }) => onOpenReviewerTerminal={({ handleId, harness }) =>
setTerminalTarget({ kind: "reviewer", handleId, harness }) setTerminalTarget({ kind: "reviewer", handleId, harness })
} }
onToggleBrowserPopOut={setBrowserPoppedOut}
onViewChange={setInspectorView}
view={inspectorView}
browserView={browserView}
session={session} session={session}
/> />
</div> </div>

View File

@ -0,0 +1,180 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useBrowserView, type BrowserNavState } from "./useBrowserView";
type Listener = (state: BrowserNavState) => void;
function createSlot(rect: Partial<DOMRect> = {}) {
const slot = document.createElement("div");
document.body.appendChild(slot);
slot.getBoundingClientRect = vi.fn(() => ({
x: 12,
y: 34,
width: 320,
height: 240,
top: 34,
right: 332,
bottom: 274,
left: 12,
toJSON: () => ({}),
...rect,
}));
return slot;
}
function setupBridge() {
const listeners = new Set<Listener>();
const bridge = {
stateFor(viewId: string): BrowserNavState {
return {
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
};
},
ensure: vi.fn(
async (sessionId: string): Promise<BrowserNavState> => ({
viewId: `42:${sessionId}`,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
),
setBounds: vi.fn(),
navigate: vi.fn(async ({ viewId }: { 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)),
stop: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
destroy: vi.fn(),
onNavState: vi.fn((listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
}),
emit(state: BrowserNavState) {
listeners.forEach((listener) => listener(state));
},
};
window.ao = { ...window.ao!, browser: bridge };
return bridge;
}
describe("useBrowserView", () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.replaceChildren();
});
it("ensures a scoped browser view and reports the measured slot bounds", async () => {
const bridge = setupBridge();
const slot = createSlot();
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
await waitFor(() => expect(bridge.ensure).toHaveBeenCalledWith("sess-1"));
act(() => result.current.slotRef(slot));
await waitFor(() =>
expect(bridge.setBounds).toHaveBeenCalledWith({
viewId: "42:sess-1",
rect: { x: 12, y: 34, width: 320, height: 240 },
visible: true,
}),
);
expect(result.current.viewId).toBe("42:sess-1");
});
it("hides the native view when inactive and on unmount without destroying session state", async () => {
const bridge = setupBridge();
const slot = createSlot();
const { result, rerender, unmount } = renderHook(
({ active }) => useBrowserView({ sessionId: "sess-1", active, poppedOut: false }),
{ initialProps: { active: true } },
);
await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
act(() => result.current.slotRef(slot));
rerender({ active: false });
await waitFor(() =>
expect(bridge.setBounds).toHaveBeenLastCalledWith({
viewId: "42:sess-1",
rect: { x: 0, y: 0, width: 0, height: 0 },
visible: false,
}),
);
unmount();
expect(bridge.setBounds).toHaveBeenLastCalledWith({
viewId: "42:sess-1",
rect: { x: 0, y: 0, width: 0, height: 0 },
visible: false,
});
expect(bridge.destroy).not.toHaveBeenCalled();
});
it("updates nav state only for the current view", 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"));
act(() =>
bridge.emit({
viewId: "other:sess-1",
url: "https://ignored.test/",
title: "Ignored",
canGoBack: true,
canGoForward: true,
isLoading: true,
}),
);
expect(result.current.navState.url).toBe("");
act(() =>
bridge.emit({
viewId: "42:sess-1",
url: "http://localhost:5173/",
title: "Local app",
canGoBack: false,
canGoForward: true,
isLoading: false,
}),
);
expect(result.current.navState.url).toBe("http://localhost:5173/");
expect(result.current.navState.title).toBe("Local app");
});
it("navigates to a daemon-set preview URL and re-navigates only when it changes", async () => {
const bridge = setupBridge();
const { rerender } = renderHook(
({ previewUrl }) => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl }),
{ initialProps: { previewUrl: "http://localhost:5173/" } },
);
await waitFor(() =>
expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "http://localhost:5173/" }),
);
expect(bridge.navigate).toHaveBeenCalledTimes(1);
// Same URL replayed (CDC re-emits the session payload) must not re-navigate.
rerender({ previewUrl: "http://localhost:5173/" });
expect(bridge.navigate).toHaveBeenCalledTimes(1);
// A changed target re-navigates.
rerender({ previewUrl: "file:///tmp/preview/index.html" });
await waitFor(() =>
expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "file:///tmp/preview/index.html" }),
);
expect(bridge.navigate).toHaveBeenCalledTimes(2);
});
it("does not navigate without a preview URL", 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();
});
});

View File

@ -0,0 +1,203 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { BrowserNavState, BrowserRect } from "../../main/browser-view-host";
export type { BrowserNavState };
type UseBrowserViewOptions = {
sessionId: string;
active: boolean;
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.
*/
previewUrl?: string;
};
export type BrowserViewModel = {
viewId: string;
navState: BrowserNavState;
slotRef: (node: HTMLDivElement | null) => void;
navigate: (url: string) => Promise<void>;
goBack: () => Promise<void>;
goForward: () => Promise<void>;
reload: () => Promise<void>;
stop: () => Promise<void>;
destroy: () => void;
};
const EMPTY_NAV_STATE: BrowserNavState = {
viewId: "",
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
};
const HIDDEN_RECT: BrowserRect = { x: 0, y: 0, width: 0, height: 0 };
export function useBrowserView({ sessionId, active, poppedOut, previewUrl }: UseBrowserViewOptions): BrowserViewModel {
const [viewId, setViewId] = useState("");
const [navState, setNavState] = useState<BrowserNavState>(EMPTY_NAV_STATE);
const slotNodeRef = useRef<HTMLDivElement | null>(null);
const viewIdRef = useRef("");
const activeRef = useRef(active);
const frameRef = useRef<number | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const previewNavRef = useRef<string | null>(null);
useEffect(() => {
activeRef.current = active;
}, [active]);
const sendHiddenBounds = useCallback((id = viewIdRef.current) => {
if (!id) return;
window.ao?.browser.setBounds({ viewId: id, rect: HIDDEN_RECT, visible: false });
}, []);
const measureAndSend = useCallback(() => {
frameRef.current = null;
const id = viewIdRef.current;
const node = slotNodeRef.current;
if (!id) return;
if (!activeRef.current || !node || !node.isConnected) {
sendHiddenBounds(id);
return;
}
const rect = node.getBoundingClientRect();
const payload = {
viewId: id,
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
},
visible: rect.width > 0 && rect.height > 0,
};
window.ao?.browser.setBounds(payload);
}, [sendHiddenBounds]);
const cancelScheduledMeasure = useCallback(() => {
if (frameRef.current === null) return;
if (window.cancelAnimationFrame) {
window.cancelAnimationFrame(frameRef.current);
}
window.clearTimeout(frameRef.current);
frameRef.current = null;
}, []);
const scheduleMeasure = useCallback(() => {
if (frameRef.current !== null) return;
frameRef.current = window.requestAnimationFrame
? window.requestAnimationFrame(() => measureAndSend())
: window.setTimeout(() => measureAndSend(), 16);
}, [measureAndSend]);
const slotRef = useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
slotNodeRef.current = node;
if (node) {
const observer = new ResizeObserver(scheduleMeasure);
observer.observe(node);
observerRef.current = observer;
}
scheduleMeasure();
},
[scheduleMeasure],
);
useEffect(() => {
let disposed = false;
window.ao?.browser.ensure(sessionId).then((state) => {
if (disposed) return;
viewIdRef.current = state.viewId;
setViewId(state.viewId);
setNavState(state);
scheduleMeasure();
});
return () => {
disposed = true;
const id = viewIdRef.current;
if (id) {
sendHiddenBounds(id);
}
viewIdRef.current = "";
};
}, [scheduleMeasure, sendHiddenBounds, sessionId]);
useEffect(() => {
return window.ao?.browser.onNavState((state) => {
if (state.viewId !== viewIdRef.current) return;
setNavState(state);
});
}, []);
useEffect(() => {
if (active) {
scheduleMeasure();
} else {
sendHiddenBounds();
}
}, [active, poppedOut, scheduleMeasure, sendHiddenBounds]);
useEffect(() => {
const handle = () => scheduleMeasure();
window.addEventListener("resize", handle);
window.addEventListener("scroll", handle, true);
return () => {
window.removeEventListener("resize", handle);
window.removeEventListener("scroll", handle, true);
observerRef.current?.disconnect();
cancelScheduledMeasure();
};
}, [cancelScheduledMeasure, scheduleMeasure]);
const withView = useCallback(async (fn: (id: string) => Promise<BrowserNavState | void>) => {
const id = viewIdRef.current;
if (!id) return;
const next = await fn(id);
if (next) setNavState(next);
}, []);
const navigate = useCallback(
(url: string) => withView((id) => window.ao!.browser.navigate({ viewId: id, url })),
[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).
useEffect(() => {
const target = previewUrl?.trim();
if (!target || !viewId) return;
if (previewNavRef.current === target || navState.url === target) {
previewNavRef.current = target;
return;
}
previewNavRef.current = target;
void navigate(target);
}, [navState.url, navigate, previewUrl, viewId]);
const destroy = useCallback(() => {
const id = viewIdRef.current;
if (!id) return;
sendHiddenBounds(id);
window.ao?.browser.destroy(id);
viewIdRef.current = "";
}, [sendHiddenBounds]);
return {
viewId,
navState,
slotRef,
navigate,
goBack: () => withView((id) => window.ao!.browser.goBack(id)),
goForward: () => withView((id) => window.ao!.browser.goForward(id)),
reload: () => withView((id) => window.ao!.browser.reload(id)),
stop: () => withView((id) => window.ao!.browser.stop(id)),
destroy,
};
}

View File

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

View File

@ -27,6 +27,59 @@ export const aoBridge: AoBridge =
telemetry: { telemetry: {
getBootstrap: async () => null, getBootstrap: async () => null,
}, },
browser: {
ensure: async (sessionId: string) => ({
viewId: `preview:${sessionId}`,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
setBounds: () => undefined,
navigate: async ({ viewId, url }) => ({
viewId,
url,
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
goBack: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
goForward: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
reload: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
stop: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
destroy: () => undefined,
onNavState: () => () => undefined,
},
notifications: { notifications: {
show: async () => undefined, show: async () => undefined,
onClick: () => () => undefined, onClick: () => () => undefined,

View File

@ -1314,3 +1314,91 @@ body.is-resizing-x [data-slot="sidebar-container"] {
font-family: var(--font-mono); font-family: var(--font-mono);
margin-top: 2px; margin-top: 2px;
} }
.browser-panel {
display: flex;
height: 100%;
min-height: 320px;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
}
.browser-panel__toolbar {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
min-width: 0;
padding: 7px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.browser-panel__url {
position: relative;
min-width: 0;
flex: 1;
}
.browser-panel__url-icon {
position: absolute;
left: 9px;
top: 50%;
width: 14px;
height: 14px;
transform: translateY(-50%);
color: var(--fg-passive);
pointer-events: none;
}
.browser-panel__url-input {
height: 29px;
padding-left: 29px;
font-family: var(--font-mono);
font-size: 12px;
}
.browser-panel__content {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.browser-panel__slot {
position: absolute;
inset: 0;
min-width: 1px;
min-height: 1px;
}
.browser-panel__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
text-align: center;
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg-passive);
pointer-events: none;
}
.browser-panel__error {
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
margin: 0;
padding: 8px 10px;
border: 1px solid color-mix(in srgb, var(--red) 35%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--red) 8%, var(--bg));
color: var(--red);
font-size: 12px;
}

View File

@ -68,6 +68,59 @@ window.ao = {
telemetry: { telemetry: {
getBootstrap: async () => null, getBootstrap: async () => null,
}, },
browser: {
ensure: async (sessionId: string) => ({
viewId: `test:${sessionId}`,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
setBounds: () => undefined,
navigate: async ({ viewId }: { viewId: string }) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
goBack: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
goForward: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
reload: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
stop: async (viewId: string) => ({
viewId,
url: "",
title: "",
canGoBack: false,
canGoForward: false,
isLoading: false,
}),
destroy: () => undefined,
onNavState: () => () => undefined,
},
notifications: { notifications: {
show: async () => undefined, show: async () => undefined,
onClick: () => () => undefined, onClick: () => () => undefined,

View File

@ -104,6 +104,11 @@ export type WorkspaceSession = {
createdAt?: string; createdAt?: string;
/** ISO timestamp from the daemon. */ /** ISO timestamp from the daemon. */
updatedAt: string; updatedAt: string;
/**
* Live preview target set by the daemon (via `ao preview`) and streamed over
* CDC. When non-empty, the browser panel opens and navigates here.
*/
previewUrl?: string;
/** 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. */

View File

@ -85,6 +85,7 @@ export default defineConfig({
], ],
test: { test: {
environment: "jsdom", environment: "jsdom",
testTimeout: 20_000,
// Anchor node_modules at any depth: a bare "node_modules/**" replaces // Anchor node_modules at any depth: a bare "node_modules/**" replaces
// vitest's default "**/node_modules/**" and only matches the root, so the // vitest's default "**/node_modules/**" and only matches the root, so the
// tracked src/landing preview app's nested node_modules would otherwise // tracked src/landing preview app's nested node_modules would otherwise