diff --git a/AGENTS.md b/AGENTS.md index c75737245..82cb2b0f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ npm run typecheck 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 - `README.md` — current run/config/test quickstart. diff --git a/CLAUDE.md b/CLAUDE.md index 1678b2c7e..7b3355034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 deviate without explicit user approval. In QA/review, flag any renderer code that 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. diff --git a/README.md b/README.md index 8ee063e7c..27398e1a8 100644 --- a/README.md +++ b/README.md @@ -128,31 +128,32 @@ The CLI is intentionally thin: every product command resolves to a daemon HTTP route. Run `ao --help` for the authoritative flag shape; the table below groups what's on `main` today. -| Lane | Command | Purpose | -| ------------ | ------------------------------------ | ------------------------------------------------------------ | -| Daemon | `ao start` | Start the daemon in the background and wait for `/readyz`. | -| Daemon | `ao stop` | Graceful shutdown via loopback `POST /shutdown`. | -| Daemon | `ao status` | Report PID/port/health/readiness from `running.json`. | -| Daemon | `ao daemon` | Hidden internal entrypoint used by `ao start`. | -| Project | `ao project add` | Register a local git repo as a project. | -| Project | `ao project ls` | List registered projects. | -| Project | `ao project get ` | Fetch one project. | -| Project | `ao project set-config ` | Update per-project config. | -| Project | `ao project rm ` | Remove a project. | -| Session | `ao spawn` | Spawn a worker session in a registered project. | -| Session | `ao session ls` | List sessions (filter by project, include terminated). | -| Session | `ao session get ` | Fetch one session. | -| Session | `ao session kill ` | Terminate a session. | -| Session | `ao session rename ` | Rename a session. | -| Session | `ao session restore ` | Relaunch a terminated session. | -| Session | `ao session cleanup` | Reclaim eligible workspaces for terminated sessions. | -| Session | `ao session claim-pr ` | Attach an existing PR to a session. | -| Orchestrator | `ao orchestrator ls` | List orchestrator sessions. | -| Messaging | `ao send` | Send a message to a running agent session. | -| Utility | `ao doctor` | Local health checks (config, data dir, DB, `git`, `zellij`). | -| Utility | `ao completion ` | Generate bash/zsh/fish/powershell completions. | -| Utility | `ao version` | Print build metadata. | -| Internal | `ao hooks ` | Hidden adapter hook callback. | +| Lane | Command | Purpose | +| ------------ | ------------------------------------ | ---------------------------------------------------------------------------------- | +| Daemon | `ao start` | Start the daemon in the background and wait for `/readyz`. | +| Daemon | `ao stop` | Graceful shutdown via loopback `POST /shutdown`. | +| Daemon | `ao status` | Report PID/port/health/readiness from `running.json`. | +| Daemon | `ao daemon` | Hidden internal entrypoint used by `ao start`. | +| Project | `ao project add` | Register a local git repo as a project. | +| Project | `ao project ls` | List registered projects. | +| Project | `ao project get ` | Fetch one project. | +| Project | `ao project set-config ` | Update per-project config. | +| Project | `ao project rm ` | Remove a project. | +| Session | `ao spawn` | Spawn a worker session in a registered project. | +| Session | `ao session ls` | List sessions (filter by project, include terminated). | +| Session | `ao session get ` | Fetch one session. | +| Session | `ao session kill ` | Terminate a session. | +| Session | `ao session rename ` | Rename a session. | +| Session | `ao session restore ` | Relaunch a terminated session. | +| Session | `ao session cleanup` | Reclaim eligible workspaces for terminated sessions. | +| Session | `ao session claim-pr ` | Attach an existing PR to a session. | +| Orchestrator | `ao orchestrator ls` | List orchestrator sessions. | +| 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 completion ` | Generate bash/zsh/fish/powershell completions. | +| Utility | `ao version` | Print build metadata. | +| Internal | `ao hooks ` | Hidden adapter hook callback. | See [`docs/cli/`](docs/cli/) for the daemon-control intent and command shape. diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 46b7c2266..5c7e924ac 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -90,6 +90,10 @@ func (f *fakeSessionService) Rename(context.Context, domain.SessionID, string) e 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 { return nil } diff --git a/backend/internal/cli/preview.go b/backend/internal/cli/preview.go new file mode 100644 index 000000000..c81cf06ad --- /dev/null +++ b/backend/internal/cli/preview.go @@ -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) +} diff --git a/backend/internal/cli/preview_test.go b/backend/internal/cli/preview_test.go new file mode 100644 index 000000000..b5dfaabcb --- /dev/null +++ b/backend/internal/cli/preview_test.go @@ -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") + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 47d202464..3bb1fcedd 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -185,6 +185,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newDoctorCommand(ctx)) root.AddCommand(newSpawnCommand(ctx)) root.AddCommand(newSendCommand(ctx)) + root.AddCommand(newPreviewCommand(ctx)) root.AddCommand(newHooksCommand(ctx)) root.AddCommand(newLaunchCommand(ctx)) root.AddCommand(newImportCommand(ctx)) diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index 19d777acc..79f94a0d2 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -30,6 +30,10 @@ type SessionMetadata struct { RuntimeHandleID string `json:"runtimeHandleId,omitempty"` AgentSessionID string `json:"agentSessionId,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 diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2a76e625f..bc388206b 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -938,6 +938,134 @@ paths: summary: Claim an existing pull request for a session tags: - 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: post: operationId: restoreSession @@ -1354,6 +1482,8 @@ components: type: string kind: type: string + previewUrl: + type: string projectId: type: string prs: @@ -2088,6 +2218,17 @@ components: - count - links type: object + SessionPreviewResponse: + properties: + entry: + type: string + previewUrl: + type: string + sessionId: + type: string + required: + - sessionId + type: object SessionResponse: properties: session: @@ -2128,6 +2269,13 @@ components: required: - config 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: properties: clean: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 6c7c7ad35..c0383858c 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -139,6 +139,8 @@ var schemaNames = map[string]string{ "ControllersListSessionsResponse": "ListSessionsResponse", "ControllersSpawnSessionRequest": "SpawnSessionRequest", "ControllersSessionResponse": "SessionResponse", + "ControllersSessionPreviewResponse": "SessionPreviewResponse", + "ControllersSetSessionPreviewRequest": "SetSessionPreviewRequest", "ControllersRenameSessionRequest": "RenameSessionRequest", "ControllersRenameSessionResponse": "RenameSessionResponse", "ControllersRestoreSessionResponse": "RestoreSessionResponse", @@ -480,6 +482,42 @@ func sessionOperations() []operation { {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", summary: "List pull requests owned by a session", diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 1a9cb1764..6a0401540 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -119,8 +119,12 @@ type CleanupSessionsQuery struct { // fields are json:"-"; these curated fields are what serialize. type SessionView struct { domain.Session - Branch string `json:"branch,omitempty"` - PRs []SessionPRFacts `json:"prs"` + 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"` } // ListSessionsResponse is the body of GET /api/v1/sessions. @@ -143,11 +147,25 @@ type SessionResponse struct { 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}. type RenameSessionRequest struct { 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}. type RenameSessionResponse struct { OK bool `json:"ok"` diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 80e638624..f676219ff 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -4,6 +4,10 @@ import ( "context" "errors" "net/http" + "net/url" + "os" + "path" + "path/filepath" "strconv" "strings" @@ -32,6 +36,7 @@ type SessionService interface { RollbackSpawn(ctx context.Context, id domain.SessionID) (sessionsvc.RollbackOutcome, error) Cleanup(ctx context.Context, project domain.ProjectID) (sessionsvc.CleanupOutcome, 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 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) @@ -59,6 +64,9 @@ func (c *SessionsController) Register(r chi.Router) { r.Post("/sessions", c.spawn) r.Post("/sessions/cleanup", c.cleanup) 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.Post("/sessions/{sessionId}/pr/claim", c.claimPR) 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)}) } +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) { if c.Svc == nil { 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 { - 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 { diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 6a0ec96e6..f23219387 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -6,6 +6,10 @@ import ( "log/slog" "net/http" "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" "testing" "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) { - 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) { @@ -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(``), 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(``), 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) { svc := newFakeSessionService() svc.spawnErr = apierr.Invalid("BRANCH_NOT_FETCHED", `workspace: branch is not fetched: "feature/missing"`, nil) diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 92927c70a..090c11b95 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -20,6 +20,7 @@ type Store interface { ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, 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) ListPRFactsForSession(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, 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 } +// 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 // reports both reclaimed and preserved (skipped) workspaces. func (s *Service) Cleanup(ctx context.Context, project domain.ProjectID) (CleanupOutcome, error) { diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index c6aa73c59..81f02d299 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -82,6 +82,17 @@ func (f *fakeStore) RenameSession(_ context.Context, id domain.SessionID, displa 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) { pr, ok := f.pr[id] 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) { st := newFakeStore() diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 46995c98a..234581550 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -167,6 +167,7 @@ type Session struct { UpdatedAt time.Time DisplayName string FirstSignalAt sql.NullTime + PreviewURL string } type SessionWorktree struct { diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index 920e17f1a..f788414d7 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -16,7 +16,7 @@ import ( const getSession = `-- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url FROM sessions WHERE id = ? ` @@ -42,6 +42,7 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session, &i.UpdatedAt, &i.DisplayName, &i.FirstSignalAt, + &i.PreviewURL, ) return i, err } @@ -51,8 +52,8 @@ INSERT INTO sessions ( id, project_id, num, issue_id, kind, harness, display_name, activity_state, activity_last_at, first_signal_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, - created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + preview_url, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertSessionParams struct { @@ -72,6 +73,7 @@ type InsertSessionParams struct { RuntimeHandleID string AgentSessionID string Prompt string + PreviewURL string CreatedAt time.Time UpdatedAt time.Time } @@ -94,6 +96,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er arg.RuntimeHandleID, arg.AgentSessionID, arg.Prompt, + arg.PreviewURL, arg.CreatedAt, arg.UpdatedAt, ) @@ -103,7 +106,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er const listAllSessions = `-- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + 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 ` @@ -135,6 +138,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { &i.UpdatedAt, &i.DisplayName, &i.FirstSignalAt, + &i.PreviewURL, ); err != nil { return nil, err } @@ -152,7 +156,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { const listSessionsByProject = `-- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + 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 ` @@ -184,6 +188,7 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.Pr &i.UpdatedAt, &i.DisplayName, &i.FirstSignalAt, + &i.PreviewURL, ); err != nil { return nil, err } @@ -251,12 +256,30 @@ func (q *Queries) SessionIsSeed(ctx context.Context, id domain.SessionID) (bool, 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 UPDATE sessions SET issue_id = ?, kind = ?, harness = ?, display_name = ?, activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, - updated_at = ? + preview_url = ?, updated_at = ? WHERE id = ? ` @@ -274,6 +297,7 @@ type UpdateSessionParams struct { RuntimeHandleID string AgentSessionID string Prompt string + PreviewURL string UpdatedAt time.Time ID domain.SessionID } @@ -293,6 +317,7 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) er arg.RuntimeHandleID, arg.AgentSessionID, arg.Prompt, + arg.PreviewURL, arg.UpdatedAt, arg.ID, ) diff --git a/backend/internal/storage/sqlite/migrations/0017_add_session_preview_url.sql b/backend/internal/storage/sqlite/migrations/0017_add_session_preview_url.sql new file mode 100644 index 000000000..c182c9838 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0017_add_session_preview_url.sql @@ -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 diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index 5c66c07c0..5e44a567e 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -6,39 +6,42 @@ INSERT INTO sessions ( id, project_id, num, issue_id, kind, harness, display_name, activity_state, activity_last_at, first_signal_at, is_terminated, branch, workspace_path, runtime_handle_id, agent_session_id, prompt, - created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + preview_url, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateSession :exec UPDATE sessions SET issue_id = ?, kind = ?, harness = ?, display_name = ?, activity_state = ?, activity_last_at = ?, first_signal_at = ?, is_terminated = ?, branch = ?, workspace_path = ?, runtime_handle_id = ?, agent_session_id = ?, prompt = ?, - updated_at = ? + preview_url = ?, updated_at = ? WHERE id = ?; -- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, preview_url FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + 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; -- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + 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; -- name: RenameSession :execrows UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?; +-- name: SetSessionPreviewURL :execrows +UPDATE sessions SET preview_url = ?, updated_at = ? WHERE id = ?; + -- name: SessionIsSeed :one -- SessionIsSeed reports whether the session id matches a row still in seed -- state (see DeleteSeedSession for the conditions). Callers probe with this diff --git a/backend/internal/storage/sqlite/store/session_store.go b/backend/internal/storage/sqlite/store/session_store.go index 84f17a50d..03ae4c0e5 100644 --- a/backend/internal/storage/sqlite/store/session_store.go +++ b/backend/internal/storage/sqlite/store/session_store.go @@ -56,6 +56,24 @@ func (s *Store) RenameSession(ctx context.Context, id domain.SessionID, displayN 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 // (no workspace, no runtime handle, no agent session id, no prompt, and not // already terminated). Rows that have observable spawn output are immutable @@ -188,6 +206,7 @@ func rowToRecord(row gen.Session) domain.SessionRecord { RuntimeHandleID: row.RuntimeHandleID, AgentSessionID: row.AgentSessionID, Prompt: row.Prompt, + PreviewURL: row.PreviewURL, }, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, @@ -213,6 +232,7 @@ func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams RuntimeHandleID: rec.Metadata.RuntimeHandleID, AgentSessionID: rec.Metadata.AgentSessionID, Prompt: rec.Metadata.Prompt, + PreviewURL: rec.Metadata.PreviewURL, CreatedAt: rec.CreatedAt, UpdatedAt: rec.UpdatedAt, } @@ -235,6 +255,7 @@ func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { RuntimeHandleID: rec.Metadata.RuntimeHandleID, AgentSessionID: rec.Metadata.AgentSessionID, Prompt: rec.Metadata.Prompt, + PreviewURL: rec.Metadata.PreviewURL, UpdatedAt: rec.UpdatedAt, } } diff --git a/docs/cli/README.md b/docs/cli/README.md index f874e33aa..64d8a038c 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -41,8 +41,14 @@ Every product command resolves to a daemon HTTP route. Run `ao | `ao session claim-pr ` | `POST /api/v1/sessions/{id}/pr/claim` | | `ao orchestrator ls` | `GET /api/v1/orchestrators` | | `ao send` | `POST /api/v1/sessions/{id}/send` | +| `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` | | `ao hooks ` | `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. PR and review actions (merge, resolve-comments, review execute/send) are diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index e441d4a37..8c984a4af 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -75,6 +75,7 @@ const config: ForgeConfig = { build: [ { entry: "src/main.ts", config: "vite.main.config.ts", target: "main" }, { 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" }], }), diff --git a/frontend/src/annotate-preload.ts b/frontend/src/annotate-preload.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/frontend/src/annotate-preload.ts @@ -0,0 +1 @@ +export {}; diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 91066f986..9f895e642 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -315,6 +315,41 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -489,6 +524,7 @@ export interface components { isTerminated: boolean; issueId?: string; kind: string; + previewUrl?: string; projectId: string; prs: components["schemas"]["SessionPRFacts"][]; /** @enum {string} */ @@ -759,6 +795,11 @@ export interface components { links: components["schemas"]["SessionPRReviewCommentLink"][]; reviewerId: string; }; + SessionPreviewResponse: { + entry?: string; + previewUrl?: string; + sessionId: string; + }; SessionResponse: { session: components["schemas"]["ControllersSessionView"]; }; @@ -777,6 +818,10 @@ export interface components { SetProjectConfigInput: { 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: { clean?: boolean; 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: { parameters: { query?: never; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e2a80e365..4c2aeaf59 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,6 +8,7 @@ import { Notification as ElectronNotification, protocol, shell, + WebContentsView, type OpenDialogOptions, } from "electron"; import { updateElectronApp } from "update-electron-app"; @@ -22,6 +23,7 @@ import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./sha import type { DaemonStatus } from "./shared/daemon-status"; import { DEFAULT_POSTHOG_HOST, DEFAULT_POSTHOG_PROJECT_KEY } from "./shared/posthog-config"; import { buildTelemetryBootstrap } from "./shared/telemetry"; +import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view-host"; // Globals injected at compile time by @electron-forge/plugin-vite. declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined; @@ -44,6 +46,7 @@ let daemonStoppingProcess: ChildProcessWithoutNullStreams | null = null; let daemonStartPromise: Promise | null = null; let daemonStartEpoch = 0; let daemonStatus: DaemonStatus = { state: "stopped" }; +let browserViewHost: BrowserViewHost | null = null; const isDev = !app.isPackaged; @@ -100,6 +103,10 @@ function preloadPath(): string { 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 // uses the .app bundle's .icns instead. Packaged: shipped via extraResource to // resources/icon.png; dev: the source asset under frontend/assets. @@ -116,6 +123,8 @@ function setDaemonStatus(nextStatus: DaemonStatus): void { } function createWindow(): void { + browserViewHost?.dispose(); + browserViewHost = null; mainWindow = new BrowserWindow({ width: 1320, height: 860, @@ -154,6 +163,15 @@ function createWindow(): void { } }); + browserViewHost = createBrowserViewHost({ + mainWindow, + ipcMain, + shell, + WebContentsView, + annotatePreloadPath: annotatePreloadPath(), + rendererOrigin: RENDERER_ORIGIN, + }); + void mainWindow.loadURL(rendererUrl()); if (isDev && process.env.AO_OPEN_DEVTOOLS === "1") { @@ -163,6 +181,8 @@ function createWindow(): void { } mainWindow.on("closed", () => { + browserViewHost?.dispose(); + browserViewHost = null; mainWindow = null; }); } @@ -598,6 +618,8 @@ app.whenReady().then(() => { }); app.on("before-quit", () => { + browserViewHost?.dispose(); + browserViewHost = null; if (daemonProcess) { killDaemon(daemonProcess); } diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts new file mode 100644 index 000000000..7efff289d --- /dev/null +++ b/frontend/src/main/browser-view-host.test.ts @@ -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, + }); + }); +}); diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts new file mode 100644 index 000000000..d49b5a069 --- /dev/null +++ b/frontend/src/main/browser-view-host.ts @@ -0,0 +1,315 @@ +import type { IpcMain, IpcMainEvent, IpcMainInvokeEvent, Rectangle, View, WebContents } from "electron"; + +export type BrowserRect = Pick; + +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; +}; + +type ShellLike = { + openExternal: (url: string) => Promise; +}; + +type WebContentsViewConstructor = new (options: { webPreferences: Electron.WebPreferences }) => BrowserViewLike; + +export type BrowserViewHostOptions = { + mainWindow: BrowserWindowLike; + ipcMain: Pick; + 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 { + 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(); + 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 => { + 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 = ( + channel: string, + fn: (event: IpcMainInvokeEvent, ...args: Args) => Result, + ): void => { + options.ipcMain.handle(channel, fn); + ipcDisposers.push(() => options.ipcMain.removeHandler(channel)); + }; + const on = (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(), + }; +} diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index 09e7ac29f..986eb651e 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -1,7 +1,19 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { BrowserNavState, BrowserRect } from "./main/browser-view-host"; import type { DaemonStatus } from "./shared/daemon-status"; import type { TelemetryBootstrap } from "./shared/telemetry"; +export type BrowserBoundsInput = { + viewId: string; + rect: BrowserRect; + visible: boolean; +}; + +export type BrowserNavigateInput = { + viewId: string; + url: string; +}; + const api = { app: { getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise, @@ -26,6 +38,24 @@ const api = { telemetry: { getBootstrap: () => ipcRenderer.invoke("telemetry:getBootstrap") as Promise, }, + browser: { + ensure: (sessionId: string) => ipcRenderer.invoke("browser:ensure", sessionId) as Promise, + setBounds: (input: BrowserBoundsInput) => ipcRenderer.send("browser:setBounds", input), + navigate: (input: BrowserNavigateInput) => + ipcRenderer.invoke("browser:navigate", input) as Promise, + goBack: (viewId: string) => ipcRenderer.invoke("browser:goBack", viewId) as Promise, + goForward: (viewId: string) => ipcRenderer.invoke("browser:goForward", viewId) as Promise, + reload: (viewId: string) => ipcRenderer.invoke("browser:reload", viewId) as Promise, + stop: (viewId: string) => ipcRenderer.invoke("browser:stop", viewId) as Promise, + 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: { show: (notification: { id: string; title: string; body?: string }) => ipcRenderer.invoke("notifications:show", notification) as Promise, diff --git a/frontend/src/renderer/components/BrowserPanel.test.tsx b/frontend/src/renderer/components/BrowserPanel.test.tsx new file mode 100644 index 000000000..a11befafb --- /dev/null +++ b/frontend/src/renderer/components/BrowserPanel.test.tsx @@ -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( 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( + 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( 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( 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(); + + await userEvent.click(screen.getByRole("button", { name: /pop out/i })); + + expect(onTogglePopOut).toHaveBeenCalledWith(true); + }); +}); diff --git a/frontend/src/renderer/components/BrowserPanel.tsx b/frontend/src/renderer/components/BrowserPanel.tsx new file mode 100644 index 000000000..3cb6580e4 --- /dev/null +++ b/frontend/src/renderer/components/BrowserPanel.tsx @@ -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 ( + + ); +} + +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) => { + event.preventDefault(); + const nextURL = urlInput.trim(); + if (nextURL) void navigate(nextURL); + }; + + return ( +
+
+ + + +
+
+ +
+
+
+ {navState.url === "" ? ( +
+

Enter a dev-server URL to preview it here.

+
+ ) : null} + {navState.error ?

{navState.error}

: null} +
+
+ ); +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 99569a81e..9ecb1cbaa 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -136,7 +136,7 @@ describe("ProjectSettingsForm", () => { }, }); expect(await screen.findByText("Saved.")).toBeInTheDocument(); - }, 10_000); + }, 20_000); it("shows the daemon validation message when save fails", async () => { getMock.mockResolvedValue({ diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index efcb2b9d2..83464c435 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -18,7 +18,10 @@ import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSession import { prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display"; import type { SessionStatus, WorkspaceSession } 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 { Button } from "./ui/button"; import { cn } from "../lib/utils"; import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay"; @@ -27,7 +30,7 @@ type ReviewRun = components["schemas"]["ReviewRun"]; type ReviewsResponse = components["schemas"]["ListReviewsResponse"]; 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 }[] = [ { @@ -79,11 +82,29 @@ const prStateTone: Record = { export function SessionInspector({ session, onOpenReviewerTerminal, + browserPoppedOut = false, + isInspectorVisible = true, + onToggleBrowserPopOut, + browserView, + view: viewProp, + onViewChange, }: { session?: WorkspaceSession; 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("summary"); + const [internalView, setInternalView] = useState("summary"); + const view = viewProp ?? internalView; + const setView = (next: InspectorView) => { + setInternalView(next); + onViewChange?.(next); + }; if (!session) { return ( @@ -116,7 +137,15 @@ export function SessionInspector({
{view === "summary" ? : null} {view === "reviews" ? : null} - {view === "browser" ? : null} + {view === "browser" ? ( + + ) : null}
); @@ -563,19 +592,44 @@ function reviewStatus(review?: ReviewRun): { return { label: "Complete", tone: "success", icon: