Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/browser/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"time"

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions internal/browser/chrome_live_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package browser

import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image/jpeg"
"net/http"
"net/http/httptest"
"os/exec"
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions internal/browser/chrome_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
91 changes: 84 additions & 7 deletions native/cmd/lich-native/browser_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"fmt"
"image"
"image/jpeg"
"log"
"slices"
"sync"
"time"

"gioui.org/op/paint"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 := ""
Expand All @@ -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
Expand All @@ -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
}
Expand Down
Loading
Loading