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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- The Pulls screen opens on an index of the project's pull requests across the whole screen, and a pull request opens in its place instead of beside the list. A back button over its title returns to the index as you left it; beside it, the number opens a menu of the other pull requests, and previous and next walk the index's filter. On the Files tab only the file tree sits beside the diff, which gains the width the list took. The Overview puts the description beside a column with the reviewers, checks, mergeability and the checkout the branch sits in; the tab strip ends with the pull request's size. Open in Session and Merge stay in the header, and the hand-off, Approve, Refresh and Open on GitHub move to the ⋯ menu. On the index, j and k or the arrows move, Enter opens and / goes to the filter; on a pull request, Escape goes back and Alt with an arrow steps to the previous or next one.

### Fixed

- A file input in the session browser, such as a profile picture upload, opens the system's file chooser when you click it, and takes several files when the input allows it. Before, the click did nothing.
- Leave on a page in the session browser that asks before you go no longer reloads the page instead on a busy machine; the browser goes where it was going.

## [0.83.0] - 2026-10-29

### Added
Expand Down
12 changes: 11 additions & 1 deletion docs/browser-tab.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,17 @@ input. lich answers no dialog on its own:

Accepting a `beforeunload` less than a second after it opened waits out the
rest of that second: Chrome aborts the navigation it held when it is accepted
sooner, and reloads the page, which asks again.
sooner, and reloads the page, which asks again. On a busy machine that second
can still be too soon; lich then takes the leave up again once the reload
lands, so Leave goes where it was going.

## File inputs

A click on a file input opens the system's file chooser, the one the footer's
attach button opens, for one file or several as the input asks; what the
person picks fills the input, and Cancel leaves it as it was. It opens only
while a pane shows the page: an agent's click on a file input in a page nobody
is looking at leaves it empty, and no tool fills one.

## Languages of the page answer

Expand Down
13 changes: 12 additions & 1 deletion docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -1065,14 +1065,25 @@ work when nobody knows it and that the call site never shows. The mechanism and
stood, not as the dialog's answer left it. A dialog the page opens on a timer interrupts whichever call is
running. Accepting a `beforeunload` less than a second after it opened is held to that second: accepted sooner,
Chrome aborts the navigation it held and reloads the page, which asks again (3 of 3 at 300 ms, 3 of 6 at 400 ms,
none of 30 from 500 ms; Chrome for Testing 155 and Helium 0.19.2.1). The agent hears of a dialog the person
none of 30 from 500 ms; Chrome for Testing 155 and Helium 0.19.2.1). That bound grows with the machine's load
(2 to 3 s with Chromium held to 30% of a core), so a reload after an accepted `beforeunload` is taken for an
abort: lich accepts the reload's own `beforeunload` unseen and loads the held URL again once the reload lands,
up to three times, and the agent's answer waits up to 15 seconds for it. A held navigation that a redirect
would have sent elsewhere is taken up at the URL it started with. The agent hears of a dialog the person
answered, at most twenty between two page answers, but not of one the agent itself answered. A held page cannot lay
out, so a pane opened or resized meanwhile shows it at the size it had, and lays it out at the pane's once the
dialog closes; a screenshot fails until then. On Chrome 153 and older, which answers a screencast's start and
stop only once the dialog closes, a pane resized while one is open keeps its last frame until it is answered
(Chrome 151 on the macos-15-intel runner, measured; 154 on answers at once).
A window opened with Open in a window was not tried with a dialog.

- **A file input in a session's page opens the OS file chooser only while a pane shows the page**
(`internal/browser/files.go`, `chrome_files.go`): headless Chrome has no chooser of its own, so lich intercepts
it and opens the one the attach button uses, one file or several as the input asks. An agent's click on a file
input in a page nobody is looking at leaves the input empty, and no tool fills one; an agent's click while a
pane shows the page opens the chooser in front of the person. The input's `accept` filter is not passed on: the
chooser lists every file and the page refuses a type it does not take.

## Languages

- **The prompt language reaches a running session only through lich itself** (`internal/prompt`): text lich
Expand Down
4 changes: 4 additions & 0 deletions internal/browser/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ type Service struct {
// isLocal and local are SetLocal's.
isLocal func(raw string) bool
local func(owner, raw string) (string, error)
// pickFile and pickFiles are SetPicker's.
pickFile func(title string) (string, error)
pickFiles func(title string) ([]string, error)
}

type session struct {
Expand Down Expand Up @@ -286,6 +289,7 @@ func (s *Service) start(spec launchSpec) (Handle, error) {
id: newID(), owner: spec.owner, dir: spec.dir, url: spec.url, headed: spec.headed,
byAgent: spec.byAgent, page: page, lastUsed: s.now(),
}
page.OnFileChooser(s.fileSink(sess))
page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess), s.consoleSink(sess), s.dialogSink(sess), s.tabsSink(sess))
view := s.unwatchedView()
if err := runPage(func(ctx context.Context) error {
Expand Down
8 changes: 7 additions & 1 deletion internal/browser/chrome.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func chromeLaunch(dir string, headed bool) (Page, error) {
}
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, cancel := chromedp.NewContext(allocCtx)
if err := chromedp.Run(ctx); err != nil {
if err := chromedp.Run(ctx, interceptFiles); err != nil {
cancel()
allocCancel()
return nil, err
Expand Down Expand Up @@ -107,6 +107,11 @@ type chromePage struct {
castMu sync.Mutex
castLast chan struct{}
casting bool
// leaveMu guards held, the navigation the open beforeunload holds, and
// leave, the one an accepted beforeunload left for (chrome_leave.go).
leaveMu sync.Mutex
held *leave
leave *leave
}

// downloadFolder is downloadDir, which the live tests point at a folder of
Expand Down Expand Up @@ -234,6 +239,7 @@ var errNavAborted = errors.New("page load error " + abortedText)
const navGrace = 150 * time.Millisecond

func (p *chromePage) Settle(ctx context.Context) error {
p.awaitLeave(ctx)
return p.run(ctx, settleAfter(func(ctx context.Context) error {
t := time.NewTimer(navGrace)
defer t.Stop()
Expand Down
8 changes: 7 additions & 1 deletion internal/browser/chrome_dialog.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,11 @@ func (p *chromePage) layOut(v Viewport) {
func (p *chromePage) AnswerDialog(ctx context.Context, accept bool, text string) error {
tabCtx, cancel := withPageDeadline(p.tabs.dialogHolder().ctx, ctx)
defer cancel()
return chromedp.Run(tabCtx, page.HandleJavaScriptDialog(accept).WithPromptText(text))
// The leave is in place before the page can land on its new document.
l := p.leaving(accept)
if err := chromedp.Run(tabCtx, page.HandleJavaScriptDialog(accept).WithPromptText(text)); err != nil {
p.forgetLeave(l)
return err
}
return nil
}
44 changes: 44 additions & 0 deletions internal/browser/chrome_files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package browser

import (
"context"
"log/slog"

"github.com/chromedp/cdproto/dom"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)

// interceptFiles turns a file input's chooser into an event: a headless
// Chrome has no dialog to show, and a click on the input did nothing.
var interceptFiles = page.SetInterceptFileChooserDialog(true)

func (p *chromePage) OnFileChooser(pick func(page.FileChooserOpenedMode) ([]string, error)) {
p.listen.mu.Lock()
p.listen.onFileChooser = pick
p.listen.mu.Unlock()
}

// chooseFiles fills the file input of t that asked for files with what the
// picker answers.
func (p *chromePage) chooseFiles(t *tab, e *page.EventFileChooserOpened) {
p.listen.mu.Lock()
pick := p.listen.onFileChooser
p.listen.mu.Unlock()
if pick == nil {
return
}
paths, err := pick(e.Mode)
if err != nil {
slog.Warn("browser: open the file chooser", "err", err)
return
}
if len(paths) == 0 {
return
}
ctx, cancel := context.WithTimeout(t.ctx, pageWait)
defer cancel()
if err := chromedp.Run(ctx, dom.SetFileInputFiles(paths).WithBackendNodeID(e.BackendNodeID)); err != nil && p.Alive() {
slog.Warn("browser: fill a file input", "err", err)
}
}
154 changes: 154 additions & 0 deletions internal/browser/chrome_leave.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package browser

import (
"context"
"log/slog"
"time"

"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)

// A beforeunload accepted too soon after it opened has Chrome abort the
// navigation it held and reload the page instead, and how soon is too soon
// grows with the machine's load: about 450 ms on an idle one, 2 to 3 s with
// the browser held to 30% of a core (Chromium 155, measured 2026-10-30). No
// CDP event marks the moment, so beforeunloadSettle is a guess, and a leave
// watches for the reload and goes where the person said to go once it lands.

// leaveTries is how many aborted leaves are taken up again before the page
// is left where Chrome put it.
const leaveTries = 3

// leaveWait bounds how long Settle waits on a leave: a held navigation that
// turned into a download never lands.
const leaveWait = 15 * time.Second

// leave is a beforeunload lich accepted, until the navigation it held lands.
type leave struct {
tab *tab
url string
// aborted: Chrome reloaded the page instead.
aborted bool
tries int
done chan struct{}
}

// holdLeave keeps url, the navigation the dialog t just opened holds, for
// the accept that would leave for it; "" for a dialog that holds none.
func (p *chromePage) holdLeave(t *tab, url string) {
p.leaveMu.Lock()
defer p.leaveMu.Unlock()
p.held = &leave{tab: t, url: url}
}

// leaving is the answer to the open dialog: the leave an accepted
// beforeunload starts, nil for any other answer.
func (p *chromePage) leaving(accept bool) *leave {
p.leaveMu.Lock()
defer p.leaveMu.Unlock()
held := p.held
p.held = nil
if !accept || held == nil || held.url == "" {
return nil
}
held.done = make(chan struct{})
p.leave = held
return held
}

// forgetLeave drops l, the leave of an answer that never reached Chrome.
func (p *chromePage) forgetLeave(l *leave) {
p.leaveMu.Lock()
defer p.leaveMu.Unlock()
if l != nil && p.leave == l {
close(l.done)
p.leave = nil
}
}

// leaveStarted marks the leave on t aborted when Chrome starts reloading the
// page instead.
func (p *chromePage) leaveStarted(t *tab, kind page.FrameStartedNavigatingNavigationType) {
p.leaveMu.Lock()
defer p.leaveMu.Unlock()
if l := p.leave; l != nil && l.tab == t && kind == page.FrameStartedNavigatingNavigationTypeReload {
l.aborted = true
}
}

// leaveLanded ends the leave on t once a document lands, or, when it is the
// reload of an aborted one, goes for the held navigation again. The reloaded
// document has no user activation, so that navigation asks nothing.
func (p *chromePage) leaveLanded(t *tab) {
p.leaveMu.Lock()
defer p.leaveMu.Unlock()
l := p.leave
if l == nil || l.tab != t {
return
}
if !l.aborted || l.tries >= leaveTries {
close(l.done)
p.leave = nil
return
}
l.aborted = false
l.tries++
go p.leaveAgain(t, l.url)
}

func (p *chromePage) leaveAgain(t *tab, url string) {
ctx, cancel := context.WithTimeout(t.ctx, pageWait)
defer cancel()
if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
_, _, _, _, err := page.Navigate(url).Do(ctx)
return err
})); err != nil && p.Alive() {
slog.Warn("browser: take up a leave Chrome aborted", "url", url, "err", err)
}
}

// reloadAsks accepts, without showing it, the beforeunload a reload of an
// aborted leave on t opens, since the person already chose to leave; false
// when there is no such leave. It waits longer each try for the same reason
// the first accept waited.
func (p *chromePage) reloadAsks(t *tab) bool {
p.leaveMu.Lock()
l := p.leave
ok := l != nil && l.tab == t && l.aborted
var wait time.Duration
if ok {
wait = time.Duration(l.tries+2) * beforeunloadSettle
}
p.leaveMu.Unlock()
if !ok {
return false
}
go func() {
time.Sleep(wait)
ctx, cancel := context.WithTimeout(t.ctx, pageWait)
defer cancel()
if err := chromedp.Run(ctx, page.HandleJavaScriptDialog(true)); err != nil && p.Alive() {
slog.Warn("browser: accept the beforeunload of a reload", "err", err)
}
}()
return true
}

// awaitLeave waits for a leave in flight to land, bounded by ctx and
// leaveWait.
func (p *chromePage) awaitLeave(ctx context.Context) {
p.leaveMu.Lock()
l := p.leave
p.leaveMu.Unlock()
if l == nil {
return
}
t := time.NewTimer(leaveWait)
defer t.Stop()
select {
case <-l.done:
case <-ctx.Done():
case <-t.C:
}
}
75 changes: 75 additions & 0 deletions internal/browser/chrome_leave_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package browser

import (
"context"
"testing"

"github.com/chromedp/cdproto/page"
)

func landedPage(t *testing.T) (*chromePage, *tab) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
// A gone page: the re-navigation of an aborted leave fails quietly.
cancel()
return &chromePage{ctx: ctx}, &tab{ctx: ctx, id: "t"}
}

func isDone(l *leave) bool {
select {
case <-l.done:
return true
default:
return false
}
}

func TestALeaveEndsWhereTheNavigationLands(t *testing.T) {
p, tb := landedPage(t)
p.holdLeave(tb, "http://x/next")
l := p.leaving(true)
if l == nil {
t.Fatal("an accepted beforeunload left for nothing")
}
p.leaveStarted(tb, page.FrameStartedNavigatingNavigationTypeDifferentDocument)
p.leaveLanded(tb)
if !isDone(l) || p.leave != nil {
t.Fatal("the leave outlived the document it landed on")
}
}

func TestAnAbortedLeaveIsTakenUpAgainUntilItsTriesRunOut(t *testing.T) {
p, tb := landedPage(t)
p.holdLeave(tb, "http://x/next")
l := p.leaving(true)
for try := 1; try <= leaveTries; try++ {
p.leaveStarted(tb, page.FrameStartedNavigatingNavigationTypeReload)
if !p.reloadAsks(tb) {
t.Fatalf("try %d: the reload's beforeunload was left to show", try)
}
p.leaveLanded(tb)
if isDone(l) || l.tries != try {
t.Fatalf("try %d: done %v after %d tries, want it taken up again", try, isDone(l), l.tries)
}
}
p.leaveStarted(tb, page.FrameStartedNavigatingNavigationTypeReload)
p.leaveLanded(tb)
if !isDone(l) {
t.Fatal("the leave went on past its tries")
}
}

func TestOnlyAnAcceptedBeforeunloadLeaves(t *testing.T) {
p, tb := landedPage(t)
p.holdLeave(tb, "http://x/next")
if p.leaving(false) != nil {
t.Fatal("a dismissed beforeunload left")
}
p.holdLeave(tb, "")
if p.leaving(true) != nil {
t.Fatal("an accepted alert left")
}
if p.reloadAsks(tb) {
t.Fatal("a beforeunload with no leave was accepted unseen")
}
}
Loading
Loading