diff --git a/CHANGELOG.md b/CHANGELOG.md index c94ba35f8..c04cc7b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Continue in keeps the session's browser. Moving a session to another profile closed its browser, and its page and cookies were gone when the session came back up. +- The session browser keeps up with a busy page. On a page that animates or runs a lot of JavaScript, the browser tab fell seconds behind the page and the rest of the window waited with it; it now skips to the newest frame. +- The session browser is sharp on a HiDPI screen. Its tab showed the page at 1x, blurred, on any display; it now draws it at up to 1.5x. - Notes from the session browser, and the Pulls screen's Fix CI errors and Resolve conflicts, reach a Claude Code session you stopped with Esc. Claude Code turned the message down until someone typed at the session, and the tray said "its mod refused it"; lich now types the message at the prompt instead. ## [0.81.0] - 2026-10-28 diff --git a/docs/ceilings.md b/docs/ceilings.md index d273cbc9a..7ef0f0157 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -977,8 +977,9 @@ work when nobody knows it and that the call site never shows. The mechanism and - **A session's browser is a second Chromium, one per card, drawn from frames** (`internal/browser`, `docs/browser-tab.md`): a headless Chrome, Edge, Chromium, Brave, Vivaldi or Helium already installed, - whose page reaches the session's browser tab as a JPEG screencast over `/events`, at the pace the window - takes frames. The native window cannot hold a webview: on Linux it speaks Wayland without GTK. So the pane + whose page reaches the session's browser tab as a JPEG screencast over `/events`, at the page's own frame + rate; a window slower to decode skips to the newest frame. A HiDPI pane gets frames drawn at 1.5x at most + (`frameScale`): the browser draws at a scale forced at its launch, and 2x halved a busy page's frame rate. The native window cannot hold a webview: on Linux it speaks Wayland without GTK. So the pane has no DevTools, shows the arrow whatever cursor the page asks for, loads what the page opens in a new tab or window in the page itself, and leaves the page's own dialogs out; Open in a window moves the page to a real Chromium window for those, reopening it on the same profile with every cookie, since chromedp cannot show a headless process in place. A half-filled form or sessionStorage is diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index e3cf7f53c..fb39e5d43 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "sync" "time" @@ -54,6 +55,8 @@ func chromeLaunch(dir string, headed bool) (Page, error) { chromedp.Flag("disable-features", disabledFeatures)) if headed { opts = append(opts, chromedp.Flag("headless", false)) + } else { + opts = append(opts, chromedp.Flag("force-device-scale-factor", strconv.FormatFloat(frameScale, 'f', -1, 64))) } allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) ctx, cancel := chromedp.NewContext(allocCtx) @@ -69,6 +72,14 @@ func chromeLaunch(dir string, headed bool) (Page, error) { return p, nil } +// frameScale is the scale a headless browser draws at, forced at its launch, +// and the most a pane's frames get. The screencast copies the browser's own +// surface, which an emulated scale does not change: a 2x pane got 1x frames, +// blurred. A busy page forced to 2 and drawn at 2x ran at 28 fps against 45 at +// 1x, and at 33 drawn at 1.5x (measured 2026-10-29): 1.5 trades sharpness +// for frame rate. +const frameScale = 1.5 + // disabledFeatures replaces chromedp's own disable-features list, which it // repeats, adding the optimization guide's model downloads. Those go through // the profile's download manager with no page and no download event, so with diff --git a/internal/browser/chrome_live_test.go b/internal/browser/chrome_live_test.go index 12ad9f8b7..a4404c7f4 100644 --- a/internal/browser/chrome_live_test.go +++ b/internal/browser/chrome_live_test.go @@ -1,9 +1,11 @@ package browser import ( + "bytes" "context" "encoding/base64" "fmt" + "image/jpeg" "net/http" "net/http/httptest" "os/exec" @@ -179,6 +181,47 @@ func TestChromeScreencastSendsFramesAndPageInfo(t *testing.T) { } } +func TestChromeScreencastDrawsAHiDPIPaneAtTheFrameScale(t *testing.T) { + p, srv := liveChrome(t) + frames := make(chan string, 1) + p.Listen(func(data string) { + select { + case frames <- data: + default: + } + }, func(string, string) {}, func(string, api.BrowserDownload) {}, func(string) {}, func(*api.BrowserDialog) {}) + ctx, cancel := context.WithTimeout(context.Background(), pageWait) + defer cancel() + if err := p.Navigate(ctx, srv.URL); err != nil { + t.Fatal(err) + } + if err := p.Screencast(ctx, Viewport{W: 400, H: 300, Scale: 2}); err != nil { + t.Fatal(err) + } + // The first frames can predate the layout at the pane's size. + deadline := time.After(10 * time.Second) + last := "none" + for { + select { + case data := <-frames: + raw, err := base64.StdEncoding.DecodeString(data) + if err != nil { + t.Fatal(err) + } + cfg, err := jpeg.DecodeConfig(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if cfg.Width == 600 && cfg.Height == 450 { + return + } + last = fmt.Sprintf("%dx%d", cfg.Width, cfg.Height) + case <-deadline: + t.Fatalf("last frame %s, want the 2x pane drawn at 1.5x, 600x450", last) + } + } +} + func TestChromeWindowInputTypesAndSubmits(t *testing.T) { p, srv := liveChrome(t) ctx, cancel := context.WithTimeout(context.Background(), pageWait) diff --git a/internal/browser/chrome_view.go b/internal/browser/chrome_view.go index 0bf914b4f..b4c70beef 100644 --- a/internal/browser/chrome_view.go +++ b/internal/browser/chrome_view.go @@ -190,6 +190,7 @@ func (p *chromePage) deliver(frame *page.EventScreencastFrame) { // Screencast starts the frames at v, or restarts them there: Chrome keeps // the size a screencast started with. func (p *chromePage) Screencast(ctx context.Context, v Viewport) error { + v = v.atFrameScale() w, h := v.devicePixels() return p.castPastDialog(func() error { if err := p.stopCast(ctx); err != nil { @@ -263,6 +264,7 @@ func (p *chromePage) castPastDialog(step func() error) error { // started (measured 2026-10-28). That size is laid out once the dialog closes; // until then the frames come at the size the page had. func (p *chromePage) Resize(ctx context.Context, v Viewport) error { + v = v.atFrameScale() err := p.run(ctx, emulation.SetDeviceMetricsOverride(int64(v.W), int64(v.H), v.Scale, false)) if !errors.Is(err, errDialogOpen) { return err @@ -343,6 +345,13 @@ var mouseTypes = map[string]input.MouseType{ // mouseButtons is CDP's buttons bit set: left 1, right 2, middle 4. var mouseButtons = map[string]int64{"left": 1, "right": 2, "middle": 4} +// atFrameScale is v at no more than frameScale: a page laid out past the +// scale its browser draws at reads a devicePixelRatio its frames do not have. +func (v Viewport) atFrameScale() Viewport { + v.Scale = min(v.Scale, frameScale) + return v +} + func (v Viewport) devicePixels() (w, h int64) { return int64(math.Round(float64(v.W) * v.Scale)), int64(math.Round(float64(v.H) * v.Scale)) } diff --git a/native/cmd/lich-native/browser_model.go b/native/cmd/lich-native/browser_model.go index 81d03476a..11dd2bd8b 100644 --- a/native/cmd/lich-native/browser_model.go +++ b/native/cmd/lich-native/browser_model.go @@ -7,7 +7,9 @@ import ( "fmt" "image" "image/jpeg" + "log" "slices" + "sync" "time" "gioui.org/op/paint" @@ -24,6 +26,14 @@ import ( type browserFrame struct { img paint.ImageOp size image.Point + // seq orders the frames of a page: decoders finish out of order. + seq uint64 +} + +// pendingFrame is a page's newest frame not yet decoded. +type pendingFrame struct { + ev backend.Event + seq uint64 } // browserMark is one agent action on its page: which one, where, and for a @@ -45,12 +55,17 @@ const agentMarksKept = 8 type browserStore struct { states map[string]api.BrowserState frames map[string]browserFrame - agent map[string][]browserMark - seq uint64 + // pending is each page's newest frame not yet decoded (decodeFrames), + // under its own lock: the /events loop never waits on m.mu for a frame. + pendingMu sync.Mutex + pending map[string]pendingFrame + frameSeq uint64 + agent map[string][]browserMark + seq uint64 } func newBrowserStore() browserStore { - return browserStore{states: map[string]api.BrowserState{}, frames: map[string]browserFrame{}, agent: map[string][]browserMark{}} + return browserStore{states: map[string]api.BrowserState{}, frames: map[string]browserFrame{}, pending: map[string]pendingFrame{}, agent: map[string][]browserMark{}} } // hydrateBrowsers reads the browsers running now, and takes out of the @@ -105,6 +120,9 @@ func (m *model) applyBrowserState(ctx context.Context, st api.BrowserState) { } else { delete(m.browsers.states, st.Owner) delete(m.browsers.frames, st.Owner) + m.browsers.pendingMu.Lock() + delete(m.browsers.pending, st.Owner) + m.browsers.pendingMu.Unlock() delete(m.browsers.agent, st.Owner) } shownTab := "" @@ -124,9 +142,66 @@ func (m *model) applyBrowserState(ctx context.Context, st api.BrowserState) { } } +// frameDecoders is how many frames the window decodes at once. A busy page's +// 1280x720 frame takes 15 ms to decode (measured 2026-10-29), so one decoder +// only just keeps up with 60 fps; four leave room for a slower CPU. +const frameDecoders = 4 + +// offerBrowserFrame hands owner's newest frame to decodeFrames, replacing one +// no decoder has taken yet. +func (m *model) offerBrowserFrame(owner string, ev backend.Event, kick chan<- struct{}) { + m.browsers.pendingMu.Lock() + m.browsers.frameSeq++ + m.browsers.pending[owner] = pendingFrame{ev: ev, seq: m.browsers.frameSeq} + m.browsers.pendingMu.Unlock() + select { + case kick <- struct{}{}: + default: + } +} + +// takeBrowserFrame takes one page's pending frame, false for none. +func (m *model) takeBrowserFrame() (string, pendingFrame, bool) { + m.browsers.pendingMu.Lock() + defer m.browsers.pendingMu.Unlock() + for owner, f := range m.browsers.pending { + delete(m.browsers.pending, owner) + return owner, f, true + } + return "", pendingFrame{}, false +} + +// decodeFrames decodes, off the /events loop and frameDecoders at a time, +// only each page's newest frame, until kick closes; then it closes the +// returned channel. A page animating at 60 fps sends frames faster than one +// decode takes, and decoding each in turn queued them for seconds, and every +// other event behind them. kick holds a wake per decoder. +func (m *model) decodeFrames(kick <-chan struct{}) <-chan struct{} { + var wg sync.WaitGroup + for range frameDecoders { + wg.Go(func() { + for range kick { + for owner, f, ok := m.takeBrowserFrame(); ok; owner, f, ok = m.takeBrowserFrame() { + if err := m.applyBrowserFrame(owner, f.ev, f.seq); err != nil { + log.Print(err) + } + m.invalidate() + } + } + }) + } + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + return done +} + // applyBrowserFrame decodes one frame of owner's page off the frame -// goroutine, so the pane only draws it. -func (m *model) applyBrowserFrame(owner string, ev backend.Event) error { +// goroutine, so the pane only draws it, and keeps it unless a newer one +// landed first. +func (m *model) applyBrowserFrame(owner string, ev backend.Event, seq uint64) error { data, err := backend.Decode(ev, api.BrowserFrameEvents.Of(owner)) if err != nil { return err @@ -139,9 +214,11 @@ func (m *model) applyBrowserFrame(owner string, ev backend.Event) error { if err != nil { return fmt.Errorf("%s: %w", ev.Name, err) } - frame := browserFrame{img: paint.NewImageOp(img), size: img.Bounds().Size()} + frame := browserFrame{img: paint.NewImageOp(img), size: img.Bounds().Size(), seq: seq} m.mu.Lock() - m.browsers.frames[owner] = frame + if frame.seq > m.browsers.frames[owner].seq { + m.browsers.frames[owner] = frame + } m.mu.Unlock() return nil } diff --git a/native/cmd/lich-native/browser_model_test.go b/native/cmd/lich-native/browser_model_test.go index 5971d7c91..e0b1cf81b 100644 --- a/native/cmd/lich-native/browser_model_test.go +++ b/native/cmd/lich-native/browser_model_test.go @@ -4,10 +4,15 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "image" "image/jpeg" + "log" + "os" "path/filepath" + "runtime" "testing" + "time" "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" @@ -109,6 +114,106 @@ func TestBrowserFrameIsDecodedForThePane(t *testing.T) { } } +func TestBrowserFramesDecodeTwoAtOnce(t *testing.T) { + m, _ := newLayoutsModel(t) + kick := make(chan struct{}, frameDecoders) + decoded := m.decodeFrames(kick) + name := api.BrowserFrameEvents.Of("orch").Name + m.mu.Lock() + for range 2 { + if !takenWhileDecoding(m, jpegFrame(t, name, 8), kick) { + m.mu.Unlock() + t.Fatal("a frame waited for a busy decoder") + } + } + m.mu.Unlock() + close(kick) + <-decoded +} + +func TestBrowserFrameSurplusDecodesOnlyTheNewest(t *testing.T) { + m, _ := newLayoutsModel(t) + var logged bytes.Buffer + log.SetOutput(&logged) + t.Cleanup(func() { log.SetOutput(os.Stderr) }) + name := api.BrowserFrameEvents.Of("orch").Name + kick := make(chan struct{}, frameDecoders) + decoded := m.decodeFrames(kick) + m.mu.Lock() + for range frameDecoders { + if !takenWhileDecoding(m, jpegFrame(t, name, 8), kick) { + m.mu.Unlock() + t.Fatal("a frame waited for a busy decoder while another was free") + } + } + newest := jpegFrame(t, name, 9) + offered := make(chan struct{}) + go func() { + // A stale frame that fails to decode: decoding it would log. + m.offerBrowserFrame("orch", backend.Event{Name: name, Data: json.RawMessage(`"!!"`)}, kick) + m.offerBrowserFrame("orch", newest, kick) + close(offered) + }() + select { + case <-offered: + case <-time.After(2 * time.Second): + t.Error("offering a frame waited on a decode") + } + m.mu.Unlock() + <-offered + close(kick) + <-decoded + if _, f, _ := m.browserView("orch"); f.size != image.Pt(9, 6) { + t.Fatalf("frame size %v, want the newest", f.size) + } + if logged.Len() > 0 { + t.Fatalf("decoded a frame a newer one replaced: %s", logged.String()) + } +} + +func TestOlderBrowserFrameDecodedLastKeepsTheNewer(t *testing.T) { + m, _ := newLayoutsModel(t) + name := api.BrowserFrameEvents.Of("orch").Name + if err := m.applyBrowserFrame("orch", jpegFrame(t, name, 9), 2); err != nil { + t.Fatal(err) + } + if err := m.applyBrowserFrame("orch", jpegFrame(t, name, 8), 1); err != nil { + t.Fatal(err) + } + if _, f, _ := m.browserView("orch"); f.size != image.Pt(9, 6) { + t.Fatalf("frame size %v, want the newer frame's", f.size) + } +} + +// takenWhileDecoding offers frame with m.mu held, which keeps each decoder +// that takes one waiting to keep it, as a decode slower than the page sends +// frames would; it reports whether a decoder took it. +func takenWhileDecoding(m *model, frame backend.Event, kick chan<- struct{}) bool { + m.offerBrowserFrame("orch", frame, kick) + deadline := time.Now().Add(2 * time.Second) + for pendingFrames(m) > 0 && time.Now().Before(deadline) { + runtime.Gosched() + } + return pendingFrames(m) == 0 +} + +// jpegFrame is a frame event of a w by 6 page. +func jpegFrame(t *testing.T, name string, w int) backend.Event { + t.Helper() + var buf bytes.Buffer + if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, 6)), nil); err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(base64.StdEncoding.EncodeToString(buf.Bytes())) + return backend.Event{Name: name, Data: data} +} + +func pendingFrames(m *model) int { + m.browsers.pendingMu.Lock() + defer m.browsers.pendingMu.Unlock() + return len(m.browsers.pending) +} + func TestTypedURL(t *testing.T) { cases := map[string]string{ "": "about:blank", diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index d8815e450..c74b15be8 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -305,12 +305,12 @@ func (m *model) hydrateTurnStates(ctx context.Context) error { // what hydrateTurnStates read. When the stream ends first, the backend dropped this window: /events // serves one client and a newer one replaces it, or lich is gone. func (m *model) follow(ctx context.Context, events <-chan backend.Event) { + kick := make(chan struct{}, frameDecoders) + decoded := m.decodeFrames(kick) + defer func() { close(kick); <-decoded }() for ev := range events { if owner, frame := api.BrowserFrameEvents.ID(ev.Name); frame { - if err := m.applyBrowserFrame(owner, ev); err != nil { - log.Print(err) - } - m.invalidate() + m.offerBrowserFrame(owner, ev, kick) continue } if id, exited := api.TerminalExitEvents.ID(ev.Name); exited {