feat(frontend): add live browser panel (#375)
* feat(frontend): add live browser panel * chore: format with prettier [skip ci] * feat: preserve and auto-open browser previews * fix: retry browser preview after session updates * fix: wait for browser view before preview navigation * fix: reopen preview after session switches * fix: preserve browser views across session switches * chore: format with prettier [skip ci] * feat: add `ao preview` command to drive the session browser panel Replaces the browser panel's auto-detect with an explicit, session-scoped command. `ao preview [url]` runs inside a session (derives the target from AO_SESSION_ID; rejects when unset or when the session is unknown): - with a url, opens it verbatim (file://, http, https; no sanitization for now) - with no url, autodetects index.html in the workspace as before The resolved target is persisted as a new `previewUrl` session field and fans out over the existing CDC /events stream (the sessions update trigger now fires on preview_url and carries previewUrl in its payload). The desktop browser panel reflects session.previewUrl: it opens, switches the center pane to the browser, and navigates, re-navigating only when the target changes. ponytail: file:// preview targets are accepted unsanitized; agent-trusted for now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(cli): document the `ao preview` command Add `ao preview` to the CLI command tables in README.md and docs/cli/README.md, noting it resolves its session from AO_SESSION_ID and its no-arg autodetect vs explicit-URL behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] * fix(frontend): reveal `ao preview` in the inspector Browser tab, not the center pane `ao preview` set session.previewUrl, and SessionView surfaced it by popping the browser into the center pane, replacing the terminal. Reveal it in the inspector rail's Browser tab instead (opening the rail if it is collapsed); the manual pop-out button still expands it to the center. Lifts the inspector's active tab to an optional controlled prop so SessionView can drive it, and adds a regression test asserting the center pane keeps the terminal while the rail switches to Browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: instruct agents to `ao preview` when showing frontend changes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Vaibhaav <user@example.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Harshit Singh Bhandari <dev@theharshitsingh.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7ba860741c
commit
c6d9692d37
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ route. Run `ao <command> --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`. |
|
||||
|
|
@ -149,6 +149,7 @@ below groups what's on `main` today.
|
|||
| Session | `ao session claim-pr <session> <pr>` | Attach an existing PR to a session. |
|
||||
| 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 <shell>` | Generate bash/zsh/fish/powershell completions. |
|
||||
| Utility | `ao version` | Print build metadata. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ type CleanupSessionsQuery struct {
|
|||
type SessionView struct {
|
||||
domain.Session
|
||||
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"`
|
||||
}
|
||||
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(`<link rel="stylesheet" href="styles.css"><script src="app.js"></script>`), 0o644); err != nil {
|
||||
t.Fatalf("write index: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, "styles.css"), []byte(`body { color: red; }`), 0o644); err != nil {
|
||||
t.Fatalf("write css: %v", err)
|
||||
}
|
||||
s := svc.sessions["ao-1"]
|
||||
s.Metadata = domain.SessionMetadata{WorkspacePath: workspace}
|
||||
svc.sessions["ao-1"] = s
|
||||
srv := newSessionTestServer(t, svc)
|
||||
|
||||
body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions/ao-1/preview", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("preview = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
var preview struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
Entry string `json:"entry"`
|
||||
}
|
||||
mustJSON(t, body, &preview)
|
||||
if preview.SessionID != "ao-1" || preview.Entry != "index.html" || preview.PreviewURL == "" {
|
||||
t.Fatalf("preview response = %#v", preview)
|
||||
}
|
||||
if strings.Contains(preview.PreviewURL, workspace) {
|
||||
t.Fatalf("preview leaked workspace path: %s", preview.PreviewURL)
|
||||
}
|
||||
if !strings.Contains(preview.PreviewURL, "/index.html") {
|
||||
t.Fatalf("preview URL = %q, want index.html asset path", preview.PreviewURL)
|
||||
}
|
||||
parsed, err := url.Parse(preview.PreviewURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse preview URL: %v", err)
|
||||
}
|
||||
body, status, headers := doRequest(t, srv, "GET", parsed.RequestURI(), "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("preview file = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
if !strings.Contains(headers.Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("content type = %q, want text/html", headers.Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(string(body), "styles.css") {
|
||||
t.Fatalf("preview body did not serve index: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsAPI_SetPreviewExplicitURLPersists(t *testing.T) {
|
||||
svc := newFakeSessionService()
|
||||
srv := newSessionTestServer(t, svc)
|
||||
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":"http://localhost:5173/"}`)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
var resp struct {
|
||||
Session struct {
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
} `json:"session"`
|
||||
}
|
||||
mustJSON(t, body, &resp)
|
||||
if resp.Session.PreviewURL != "http://localhost:5173/" {
|
||||
t.Fatalf("response previewUrl = %q, want explicit url", resp.Session.PreviewURL)
|
||||
}
|
||||
if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "http://localhost:5173/" {
|
||||
t.Fatalf("persisted previewUrl = %q, want explicit url", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsAPI_SetPreviewEmptyURLAutodetectsIndex(t *testing.T) {
|
||||
svc := newFakeSessionService()
|
||||
workspace := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(`<html></html>`), 0o644); err != nil {
|
||||
t.Fatalf("write index: %v", err)
|
||||
}
|
||||
s := svc.sessions["ao-1"]
|
||||
s.Metadata = domain.SessionMetadata{WorkspacePath: workspace}
|
||||
svc.sessions["ao-1"] = s
|
||||
srv := newSessionTestServer(t, svc)
|
||||
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
|
||||
}
|
||||
var resp struct {
|
||||
Session struct {
|
||||
PreviewURL string `json:"previewUrl"`
|
||||
} `json:"session"`
|
||||
}
|
||||
mustJSON(t, body, &resp)
|
||||
if !strings.Contains(resp.Session.PreviewURL, "/index.html") {
|
||||
t.Fatalf("response previewUrl = %q, want autodetected index.html URL", resp.Session.PreviewURL)
|
||||
}
|
||||
if strings.Contains(resp.Session.PreviewURL, workspace) {
|
||||
t.Fatalf("preview leaked workspace path: %s", resp.Session.PreviewURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsAPI_SetPreviewEmptyURLNoEntry(t *testing.T) {
|
||||
svc := newFakeSessionService()
|
||||
s := svc.sessions["ao-1"]
|
||||
s.Metadata = domain.SessionMetadata{WorkspacePath: t.TempDir()}
|
||||
svc.sessions["ao-1"] = s
|
||||
srv := newSessionTestServer(t, svc)
|
||||
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
|
||||
assertErrorCode(t, body, status, http.StatusNotFound, "NO_PREVIEW_ENTRY")
|
||||
}
|
||||
|
||||
func TestSessionsAPI_SetPreviewNotFound(t *testing.T) {
|
||||
srv := newSessionTestServer(t, newFakeSessionService())
|
||||
|
||||
body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/missing-1/preview", `{"url":"http://x"}`)
|
||||
assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) {
|
||||
svc := newFakeSessionService()
|
||||
svc.spawnErr = apierr.Invalid("BRANCH_NOT_FETCHED", `workspace: branch is not fetched: "feature/missing"`, nil)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ type Session struct {
|
|||
UpdatedAt time.Time
|
||||
DisplayName string
|
||||
FirstSignalAt sql.NullTime
|
||||
PreviewURL string
|
||||
}
|
||||
|
||||
type SessionWorktree struct {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,14 @@ Every product command resolves to a daemon HTTP route. Run `ao <command>
|
|||
| `ao session claim-pr <id> <pr-ref>` | `POST /api/v1/sessions/{id}/pr/claim` |
|
||||
| `ao 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 <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) |
|
||||
|
||||
`ao preview` resolves its session from the `AO_SESSION_ID` environment variable
|
||||
(it is meant to run inside a session), not a flag. With no argument it
|
||||
autodetects an `index.html` in the session workspace; with a URL argument it
|
||||
opens that URL verbatim (`file://`, `http`, `https`).
|
||||
|
||||
`go run .` in `backend/` remains a compatibility wrapper around the daemon.
|
||||
|
||||
PR and review actions (merge, resolve-comments, review execute/send) are
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<DaemonStatus> | 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
import type { IpcMain, IpcMainEvent, IpcMainInvokeEvent, Rectangle, View, WebContents } from "electron";
|
||||
|
||||
export type BrowserRect = Pick<Rectangle, "x" | "y" | "width" | "height">;
|
||||
|
||||
export type BrowserNavState = {
|
||||
viewId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
isLoading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type BrowserBoundsInput = {
|
||||
viewId: string;
|
||||
rect: BrowserRect;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
type BrowserNavigateInput = {
|
||||
viewId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type BrowserWebContents = Pick<
|
||||
WebContents,
|
||||
| "canGoBack"
|
||||
| "canGoForward"
|
||||
| "getTitle"
|
||||
| "getURL"
|
||||
| "goBack"
|
||||
| "goForward"
|
||||
| "isLoading"
|
||||
| "loadURL"
|
||||
| "on"
|
||||
| "reload"
|
||||
| "send"
|
||||
| "setWindowOpenHandler"
|
||||
| "stop"
|
||||
> & {
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
type BrowserViewLike = View & {
|
||||
webContents: BrowserWebContents;
|
||||
setBounds: (bounds: BrowserRect) => void;
|
||||
setVisible?: (visible: boolean) => void;
|
||||
};
|
||||
|
||||
type BrowserWindowLike = {
|
||||
contentView: {
|
||||
addChildView: (view: BrowserViewLike) => void;
|
||||
removeChildView?: (view: BrowserViewLike) => void;
|
||||
};
|
||||
getContentBounds: () => BrowserRect;
|
||||
webContents: Pick<WebContents, "id" | "send">;
|
||||
};
|
||||
|
||||
type ShellLike = {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type WebContentsViewConstructor = new (options: { webPreferences: Electron.WebPreferences }) => BrowserViewLike;
|
||||
|
||||
export type BrowserViewHostOptions = {
|
||||
mainWindow: BrowserWindowLike;
|
||||
ipcMain: Pick<IpcMain, "handle" | "on" | "removeHandler" | "off">;
|
||||
shell: ShellLike;
|
||||
WebContentsView: WebContentsViewConstructor;
|
||||
annotatePreloadPath: string;
|
||||
rendererOrigin: string;
|
||||
};
|
||||
|
||||
export type BrowserViewHost = {
|
||||
dispose: () => void;
|
||||
destroy: (viewId: string) => void;
|
||||
destroyAll: () => void;
|
||||
};
|
||||
|
||||
type BrowserEntry = {
|
||||
view: BrowserViewLike;
|
||||
state: BrowserNavState;
|
||||
};
|
||||
|
||||
const OFFSCREEN_BOUNDS: BrowserRect = { x: -10_000, y: -10_000, width: 0, height: 0 };
|
||||
// ponytail: file:// allowed unsanitized; preview targets are agent-trusted for now
|
||||
const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "file:"]);
|
||||
|
||||
export function normalizeBrowserURL(input: string): URL {
|
||||
const raw = input.trim();
|
||||
if (raw === "") {
|
||||
throw new Error("URL is required");
|
||||
}
|
||||
const candidate = withDefaultScheme(raw);
|
||||
const url = new URL(candidate);
|
||||
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||
throw new Error(`Unsupported browser URL scheme: ${url.protocol}`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function isAllowedBrowserURL(input: string, rendererOrigin?: string): boolean {
|
||||
try {
|
||||
const url = normalizeBrowserURL(input);
|
||||
if (rendererOrigin && url.origin === rendererOrigin) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clampBoundsToWindow(
|
||||
rect: BrowserRect,
|
||||
windowBounds: Pick<BrowserRect, "width" | "height">,
|
||||
): BrowserRect {
|
||||
const rounded = {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.max(0, Math.round(rect.width)),
|
||||
height: Math.max(0, Math.round(rect.height)),
|
||||
};
|
||||
const maxX = Math.max(0, Math.round(windowBounds.width));
|
||||
const maxY = Math.max(0, Math.round(windowBounds.height));
|
||||
const x = Math.min(Math.max(rounded.x, 0), maxX);
|
||||
const y = Math.min(Math.max(rounded.y, 0), maxY);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.min(rounded.width, Math.max(0, maxX - x)),
|
||||
height: Math.min(rounded.height, Math.max(0, maxY - y)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserViewHost {
|
||||
const entries = new Map<string, BrowserEntry>();
|
||||
const ipcDisposers: Array<() => void> = [];
|
||||
|
||||
const ensure = (viewId: string): BrowserEntry => {
|
||||
const existing = entries.get(viewId);
|
||||
if (existing) return existing;
|
||||
|
||||
const view = new options.WebContentsView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: options.annotatePreloadPath,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
view.setBounds(OFFSCREEN_BOUNDS);
|
||||
view.setVisible?.(false);
|
||||
options.mainWindow.contentView.addChildView(view);
|
||||
|
||||
const state: BrowserNavState = emptyNavState(viewId);
|
||||
const entry = { view, state };
|
||||
entries.set(viewId, entry);
|
||||
hardenWebContents(view.webContents, options, entry);
|
||||
wireNavEvents(view.webContents, options, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const setBounds = ({ viewId, rect, visible }: BrowserBoundsInput): void => {
|
||||
const entry = entries.get(viewId);
|
||||
if (!entry) return;
|
||||
if (!visible) {
|
||||
entry.view.setVisible?.(false);
|
||||
entry.view.setBounds(OFFSCREEN_BOUNDS);
|
||||
return;
|
||||
}
|
||||
const bounds = clampBoundsToWindow(rect, options.mainWindow.getContentBounds());
|
||||
entry.view.setBounds(bounds);
|
||||
entry.view.setVisible?.(bounds.width > 0 && bounds.height > 0);
|
||||
};
|
||||
|
||||
const navigate = async ({ viewId, url }: BrowserNavigateInput): Promise<BrowserNavState> => {
|
||||
const entry = ensure(viewId);
|
||||
const normalized = normalizeBrowserURL(url);
|
||||
if (!isAllowedBrowserURL(normalized.href, options.rendererOrigin)) {
|
||||
throw new Error("Unsupported browser URL");
|
||||
}
|
||||
await entry.view.webContents.loadURL(normalized.href);
|
||||
return pushNavState(options, entry);
|
||||
};
|
||||
|
||||
const destroy = (viewId: string): void => {
|
||||
const entry = entries.get(viewId);
|
||||
if (!entry) return;
|
||||
entries.delete(viewId);
|
||||
entry.view.setVisible?.(false);
|
||||
entry.view.setBounds(OFFSCREEN_BOUNDS);
|
||||
options.mainWindow.contentView.removeChildView?.(entry.view);
|
||||
entry.view.webContents.close?.();
|
||||
};
|
||||
|
||||
const invokeNav = (viewId: string, action: (contents: BrowserWebContents) => void): BrowserNavState => {
|
||||
const entry = entries.get(viewId);
|
||||
if (!entry) return emptyNavState(viewId);
|
||||
action(entry.view.webContents);
|
||||
return pushNavState(options, entry);
|
||||
};
|
||||
|
||||
const handle = <Args extends unknown[], Result>(
|
||||
channel: string,
|
||||
fn: (event: IpcMainInvokeEvent, ...args: Args) => Result,
|
||||
): void => {
|
||||
options.ipcMain.handle(channel, fn);
|
||||
ipcDisposers.push(() => options.ipcMain.removeHandler(channel));
|
||||
};
|
||||
const on = <Args extends unknown[]>(channel: string, fn: (event: IpcMainEvent, ...args: Args) => void): void => {
|
||||
options.ipcMain.on(channel, fn);
|
||||
ipcDisposers.push(() => options.ipcMain.off(channel, fn));
|
||||
};
|
||||
|
||||
handle("browser:ensure", (event, sessionId: string) => pushNavState(options, ensure(scopedViewId(event, sessionId))));
|
||||
on("browser:setBounds", (_event, input: BrowserBoundsInput) => setBounds(input));
|
||||
handle("browser:navigate", (_event, input: BrowserNavigateInput) => navigate(input));
|
||||
handle("browser:goBack", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goBack()));
|
||||
handle("browser:goForward", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.goForward()));
|
||||
handle("browser:reload", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.reload()));
|
||||
handle("browser:stop", (_event, viewId: string) => invokeNav(viewId, (contents) => contents.stop()));
|
||||
on("browser:destroy", (_event, viewId: string) => destroy(viewId));
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
ipcDisposers.splice(0).forEach((dispose) => dispose());
|
||||
for (const viewId of [...entries.keys()]) {
|
||||
destroy(viewId);
|
||||
}
|
||||
},
|
||||
destroy,
|
||||
destroyAll: () => {
|
||||
for (const viewId of [...entries.keys()]) {
|
||||
destroy(viewId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withDefaultScheme(raw: string): string {
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
if (isLocalhostLike(raw)) return `http://${raw}`;
|
||||
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(raw)) return raw;
|
||||
return `https://${raw}`;
|
||||
}
|
||||
|
||||
function isLocalhostLike(raw: string): boolean {
|
||||
return /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(raw);
|
||||
}
|
||||
|
||||
function emptyNavState(viewId: string): BrowserNavState {
|
||||
return {
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
|
||||
function scopedViewId(event: IpcMainInvokeEvent, sessionId: string): string {
|
||||
return `${event.sender.id}:${sessionId}`;
|
||||
}
|
||||
|
||||
function hardenWebContents(contents: BrowserWebContents, options: BrowserViewHostOptions, entry: BrowserEntry): void {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
if (isAllowedBrowserURL(url, options.rendererOrigin)) {
|
||||
void options.shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
const blockUnsafeNavigation = (event: Electron.Event, url: string) => {
|
||||
if (!isAllowedBrowserURL(url, options.rendererOrigin)) {
|
||||
event.preventDefault();
|
||||
entry.state = { ...entry.state, error: "Unsupported browser URL" };
|
||||
options.mainWindow.webContents.send("browser:navState", entry.state);
|
||||
}
|
||||
};
|
||||
contents.on("will-navigate", blockUnsafeNavigation);
|
||||
contents.on("will-redirect", blockUnsafeNavigation);
|
||||
}
|
||||
|
||||
function wireNavEvents(contents: BrowserWebContents, options: BrowserViewHostOptions, entry: BrowserEntry): void {
|
||||
const update = () => {
|
||||
pushNavState(options, entry);
|
||||
};
|
||||
contents.on("did-navigate", update);
|
||||
contents.on("did-navigate-in-page", update);
|
||||
contents.on("page-title-updated", update);
|
||||
contents.on("did-start-loading", update);
|
||||
contents.on("did-stop-loading", update);
|
||||
contents.on("did-fail-load", (_event, _errorCode, errorDescription) => {
|
||||
entry.state = { ...readNavState(entry), error: String(errorDescription || "Unable to load page") };
|
||||
options.mainWindow.webContents.send("browser:navState", entry.state);
|
||||
});
|
||||
}
|
||||
|
||||
function pushNavState(options: BrowserViewHostOptions, entry: BrowserEntry): BrowserNavState {
|
||||
entry.state = readNavState(entry);
|
||||
options.mainWindow.webContents.send("browser:navState", entry.state);
|
||||
return entry.state;
|
||||
}
|
||||
|
||||
function readNavState(entry: BrowserEntry): BrowserNavState {
|
||||
const { webContents } = entry.view;
|
||||
return {
|
||||
viewId: entry.state.viewId,
|
||||
url: webContents.getURL(),
|
||||
title: webContents.getTitle(),
|
||||
canGoBack: webContents.canGoBack(),
|
||||
canGoForward: webContents.canGoForward(),
|
||||
isLoading: webContents.isLoading(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string>,
|
||||
|
|
@ -26,6 +38,24 @@ const api = {
|
|||
telemetry: {
|
||||
getBootstrap: () => ipcRenderer.invoke("telemetry:getBootstrap") as Promise<TelemetryBootstrap | null>,
|
||||
},
|
||||
browser: {
|
||||
ensure: (sessionId: string) => ipcRenderer.invoke("browser:ensure", sessionId) as Promise<BrowserNavState>,
|
||||
setBounds: (input: BrowserBoundsInput) => ipcRenderer.send("browser:setBounds", input),
|
||||
navigate: (input: BrowserNavigateInput) =>
|
||||
ipcRenderer.invoke("browser:navigate", input) as Promise<BrowserNavState>,
|
||||
goBack: (viewId: string) => ipcRenderer.invoke("browser:goBack", viewId) as Promise<BrowserNavState>,
|
||||
goForward: (viewId: string) => ipcRenderer.invoke("browser:goForward", viewId) as Promise<BrowserNavState>,
|
||||
reload: (viewId: string) => ipcRenderer.invoke("browser:reload", viewId) as Promise<BrowserNavState>,
|
||||
stop: (viewId: string) => ipcRenderer.invoke("browser:stop", viewId) as Promise<BrowserNavState>,
|
||||
destroy: (viewId: string) => ipcRenderer.send("browser:destroy", viewId),
|
||||
onNavState: (listener: (state: BrowserNavState) => void) => {
|
||||
const wrapped = (_event: Electron.IpcRendererEvent, state: BrowserNavState) => listener(state);
|
||||
ipcRenderer.on("browser:navState", wrapped);
|
||||
return () => {
|
||||
ipcRenderer.off("browser:navState", wrapped);
|
||||
};
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
show: (notification: { id: string; title: string; body?: string }) =>
|
||||
ipcRenderer.invoke("notifications:show", notification) as Promise<void>,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BrowserPanel } from "./BrowserPanel";
|
||||
import type { BrowserNavState } from "../hooks/useBrowserView";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
navigate: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
goForward: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
previewUrl: undefined as string | undefined,
|
||||
navState: {
|
||||
viewId: "42:sess-1",
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
} as BrowserNavState,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useBrowserView", () => ({
|
||||
useBrowserView: (options: { previewUrl?: string }) => {
|
||||
hookState.previewUrl = options.previewUrl;
|
||||
return {
|
||||
viewId: "42:sess-1",
|
||||
navState: hookState.navState,
|
||||
slotRef: vi.fn(),
|
||||
navigate: hookState.navigate,
|
||||
goBack: hookState.goBack,
|
||||
goForward: hookState.goForward,
|
||||
reload: hookState.reload,
|
||||
stop: hookState.stop,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const session: WorkspaceSession = {
|
||||
id: "sess-1",
|
||||
workspaceId: "ws-1",
|
||||
workspaceName: "my-app",
|
||||
title: "do the thing",
|
||||
provider: "claude-code",
|
||||
kind: "worker",
|
||||
branch: "feat/ns",
|
||||
status: "working",
|
||||
updatedAt: "2026-06-15T00:00:00Z",
|
||||
prs: [],
|
||||
};
|
||||
|
||||
describe("BrowserPanel", () => {
|
||||
beforeEach(() => {
|
||||
hookState.navigate.mockReset();
|
||||
hookState.goBack.mockReset();
|
||||
hookState.goForward.mockReset();
|
||||
hookState.reload.mockReset();
|
||||
hookState.stop.mockReset();
|
||||
hookState.previewUrl = undefined;
|
||||
hookState.navState = {
|
||||
viewId: "42:sess-1",
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
|
||||
it("navigates to the entered URL on submit", async () => {
|
||||
render(<BrowserPanel active onTogglePopOut={() => undefined} poppedOut={false} session={session} />);
|
||||
const input = screen.getByRole("textbox", { name: /browser url/i });
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "localhost:5173{Enter}");
|
||||
|
||||
expect(hookState.navigate).toHaveBeenCalledWith("localhost:5173");
|
||||
});
|
||||
|
||||
it("threads the session preview URL into the browser view (which drives navigation)", () => {
|
||||
render(
|
||||
<BrowserPanel
|
||||
active
|
||||
onTogglePopOut={() => undefined}
|
||||
poppedOut={false}
|
||||
session={{ ...session, previewUrl: "file:///tmp/preview/index.html" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(hookState.previewUrl).toBe("file:///tmp/preview/index.html");
|
||||
});
|
||||
|
||||
it("binds navigation controls to nav state", async () => {
|
||||
hookState.navState = {
|
||||
viewId: "42:sess-1",
|
||||
url: "http://localhost:5173/",
|
||||
title: "Local app",
|
||||
canGoBack: true,
|
||||
canGoForward: false,
|
||||
isLoading: true,
|
||||
};
|
||||
render(<BrowserPanel active onTogglePopOut={() => undefined} poppedOut={false} session={session} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /back/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /stop/i }));
|
||||
|
||||
expect(hookState.goBack).toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: /forward/i })).toBeDisabled();
|
||||
expect(hookState.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows empty and error states", () => {
|
||||
hookState.navState = { ...hookState.navState, error: "Connection refused" };
|
||||
render(<BrowserPanel active onTogglePopOut={() => undefined} poppedOut={false} session={session} />);
|
||||
|
||||
expect(screen.getByText("Enter a dev-server URL to preview it here.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Connection refused")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles pop-out mode", async () => {
|
||||
const onTogglePopOut = vi.fn();
|
||||
render(<BrowserPanel active onTogglePopOut={onTogglePopOut} poppedOut={false} session={session} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /pop out/i }));
|
||||
|
||||
expect(onTogglePopOut).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { ArrowLeft, ArrowRight, Globe2, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
|
||||
import { useBrowserView, type BrowserViewModel } from "../hooks/useBrowserView";
|
||||
import type { WorkspaceSession } from "../types/workspace";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
type BrowserPanelProps = {
|
||||
session: WorkspaceSession;
|
||||
active: boolean;
|
||||
poppedOut: boolean;
|
||||
onTogglePopOut: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export function BrowserPanel({ session, active, poppedOut, onTogglePopOut }: BrowserPanelProps) {
|
||||
const browserView = useBrowserView({
|
||||
sessionId: session.id,
|
||||
active,
|
||||
poppedOut,
|
||||
previewUrl: session.previewUrl,
|
||||
});
|
||||
return (
|
||||
<BrowserPanelView
|
||||
active={active}
|
||||
browserView={browserView}
|
||||
onTogglePopOut={onTogglePopOut}
|
||||
poppedOut={poppedOut}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowserPanelView({
|
||||
poppedOut,
|
||||
onTogglePopOut,
|
||||
browserView,
|
||||
}: BrowserPanelProps & { browserView: BrowserViewModel }) {
|
||||
const { navState, slotRef, navigate, goBack, goForward, reload, stop } = browserView;
|
||||
const [urlInput, setUrlInput] = useState(navState.url);
|
||||
|
||||
useEffect(() => {
|
||||
setUrlInput(navState.url);
|
||||
}, [navState.url]);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const nextURL = urlInput.trim();
|
||||
if (nextURL) void navigate(nextURL);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="browser-panel" role="tabpanel">
|
||||
<form className="browser-panel__toolbar" onSubmit={submit}>
|
||||
<Button
|
||||
aria-label="Back"
|
||||
disabled={!navState.canGoBack}
|
||||
onClick={() => void goBack()}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Forward"
|
||||
disabled={!navState.canGoForward}
|
||||
onClick={() => void goForward()}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowRight aria-hidden="true" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={navState.isLoading ? "Stop" : "Reload"}
|
||||
onClick={() => void (navState.isLoading ? stop() : reload())}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{navState.isLoading ? (
|
||||
<X aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<RefreshCw aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="browser-panel__url">
|
||||
<Globe2 aria-hidden="true" className="browser-panel__url-icon" />
|
||||
<Input
|
||||
aria-label="Browser URL"
|
||||
className="browser-panel__url-input"
|
||||
onChange={(event) => setUrlInput(event.target.value)}
|
||||
placeholder="localhost:5173"
|
||||
value={urlInput}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={poppedOut ? "Return to panel" : "Pop out"}
|
||||
onClick={() => onTogglePopOut(!poppedOut)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{poppedOut ? (
|
||||
<Minimize2 aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="browser-panel__content">
|
||||
<div className="browser-panel__slot" ref={slotRef} />
|
||||
{navState.url === "" ? (
|
||||
<div className="browser-panel__overlay">
|
||||
<p>Enter a dev-server URL to preview it here.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{navState.error ? <p className="browser-panel__error">{navState.error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<SessionPRSummary["state"], string> = {
|
|||
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<InspectorView>("summary");
|
||||
const [internalView, setInternalView] = useState<InspectorView>("summary");
|
||||
const view = viewProp ?? internalView;
|
||||
const setView = (next: InspectorView) => {
|
||||
setInternalView(next);
|
||||
onViewChange?.(next);
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
|
|
@ -116,7 +137,15 @@ export function SessionInspector({
|
|||
<div className="session-inspector__body">
|
||||
{view === "summary" ? <SummaryView session={session} /> : null}
|
||||
{view === "reviews" ? <ReviewsView onOpenReviewerTerminal={onOpenReviewerTerminal} session={session} /> : null}
|
||||
{view === "browser" ? <BrowserView /> : null}
|
||||
{view === "browser" ? (
|
||||
<BrowserView
|
||||
browserPoppedOut={browserPoppedOut}
|
||||
browserView={browserView}
|
||||
isActive={isInspectorVisible && !browserPoppedOut}
|
||||
onTogglePopOut={onToggleBrowserPopOut}
|
||||
session={session}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
@ -563,22 +592,47 @@ function reviewStatus(review?: ReviewRun): {
|
|||
return { label: "Complete", tone: "success", icon: <CheckCircle2 aria-hidden="true" /> };
|
||||
}
|
||||
|
||||
function BrowserView() {
|
||||
function BrowserView({
|
||||
session,
|
||||
isActive,
|
||||
browserPoppedOut,
|
||||
onTogglePopOut,
|
||||
browserView,
|
||||
}: {
|
||||
session: WorkspaceSession;
|
||||
isActive: boolean;
|
||||
browserPoppedOut: boolean;
|
||||
onTogglePopOut?: (next: boolean) => void;
|
||||
browserView?: BrowserViewModel;
|
||||
}) {
|
||||
if (browserPoppedOut) {
|
||||
return (
|
||||
<div role="tabpanel">
|
||||
<div className="inspector-empty inspector-empty--browser">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
|
||||
</svg>
|
||||
<p>No live browser preview.</p>
|
||||
<span>A browser plugin will render what the agent is viewing here.</span>
|
||||
<p>Browser preview is in the center pane.</p>
|
||||
<Button onClick={() => onTogglePopOut?.(false)} size="sm" type="button" variant="outline">
|
||||
Return to panel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!browserView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserPanelView
|
||||
active={isActive}
|
||||
browserView={browserView}
|
||||
onTogglePopOut={(next) => onTogglePopOut?.(next)}
|
||||
poppedOut={false}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="inspector-kv__row">
|
||||
|
|
|
|||
|
|
@ -45,8 +45,48 @@ const { workspaces, panels } = vi.hoisted(() => {
|
|||
|
||||
// The terminal and inspector body pull in xterm/SSE machinery irrelevant to
|
||||
// the split under test. (The topbar is shell-owned — see ShellTopbar.)
|
||||
vi.mock("./CenterPane", () => ({ CenterPane: () => <div /> }));
|
||||
vi.mock("./SessionInspector", () => ({ SessionInspector: () => <div /> }));
|
||||
vi.mock("./CenterPane", () => ({ CenterPane: () => <div>terminal center</div> }));
|
||||
vi.mock("./BrowserPanel", () => ({
|
||||
BrowserPanelView: ({
|
||||
poppedOut,
|
||||
onTogglePopOut,
|
||||
}: {
|
||||
poppedOut: boolean;
|
||||
onTogglePopOut: (next: boolean) => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onTogglePopOut(!poppedOut)}>
|
||||
{poppedOut ? "browser center" : "browser rail"}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
const browserDestroy = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../hooks/useBrowserView", () => ({
|
||||
useBrowserView: () => ({
|
||||
viewId: "browser:sess-1",
|
||||
navState: {
|
||||
viewId: "browser:sess-1",
|
||||
url: "http://127.0.0.1:4173/",
|
||||
title: "Calculator",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
},
|
||||
slotRef: vi.fn(),
|
||||
navigate: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
goForward: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
destroy: browserDestroy,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./SessionInspector", () => ({
|
||||
SessionInspector: ({ onToggleBrowserPopOut, view }: { onToggleBrowserPopOut?: () => void; view?: string }) => (
|
||||
<button type="button" data-view={view} onClick={onToggleBrowserPopOut}>
|
||||
pop browser
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("../lib/shell-context", () => ({
|
||||
useShell: () => ({ daemonStatus: { state: "ready" } }),
|
||||
}));
|
||||
|
|
@ -129,6 +169,7 @@ describe("SessionView", () => {
|
|||
window.localStorage.clear();
|
||||
useUiStore.setState({ isInspectorOpen: true });
|
||||
panels.clear();
|
||||
browserDestroy.mockReset();
|
||||
});
|
||||
|
||||
// Regression: react-resizable-panels v4 treats bare numeric sizes as PIXELS
|
||||
|
|
@ -259,4 +300,36 @@ describe("SessionView", () => {
|
|||
fireEvent.keyDown(window, { key: "B", metaKey: true, shiftKey: true });
|
||||
expect(useUiStore.getState().isInspectorOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the browser take over the center pane and return to the rail", () => {
|
||||
render(<SessionView sessionId="sess-1" />);
|
||||
|
||||
expect(screen.getByText("terminal center")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "pop browser" }));
|
||||
|
||||
expect(screen.queryByText("terminal center")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "browser center" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "browser center" }));
|
||||
expect(screen.getByText("terminal center")).toBeInTheDocument();
|
||||
expect(browserDestroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reveals an `ao preview` URL in the inspector Browser tab, not the center pane", () => {
|
||||
const worker = workspaces[0].sessions[0];
|
||||
worker.previewUrl = "http://localhost:5173/";
|
||||
try {
|
||||
useUiStore.setState({ isInspectorOpen: false });
|
||||
render(<SessionView sessionId="sess-1" />);
|
||||
|
||||
// Center pane keeps the terminal — the preview must not pop out over it.
|
||||
expect(screen.getByText("terminal center")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "browser center" })).not.toBeInTheDocument();
|
||||
// Rail opened and switched to the Browser tab.
|
||||
expect(useUiStore.getState().isInspectorOpen).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "pop browser" })).toHaveAttribute("data-view", "browser");
|
||||
} finally {
|
||||
delete worker.previewUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PanelImperativeHandle, PanelSize } from "react-resizable-panels";
|
||||
import { BrowserPanelView } from "./BrowserPanel";
|
||||
import { CenterPane } from "./CenterPane";
|
||||
import { SessionInspector } from "./SessionInspector";
|
||||
import { SessionInspector, type InspectorView } from "./SessionInspector";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
|
||||
import { useUiStore } from "../stores/ui-store";
|
||||
import { useShell } from "../lib/shell-context";
|
||||
import { useBrowserView } from "../hooks/useBrowserView";
|
||||
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
|
||||
import { isOrchestratorSession } from "../types/workspace";
|
||||
import type { TerminalTarget } from "../types/terminal";
|
||||
|
|
@ -44,16 +46,40 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
|||
const inspectorRef = useRef<PanelImperativeHandle | null>(null);
|
||||
const inspectorSeparatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [terminalTarget, setTerminalTarget] = useState<TerminalTarget>({ kind: "worker" });
|
||||
const [browserPoppedOut, setBrowserPoppedOut] = useState(false);
|
||||
const [inspectorView, setInspectorView] = useState<InspectorView>("summary");
|
||||
|
||||
const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId);
|
||||
const isOrchestrator = session ? isOrchestratorSession(session) : false;
|
||||
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
|
||||
const hasInspector = !isOrchestrator;
|
||||
const previewUrl = session?.previewUrl?.trim() || undefined;
|
||||
const revealedPreviewRef = useRef<string | null>(null);
|
||||
const browserView = useBrowserView({
|
||||
sessionId,
|
||||
active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)),
|
||||
poppedOut: browserPoppedOut,
|
||||
previewUrl,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalTarget({ kind: "worker" });
|
||||
setBrowserPoppedOut(false);
|
||||
setInspectorView("summary");
|
||||
revealedPreviewRef.current = null;
|
||||
}, [sessionId]);
|
||||
|
||||
// Orchestrator sessions are terminal-only; only worker sessions have the rail.
|
||||
const hasInspector = !isOrchestrator;
|
||||
// `ao preview` sets session.previewUrl (streamed over CDC); surface the result
|
||||
// in the inspector rail's Browser tab (opening the rail if collapsed), not the
|
||||
// center pane. Tracked per URL so re-revealing fires on a fresh target but a
|
||||
// manual tab switch sticks while the URL is unchanged.
|
||||
useEffect(() => {
|
||||
if (!previewUrl || revealedPreviewRef.current === previewUrl) return;
|
||||
revealedPreviewRef.current = previewUrl;
|
||||
setInspectorView("browser");
|
||||
if (!useUiStore.getState().isInspectorOpen) toggleInspector();
|
||||
}, [previewUrl, toggleInspector]);
|
||||
|
||||
// Computed when the inspector panel mounts and frozen while it stays
|
||||
// mounted: rrp re-registers the panel (a layout effect keyed on defaultSize,
|
||||
// among others) whenever this prop's identity changes, and the imperative
|
||||
|
|
@ -151,6 +177,15 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
|||
{/* react-resizable-panels v4: bare numbers are PIXELS; percentages must
|
||||
be strings. Numeric sizes here once clamped the inspector to 45px. */}
|
||||
<ResizablePanel defaultSize="72%" id="terminal" minSize="45%">
|
||||
{browserPoppedOut && session ? (
|
||||
<BrowserPanelView
|
||||
active
|
||||
browserView={browserView}
|
||||
onTogglePopOut={setBrowserPoppedOut}
|
||||
poppedOut
|
||||
session={session}
|
||||
/>
|
||||
) : (
|
||||
<CenterPane
|
||||
daemonReady={daemonStatus.state === "ready"}
|
||||
onSelectWorkerTerminal={() => setTerminalTarget({ kind: "worker" })}
|
||||
|
|
@ -158,6 +193,7 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
|||
terminalTarget={terminalTarget}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
{hasInspector ? (
|
||||
<>
|
||||
|
|
@ -181,9 +217,15 @@ export function SessionView({ sessionId }: SessionViewProps) {
|
|||
the pane clips instead of reflowing the inspector mid-collapse. */}
|
||||
<div className="h-full min-w-[280px]">
|
||||
<SessionInspector
|
||||
browserPoppedOut={browserPoppedOut}
|
||||
isInspectorVisible={isInspectorOpen}
|
||||
onOpenReviewerTerminal={({ handleId, harness }) =>
|
||||
setTerminalTarget({ kind: "reviewer", handleId, harness })
|
||||
}
|
||||
onToggleBrowserPopOut={setBrowserPoppedOut}
|
||||
onViewChange={setInspectorView}
|
||||
view={inspectorView}
|
||||
browserView={browserView}
|
||||
session={session}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useBrowserView, type BrowserNavState } from "./useBrowserView";
|
||||
|
||||
type Listener = (state: BrowserNavState) => void;
|
||||
|
||||
function createSlot(rect: Partial<DOMRect> = {}) {
|
||||
const slot = document.createElement("div");
|
||||
document.body.appendChild(slot);
|
||||
slot.getBoundingClientRect = vi.fn(() => ({
|
||||
x: 12,
|
||||
y: 34,
|
||||
width: 320,
|
||||
height: 240,
|
||||
top: 34,
|
||||
right: 332,
|
||||
bottom: 274,
|
||||
left: 12,
|
||||
toJSON: () => ({}),
|
||||
...rect,
|
||||
}));
|
||||
return slot;
|
||||
}
|
||||
|
||||
function setupBridge() {
|
||||
const listeners = new Set<Listener>();
|
||||
const bridge = {
|
||||
stateFor(viewId: string): BrowserNavState {
|
||||
return {
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
ensure: vi.fn(
|
||||
async (sessionId: string): Promise<BrowserNavState> => ({
|
||||
viewId: `42:${sessionId}`,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
),
|
||||
setBounds: vi.fn(),
|
||||
navigate: vi.fn(async ({ viewId }: { viewId: string }) => bridge.stateFor(viewId)),
|
||||
goBack: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
||||
goForward: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
||||
reload: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
||||
stop: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
||||
destroy: vi.fn(),
|
||||
onNavState: vi.fn((listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}),
|
||||
emit(state: BrowserNavState) {
|
||||
listeners.forEach((listener) => listener(state));
|
||||
},
|
||||
};
|
||||
window.ao = { ...window.ao!, browser: bridge };
|
||||
return bridge;
|
||||
}
|
||||
|
||||
describe("useBrowserView", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("ensures a scoped browser view and reports the measured slot bounds", async () => {
|
||||
const bridge = setupBridge();
|
||||
const slot = createSlot();
|
||||
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
|
||||
|
||||
await waitFor(() => expect(bridge.ensure).toHaveBeenCalledWith("sess-1"));
|
||||
act(() => result.current.slotRef(slot));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(bridge.setBounds).toHaveBeenCalledWith({
|
||||
viewId: "42:sess-1",
|
||||
rect: { x: 12, y: 34, width: 320, height: 240 },
|
||||
visible: true,
|
||||
}),
|
||||
);
|
||||
expect(result.current.viewId).toBe("42:sess-1");
|
||||
});
|
||||
|
||||
it("hides the native view when inactive and on unmount without destroying session state", async () => {
|
||||
const bridge = setupBridge();
|
||||
const slot = createSlot();
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ active }) => useBrowserView({ sessionId: "sess-1", active, poppedOut: false }),
|
||||
{ initialProps: { active: true } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
|
||||
act(() => result.current.slotRef(slot));
|
||||
|
||||
rerender({ active: false });
|
||||
await waitFor(() =>
|
||||
expect(bridge.setBounds).toHaveBeenLastCalledWith({
|
||||
viewId: "42:sess-1",
|
||||
rect: { x: 0, y: 0, width: 0, height: 0 },
|
||||
visible: false,
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
expect(bridge.setBounds).toHaveBeenLastCalledWith({
|
||||
viewId: "42:sess-1",
|
||||
rect: { x: 0, y: 0, width: 0, height: 0 },
|
||||
visible: false,
|
||||
});
|
||||
expect(bridge.destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates nav state only for the current view", async () => {
|
||||
const bridge = setupBridge();
|
||||
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
|
||||
await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
|
||||
|
||||
act(() =>
|
||||
bridge.emit({
|
||||
viewId: "other:sess-1",
|
||||
url: "https://ignored.test/",
|
||||
title: "Ignored",
|
||||
canGoBack: true,
|
||||
canGoForward: true,
|
||||
isLoading: true,
|
||||
}),
|
||||
);
|
||||
expect(result.current.navState.url).toBe("");
|
||||
|
||||
act(() =>
|
||||
bridge.emit({
|
||||
viewId: "42:sess-1",
|
||||
url: "http://localhost:5173/",
|
||||
title: "Local app",
|
||||
canGoBack: false,
|
||||
canGoForward: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
);
|
||||
expect(result.current.navState.url).toBe("http://localhost:5173/");
|
||||
expect(result.current.navState.title).toBe("Local app");
|
||||
});
|
||||
|
||||
it("navigates to a daemon-set preview URL and re-navigates only when it changes", async () => {
|
||||
const bridge = setupBridge();
|
||||
const { rerender } = renderHook(
|
||||
({ previewUrl }) => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false, previewUrl }),
|
||||
{ initialProps: { previewUrl: "http://localhost:5173/" } },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "http://localhost:5173/" }),
|
||||
);
|
||||
expect(bridge.navigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Same URL replayed (CDC re-emits the session payload) must not re-navigate.
|
||||
rerender({ previewUrl: "http://localhost:5173/" });
|
||||
expect(bridge.navigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A changed target re-navigates.
|
||||
rerender({ previewUrl: "file:///tmp/preview/index.html" });
|
||||
await waitFor(() =>
|
||||
expect(bridge.navigate).toHaveBeenCalledWith({ viewId: "42:sess-1", url: "file:///tmp/preview/index.html" }),
|
||||
);
|
||||
expect(bridge.navigate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not navigate without a preview URL", async () => {
|
||||
const bridge = setupBridge();
|
||||
const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false }));
|
||||
await waitFor(() => expect(result.current.viewId).toBe("42:sess-1"));
|
||||
expect(bridge.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { BrowserNavState, BrowserRect } from "../../main/browser-view-host";
|
||||
|
||||
export type { BrowserNavState };
|
||||
|
||||
type UseBrowserViewOptions = {
|
||||
sessionId: string;
|
||||
active: boolean;
|
||||
poppedOut: boolean;
|
||||
/**
|
||||
* Preview target driven by the daemon (via `ao preview`, streamed over CDC).
|
||||
* When set, the view navigates here automatically; changing it re-navigates.
|
||||
*/
|
||||
previewUrl?: string;
|
||||
};
|
||||
|
||||
export type BrowserViewModel = {
|
||||
viewId: string;
|
||||
navState: BrowserNavState;
|
||||
slotRef: (node: HTMLDivElement | null) => void;
|
||||
navigate: (url: string) => Promise<void>;
|
||||
goBack: () => Promise<void>;
|
||||
goForward: () => Promise<void>;
|
||||
reload: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
const EMPTY_NAV_STATE: BrowserNavState = {
|
||||
viewId: "",
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
const HIDDEN_RECT: BrowserRect = { x: 0, y: 0, width: 0, height: 0 };
|
||||
|
||||
export function useBrowserView({ sessionId, active, poppedOut, previewUrl }: UseBrowserViewOptions): BrowserViewModel {
|
||||
const [viewId, setViewId] = useState("");
|
||||
const [navState, setNavState] = useState<BrowserNavState>(EMPTY_NAV_STATE);
|
||||
const slotNodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewIdRef = useRef("");
|
||||
const activeRef = useRef(active);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const previewNavRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
}, [active]);
|
||||
|
||||
const sendHiddenBounds = useCallback((id = viewIdRef.current) => {
|
||||
if (!id) return;
|
||||
window.ao?.browser.setBounds({ viewId: id, rect: HIDDEN_RECT, visible: false });
|
||||
}, []);
|
||||
|
||||
const measureAndSend = useCallback(() => {
|
||||
frameRef.current = null;
|
||||
const id = viewIdRef.current;
|
||||
const node = slotNodeRef.current;
|
||||
if (!id) return;
|
||||
if (!activeRef.current || !node || !node.isConnected) {
|
||||
sendHiddenBounds(id);
|
||||
return;
|
||||
}
|
||||
const rect = node.getBoundingClientRect();
|
||||
const payload = {
|
||||
viewId: id,
|
||||
rect: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
window.ao?.browser.setBounds(payload);
|
||||
}, [sendHiddenBounds]);
|
||||
|
||||
const cancelScheduledMeasure = useCallback(() => {
|
||||
if (frameRef.current === null) return;
|
||||
if (window.cancelAnimationFrame) {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
window.clearTimeout(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleMeasure = useCallback(() => {
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = window.requestAnimationFrame
|
||||
? window.requestAnimationFrame(() => measureAndSend())
|
||||
: window.setTimeout(() => measureAndSend(), 16);
|
||||
}, [measureAndSend]);
|
||||
|
||||
const slotRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
observerRef.current?.disconnect();
|
||||
slotNodeRef.current = node;
|
||||
if (node) {
|
||||
const observer = new ResizeObserver(scheduleMeasure);
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
}
|
||||
scheduleMeasure();
|
||||
},
|
||||
[scheduleMeasure],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
window.ao?.browser.ensure(sessionId).then((state) => {
|
||||
if (disposed) return;
|
||||
viewIdRef.current = state.viewId;
|
||||
setViewId(state.viewId);
|
||||
setNavState(state);
|
||||
scheduleMeasure();
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
const id = viewIdRef.current;
|
||||
if (id) {
|
||||
sendHiddenBounds(id);
|
||||
}
|
||||
viewIdRef.current = "";
|
||||
};
|
||||
}, [scheduleMeasure, sendHiddenBounds, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
return window.ao?.browser.onNavState((state) => {
|
||||
if (state.viewId !== viewIdRef.current) return;
|
||||
setNavState(state);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
scheduleMeasure();
|
||||
} else {
|
||||
sendHiddenBounds();
|
||||
}
|
||||
}, [active, poppedOut, scheduleMeasure, sendHiddenBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = () => scheduleMeasure();
|
||||
window.addEventListener("resize", handle);
|
||||
window.addEventListener("scroll", handle, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handle);
|
||||
window.removeEventListener("scroll", handle, true);
|
||||
observerRef.current?.disconnect();
|
||||
cancelScheduledMeasure();
|
||||
};
|
||||
}, [cancelScheduledMeasure, scheduleMeasure]);
|
||||
|
||||
const withView = useCallback(async (fn: (id: string) => Promise<BrowserNavState | void>) => {
|
||||
const id = viewIdRef.current;
|
||||
if (!id) return;
|
||||
const next = await fn(id);
|
||||
if (next) setNavState(next);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback(
|
||||
(url: string) => withView((id) => window.ao!.browser.navigate({ viewId: id, url })),
|
||||
[withView],
|
||||
);
|
||||
|
||||
// Drive navigation from the daemon-set preview URL. Re-navigate only when the
|
||||
// target actually changes; skip when it already matches what the view shows
|
||||
// (the CDC stream replays the same session payload on unrelated updates).
|
||||
useEffect(() => {
|
||||
const target = previewUrl?.trim();
|
||||
if (!target || !viewId) return;
|
||||
if (previewNavRef.current === target || navState.url === target) {
|
||||
previewNavRef.current = target;
|
||||
return;
|
||||
}
|
||||
previewNavRef.current = target;
|
||||
void navigate(target);
|
||||
}, [navState.url, navigate, previewUrl, viewId]);
|
||||
|
||||
const destroy = useCallback(() => {
|
||||
const id = viewIdRef.current;
|
||||
if (!id) return;
|
||||
sendHiddenBounds(id);
|
||||
window.ao?.browser.destroy(id);
|
||||
viewIdRef.current = "";
|
||||
}, [sendHiddenBounds]);
|
||||
|
||||
return {
|
||||
viewId,
|
||||
navState,
|
||||
slotRef,
|
||||
navigate,
|
||||
goBack: () => withView((id) => window.ao!.browser.goBack(id)),
|
||||
goForward: () => withView((id) => window.ao!.browser.goForward(id)),
|
||||
reload: () => withView((id) => window.ao!.browser.reload(id)),
|
||||
stop: () => withView((id) => window.ao!.browser.stop(id)),
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
|
|||
status: toSessionStatus(session.status, session.isTerminated),
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
previewUrl: session.previewUrl,
|
||||
prs: (session.prs ?? []).map(toPullRequestFacts),
|
||||
})),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -27,6 +27,59 @@ export const aoBridge: AoBridge =
|
|||
telemetry: {
|
||||
getBootstrap: async () => null,
|
||||
},
|
||||
browser: {
|
||||
ensure: async (sessionId: string) => ({
|
||||
viewId: `preview:${sessionId}`,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
setBounds: () => undefined,
|
||||
navigate: async ({ viewId, url }) => ({
|
||||
viewId,
|
||||
url,
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
goBack: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
goForward: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
reload: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
stop: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
destroy: () => undefined,
|
||||
onNavState: () => () => undefined,
|
||||
},
|
||||
notifications: {
|
||||
show: async () => undefined,
|
||||
onClick: () => () => undefined,
|
||||
|
|
|
|||
|
|
@ -1314,3 +1314,91 @@ body.is-resizing-x [data-slot="sidebar-container"] {
|
|||
font-family: var(--font-mono);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.browser-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 320px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.browser-panel__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
padding: 7px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.browser-panel__url {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.browser-panel__url-icon {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--fg-passive);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-panel__url-input {
|
||||
height: 29px;
|
||||
padding-left: 29px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.browser-panel__content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.browser-panel__slot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-width: 1px;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.browser-panel__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-passive);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.browser-panel__error {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--red) 35%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--red) 8%, var(--bg));
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,59 @@ window.ao = {
|
|||
telemetry: {
|
||||
getBootstrap: async () => null,
|
||||
},
|
||||
browser: {
|
||||
ensure: async (sessionId: string) => ({
|
||||
viewId: `test:${sessionId}`,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
setBounds: () => undefined,
|
||||
navigate: async ({ viewId }: { viewId: string }) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
goBack: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
goForward: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
reload: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
stop: async (viewId: string) => ({
|
||||
viewId,
|
||||
url: "",
|
||||
title: "",
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
destroy: () => undefined,
|
||||
onNavState: () => () => undefined,
|
||||
},
|
||||
notifications: {
|
||||
show: async () => undefined,
|
||||
onClick: () => () => undefined,
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ export type WorkspaceSession = {
|
|||
createdAt?: string;
|
||||
/** ISO timestamp from the daemon. */
|
||||
updatedAt: string;
|
||||
/**
|
||||
* Live preview target set by the daemon (via `ao preview`) and streamed over
|
||||
* CDC. When non-empty, the browser panel opens and navigates here.
|
||||
*/
|
||||
previewUrl?: string;
|
||||
/** The session's git diff against its base, when known. */
|
||||
changedFiles?: ChangedFile[];
|
||||
/** Pre-filled commit subject for the Git rail, when known. */
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export default defineConfig({
|
|||
],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
testTimeout: 20_000,
|
||||
// Anchor node_modules at any depth: a bare "node_modules/**" replaces
|
||||
// vitest's default "**/node_modules/**" and only matches the root, so the
|
||||
// tracked src/landing preview app's nested node_modules would otherwise
|
||||
|
|
|
|||
Loading…
Reference in New Issue