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:
parent
982cd62886
commit
cfe505eb4d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isMac && (
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue