diff --git a/backend/internal/adapters/runtime/ptyexec/spawn_windows.go b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go index 8c7136a0f..fb9ac46f3 100644 --- a/backend/internal/adapters/runtime/ptyexec/spawn_windows.go +++ b/backend/internal/adapters/runtime/ptyexec/spawn_windows.go @@ -8,8 +8,9 @@ import ( "sync" "time" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" winpty "github.com/aymanbagabas/go-pty" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // detachGrace mirrors the Unix value: how long Close waits for the attach diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index 7d03e1260..b5a255125 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -87,7 +87,9 @@ func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts var err error if appPath == "" { - appPath, err = c.fetchApp(ctx) + // Progress for the fetch path goes to stderr so stdout stays pure JSON + // under --json. The resolve-and-launch fast path above stays quiet. + appPath, err = c.fetchApp(ctx, cmd.ErrOrStderr()) if err != nil { return err } @@ -233,14 +235,14 @@ func isUsableBundle(p string) bool { // the resolved bundle path (spec §6.3). macOS unpacks a signed zip into staging, // Windows runs the NSIS installer silently, and Linux drops a chmod'd AppImage at // a stable path. -func (c *commandContext) fetchApp(ctx context.Context) (string, error) { +func (c *commandContext) fetchApp(ctx context.Context, w io.Writer) (string, error) { switch runtime.GOOS { case "darwin": - return c.fetchAppDarwin(ctx) + return c.fetchAppDarwin(ctx, w) case "windows": - return c.fetchAppWindows(ctx) + return c.fetchAppWindows(ctx, w) case "linux": - return c.fetchAppLinux(ctx) + return c.fetchAppLinux(ctx, w) default: return "", fmt.Errorf("ao start: fetch not supported on %s", runtime.GOOS) } @@ -248,7 +250,7 @@ func (c *commandContext) fetchApp(ctx context.Context) (string, error) { // fetchAppDarwin downloads the latest macOS release zip and unpacks it into a // staging dir under ~/.ao/staging, returning the .app bundle path (spec §6.3). -func (c *commandContext) fetchAppDarwin(ctx context.Context) (string, error) { +func (c *commandContext) fetchAppDarwin(ctx context.Context, w io.Writer) (string, error) { asset, err := assetName() if err != nil { return "", err @@ -270,10 +272,13 @@ func (c *commandContext) fetchAppDarwin(ctx context.Context) (string, error) { } zipPath := filepath.Join(staging, asset) - if err := c.download(ctx, url, zipPath); err != nil { + if err := c.download(ctx, w, url, asset, zipPath); err != nil { return "", fmt.Errorf("download %s: %w", url, err) } + // The unpack step is silent and can take seconds on a large bundle; announce + // it so a quiet `ao start` doesn't look hung after the download finishes. + _, _ = fmt.Fprintln(w, "Unpacking...") // ditto preserves the .app code signature; plain unzip corrupts it (spec §6.3). if out, err := c.deps.CommandOutput(ctx, "ditto", "-x", "-k", zipPath, staging); err != nil { return "", fmt.Errorf("ditto unpack: %w: %s", err, out) @@ -293,7 +298,7 @@ func (c *commandContext) fetchAppDarwin(ctx context.Context) (string, error) { // untested on real Windows hardware (this build host is macOS). If the installed // exe isn't where electron-builder's defaults put it, this surfaces as a clear // "not found" error rather than silently launching the wrong thing. -func (c *commandContext) fetchAppWindows(ctx context.Context) (string, error) { +func (c *commandContext) fetchAppWindows(ctx context.Context, w io.Writer) (string, error) { asset, err := assetName() if err != nil { return "", err @@ -313,10 +318,13 @@ func (c *commandContext) fetchAppWindows(ctx context.Context) (string, error) { } installerPath := filepath.Join(staging, asset) - if err := c.download(ctx, url, installerPath); err != nil { + if err := c.download(ctx, w, url, asset, installerPath); err != nil { return "", fmt.Errorf("download %s: %w", url, err) } + // The `/S` installer runs with no UI and no output; announce it so the wait + // after the download reads as progress, not a hang. + _, _ = fmt.Fprintln(w, "Installing...") // NSIS silent install (`/S`) to the default per-user location. The installer // is configured oneClick:false/perMachine:false, so `/S` installs without UI // under %LOCALAPPDATA% for the current user. @@ -338,7 +346,7 @@ func (c *commandContext) fetchAppWindows(ctx context.Context) (string, error) { // fetchAppLinux downloads the self-contained AppImage to a stable path under // ~/.ao, makes it executable, and returns it. There is no install step (spec // §6.3). Re-runs resolve the existing file via knownAppLocations and skip fetch. -func (c *commandContext) fetchAppLinux(ctx context.Context) (string, error) { +func (c *commandContext) fetchAppLinux(ctx context.Context, w io.Writer) (string, error) { asset, err := assetName() if err != nil { return "", err @@ -352,9 +360,12 @@ func (c *commandContext) fetchAppLinux(ctx context.Context) (string, error) { // Download to a temp name in the same dir, then rename for atomicity so a // killed download never leaves a half-written executable at the stable path. tmpPath := appPath + ".part" - if err := c.download(ctx, url, tmpPath); err != nil { + if err := c.download(ctx, w, url, asset, tmpPath); err != nil { return "", fmt.Errorf("download %s: %w", url, err) } + // chmod+rename is near-instant, but a one-line marker keeps the post-download + // step from looking silent and matches the other platforms. + _, _ = fmt.Fprintln(w, "Installing...") // An AppImage is a self-contained executable; it must be 0755 to launch. if err := os.Chmod(tmpPath, 0o755); err != nil { //nolint:gosec // G302: AppImage must be executable return "", fmt.Errorf("chmod AppImage: %w", err) @@ -368,8 +379,13 @@ func (c *commandContext) fetchAppLinux(ctx context.Context) (string, error) { return appPath, nil } -// download streams url to dst using the injected HTTP client. -func (c *commandContext) download(ctx context.Context, url, dst string) error { +// download streams url to dst using the injected HTTP client, reporting progress +// to w. asset is the human-facing filename used in the announce line. A silent +// hundreds-of-MB fetch reads as a hang, so we print a start line (with the size +// from Content-Length when present) and then progress: a live carriage-return +// percentage when w is an interactive terminal, or a plain start+done pair when +// it is not (CI, pipes, redirected output). +func (c *commandContext) download(ctx context.Context, w io.Writer, url, asset, dst string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return err @@ -389,17 +405,95 @@ func (c *commandContext) download(ctx context.Context, url, dst string) error { return fmt.Errorf("unexpected status %s", resp.Status) } + // Content-Length is -1 when the server omits the header; total<=0 means the + // percentage is unknown and we report transferred bytes instead. + total := resp.ContentLength + if total > 0 { + _, _ = fmt.Fprintf(w, "Downloading Agent Orchestrator (%s, ~%s) from %s...\n", asset, humanBytes(total), releaseRepo) + } else { + _, _ = fmt.Fprintf(w, "Downloading Agent Orchestrator (%s) from %s...\n", asset, releaseRepo) + } + f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } defer func() { _ = f.Close() }() - if _, err := io.Copy(f, resp.Body); err != nil { + + pw := &progressWriter{w: w, total: total, tty: writerIsInteractive(w)} + if _, err := io.Copy(f, io.TeeReader(resp.Body, pw)); err != nil { return err } + pw.done() return f.Close() } +// progressWriter counts bytes copied through it and renders download progress to +// w. On an interactive terminal it overwrites a single line with a carriage +// return; otherwise it stays quiet during the copy and prints one "Done" line at +// the end, so logs and pipes never fill with \r spam or per-chunk noise. +type progressWriter struct { + w io.Writer + total int64 // bytes; <=0 when Content-Length was absent + tty bool + written int64 +} + +func (p *progressWriter) Write(b []byte) (int, error) { + n := len(b) + p.written += int64(n) + if p.tty { + if p.total > 0 { + pct := p.written * 100 / p.total + _, _ = fmt.Fprintf(p.w, "\r %d%% (%s / %s)", pct, humanBytes(p.written), humanBytes(p.total)) + } else { + _, _ = fmt.Fprintf(p.w, "\r %s", humanBytes(p.written)) + } + } + return n, nil +} + +// done emits the terminal progress line. On a TTY it closes the live line with a +// newline; off a TTY it prints the single completion line. +func (p *progressWriter) done() { + if p.tty { + _, _ = fmt.Fprintln(p.w) + return + } + _, _ = fmt.Fprintf(p.w, "Downloaded %s.\n", humanBytes(p.written)) +} + +// writerIsInteractive reports whether w is an interactive terminal, mirroring +// stdinIsInteractive: only a real *os.File backed by a character device counts, +// so a bytes.Buffer or pipe is treated as non-interactive. +func writerIsInteractive(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + info, err := f.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +// humanBytes formats a byte count with a binary-unit suffix (KiB, MiB, ...), +// e.g. 314572800 -> "300.0 MiB". A tiny hand-rolled formatter avoids promoting +// the already-indirect go-humanize dependency to a direct one for one string. +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + // assetName maps the current GOOS/GOARCH to the stable release asset name the // release pipeline publishes (spec §6.3, §8). The pipeline uses "x64" for amd64. // - darwin: agent-orchestrator-darwin-{arm64,x64}.zip (signed bundle zip) diff --git a/backend/internal/cli/start_test.go b/backend/internal/cli/start_test.go index efcd5f81c..d818db106 100644 --- a/backend/internal/cli/start_test.go +++ b/backend/internal/cli/start_test.go @@ -1,14 +1,17 @@ package cli import ( + "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "runtime" + "strings" "testing" "time" ) @@ -317,7 +320,7 @@ func TestDownload_IgnoresShortClientTimeout(t *testing.T) { }.withDefaults()} dst := filepath.Join(t.TempDir(), "out.zip") - if err := c.download(context.Background(), srv.URL, dst); err != nil { + if err := c.download(context.Background(), io.Discard, srv.URL, "out.zip", dst); err != nil { t.Fatalf("download failed (short client timeout leaked into large-asset path?): %v", err) } got, err := os.ReadFile(dst) @@ -329,6 +332,104 @@ func TestDownload_IgnoresShortClientTimeout(t *testing.T) { } } +// TestDownload_NonTTYProgress proves a non-interactive writer (a bytes.Buffer is +// not an *os.File, so it's non-TTY) gets a plain start line plus a final done +// line and NO carriage returns, while the bytes still land on disk correctly. +func TestDownload_NonTTYProgress(t *testing.T) { + const body = "release-zip-bytes-payload" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) // net/http sets Content-Length for a known body + })) + t.Cleanup(srv.Close) + + orig := releaseRepo + releaseRepo = "owner/repo" + t.Cleanup(func() { releaseRepo = orig }) + + c := &commandContext{deps: Deps{}.withDefaults()} + dst := filepath.Join(t.TempDir(), "out.zip") + + var buf bytes.Buffer + if err := c.download(context.Background(), &buf, srv.URL, "agent-orchestrator.zip", dst); err != nil { + t.Fatalf("download failed: %v", err) + } + + // Bytes on disk are intact. + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(got) != body { + t.Fatalf("downloaded %q, want %q", got, body) + } + + out := buf.String() + if strings.Contains(out, "\r") { + t.Fatalf("non-TTY progress must not emit carriage returns, got %q", out) + } + // Start line: asset name, size, and repo are all present. + if !strings.Contains(out, "Downloading Agent Orchestrator (agent-orchestrator.zip, ") { + t.Fatalf("missing start line in %q", out) + } + if !strings.Contains(out, "from owner/repo...") { + t.Fatalf("start line missing repo in %q", out) + } + // Done line is present (the only per-copy line off a TTY). + if !strings.Contains(out, "Downloaded ") { + t.Fatalf("missing done line in %q", out) + } +} + +// TestDownload_NoContentLengthOmitsSize proves the start line drops the size +// segment when the server omits Content-Length, and still reports transferred +// bytes on the done line. +func TestDownload_NoContentLengthOmitsSize(t *testing.T) { + const body = "streamed-bytes" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Chunked transfer (no Content-Length): flush so the header omits length. + w.Header().Set("Transfer-Encoding", "chunked") + _, _ = w.Write([]byte(body)) + if fl, ok := w.(http.Flusher); ok { + fl.Flush() + } + })) + t.Cleanup(srv.Close) + + c := &commandContext{deps: Deps{}.withDefaults()} + dst := filepath.Join(t.TempDir(), "out.bin") + + var buf bytes.Buffer + if err := c.download(context.Background(), &buf, srv.URL, "asset.bin", dst); err != nil { + t.Fatalf("download failed: %v", err) + } + out := buf.String() + // No "~)" segment when Content-Length is absent. + if strings.Contains(out, "~") { + t.Fatalf("size segment should be omitted without Content-Length, got %q", out) + } + if !strings.Contains(out, "Downloading Agent Orchestrator (asset.bin) from") { + t.Fatalf("unexpected start line in %q", out) + } + if !strings.Contains(out, "Downloaded ") { + t.Fatalf("missing done line in %q", out) + } +} + +func TestHumanBytes(t *testing.T) { + cases := map[int64]string{ + 0: "0 B", + 512: "512 B", + 1024: "1.0 KiB", + 1536: "1.5 KiB", + 314572800: "300.0 MiB", + } + for n, want := range cases { + if got := humanBytes(n); got != want { + t.Errorf("humanBytes(%d) = %q, want %q", n, got, want) + } + } +} + // swapScanLocations replaces the scan-location seam and returns a restore func. func swapScanLocations(fn func() []string) func() { orig := appScanLocations diff --git a/backend/internal/legacyimport/config.go b/backend/internal/legacyimport/config.go index bed1dabd6..c89274e1e 100644 --- a/backend/internal/legacyimport/config.go +++ b/backend/internal/legacyimport/config.go @@ -21,11 +21,11 @@ type legacyConfig struct { // represent are typed; the rest are captured as raw nodes purely so the importer // can report them as dropped (issue #247 §4). type legacyProjectConfig struct { - Path string `yaml:"path"` - Name string `yaml:"name"` + Path string `yaml:"path"` + Name string `yaml:"name"` // Repo is captured as a raw YAML node but never consumed; the origin URL is // re-resolved from the repo path at import time. - Repo *yaml.Node `yaml:"repo"` + Repo *yaml.Node `yaml:"repo"` DefaultBranch string `yaml:"defaultBranch"` SessionPrefix string `yaml:"sessionPrefix"` Env map[string]string `yaml:"env"` diff --git a/backend/internal/legacyimport/project_test.go b/backend/internal/legacyimport/project_test.go index ae69fca68..240640b0e 100644 --- a/backend/internal/legacyimport/project_test.go +++ b/backend/internal/legacyimport/project_test.go @@ -4,8 +4,9 @@ import ( "testing" "time" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" yaml "gopkg.in/yaml.v3" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) // nonNilNode returns a non-nil *yaml.Node for struct fields that are captured diff --git a/backend/internal/service/importer/importer_test.go b/backend/internal/service/importer/importer_test.go index 8c4fa33c7..9f0c96516 100644 --- a/backend/internal/service/importer/importer_test.go +++ b/backend/internal/service/importer/importer_test.go @@ -9,7 +9,9 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -type fakeStore struct{ projects map[string]domain.ProjectRecord } +type fakeStore struct { + projects map[string]domain.ProjectRecord +} func newFakeStore() *fakeStore { return &fakeStore{projects: map[string]domain.ProjectRecord{}} } func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index a0ff69d63..8e88420a2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -11,7 +11,20 @@ import { WebContentsView, type OpenDialogOptions, } from "electron"; -import { startAutoUpdates, ensureUpdatePrefs } from "./main/auto-updater"; +import { + startAutoUpdates, + ensureUpdatePrefs, + checkForUpdatesNow, + downloadUpdateNow, + quitAndInstallUpdate, + getUpdateStatus, +} from "./main/auto-updater"; +import { + readUpdateSettings, + writeUpdateSettings, + type UpdateSettings, + type UpdateStatus, +} from "./main/update-settings"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; @@ -806,6 +819,30 @@ ipcMain.handle("appState:setMigration", async (_event, migration: MigrationState await updateMigration({ stateDir: path.dirname(runFile), migration, now: () => new Date() }); }); +ipcMain.handle("updateSettings:get", async (): Promise => { + const runFile = runFilePath(); + if (!runFile) return { enabled: false, channel: "latest", nightlyAck: false }; + return readUpdateSettings(path.dirname(runFile)); +}); +ipcMain.handle("updateSettings:set", async (_event, settings: UpdateSettings) => { + const runFile = runFilePath(); + if (!runFile) return; + await writeUpdateSettings(path.dirname(runFile), settings); +}); + +ipcMain.handle("updates:getStatus", (): UpdateStatus => getUpdateStatus()); +ipcMain.handle("updates:check", async () => { + const runFile = runFilePath(); + if (!runFile) return; + await checkForUpdatesNow(path.dirname(runFile)); +}); +ipcMain.handle("updates:download", async () => { + await downloadUpdateNow(); +}); +ipcMain.handle("updates:install", () => { + quitAndInstallUpdate(); +}); + ipcMain.handle("notifications:show", (_event, notification: { id: string; title: string; body?: string }) => { if (!notification.id || !notification.title || !ElectronNotification.isSupported()) return; const toast = new ElectronNotification({ diff --git a/frontend/src/main/auto-updater.ts b/frontend/src/main/auto-updater.ts index bab389017..ea6057998 100644 --- a/frontend/src/main/auto-updater.ts +++ b/frontend/src/main/auto-updater.ts @@ -1,8 +1,14 @@ import { autoUpdater } from "electron-updater"; -import { dialog } from "electron"; +import { app, BrowserWindow, dialog } from "electron"; import { existsSync } from "node:fs"; import path from "node:path"; -import { readUpdateSettings, writeUpdateSettings, UPDATE_SETTINGS_FILE_NAME } from "./update-settings"; +import { + readUpdateSettings, + writeUpdateSettings, + UPDATE_SETTINGS_FILE_NAME, + type UpdateChannel, + type UpdateStatus, +} from "./update-settings"; // Default release repo, mirroring backend cli.releaseRepo. Override via env for // fork test builds (AO_RELEASE_REPO=owner/repo). @@ -15,6 +21,50 @@ function repo(): { owner: string; name: string } { return { owner: defOwner, name: defName }; } +// configureFeed points electron-updater at the GitHub Releases feed for the +// chosen channel. Shared by the launch auto-check and the manual Settings flow. +function configureFeed(channel: UpdateChannel): void { + const { owner, name } = repo(); + autoUpdater.setFeedURL({ provider: "github", owner, repo: name }); + autoUpdater.channel = channel; // "latest" | "nightly" + autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch +} + +let lastStatus: UpdateStatus = { state: "idle" }; +let eventsWired = false; + +// broadcast pushes the latest update status to every renderer window so the +// Global Settings Updates section can reflect check/download progress live. +function broadcast(status: UpdateStatus): void { + lastStatus = status; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send("updates:status", status); + } +} + +// wireUpdaterEvents registers electron-updater listeners once and forwards each +// to the renderer as an UpdateStatus. Idempotent: safe to call on every entry +// point (launch auto-check and manual check). +function wireUpdaterEvents(): void { + if (eventsWired) return; + eventsWired = true; + autoUpdater.on("checking-for-update", () => broadcast({ state: "checking" })); + autoUpdater.on("update-available", (info) => broadcast({ state: "available", version: info?.version })); + autoUpdater.on("update-not-available", () => broadcast({ state: "not-available" })); + autoUpdater.on("download-progress", (p) => + broadcast({ state: "downloading", percent: Math.max(0, Math.min(100, Math.round(p?.percent ?? 0))) }), + ); + autoUpdater.on("update-downloaded", (info) => broadcast({ state: "downloaded", version: info?.version })); + autoUpdater.on("error", (err) => { + // Never crash on update failure (offline, unsigned macOS, etc.). + broadcast({ state: "error", message: err?.message ?? String(err) }); + }); +} + +export function getUpdateStatus(): UpdateStatus { + return lastStatus; +} + // startAutoUpdates configures electron-updater from the user's ~/.ao settings. // It is a thin shell: all policy (channel, opt-in) comes from update-settings. // Caller guards on app.isPackaged. @@ -22,18 +72,11 @@ export async function startAutoUpdates(stateDir: string): Promise { const settings = await readUpdateSettings(stateDir); if (!settings.enabled) return; - const { owner, name } = repo(); - autoUpdater.setFeedURL({ provider: "github", owner, repo: name }); - autoUpdater.channel = settings.channel; // "latest" | "nightly" - autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch + wireUpdaterEvents(); + configureFeed(settings.channel); autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.on("error", (err) => { - // Never crash on update failure (offline, unsigned macOS, etc.). - console.error("auto-update error:", err?.message ?? err); - }); - try { await autoUpdater.checkForUpdates(); } catch (err) { @@ -41,6 +84,50 @@ export async function startAutoUpdates(stateDir: string): Promise { } } +// checkForUpdatesNow runs a manual update check regardless of the auto-update +// opt-in, so a user who never enabled auto-updates can still pull the latest +// build from Settings. It does NOT auto-download — the user clicks Update — and +// reports progress via the broadcast status. Updates only work in the packaged, +// signed app; in dev electron-updater has no feed, so surface that plainly. +export async function checkForUpdatesNow(stateDir: string): Promise { + wireUpdaterEvents(); + if (!app.isPackaged) { + broadcast({ state: "unsupported", message: "Updates are only available in the installed app." }); + return; + } + const settings = await readUpdateSettings(stateDir); + configureFeed(settings.channel); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + broadcast({ state: "checking" }); + try { + await autoUpdater.checkForUpdates(); + } catch (err) { + broadcast({ state: "error", message: (err as Error)?.message ?? "Update check failed" }); + } +} + +// downloadUpdateNow starts downloading the update found by checkForUpdatesNow. +export async function downloadUpdateNow(): Promise { + wireUpdaterEvents(); + if (!app.isPackaged) { + broadcast({ state: "unsupported", message: "Updates are only available in the installed app." }); + return; + } + try { + await autoUpdater.downloadUpdate(); + } catch (err) { + broadcast({ state: "error", message: (err as Error)?.message ?? "Download failed" }); + } +} + +// quitAndInstallUpdate installs a downloaded update and relaunches. isSilent +// false keeps the installer UI on Windows; isForceRunAfter relaunches the app. +export function quitAndInstallUpdate(): void { + if (!app.isPackaged) return; + autoUpdater.quitAndInstall(false, true); +} + // ensureUpdatePrefs prompts once (first run, before any settings file exists) // for auto-update opt-in + channel, with a nightly instability disclaimer. export async function ensureUpdatePrefs(stateDir: string): Promise { diff --git a/frontend/src/main/update-settings.ts b/frontend/src/main/update-settings.ts index 30d47a476..3d4720575 100644 --- a/frontend/src/main/update-settings.ts +++ b/frontend/src/main/update-settings.ts @@ -9,6 +9,18 @@ export interface UpdateSettings { nightlyAck: boolean; } +// Live state of a manual update check/download, streamed to the renderer so the +// Global Settings "Check for updates" / "Update" buttons can reflect progress. +export type UpdateState = + "idle" | "checking" | "available" | "not-available" | "downloading" | "downloaded" | "error" | "unsupported"; + +export interface UpdateStatus { + state: UpdateState; + version?: string; + percent?: number; + message?: string; +} + /** File holding the user's auto-update preferences under the ~/.ao state dir. */ export const UPDATE_SETTINGS_FILE_NAME = "update-settings.json"; diff --git a/frontend/src/preload.ts b/frontend/src/preload.ts index c0000ff55..2284e6c44 100644 --- a/frontend/src/preload.ts +++ b/frontend/src/preload.ts @@ -3,6 +3,7 @@ import type { BrowserNavState, BrowserRect } from "./main/browser-view-host"; import type { DaemonStatus } from "./shared/daemon-status"; import type { TelemetryBootstrap } from "./shared/telemetry"; import type { MigrationState } from "./main/app-state"; +import type { UpdateSettings, UpdateStatus } from "./main/update-settings"; export type BrowserBoundsInput = { viewId: string; @@ -74,6 +75,23 @@ const api = { setMigration: (migration: MigrationState) => ipcRenderer.invoke("appState:setMigration", migration) as Promise, }, + updateSettings: { + get: () => ipcRenderer.invoke("updateSettings:get") as Promise, + set: (settings: UpdateSettings) => ipcRenderer.invoke("updateSettings:set", settings) as Promise, + }, + updates: { + getStatus: () => ipcRenderer.invoke("updates:getStatus") as Promise, + check: () => ipcRenderer.invoke("updates:check") as Promise, + download: () => ipcRenderer.invoke("updates:download") as Promise, + install: () => ipcRenderer.invoke("updates:install") as Promise, + onStatus: (listener: (status: UpdateStatus) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => listener(status); + ipcRenderer.on("updates:status", wrapped); + return () => { + ipcRenderer.off("updates:status", wrapped); + }; + }, + }, }; contextBridge.exposeInMainWorld("ao", api); diff --git a/frontend/src/renderer/components/GlobalSettingsForm.test.tsx b/frontend/src/renderer/components/GlobalSettingsForm.test.tsx new file mode 100644 index 000000000..a98d7a3b4 --- /dev/null +++ b/frontend/src/renderer/components/GlobalSettingsForm.test.tsx @@ -0,0 +1,184 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GlobalSettingsForm } from "./GlobalSettingsForm"; + +const { + getMock, + postMock, + getMigration, + setMigration, + getUpdate, + setUpdate, + updGetStatus, + updCheck, + updDownload, + updInstall, + updOnStatus, + getVersion, +} = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + getMigration: vi.fn(), + setMigration: vi.fn(), + getUpdate: vi.fn(), + setUpdate: vi.fn(), + updGetStatus: vi.fn(), + updCheck: vi.fn(), + updDownload: vi.fn(), + updInstall: vi.fn(), + updOnStatus: vi.fn(), + getVersion: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock, POST: postMock }, + apiErrorMessage: (e: unknown, fb = "Request failed") => + e instanceof Error ? e.message : ((e as { message?: string })?.message ?? fb), +})); +vi.mock("../lib/bridge", () => ({ + aoBridge: { + app: { getVersion }, + appState: { getMigration, setMigration }, + updateSettings: { get: getUpdate, set: setUpdate }, + updates: { + getStatus: updGetStatus, + check: updCheck, + download: updDownload, + install: updInstall, + onStatus: updOnStatus, + }, + }, +})); + +function renderForm() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return qc; +} + +beforeEach(() => { + for (const m of [getMock, postMock, getMigration, setMigration, getUpdate, setUpdate]) m.mockReset(); + getMigration.mockResolvedValue({ status: "pending" }); + getMock.mockResolvedValue({ data: { available: true, legacyRoot: "/home/u/.agent-orchestrator" }, error: undefined }); + postMock.mockResolvedValue({ data: { report: { projectsImported: 2, projectsSkipped: 1 } }, error: undefined }); + setMigration.mockResolvedValue(undefined); + getUpdate.mockResolvedValue({ enabled: true, channel: "latest", nightlyAck: false }); + setUpdate.mockResolvedValue(undefined); + updGetStatus.mockResolvedValue({ state: "idle" }); + updCheck.mockResolvedValue(undefined); + updDownload.mockResolvedValue(undefined); + updInstall.mockResolvedValue(undefined); + updOnStatus.mockReturnValue(() => undefined); + getVersion.mockResolvedValue("1.4.0"); +}); + +describe("GlobalSettingsForm", () => { + it("renders the Updates and Migration sections", async () => { + renderForm(); + expect(await screen.findByText("Updates")).toBeInTheDocument(); + expect(screen.getByText("Migration")).toBeInTheDocument(); + }); + + it("shows the nightly warning and saves the loaded channel", async () => { + getUpdate.mockResolvedValue({ enabled: true, channel: "nightly", nightlyAck: true }); + renderForm(); + expect(await screen.findByText(/Nightly builds are cut every day/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => + expect(setUpdate).toHaveBeenCalledWith(expect.objectContaining({ channel: "nightly", enabled: true })), + ); + }); + + it("hides the nightly warning on the stable channel", async () => { + renderForm(); + await screen.findByText("Updates"); + expect(screen.queryByText(/Nightly builds are cut every day/i)).not.toBeInTheDocument(); + }); + + it("shows migration status and the available legacy root", async () => { + renderForm(); + expect(await screen.findByText("Not migrated yet")).toBeInTheDocument(); + expect(await screen.findByText("/home/u/.agent-orchestrator")).toBeInTheDocument(); + }); + + it("Run migration imports and marks completed", async () => { + renderForm(); + const btn = await screen.findByRole("button", { name: "Run migration" }); + await userEvent.click(btn); + await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import")); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "completed" })); + expect(await screen.findByText("Migration complete.")).toBeInTheDocument(); + }); + + it("lets a declined user re-run the migration", async () => { + getMigration.mockResolvedValue({ status: "declined", lastAttemptAt: "2026-06-01T00:00:00.000Z" }); + renderForm(); + expect(await screen.findByText("Declined")).toBeInTheDocument(); + const btn = await screen.findByRole("button", { name: "Run migration" }); + expect(btn).toBeEnabled(); + await userEvent.click(btn); + await waitFor(() => expect(postMock).toHaveBeenCalledWith("/api/v1/import")); + }); + + it("disables Run when no legacy install is available", async () => { + getMock.mockResolvedValue({ data: { available: false, legacyRoot: "" }, error: undefined }); + renderForm(); + expect(await screen.findByText("None found")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Run migration" })).toBeDisabled(); + }); + + it("shows the current app version", async () => { + renderForm(); + expect(await screen.findByText("v1.4.0")).toBeInTheDocument(); + }); + + it("Check for updates triggers a manual check", async () => { + renderForm(); + const btn = await screen.findByRole("button", { name: "Check for updates" }); + await userEvent.click(btn); + expect(updCheck).toHaveBeenCalled(); + }); + + it("offers an Update button when an update is available and downloads it", async () => { + let emit: (s: { state: string; version?: string }) => void = () => undefined; + updOnStatus.mockImplementation((cb: (s: unknown) => void) => { + emit = cb as typeof emit; + return () => undefined; + }); + renderForm(); + await screen.findByRole("button", { name: "Check for updates" }); + act(() => emit({ state: "available", version: "1.2.3" })); + const updateBtn = await screen.findByRole("button", { name: "Update to v1.2.3" }); + await userEvent.click(updateBtn); + expect(updDownload).toHaveBeenCalled(); + }); + + it("offers Restart & install once downloaded and installs it", async () => { + let emit: (s: { state: string; version?: string }) => void = () => undefined; + updOnStatus.mockImplementation((cb: (s: unknown) => void) => { + emit = cb as typeof emit; + return () => undefined; + }); + renderForm(); + await screen.findByRole("button", { name: "Check for updates" }); + act(() => emit({ state: "downloaded", version: "1.2.3" })); + const installBtn = await screen.findByRole("button", { name: /Restart & install/ }); + await userEvent.click(installBtn); + expect(updInstall).toHaveBeenCalled(); + }); + + it("a failed import surfaces the error and marks failed", async () => { + postMock.mockResolvedValue({ data: undefined, error: { message: "disk full" } }); + renderForm(); + const btn = await screen.findByRole("button", { name: "Run migration" }); + await userEvent.click(btn); + expect(await screen.findByText(/disk full/i)).toBeInTheDocument(); + expect(setMigration).toHaveBeenCalledWith(expect.objectContaining({ status: "failed", error: "disk full" })); + }); +}); diff --git a/frontend/src/renderer/components/GlobalSettingsForm.tsx b/frontend/src/renderer/components/GlobalSettingsForm.tsx index 89e280e93..bdb6ddf32 100644 --- a/frontend/src/renderer/components/GlobalSettingsForm.tsx +++ b/frontend/src/renderer/components/GlobalSettingsForm.tsx @@ -1,12 +1,20 @@ import { DashboardSubhead } from "./DashboardSubhead"; +import { MigrationSection } from "./MigrationSection"; +import { UpdatesSection } from "./UpdatesSection"; -// App-wide settings, shown from the sidebar when no project is selected. The -// body is intentionally empty for now; it will be filled in incrementally. +// App-wide settings, shown from the sidebar when no project is selected. Each +// section is a self-contained card: Updates (auto-update channel, #2207) and +// Migration (re-run the legacy-AO import, #2205). export function GlobalSettingsForm() { return (
-
+
+
+ + +
+
); } diff --git a/frontend/src/renderer/components/MigrationSection.tsx b/frontend/src/renderer/components/MigrationSection.tsx new file mode 100644 index 000000000..bcdb2e546 --- /dev/null +++ b/frontend/src/renderer/components/MigrationSection.tsx @@ -0,0 +1,185 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { aoBridge } from "../lib/bridge"; +import { migrationOfferQueryKey } from "../hooks/useMigrationOffer"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import type { MigrationState, MigrationStatus } from "../../main/app-state"; +import { Button } from "./ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; + +export const migrationSettingsQueryKey = ["migration-settings"] as const; + +interface MigrationView { + migration: MigrationState; + available: boolean; + legacyRoot: string; +} + +// fetchMigrationSettings reads the persisted decision (app marker) and asks the +// daemon whether legacy data is present. Unlike useMigrationOffer it never +// short-circuits on a terminal status: Settings always shows the full state so a +// user who declined or already completed can re-run. A 501/unreachable daemon +// resolves to "not available", never an error. +async function fetchMigrationSettings(): Promise { + const migration = await aoBridge.appState.getMigration(); + const { data, error } = await apiClient.GET("/api/v1/import"); + return { + migration, + available: !error && (data?.available ?? false), + legacyRoot: data?.legacyRoot ?? "", + }; +} + +const STATUS_LABEL: Record = { + pending: "Not migrated yet", + completed: "Completed", + declined: "Declined", + failed: "Last attempt failed", +}; + +function statusClass(status: MigrationStatus): string { + switch (status) { + case "completed": + return "text-success"; + case "failed": + return "text-error"; + default: + return "text-muted-foreground"; + } +} + +function formatTime(iso?: string): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "" : d.toLocaleString(); +} + +// MigrationSection is a drop-in Settings card for re-running the legacy-AO +// import. It reads the persisted migration decision + the daemon's availability, +// shows the last report/error, and exposes a Run / Re-run button that calls the +// idempotent POST /api/v1/import (safe even when completed/declined/failed). +// Issue #2205. +export function MigrationSection() { + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: migrationSettingsQueryKey, + queryFn: fetchMigrationSettings, + }); + + const run = useMutation({ + mutationFn: async () => { + const nowIso = () => new Date().toISOString(); + const { data, error } = await apiClient.POST("/api/v1/import"); + if (error) { + const msg = apiErrorMessage(error); + await aoBridge.appState.setMigration({ status: "failed", lastAttemptAt: nowIso(), error: msg }); + throw new Error(msg); + } + const report = data?.report; + await aoBridge.appState.setMigration({ + status: "completed", + lastAttemptAt: nowIso(), + completedAt: nowIso(), + report: report + ? { projectsImported: report.projectsImported, projectsSkipped: report.projectsSkipped } + : undefined, + }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: migrationSettingsQueryKey }); + void queryClient.invalidateQueries({ queryKey: migrationOfferQueryKey }); + void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }, + }); + + const migration = query.data?.migration ?? { status: "pending" as MigrationStatus }; + const available = query.data?.available ?? false; + const legacyRoot = query.data?.legacyRoot ?? ""; + const report = migration.report; + const completed = migration.status === "completed"; + const buttonLabel = run.isPending + ? "Running…" + : completed + ? "Re-run migration" + : migration.status === "failed" + ? "Retry migration" + : "Run migration"; + + return ( + + + Migration + + +

+ Import projects and orchestrator sessions from an earlier Agent Orchestrator install. Your old files are never + modified, and this is safe to run more than once. +

+ +
+ + {STATUS_LABEL[migration.status]} + + {formatTime(migration.completedAt || migration.lastAttemptAt) && ( + + {formatTime(migration.completedAt || migration.lastAttemptAt)} + + )} + {report && ( + + + {report.projectsImported} imported, {report.projectsSkipped} already present + + + )} + + {query.isLoading ? ( + Checking… + ) : available ? ( + {legacyRoot || "found"} + ) : ( + None found + )} + +
+ + {migration.status === "failed" && migration.error && ( +

+ {migration.error}. Your legacy projects are untouched (nothing is ever deleted). +

+ )} + {run.isError && ( +

+ {run.error instanceof Error ? run.error.message : "Migration failed."} +

+ )} + {run.isSuccess && !run.isPending &&

Migration complete.

} + +
+ + {!available && !query.isLoading && ( + Nothing to import from a legacy install. + )} +
+
+
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index c900aab4e..85cd3f99b 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -6,14 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Sidebar } from "./Sidebar"; import type { WorkspaceSummary } from "../types/workspace"; -const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn() })); +const { navigateMock, mockParams } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + mockParams: { projectId: undefined as string | undefined }, +})); vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => navigateMock, - useParams: () => ({}), + useParams: () => mockParams, useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) => select({ location: { pathname: "/" } }), }; @@ -61,6 +64,7 @@ async function chooseOption(trigger: HTMLElement, optionName: string) { beforeEach(() => { navigateMock.mockReset(); + mockParams.projectId = undefined; vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "alert").mockImplementation(() => undefined); }); @@ -145,6 +149,18 @@ describe("Sidebar", () => { expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); }); + it("shows both project and global settings in the footer menu when a project is selected", async () => { + mockParams.projectId = "proj-1"; + const user = userEvent.setup(); + renderSidebar(); + + await user.click(screen.getAllByLabelText("Settings")[0]); + expect(await screen.findByRole("menuitem", { name: "Project settings" })).toBeInTheDocument(); + await user.click(await screen.findByRole("menuitem", { name: "Global settings" })); + + expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); + }); + it("always shows action icons and reserves padding for them", () => { renderSidebar(); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index e321619ed..d9620ff9c 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -286,17 +286,16 @@ export function Sidebar({ ⌘K - {selection.activeProjectId ? ( + {selection.activeProjectId && ( selection.goSettings(selection.activeProjectId!)}> - ) : ( - - )} + + @@ -347,17 +346,16 @@ export function Sidebar({ ⌘K - {selection.activeProjectId ? ( + {selection.activeProjectId && ( selection.goSettings(selection.activeProjectId!)}> - ) : ( - - )} + + {!isMac && ( diff --git a/frontend/src/renderer/components/UpdatesSection.tsx b/frontend/src/renderer/components/UpdatesSection.tsx new file mode 100644 index 000000000..3cd1e397f --- /dev/null +++ b/frontend/src/renderer/components/UpdatesSection.tsx @@ -0,0 +1,204 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { aoBridge } from "../lib/bridge"; +import type { UpdateChannel, UpdateSettings, UpdateStatus } from "../../main/update-settings"; +import { Button } from "./ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; +import { Label } from "./ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; + +export const updateSettingsQueryKey = ["update-settings"] as const; + +const CHANNEL_OPTIONS: { value: UpdateChannel; label: string }[] = [ + { value: "latest", label: "Stable (latest release)" }, + { value: "nightly", label: "Nightly (pre-release)" }, +]; + +// UpdatesSection is the Global Settings card for the desktop auto-update channel +// (issue #2207). It reads/writes ~/.ao/update-settings.json via the main process +// (the same file auto-updater.ts consumes), letting a user pick Stable vs Nightly. +// Changes apply on the next launch / update check. +export function UpdatesSection() { + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: updateSettingsQueryKey, + queryFn: () => aoBridge.updateSettings.get(), + }); + + const [form, setForm] = useState({ enabled: false, channel: "latest", nightlyAck: false }); + const [savedAt, setSavedAt] = useState(null); + + // Seed the form once settings load (and on refetch). Keying off the loaded + // value keeps local edits responsive without a controlled-from-query loop. + useEffect(() => { + if (query.data) setForm(query.data); + }, [query.data]); + + const save = useMutation({ + mutationFn: async (next: UpdateSettings) => { + await aoBridge.updateSettings.set(next); + }, + onSuccess: () => { + setSavedAt(Date.now()); + void queryClient.invalidateQueries({ queryKey: updateSettingsQueryKey }); + }, + }); + + const setEnabled = (enabled: boolean) => { + setSavedAt(null); + setForm((f) => ({ ...f, enabled })); + }; + const setChannel = (channel: UpdateChannel) => { + setSavedAt(null); + // Selecting Nightly in Settings is itself the acknowledgement of the + // instability warning shown below; Stable clears it. + setForm((f) => ({ ...f, channel, nightlyAck: channel === "nightly" })); + }; + + return ( + + + Updates + + +
+ + +
+ +
+ + +
+ + {form.channel === "nightly" && form.enabled && ( +

+ Nightly builds are cut every day and can be unstable or lose data. Only use Nightly if you are comfortable + with that. +

+ )} + +
+ + {save.isError && ( + + {save.error instanceof Error ? save.error.message : "Save failed"} + + )} + {savedAt && !save.isPending && !save.isError && Saved.} +
+ + +
+
+ ); +} + +// UpdateActions is the on-demand update control: a Check for updates button plus +// an Update button that downloads then installs. It works even when automatic +// updates are off, so users who never opted in can still pull the latest build. +function UpdateActions() { + const [status, setStatus] = useState({ state: "idle" }); + const version = useQuery({ queryKey: ["app-version"], queryFn: () => aoBridge.app.getVersion() }); + + useEffect(() => { + let live = true; + void aoBridge.updates.getStatus().then((s) => { + if (live) setStatus(s); + }); + const off = aoBridge.updates.onStatus(setStatus); + return () => { + live = false; + off?.(); + }; + }, []); + + const checking = status.state === "checking"; + const downloading = status.state === "downloading"; + const busy = checking || downloading; + + return ( +
+
+ Current version + {version.data ? `v${version.data}` : "…"} +
+
+ + + {status.state === "available" && ( + + )} + {status.state === "downloaded" && ( + + )} + + +
+
+ ); +} + +function UpdateStatusLine({ status }: { status: UpdateStatus }) { + switch (status.state) { + case "checking": + return Checking for updates…; + case "available": + return ( + + Update available{status.version ? ` (v${status.version})` : ""}. + + ); + case "downloading": + return Downloading… {status.percent ?? 0}%; + case "downloaded": + return Downloaded. Restart to finish updating.; + case "not-available": + return You're on the latest version.; + case "unsupported": + return {status.message ?? "Updates need the installed app."}; + case "error": + return {status.message ?? "Update failed."}; + default: + return null; + } +} + +function EnabledSelect({ id, value, onChange }: { id: string; value: boolean; onChange: (value: boolean) => void }) { + return ( + + ); +} diff --git a/frontend/src/renderer/hooks/useBrowserView.test.tsx b/frontend/src/renderer/hooks/useBrowserView.test.tsx index f0b295119..206b26f95 100644 --- a/frontend/src/renderer/hooks/useBrowserView.test.tsx +++ b/frontend/src/renderer/hooks/useBrowserView.test.tsx @@ -122,6 +122,60 @@ describe("useBrowserView", () => { ); }); + it("re-measures after a layout transition settles, catching a position-only shift", async () => { + // A ResizeObserver fires on size changes only; entering pop-out / opening the + // inspector moves the slot to a new x without resizing it, so the transition + // itself must drive a settle re-measure or the native overlay keeps stale + // (spilled) bounds. This is the regression behind the preview covering the + // terminal until an unrelated window resize fixed it. + vi.useFakeTimers(); + try { + const bridge = setupBridge(); + const slot = createSlot(); + const { result, rerender } = renderHook( + ({ poppedOut }) => useBrowserView({ sessionId: "sess-1", active: true, poppedOut }), + { initialProps: { poppedOut: false } }, + ); + // ensure() resolves on a microtask; flush it without advancing timers. + await act(async () => { + await Promise.resolve(); + }); + act(() => result.current.slotRef(slot)); + // Flush the mount measure (immediate frame + settle timer). + await act(async () => { + vi.advanceTimersByTime(300); + }); + expect(bridge.setBounds).toHaveBeenCalled(); + + // Pop-out transition: the immediate frame captures the still-animating + // geometry; the final position only lands once the panel has settled. + act(() => rerender({ poppedOut: true })); + await act(async () => { + vi.advanceTimersByTime(20); + }); + bridge.setBounds.mockClear(); + slot.getBoundingClientRect = vi.fn(() => ({ + x: 240, + y: 34, + width: 320, + height: 240, + top: 34, + right: 560, + bottom: 274, + left: 240, + toJSON: () => ({}), + })); + await act(async () => { + vi.advanceTimersByTime(300); + }); + expect(bridge.setBounds).toHaveBeenCalledWith( + expect.objectContaining({ rect: expect.objectContaining({ x: 240, width: 320 }) }), + ); + } finally { + vi.useRealTimers(); + } + }); + it("hides the native view when inactive and on unmount without destroying session state", async () => { const bridge = setupBridge(); const slot = createSlot(); diff --git a/frontend/src/renderer/hooks/useBrowserView.ts b/frontend/src/renderer/hooks/useBrowserView.ts index 038992c1b..066ce7505 100644 --- a/frontend/src/renderer/hooks/useBrowserView.ts +++ b/frontend/src/renderer/hooks/useBrowserView.ts @@ -77,6 +77,7 @@ export function useBrowserView({ const viewIdRef = useRef(""); const activeRef = useRef(active); const frameRef = useRef(null); + const settleTimerRef = useRef(null); const observerRef = useRef(null); const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null); @@ -123,6 +124,23 @@ export function useBrowserView({ : window.setTimeout(() => measureAndSend(), 16); }, [measureAndSend]); + // A ResizeObserver only fires on size changes, so a position-only layout shift + // leaves the native overlay at stale bounds: entering/leaving pop-out moves the + // slot into a different panel, and opening the inspector (what `ao preview` + // does) reflows the slot's x without changing the observed node's box size. + // Neither fires the observer, so the view visibly spills over the sidebar/ + // terminal until an unrelated window resize re-measures it. Re-measure now and + // again once the panel transition has settled (~240ms) so the final geometry + // always wins. + const scheduleSettleMeasure = useCallback(() => { + scheduleMeasure(); + if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current); + settleTimerRef.current = window.setTimeout(() => { + settleTimerRef.current = null; + measureAndSend(); + }, 280); + }, [measureAndSend, scheduleMeasure]); + const slotRef = useCallback( (node: HTMLDivElement | null) => { observerRef.current?.disconnect(); @@ -151,7 +169,7 @@ export function useBrowserView({ viewIdRef.current = state.viewId; setViewId(state.viewId); setNavState(state); - scheduleMeasure(); + scheduleSettleMeasure(); }); return () => { disposed = true; @@ -161,7 +179,7 @@ export function useBrowserView({ } viewIdRef.current = ""; }; - }, [scheduleMeasure, sendHiddenBounds, sessionId]); + }, [scheduleSettleMeasure, sendHiddenBounds, sessionId]); useEffect(() => { return window.ao?.browser.onNavState((state) => { @@ -172,11 +190,11 @@ export function useBrowserView({ useEffect(() => { if (active) { - scheduleMeasure(); + scheduleSettleMeasure(); } else { sendHiddenBounds(); } - }, [active, poppedOut, scheduleMeasure, sendHiddenBounds]); + }, [active, poppedOut, scheduleSettleMeasure, sendHiddenBounds]); useEffect(() => { const handle = () => scheduleMeasure(); @@ -187,6 +205,7 @@ export function useBrowserView({ window.removeEventListener("scroll", handle, true); observerRef.current?.disconnect(); cancelScheduledMeasure(); + if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current); }; }, [cancelScheduledMeasure, scheduleMeasure]); diff --git a/frontend/src/renderer/lib/bridge.ts b/frontend/src/renderer/lib/bridge.ts index 0d16ee0c1..01f15a503 100644 --- a/frontend/src/renderer/lib/bridge.ts +++ b/frontend/src/renderer/lib/bridge.ts @@ -96,4 +96,15 @@ export const aoBridge: AoBridge = getMigration: async () => ({ status: "pending" }), setMigration: async () => undefined, }, + updateSettings: { + get: async () => ({ enabled: false, channel: "latest", nightlyAck: false }), + set: async () => undefined, + }, + updates: { + getStatus: async () => ({ state: "idle" }), + check: async () => undefined, + download: async () => undefined, + install: async () => undefined, + onStatus: () => () => undefined, + }, } satisfies AoBridge); diff --git a/frontend/src/renderer/test/setup.ts b/frontend/src/renderer/test/setup.ts index 116d2cbb7..42215650e 100644 --- a/frontend/src/renderer/test/setup.ts +++ b/frontend/src/renderer/test/setup.ts @@ -141,5 +141,16 @@ if (typeof window !== "undefined") { getMigration: async () => ({ status: "pending" }), setMigration: async () => undefined, }, + updateSettings: { + get: async () => ({ enabled: false, channel: "latest", nightlyAck: false }), + set: async () => undefined, + }, + updates: { + getStatus: async () => ({ state: "idle" }), + check: async () => undefined, + download: async () => undefined, + install: async () => undefined, + onStatus: () => () => undefined, + }, }; } // end if (typeof window !== "undefined")