feat: Global Settings (migration + update channel) + ao start download progress (#2235)

* feat(settings): IPC bridge for global update settings (get/set)

Expose readUpdateSettings/writeUpdateSettings to the renderer via
updateSettings:get/set so the Global Settings page can read and persist
the auto-update channel choice. Refs #2207.

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

* feat(cli): show ao start download + install progress

The fetch path streamed a hundreds-of-MB asset and ran a silent install
with zero output, so a slow download looked like a hang. Print a start
line (asset, ~size, repo), stream a TTY-aware progress reader (live \r
percentage on a terminal, start+done lines off a TTY), and announce the
otherwise-silent unpack/install step. stderr only, so --json stdout stays
clean; no new dependency. Closes #2234.

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

* feat(settings): Updates (channel) + Migration sections in Global Settings

- UpdatesSection: pick Stable vs Nightly auto-update channel, persisted to
  ~/.ao/update-settings.json via the new IPC bridge. Closes #2207.
- MigrationSection: drop-in card showing migration status + last report/error
  and a Run/Re-run button hitting the idempotent POST /api/v1/import; works
  even after Don't Migrate. Closes #2205.
- Wire both into the (previously blank) GlobalSettingsForm; cover both with tests.

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

* chore: format with prettier [skip ci]

* fix(browser): re-measure preview bounds after layout transitions

The native WebContentsView preview is positioned from a renderer-measured
rect, re-measured on ResizeObserver + window resize/scroll. But a
ResizeObserver fires on size changes only, so a position-only layout shift
(entering/leaving pop-out moves the slot to another panel; opening the
inspector, which ao preview does, reflows its x) left the overlay at stale
bounds, visibly spilling over the sidebar/terminal until an unrelated resize
fixed it. Drive a settle re-measure on mount and on active/pop-out
transitions (immediate frame + once the ~240ms panel transition settles) so
the final geometry always wins.

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

* chore(backend): gofmt + goimports pre-existing files (fix red CI)

config.go, project_test.go, importer_test.go and spawn_windows.go were
committed unformatted earlier and fail go.yml's gofmt check and golangci's
goimports formatter on main. Apply `golangci-lint fmt`; formatting only, no
logic change. Unblocks this PR's lint/build-test jobs.

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

* feat(settings): manual Check for updates + Update button

Auto-update only checked at launch, so users with auto-updates off (or who
just want it now) had no way to update from the app. Add an on-demand flow:
checkForUpdatesNow/downloadUpdateNow/quitAndInstallUpdate in auto-updater
(works regardless of the opt-in; not-packaged dev surfaces 'unsupported'),
forwarded to the renderer as a live UpdateStatus over updates:status. The
Updates section gains a Check for updates button and an Update button that
downloads then installs (Restart & install), with checking/available/
downloading%/downloaded/error status. Refs #2207.

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

* chore: format with prettier [skip ci]

* fix(sidebar): always offer Global settings in the footer menu

The footer Settings menu showed either Project settings (with a project
selected) or Global settings (with none), so global settings was
unreachable while inside a project. Always list Global settings; add
Project settings above it when a project is active. Refs #2205.

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

* feat(settings): show current app version in Updates section

Surface app.getVersion() as a 'Current version vX.Y.Z' line above the
update controls so users can see what they're running. Refs #2207.

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

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-27 23:08:55 +05:30 committed by GitHub
parent 982cd62886
commit cfe505eb4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1097 additions and 54 deletions

View File

@ -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

View File

@ -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)

View File

@ -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 "~<size>)" 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

View File

@ -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"`

View File

@ -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

View File

@ -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) {

View File

@ -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<UpdateSettings> => {
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({

View File

@ -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<void> {
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<void> {
}
}
// 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<void> {
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<void> {
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<void> {

View File

@ -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";

View File

@ -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<void>,
},
updateSettings: {
get: () => ipcRenderer.invoke("updateSettings:get") as Promise<UpdateSettings>,
set: (settings: UpdateSettings) => ipcRenderer.invoke("updateSettings:set", settings) as Promise<void>,
},
updates: {
getStatus: () => ipcRenderer.invoke("updates:getStatus") as Promise<UpdateStatus>,
check: () => ipcRenderer.invoke("updates:check") as Promise<void>,
download: () => ipcRenderer.invoke("updates:download") as Promise<void>,
install: () => ipcRenderer.invoke("updates:install") as Promise<void>,
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);

View File

@ -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(
<QueryClientProvider client={qc}>
<GlobalSettingsForm />
</QueryClientProvider>,
);
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" }));
});
});

View File

@ -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 (
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
<DashboardSubhead title="Global settings" subtitle="Settings that apply across all projects" />
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]" />
<div className="min-h-0 flex-1 overflow-y-auto p-[18px]">
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<UpdatesSection />
<MigrationSection />
</div>
</div>
</div>
);
}

View File

@ -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<MigrationView> {
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<MigrationStatus, string> = {
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 (
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Migration</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-[12px] leading-5 text-muted-foreground">
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.
</p>
<div className="flex flex-col gap-2 text-[12px]">
<Row label="Status">
<span className={statusClass(migration.status)}>{STATUS_LABEL[migration.status]}</span>
</Row>
{formatTime(migration.completedAt || migration.lastAttemptAt) && (
<Row label={completed ? "Completed" : "Last attempt"}>
<span className="text-foreground">{formatTime(migration.completedAt || migration.lastAttemptAt)}</span>
</Row>
)}
{report && (
<Row label="Last report">
<span className="text-foreground">
{report.projectsImported} imported, {report.projectsSkipped} already present
</span>
</Row>
)}
<Row label="Legacy install">
{query.isLoading ? (
<span className="text-passive">Checking</span>
) : available ? (
<span className="font-mono text-[11px] text-foreground">{legacyRoot || "found"}</span>
) : (
<span className="text-passive">None found</span>
)}
</Row>
</div>
{migration.status === "failed" && migration.error && (
<p className="text-[12px] leading-5 text-error">
{migration.error}. Your legacy projects are untouched (nothing is ever deleted).
</p>
)}
{run.isError && (
<p className="text-[12px] leading-5 text-error">
{run.error instanceof Error ? run.error.message : "Migration failed."}
</p>
)}
{run.isSuccess && !run.isPending && <p className="text-[12px] leading-5 text-success">Migration complete.</p>}
<div className="flex items-center gap-3">
<Button
type="button"
variant="primary"
onClick={() => run.mutate()}
disabled={run.isPending || (!available && !completed)}
>
{run.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{buttonLabel}
</Button>
{!available && !query.isLoading && (
<span className="text-[12px] text-passive">Nothing to import from a legacy install.</span>
)}
</div>
</CardContent>
</Card>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="w-28 shrink-0 text-passive">{label}</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</div>
);
}

View File

@ -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<typeof import("@tanstack/react-router")>();
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();

View File

@ -286,17 +286,16 @@ export function Sidebar({
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{selection.activeProjectId ? (
{selection.activeProjectId && (
<DropdownMenuItem onSelect={() => selection.goSettings(selection.activeProjectId!)}>
<Settings aria-hidden="true" />
Project settings
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={selection.goGlobalSettings}>
<Settings aria-hidden="true" />
Global settings
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={selection.goGlobalSettings}>
<Settings aria-hidden="true" />
Global settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
@ -347,17 +346,16 @@ export function Sidebar({
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{selection.activeProjectId ? (
{selection.activeProjectId && (
<DropdownMenuItem onSelect={() => selection.goSettings(selection.activeProjectId!)}>
<Settings aria-hidden="true" />
Project settings
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={selection.goGlobalSettings}>
<Settings aria-hidden="true" />
Global settings
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={selection.goGlobalSettings}>
<Settings aria-hidden="true" />
Global settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isMac && (

View File

@ -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<UpdateSettings>({ enabled: false, channel: "latest", nightlyAck: false });
const [savedAt, setSavedAt] = useState<number | null>(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 (
<Card>
<CardHeader>
<CardTitle className="text-[13px]">Updates</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="updatesEnabled" className="text-[12px] text-muted-foreground">
Automatic updates
</Label>
<EnabledSelect id="updatesEnabled" value={form.enabled} onChange={setEnabled} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="updateChannel" className="text-[12px] text-muted-foreground">
Update channel
</Label>
<Select value={form.channel} onValueChange={(v) => setChannel(v as UpdateChannel)} disabled={!form.enabled}>
<SelectTrigger id="updateChannel" className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHANNEL_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{form.channel === "nightly" && form.enabled && (
<p className="text-[12px] leading-5 text-warning">
Nightly builds are cut every day and can be unstable or lose data. Only use Nightly if you are comfortable
with that.
</p>
)}
<div className="flex items-center gap-3">
<Button type="button" variant="primary" onClick={() => save.mutate(form)} disabled={save.isPending}>
{save.isPending ? "Saving…" : "Save changes"}
</Button>
{save.isError && (
<span className="text-[12px] text-error">
{save.error instanceof Error ? save.error.message : "Save failed"}
</span>
)}
{savedAt && !save.isPending && !save.isError && <span className="text-[12px] text-success">Saved.</span>}
</div>
<UpdateActions />
</CardContent>
</Card>
);
}
// 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<UpdateStatus>({ 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 (
<div className="flex flex-col gap-3 border-t border-border pt-4">
<div className="flex items-center gap-2 text-[12px]">
<span className="text-passive">Current version</span>
<span className="font-mono text-[11px] text-foreground">{version.data ? `v${version.data}` : "…"}</span>
</div>
<div className="flex items-center gap-3">
<Button type="button" variant="outline" onClick={() => void aoBridge.updates.check()} disabled={busy}>
{checking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Check for updates
</Button>
{status.state === "available" && (
<Button type="button" variant="primary" onClick={() => void aoBridge.updates.download()}>
Update to {status.version ? `v${status.version}` : "latest"}
</Button>
)}
{status.state === "downloaded" && (
<Button type="button" variant="primary" onClick={() => void aoBridge.updates.install()}>
Restart &amp; install
</Button>
)}
<UpdateStatusLine status={status} />
</div>
</div>
);
}
function UpdateStatusLine({ status }: { status: UpdateStatus }) {
switch (status.state) {
case "checking":
return <span className="text-[12px] text-muted-foreground">Checking for updates</span>;
case "available":
return (
<span className="text-[12px] text-muted-foreground">
Update available{status.version ? ` (v${status.version})` : ""}.
</span>
);
case "downloading":
return <span className="text-[12px] text-muted-foreground">Downloading {status.percent ?? 0}%</span>;
case "downloaded":
return <span className="text-[12px] text-success">Downloaded. Restart to finish updating.</span>;
case "not-available":
return <span className="text-[12px] text-muted-foreground">You're on the latest version.</span>;
case "unsupported":
return <span className="text-[12px] text-passive">{status.message ?? "Updates need the installed app."}</span>;
case "error":
return <span className="text-[12px] text-error">{status.message ?? "Update failed."}</span>;
default:
return null;
}
}
function EnabledSelect({ id, value, onChange }: { id: string; value: boolean; onChange: (value: boolean) => void }) {
return (
<Select value={value ? "on" : "off"} onValueChange={(v) => onChange(v === "on")}>
<SelectTrigger id={id} className="h-8 w-full text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="on">Enabled</SelectItem>
<SelectItem value="off">Disabled</SelectItem>
</SelectContent>
</Select>
);
}

View File

@ -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();

View File

@ -77,6 +77,7 @@ export function useBrowserView({
const viewIdRef = useRef("");
const activeRef = useRef(active);
const frameRef = useRef<number | null>(null);
const settleTimerRef = useRef<number | null>(null);
const observerRef = useRef<ResizeObserver | null>(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]);

View File

@ -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);

View File

@ -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")