From c6d9692d37805b2d36b7c83f8af089deab4704e2 Mon Sep 17 00:00:00 2001 From: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> Date: Mon, 22 Jun 2026 04:14:18 +0530 Subject: [PATCH] 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 * 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 * 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 * docs: instruct agents to `ao preview` when showing frontend changes Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Vaibhaav Co-authored-by: github-actions[bot] Co-authored-by: Harshit Singh Bhandari Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 2 + CLAUDE.md | 4 + README.md | 51 +-- backend/internal/cli/dto_drift_e2e_test.go | 4 + backend/internal/cli/preview.go | 47 +++ backend/internal/cli/preview_test.go | 133 ++++++++ backend/internal/cli/root.go | 1 + backend/internal/domain/session.go | 4 + backend/internal/httpd/apispec/openapi.yaml | 148 ++++++++ .../internal/httpd/apispec/specgen/build.go | 38 +++ backend/internal/httpd/controllers/dto.go | 22 +- .../internal/httpd/controllers/sessions.go | 142 +++++++- .../httpd/controllers/sessions_test.go | 138 +++++++- backend/internal/service/session/service.go | 18 + .../internal/service/session/service_test.go | 34 ++ backend/internal/storage/sqlite/gen/models.go | 1 + .../storage/sqlite/gen/sessions.sql.go | 37 +- .../0017_add_session_preview_url.sql | 52 +++ .../storage/sqlite/queries/sessions.sql | 15 +- .../storage/sqlite/store/session_store.go | 21 ++ docs/cli/README.md | 6 + frontend/forge.config.ts | 1 + frontend/src/annotate-preload.ts | 1 + frontend/src/api/schema.ts | 208 ++++++++++++ frontend/src/main.ts | 22 ++ frontend/src/main/browser-view-host.test.ts | 51 +++ frontend/src/main/browser-view-host.ts | 315 ++++++++++++++++++ frontend/src/preload.ts | 30 ++ .../renderer/components/BrowserPanel.test.tsx | 130 ++++++++ .../src/renderer/components/BrowserPanel.tsx | 122 +++++++ .../components/ProjectSettingsForm.test.tsx | 2 +- .../renderer/components/SessionInspector.tsx | 84 ++++- .../renderer/components/SessionView.test.tsx | 77 ++++- .../src/renderer/components/SessionView.tsx | 62 +++- .../renderer/hooks/useBrowserView.test.tsx | 180 ++++++++++ frontend/src/renderer/hooks/useBrowserView.ts | 203 +++++++++++ .../src/renderer/hooks/useWorkspaceQuery.ts | 1 + frontend/src/renderer/lib/bridge.ts | 53 +++ frontend/src/renderer/styles.css | 88 +++++ frontend/src/renderer/test/setup.ts | 53 +++ frontend/src/renderer/types/workspace.ts | 5 + frontend/vite.renderer.config.ts | 1 + 42 files changed, 2538 insertions(+), 69 deletions(-) create mode 100644 backend/internal/cli/preview.go create mode 100644 backend/internal/cli/preview_test.go create mode 100644 backend/internal/storage/sqlite/migrations/0017_add_session_preview_url.sql create mode 100644 frontend/src/annotate-preload.ts create mode 100644 frontend/src/main/browser-view-host.test.ts create mode 100644 frontend/src/main/browser-view-host.ts create mode 100644 frontend/src/renderer/components/BrowserPanel.test.tsx create mode 100644 frontend/src/renderer/components/BrowserPanel.tsx create mode 100644 frontend/src/renderer/hooks/useBrowserView.test.tsx create mode 100644 frontend/src/renderer/hooks/useBrowserView.ts 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: