From ee9bdd089feeae4db6f107bdfb92198fc002f691 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 30 Oct 2026 11:59:40 -0300 Subject: [PATCH] feat(browser): ask before closing an idle session browser The reaper closed a headless browser no pane showed after five minutes unused, so working in another session for five minutes lost the page, its tabs and its login. It now marks the browser idle in its state and the window asks with a toast: Close closes it, Keep open (or a swipe) keeps it for another idle window, and unanswered it stays open and is not asked about again. Any use withdraws the question, and a window that reopens reads it from browser.Running. Three reaper tests change because the contract changed: they asserted the browser closed on its own. --- CHANGELOG.md | 1 + api/browser.go | 8 ++ api/testdata/contract.golden | 2 + docs/browser-tab.md | 6 +- docs/ceilings.md | 4 +- internal/browser/actions.go | 39 ++++--- internal/browser/browser.go | 41 ++++---- internal/browser/browser_test.go | 101 +++++++++++++++++-- internal/browser/rpc.go | 5 + internal/browser/view.go | 2 +- internal/browser/view_test.go | 6 +- internal/routes/browser.go | 1 + native/backend/browser.go | 7 ++ native/cmd/lich-native/browser_model.go | 17 +++- native/cmd/lich-native/browser_model_test.go | 32 ++++++ native/cmd/lich-native/browser_window.go | 47 +++++++++ native/cmd/lich-native/main.go | 1 + native/cmd/lich-native/model.go | 4 +- native/cmd/lich-native/model_layouts.go | 11 ++ native/cmd/lich-native/toasts.go | 7 +- native/i18n/locales/en/layouts.json | 3 + native/i18n/locales/es/layouts.json | 3 + native/i18n/locales/ja/layouts.json | 3 + native/i18n/locales/ko/layouts.json | 3 + native/i18n/locales/pt-br/layouts.json | 3 + native/i18n/locales/ru/layouts.json | 3 + native/i18n/locales/zh-cn/layouts.json | 3 + native/ui/toast.go | 3 + native/ui/toast_test.go | 43 ++++++++ native/ui/toast_view.go | 6 +- 30 files changed, 365 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb18db4f7..64f7a2180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - 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. +- A session's browser is no longer closed without asking. When no pane shows it and nobody has used it for five minutes, a toast asks whether to close it: Close closes it, and Keep open, or swiping the toast away, keeps it for another five minutes. Unanswered, the toast stays up and the browser stays open, so a page left open over lunch is still there when you come back. Using the browser, or the agent using it, takes the toast down. Before, the browser was closed after five minutes away from its session, and its page, tabs and login were lost. ### Fixed diff --git a/api/browser.go b/api/browser.go index 8302cd0ab..54e93dc79 100644 --- a/api/browser.go +++ b/api/browser.go @@ -34,6 +34,10 @@ var ( BrowserAttach = define[BrowserViewOptions, BrowserHandle]("browser.Attach") BrowserDetach = define[SessionIDOptions, None]("browser.Detach") BrowserResize = define[BrowserViewOptions, None]("browser.Resize") + // BrowserKeep answers BrowserState.Idle with keep: the browser stays and + // is asked about again after another idle window. browser.Close answers + // it with close. + BrowserKeep = define[SessionIDOptions, None]("browser.Keep") // BrowserDefaultView is the window's size, which a page no pane shows // lays out at. BrowserDefaultView = define[BrowserDefaultViewOptions, None]("browser.DefaultView") @@ -110,6 +114,10 @@ type BrowserState struct { Dialog *BrowserDialog `json:"dialog,omitempty"` // Tabs are the browser's tabs in the order the pane shows them. Tabs []BrowserTab `json:"tabs,omitempty"` + // Idle is the reaper asking whether to close it: headless, no pane shows + // it, and nobody used it for the idle window. It stays until browser.Close, + // browser.Keep or a use answers it; the browser stays open meanwhile. + Idle bool `json:"idle,omitempty"` } // BrowserTab is one tab of a session's browser. ID is Chrome's own for it; diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index 7db8709e7..b9de4049a 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -14,6 +14,7 @@ browser.Detach(SessionIDOptions) None browser.ElementAt(BrowserPointOptions) BrowserElement browser.Forward(SessionIDOptions) None browser.Info(BrowserInfoOptions) BrowserPageInfo +browser.Keep(SessionIDOptions) None browser.Key(BrowserKeyOptions) None browser.List(SessionIDOptions) []BrowserHandle browser.Locate(BrowserLocateOptions) []BrowserRect @@ -452,6 +453,7 @@ BrowserState { title string dialog,omitempty *BrowserDialog tabs,omitempty []BrowserTab + idle,omitempty bool } BrowserScreenshotOptions { diff --git a/docs/browser-tab.md b/docs/browser-tab.md index b4c0fb80e..9b3364483 100644 --- a/docs/browser-tab.md +++ b/docs/browser-tab.md @@ -36,7 +36,11 @@ clicked. `browser-state` tells the window when a browser starts, closes, moves to a window or loads another page; `browser.Running` is what a window reads when it attaches. A browser no pane shows and no one used for five minutes is -closed; one a pane shows, or a window, is not. The tab takes no other +never closed on its own: its state turns `idle`, the window asks with a toast, +and Close (`browser.Close`) or Keep open (`browser.Keep`, five more minutes) +answers. Unanswered, it stays open and is not asked about again; any use +withdraws the question. One a pane shows, or a window, is never asked about. +The tab takes no other session, and closing its page closes the browser. ## What the agent may load diff --git a/docs/ceilings.md b/docs/ceilings.md index 578ba653e..9db6efbf8 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -1009,7 +1009,9 @@ work when nobody knows it and that the call site never shows. The mechanism and lost on that move. Closing the card, or the session's process ending, closes the browser; Continue in, which ends the process and spawns it again in the same card, keeps it. Closing that window does not close the session. A headless browser no pane shows and no one used for five minutes is - closed; one a pane shows, or a window, never is. It loads only http, https and about:blank. Page info never + only asked about, in a toast, and closes only on Close: an unanswered one holds its Chromium until the + person answers or closes the card, and with the window closed nobody is asked; one a pane shows, or a + window, never is. It loads only http, https and about:blank. Page info never includes a form's value. Screenshots are files. There is no tool that runs JavaScript in the page. It is not a sandbox: the page runs as the user, in an ephemeral profile. Chromium needs its own sandbox, and on Ubuntu 23.10 and later AppArmor refuses it the user namespace unless the browser ships an AppArmor profile, diff --git a/internal/browser/actions.go b/internal/browser/actions.go index f0eeebe25..0bfc7eab1 100644 --- a/internal/browser/actions.go +++ b/internal/browser/actions.go @@ -13,7 +13,6 @@ var errNoBrowser = fmt.Errorf("no browser for this session — open one first") func (s *Service) live(id string) (*session, error) { s.mu.Lock() defer s.unlock() - s.reapLocked() sess := s.lookupLocked(id) if sess == nil { return nil, errNoBrowser @@ -21,12 +20,29 @@ func (s *Service) live(id string) (*session, error) { return sess, nil } +// touch marks sess used now, which withdraws the reaper's question about it. func (s *Service) touch(sess *session) { s.mu.Lock() - defer s.mu.Unlock() - if s.byID[sess.id] == sess { - sess.lastUsed = s.now() + registered := s.byID[sess.id] == sess + withdrawn := registered && sess.asked + if registered { + sess.lastUsed, sess.asked = s.now(), false + } + s.mu.Unlock() + if withdrawn { + s.emitState(sess, true) + } +} + +// Keep answers the reaper's question about owner's browser with keep: it +// stays, and is asked about again once it sits idle another idle window. +func (s *Service) Keep(owner string) error { + sess, err := s.live(owner) + if err != nil { + return err } + s.touch(sess) + return nil } // drop takes out a browser a call failed on, if its Chrome is gone. One that @@ -46,15 +62,14 @@ func (s *Service) drop(sess *session) { func (s *Service) remember(sess *session, url, title string) { s.mu.Lock() - defer s.mu.Unlock() - if s.byID[sess.id] != sess { - return - } - if url != "" { - sess.url = url + if s.byID[sess.id] == sess { + if url != "" { + sess.url = url + } + sess.title = title } - sess.title = title - sess.lastUsed = s.now() + s.mu.Unlock() + s.touch(sess) } // Info returns the page the agent is looking at. Never includes form values. diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 1c45913d7..ea8bf7ed0 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -16,15 +16,16 @@ import ( ) // idleAfter is how long a headless browser no pane shows sits unused before -// the reaper closes it. Five minutes matches the "walked away from the card" -// window without holding a Chromium for a session that finished hours ago. A -// headed window is never reaped: lich cannot see a person using it, so only -// the card closing ends it. +// the reaper asks the window whether to close it. Five minutes matches the +// "walked away from the card" window. The reaper never closes one on its own: +// a person away for an hour comes back to the page they left. A headed window +// is never asked about: lich cannot see a person using it, so only the card +// closing ends it. const idleAfter = 5 * time.Minute // reapEvery is how often the reaper looks. Shorter than idleAfter so a -// browser that went idle is gone within a fraction of the idle window, not a -// full extra one. +// browser that went idle is asked about within a fraction of the idle window, +// not a full extra one. const reapEvery = 30 * time.Second // pageWait bounds one CDP action. Under the 90 seconds an MCP tool call may @@ -80,8 +81,10 @@ type session struct { url, title string headed bool // byAgent: the agent started it, not the window. watching: a pane shows - // it, so the reaper leaves it alone like a headed window. - byAgent, watching bool + // it, so the reaper leaves it alone like a headed window. asked: the + // reaper asked the window whether to close it, and nobody has answered or + // used it since. + byAgent, watching, asked bool // agentAt is where the agent last acted, where its next scroll turns. agentAt *point // downloads are the page's, until the agent has heard how each ended; @@ -338,11 +341,10 @@ func (s *Service) ownerLock(owner string) *sync.Mutex { return lock } -// current is owner's live browser after a reap, or nil. +// current is owner's live browser, or nil. func (s *Service) current(owner string) *session { s.mu.Lock() defer s.unlock() - s.reapLocked() return s.liveByOwner(owner) } @@ -431,21 +433,22 @@ func (s *Service) startReaper() { }() } -// Reap closes headless browsers no pane shows that have sat idle past the -// idle window. +// Reap asks the window, once each, whether to close the headless browsers no +// pane shows that have sat idle past the idle window. Close or Keep answers. func (s *Service) Reap() { s.mu.Lock() - defer s.unlock() - s.reapLocked() -} - -func (s *Service) reapLocked() { cutoff := s.now().Add(-s.idle) + var asked []*session for _, sess := range s.byID { - if !sess.headed && !sess.watching && sess.lastUsed.Before(cutoff) { - s.buryLocked(sess) + if !sess.headed && !sess.watching && !sess.asked && sess.lastUsed.Before(cutoff) { + sess.asked = true + asked = append(asked, sess) } } + s.mu.Unlock() + for _, sess := range asked { + s.emitState(sess, true) + } } func runPage(fn func(context.Context) error) error { diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go index a419fe98d..8022d5f74 100644 --- a/internal/browser/browser_test.go +++ b/internal/browser/browser_test.go @@ -238,27 +238,116 @@ func TestNavigateRejectsBlockedURL(t *testing.T) { } } -func TestIdleReaperClosesUnused(t *testing.T) { +func TestIdleReaperAsksInsteadOfClosing(t *testing.T) { launch := &fakeLauncher{} now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC) s := newService(launch.launch, func(string, string) (string, error) { return t.TempDir(), nil }, func() time.Time { return now }, time.Minute) + out := &emitted{} + s.emit = out.emit t.Cleanup(func() { _ = s.Cleanup() }) if _, err := s.Open("s", "https://example.com"); err != nil { t.Fatal(err) } s.Reap() - if launch.pages[0].closed { - t.Fatal("reaper closed a browser that was just used") + if idle := idleStates(out); idle != 0 { + t.Fatalf("reaper asked about a browser that was just used (%d asks)", idle) } now = now.Add(time.Minute + time.Second) - s.now = func() time.Time { return now } s.Reap() - if !launch.pages[0].closed { - t.Fatal("reaper left an idle browser running") + if launch.pages[0].closed { + t.Fatal("reaper closed an idle browser nobody agreed to close") + } + if idle := idleStates(out); idle != 1 { + t.Fatalf("idle asks = %d, want 1", idle) + } + if running := s.Running(); len(running) != 1 || !running[0].Idle { + t.Fatalf("Running = %+v, want the question for a window catching up", running) + } + now = now.Add(time.Hour) + s.Reap() + if idle := idleStates(out); idle != 1 { + t.Fatalf("idle asks = %d after an hour unanswered, want still 1", idle) + } +} + +func TestKeepAnswersAndAsksAgainAfterAnotherIdleWindow(t *testing.T) { + launch := &fakeLauncher{} + now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC) + s := newService(launch.launch, func(string, string) (string, error) { + return t.TempDir(), nil + }, func() time.Time { return now }, time.Minute) + out := &emitted{} + s.emit = out.emit + t.Cleanup(func() { _ = s.Cleanup() }) + + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + now = now.Add(2 * time.Minute) + s.Reap() + if err := s.Keep("s"); err != nil { + t.Fatal(err) + } + states := out.states() + if last := states[len(states)-1]; last.Idle || !last.Open { + t.Fatalf("after keep the window got %+v, want the question withdrawn", last) + } + s.Reap() + if idle := idleStates(out); idle != 1 { + t.Fatalf("idle asks = %d right after keep, want 1", idle) + } + now = now.Add(2 * time.Minute) + s.Reap() + if idle := idleStates(out); idle != 2 { + t.Fatalf("idle asks = %d after another idle window, want 2", idle) + } + if launch.pages[0].closed { + t.Fatal("keep closed the browser") + } +} + +func TestUseWithdrawsTheIdleQuestion(t *testing.T) { + launch := &fakeLauncher{} + now := time.Date(2026, 8, 16, 12, 0, 0, 0, time.UTC) + s := newService(launch.launch, func(string, string) (string, error) { + return t.TempDir(), nil + }, func() time.Time { return now }, time.Minute) + out := &emitted{} + s.emit = out.emit + t.Cleanup(func() { _ = s.Cleanup() }) + + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + now = now.Add(2 * time.Minute) + s.Reap() + if _, err := s.Info("s"); err != nil { + t.Fatalf("the agent lost an idle browser it went back to: %v", err) + } + if running := s.Running(); len(running) != 1 || running[0].Idle { + t.Fatalf("Running = %+v, want the question withdrawn by the agent's use", running) + } +} + +func TestKeepWithoutBrowserFails(t *testing.T) { + s := testService(t, &fakeLauncher{}) + if err := s.Keep("s"); err != errNoBrowser { + t.Fatalf("Keep = %v, want errNoBrowser", err) + } +} + +// idleStates counts the times the window was asked about an idle browser. +func idleStates(out *emitted) int { + n := 0 + for _, st := range out.states() { + if st.Idle { + n++ + } } + return n } func TestOpenRequiresOwner(t *testing.T) { diff --git a/internal/browser/rpc.go b/internal/browser/rpc.go index e0127438b..75fcc42b6 100644 --- a/internal/browser/rpc.go +++ b/internal/browser/rpc.go @@ -171,6 +171,11 @@ func (s *Service) CloseOne(_ context.Context, opts api.SessionIDOptions) (api.No return api.None{}, s.Close(opts.ID) } +// KeepIdle keeps this session's idle browser for another idle window. +func (s *Service) KeepIdle(_ context.Context, opts api.SessionIDOptions) (api.None, error) { + return api.None{}, s.Keep(opts.ID) +} + func handleAPI(h Handle) api.BrowserHandle { return api.BrowserHandle{ID: h.ID, URL: h.URL, Title: h.Title, Owner: h.Owner, Headed: h.Headed} } diff --git a/internal/browser/view.go b/internal/browser/view.go index 81f7d46fa..0f5858f85 100644 --- a/internal/browser/view.go +++ b/internal/browser/view.go @@ -253,6 +253,6 @@ func (s *Service) agentPoint(owner string) *point { func (s *session) stateLocked(open bool) api.BrowserState { return api.BrowserState{ Owner: s.owner, Open: open, Headed: s.headed, ByAgent: s.byAgent, URL: s.url, Title: s.title, - Dialog: s.dialog, Tabs: s.tabs, + Dialog: s.dialog, Tabs: s.tabs, Idle: s.asked, } } diff --git a/internal/browser/view_test.go b/internal/browser/view_test.go index ab34f876a..596405a1d 100644 --- a/internal/browser/view_test.go +++ b/internal/browser/view_test.go @@ -80,7 +80,7 @@ func TestAttachRefusesABrowserInItsOwnWindow(t *testing.T) { } } -func TestReaperLeavesAWatchedBrowserUntilDetach(t *testing.T) { +func TestReaperLeavesAWatchedBrowserAloneUntilDetach(t *testing.T) { s, launch, _ := viewService(t) if _, err := s.Attach("s", Viewport{W: 800, H: 600, Scale: 1}); err != nil { t.Fatal(err) @@ -99,8 +99,8 @@ func TestReaperLeavesAWatchedBrowserUntilDetach(t *testing.T) { } s.now = func() time.Time { return later.Add(time.Hour) } s.Reap() - if !launch.pages[0].closed { - t.Fatal("reaper kept an unwatched idle browser") + if running := s.Running(); len(running) != 1 || !running[0].Idle { + t.Fatalf("Running = %+v, want an unwatched idle browser asked about", running) } } diff --git a/internal/routes/browser.go b/internal/routes/browser.go index a3a7b269d..236fcacac 100644 --- a/internal/routes/browser.go +++ b/internal/routes/browser.go @@ -29,6 +29,7 @@ func Browser(d *rpc.Handler, s *browser.Service) { rpc.Handle(d, api.BrowserAttach, s.AttachView) rpc.Handle(d, api.BrowserDetach, s.DetachView) rpc.Handle(d, api.BrowserResize, s.ResizeView) + rpc.Handle(d, api.BrowserKeep, s.KeepIdle) rpc.Handle(d, api.BrowserDefaultView, s.DefaultView) rpc.Handle(d, api.BrowserMouse, s.MouseInput) rpc.Handle(d, api.BrowserKey, s.KeyInput) diff --git a/native/backend/browser.go b/native/backend/browser.go index ba2a63e80..8afd07190 100644 --- a/native/backend/browser.go +++ b/native/backend/browser.go @@ -19,6 +19,13 @@ func (c *Client) CloseBrowser(ctx context.Context, id string) error { return err } +// KeepBrowser answers the question about session id's idle browser with +// keep. +func (c *Client) KeepBrowser(ctx context.Context, id string) error { + _, err := callMethod(ctx, c, api.BrowserKeep, api.SessionIDOptions{ID: id}) + return err +} + // RunningBrowsers is every session browser the backend runs. func (c *Client) RunningBrowsers(ctx context.Context) ([]api.BrowserState, error) { return callMethod(ctx, c, api.BrowserRunning, api.None{}) diff --git a/native/cmd/lich-native/browser_model.go b/native/cmd/lich-native/browser_model.go index 879ae1943..0fc7893c3 100644 --- a/native/cmd/lich-native/browser_model.go +++ b/native/cmd/lich-native/browser_model.go @@ -81,6 +81,7 @@ func (m *model) hydrateBrowsers(ctx context.Context) error { for _, st := range running { m.browsers.states[st.Owner] = st } + idle := m.browserIdle pruned := false for _, t := range append([]layoutTab(nil), m.layouts.tabs...) { if owner, ok := t.browserOf(); ok && !m.browserShowableLocked(owner) { @@ -91,6 +92,11 @@ func (m *model) hydrateBrowsers(ctx context.Context) error { if pruned { m.saveLayouts() } + for _, st := range running { + if st.Idle && idle != nil { + idle(st) + } + } m.invalidate() return nil } @@ -111,11 +117,13 @@ func (m *model) browserState(session string) (api.BrowserState, bool) { } // applyBrowserState takes a browser-state event: a page that can no longer -// be drawn leaves its tab, and a browser the agent just started is offered -// to the person (browserOpened). +// be drawn leaves its tab, a browser the agent just started is offered to +// the person (browserOpened), and the question about an idle one goes up or +// down (browserIdle). func (m *model) applyBrowserState(ctx context.Context, st api.BrowserState) { m.mu.Lock() - _, known := m.browsers.states[st.Owner] + prev, known := m.browsers.states[st.Owner] + idle := m.browserIdle if st.Open { m.browsers.states[st.Owner] = st } else { @@ -141,6 +149,9 @@ func (m *model) applyBrowserState(ctx context.Context, st api.BrowserState) { if st.Open && st.ByAgent && !known && m.browserOpened != nil { m.browserOpened(st) } + if prev.Idle != (st.Open && st.Idle) && idle != nil { + idle(st) + } } // frameDecoders is how many frames the window decodes at once. A busy page's diff --git a/native/cmd/lich-native/browser_model_test.go b/native/cmd/lich-native/browser_model_test.go index e0b1cf81b..17eb61be9 100644 --- a/native/cmd/lich-native/browser_model_test.go +++ b/native/cmd/lich-native/browser_model_test.go @@ -16,6 +16,7 @@ import ( "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" + "github.com/lichdotdev/lich/native/ui" ) func TestOpenBrowserTabShowsTheSessionBesideItsPage(t *testing.T) { @@ -102,6 +103,37 @@ func TestOnlyANewAgentBrowserIsOffered(t *testing.T) { } } +func TestIdleQuestionGoesUpAndDownOnlyOnChange(t *testing.T) { + m, _ := newLayoutsModel(t) + var heard []api.BrowserState + m.browserIdle = func(st api.BrowserState) { heard = append(heard, st) } + send(m, api.BrowserStateEvent.Name, api.BrowserState{Owner: "orch", Open: true}) + send(m, api.BrowserStateEvent.Name, api.BrowserState{Owner: "orch", Open: true, Idle: true}) + send(m, api.BrowserStateEvent.Name, api.BrowserState{Owner: "orch", Open: true, Idle: true, Title: "reloaded"}) + send(m, api.BrowserStateEvent.Name, api.BrowserState{Owner: "orch", Open: false, Idle: true}) + if len(heard) != 2 || !heard[0].Idle || heard[1].Open { + t.Fatalf("heard %+v, want the question raised once and taken down with the browser", heard) + } +} + +func TestIdleBrowserToastKeepsOnSwipe(t *testing.T) { + kept, closed := 0, 0 + toast := idleBrowserToast(api.BrowserState{Owner: "s", URL: "http://localhost:5173/settings"}, "Claude 3", "skipo", + func() { kept++ }, func() { closed++ }) + if toast.Duration != ui.ToastForever || toast.ID != idleBrowserToastID("s") { + t.Fatalf("toast %+v, want it up until answered under the session's id", toast) + } + toast.OnDismiss() + toast.Cancel.OnClick() + toast.Action.OnClick() + if kept != 2 || closed != 1 { + t.Fatalf("kept %d, closed %d: want swipe and Keep open to keep, Close to close", kept, closed) + } + if len(toast.Lines) != 2 || toast.Lines[0].Text != "http://localhost:5173/settings" || toast.Lines[1].Text != "skipo" { + t.Fatalf("lines %+v, want the page and the project", toast.Lines) + } +} + func TestBrowserFrameIsDecodedForThePane(t *testing.T) { m, _ := newLayoutsModel(t) var buf bytes.Buffer diff --git a/native/cmd/lich-native/browser_window.go b/native/cmd/lich-native/browser_window.go index f09d4c51f..dd1e8dc03 100644 --- a/native/cmd/lich-native/browser_window.go +++ b/native/cmd/lich-native/browser_window.go @@ -1,6 +1,7 @@ package main import ( + "context" "path/filepath" "github.com/lichdotdev/lich/api" @@ -57,6 +58,52 @@ func (u *window) offerBrowser(st api.BrowserState) { }) } +// askIdleBrowser asks whether to close session's browser, which nobody used +// for the backend's idle window, or takes the question down once it is no +// longer idle. It stays up until answered: swiping it away keeps the browser, +// since only Close closes it. +func (u *window) askIdleBrowser(st api.BrowserState) { + id := idleBrowserToastID(st.Owner) + if !st.Open || !st.Idle { + u.duplicates.Dismiss(id) + return + } + label, project := u.model.sessionPlace(st.Owner) + if label == "" { + return + } + u.duplicates.Show(idleBrowserToast(st, label, project, func() { u.answerIdleBrowser(st.Owner, u.client.KeepBrowser) }, + func() { u.answerIdleBrowser(st.Owner, u.client.CloseBrowser) })) +} + +func idleBrowserToastID(session string) string { return "browser-idle:" + session } + +func idleBrowserToast(st api.BrowserState, label, project string, keep, closeIt func()) ui.Toast { + t := ui.Toast{ + ID: idleBrowserToastID(st.Owner), + Title: i18n.T("layouts.browser.idle", i18n.Args{"label": label}), + Icon: icBrowser, + Duration: ui.ToastForever, + Cancel: &ui.ToastButton{Label: i18n.T("layouts.browser.keepOpen"), OnClick: keep}, + Action: &ui.ToastButton{Label: i18n.T("layouts.browser.closeIdle"), OnClick: closeIt}, + OnDismiss: keep, + } + if url := displayURL(st.URL); url != "" { + t.Lines = append(t.Lines, ui.ToastLine{Text: url}) + } + if project != "" { + t.Lines = append(t.Lines, ui.ToastLine{Icon: icFolder, Text: project}) + } + return t +} + +// answerIdleBrowser sends the person's answer about session's idle browser, +// KeepBrowser or CloseBrowser. A failure is logged: the browser it answers +// about is gone, or the next idle window asks again. +func (u *window) answerIdleBrowser(session string, answer func(context.Context, string) error) { + go func() { u.logErr("answer about an idle browser", answer(u.ctx, session)) }() +} + // tellDownload names a file a session's page downloaded and the folder it // went to, which the toast's button opens. func (u *window) tellDownload(d api.BrowserDownload) { diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 4dae64379..d85f023f8 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -257,6 +257,7 @@ func run(w *app.Window, rt backend.Runtime) error { u.model.serviceReveal = u.revealService u.model.duplicate = u.warnDuplicate u.model.browserOpened = u.offerBrowser + u.model.browserIdle = u.askIdleBrowser u.model.browserDownloaded = u.tellDownload u.model.pluginIncompatible = func() { go u.checkPlugin() } u.model.appUpdateProgress = u.updateProgressed diff --git a/native/cmd/lich-native/model.go b/native/cmd/lich-native/model.go index 7c9306fb9..bb646e481 100644 --- a/native/cmd/lich-native/model.go +++ b/native/cmd/lich-native/model.go @@ -121,9 +121,11 @@ type model struct { layouts layoutTabs shownLayout string // browsers are the session browsers (browser_model.go); browserOpened - // hears one the agent started. + // hears one the agent started, and browserIdle one the backend starts or + // stops asking about (State.Idle). browsers browserStore browserOpened func(api.BrowserState) + browserIdle func(api.BrowserState) // browserDownloaded hears a file a session's page finished downloading. browserDownloaded func(api.BrowserDownload) } diff --git a/native/cmd/lich-native/model_layouts.go b/native/cmd/lich-native/model_layouts.go index 2f1fb1729..c5ca0e7a0 100644 --- a/native/cmd/lich-native/model_layouts.go +++ b/native/cmd/lich-native/model_layouts.go @@ -425,6 +425,17 @@ func (m *model) sessionLabel(id string) string { return "" } +// sessionPlace is session id's label and its project's name, "" for a +// session no open project holds. +func (m *model) sessionPlace(id string) (label, project string) { + m.mu.Lock() + defer m.mu.Unlock() + if p, i := m.sessionAt(id); p != nil { + return p.Sessions[i].Label, p.Name + } + return "", "" +} + // renameLayout names layout id name, reporting false when no layout has // that id. func (m *model) renameLayout(id, name string) bool { diff --git a/native/cmd/lich-native/toasts.go b/native/cmd/lich-native/toasts.go index 3755c2e36..5cefb5c75 100644 --- a/native/cmd/lich-native/toasts.go +++ b/native/cmd/lich-native/toasts.go @@ -5,13 +5,14 @@ import ( "fmt" "strings" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/native/backend" "github.com/lichdotdev/lich/native/i18n" "github.com/lichdotdev/lich/native/ui" ) var ( - flagToasts = flag.String("toasts", "", "comma-separated toasts to raise at start, the last in front, for -shot: default, success, info, warning, error, action, attention, undo, update, progress") + flagToasts = flag.String("toasts", "", "comma-separated toasts to raise at start, the last in front, for -shot: default, success, info, warning, error, action, attention, undo, update, progress, idle") flagToastsExpanded = flag.Bool("toasts-expanded", false, "draw the toast stack expanded, as a hover would, for -shot") ) @@ -28,7 +29,8 @@ func failure(format string, args ...any) ui.Toast { // demoToasts are what -toasts raises, by name: one of each variant; action, // an unclean-exit warning; attention and undo, the // needs-your-input and closed-session toasts; update and progress, the app -// update's prompt and its download. A function, so the texts that come from +// update's prompt and its download; idle, the question about a session +// browser nobody used. A function, so the texts that come from // the catalog are read in the language of the moment. func demoToasts() map[string]ui.Toast { return map[string]ui.Toast{ @@ -43,6 +45,7 @@ func demoToasts() map[string]ui.Toast { "undo": {Title: "Closed Claude 3", Action: &ui.ToastButton{Label: "Undo"}}, "update": {Title: i18n.T("shell.appUpdateGate.available", i18n.Args{"version": "1.4.0"}), Cancel: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.later")}, Secondary: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.viewRelease")}, Action: &ui.ToastButton{Label: i18n.T("shell.appUpdateGate.updateAndInstall")}}, + "idle": idleBrowserToast(api.BrowserState{Owner: "s", URL: "http://localhost:5173/settings"}, "Claude 3", "skipo", func() {}, func() {}), "progress": updateProgressToast("1.4.0", &backend.AppUpdateProgress{Phase: backend.UpdatePhaseDownload, Received: 12_300_000, Total: 41_000_000}), } } diff --git a/native/i18n/locales/en/layouts.json b/native/i18n/locales/en/layouts.json index 5174b106f..405b18db6 100644 --- a/native/i18n/locales/en/layouts.json +++ b/native/i18n/locales/en/layouts.json @@ -31,6 +31,9 @@ "dismiss": "Dismiss", "open": "Open", "downloaded": "Downloaded {name}", + "idle": "Close {label}'s unused browser?", + "keepOpen": "Keep open", + "closeIdle": "Close", "showFolder": "Open folder", "moveBody": "It leaves \"{layout}\". A browser tab holds only {label} and its browser.", "pick": "Leave notes on the page", diff --git a/native/i18n/locales/es/layouts.json b/native/i18n/locales/es/layouts.json index 20b714c54..3ff56156c 100644 --- a/native/i18n/locales/es/layouts.json +++ b/native/i18n/locales/es/layouts.json @@ -31,6 +31,9 @@ "dismiss": "Descartar", "open": "Abrir", "downloaded": "Descargado {name}", + "idle": "¿Cerrar el navegador inactivo de {label}?", + "keepOpen": "Mantener abierto", + "closeIdle": "Cerrar", "showFolder": "Abrir carpeta", "moveBody": "Sale de \"{layout}\". La pestaña del navegador solo admite {label} y su navegador.", "pick": "Dejar notas en la página", diff --git a/native/i18n/locales/ja/layouts.json b/native/i18n/locales/ja/layouts.json index fde6395ea..39b80fc39 100644 --- a/native/i18n/locales/ja/layouts.json +++ b/native/i18n/locales/ja/layouts.json @@ -29,6 +29,9 @@ "dismiss": "閉じる", "open": "開く", "downloaded": "{name} をダウンロードしました", + "idle": "{label} の使われていないブラウザを閉じますか?", + "keepOpen": "開いたままにする", + "closeIdle": "閉じる", "showFolder": "フォルダを開く", "moveBody": "「{layout}」から外れます。ブラウザのタブには {label} とそのブラウザしか置けません。", "pick": "ページにメモを残す", diff --git a/native/i18n/locales/ko/layouts.json b/native/i18n/locales/ko/layouts.json index d22519705..f1420761e 100644 --- a/native/i18n/locales/ko/layouts.json +++ b/native/i18n/locales/ko/layouts.json @@ -29,6 +29,9 @@ "dismiss": "닫기", "open": "열기", "downloaded": "{name} 다운로드 완료", + "idle": "{label}의 사용하지 않는 브라우저를 닫을까요?", + "keepOpen": "열어 두기", + "closeIdle": "닫기", "showFolder": "폴더 열기", "moveBody": "\"{layout}\"에서 빠집니다. 브라우저 탭에는 {label}와(과) 그 브라우저만 둘 수 있습니다.", "pick": "페이지에 메모 남기기", diff --git a/native/i18n/locales/pt-br/layouts.json b/native/i18n/locales/pt-br/layouts.json index e6c1c97d0..8e23a961e 100644 --- a/native/i18n/locales/pt-br/layouts.json +++ b/native/i18n/locales/pt-br/layouts.json @@ -31,6 +31,9 @@ "dismiss": "Dispensar", "open": "Abrir", "downloaded": "{name} baixado", + "idle": "Fechar o navegador ocioso de {label}?", + "keepOpen": "Manter aberto", + "closeIdle": "Fechar", "showFolder": "Abrir pasta", "moveBody": "Ela sai de \"{layout}\". A aba do navegador só comporta {label} e o navegador dela.", "pick": "Deixar notas na página", diff --git a/native/i18n/locales/ru/layouts.json b/native/i18n/locales/ru/layouts.json index e6567f044..21a8d2891 100644 --- a/native/i18n/locales/ru/layouts.json +++ b/native/i18n/locales/ru/layouts.json @@ -35,6 +35,9 @@ "dismiss": "Скрыть", "open": "Открыть", "downloaded": "Загружен файл {name}", + "idle": "Закрыть неиспользуемый браузер {label}?", + "keepOpen": "Оставить открытым", + "closeIdle": "Закрыть", "showFolder": "Открыть папку", "moveBody": "Она уйдёт из «{layout}». Во вкладке браузера помещаются только {label} и её браузер.", "pick": "Оставить заметки на странице", diff --git a/native/i18n/locales/zh-cn/layouts.json b/native/i18n/locales/zh-cn/layouts.json index 86c8d4a2b..69272a547 100644 --- a/native/i18n/locales/zh-cn/layouts.json +++ b/native/i18n/locales/zh-cn/layouts.json @@ -29,6 +29,9 @@ "dismiss": "忽略", "open": "打开", "downloaded": "已下载 {name}", + "idle": "关闭 {label} 闲置的浏览器?", + "keepOpen": "保持打开", + "closeIdle": "关闭", "showFolder": "打开文件夹", "moveBody": "它将离开“{layout}”。浏览器标签页只容纳 {label} 及其浏览器。", "pick": "在页面上留下备注", diff --git a/native/ui/toast.go b/native/ui/toast.go index 801b8bd8c..dddf5eed8 100644 --- a/native/ui/toast.go +++ b/native/ui/toast.go @@ -75,6 +75,9 @@ type Toast struct { // Duration is how long the toast stays while the pointer is away; 0 is // sonner's default. Duration time.Duration + // OnDismiss runs when the person swipes the toast away, sonner's + // onDismiss: not on its buttons, its timer or Dismiss. + OnDismiss func() } // sonner 2.0.7's constants (dist/index.mjs). diff --git a/native/ui/toast_test.go b/native/ui/toast_test.go index 762f23efa..993212c69 100644 --- a/native/ui/toast_test.go +++ b/native/ui/toast_test.go @@ -347,6 +347,49 @@ func TestToasterSwipeRightDismisses(t *testing.T) { } } +func TestToasterSwipeRunsOnDismiss(t *testing.T) { + r := newToasterRig(t) + dismissed := 0 + r.state.Show(Toast{ID: "x", Title: "hello", Duration: ToastForever, OnDismiss: func() { dismissed++ }}) + r.settle() + at := r.onFront().Sub(f32.Pt(100, 0)) + r.pointer(pointer.Press, at) + r.frame() + r.now = r.now.Add(time.Second) + r.pointer(pointer.Drag, at.Add(f32.Pt(60, 0))) + r.frame() + r.pointer(pointer.Release, at.Add(f32.Pt(60, 0))) + r.frame() + r.frame() + if dismissed != 1 { + t.Fatalf("OnDismiss ran %d times on a swipe out, want 1", dismissed) + } +} + +func TestToasterActionAndDismissSkipOnDismiss(t *testing.T) { + r := newToasterRig(t) + dismissed := false + r.state.Show(Toast{ID: "x", Title: "Close it?", Duration: ToastForever, OnDismiss: func() { dismissed = true }, + Action: &ToastButton{Label: "Close"}}) + r.state.Show(Toast{ID: "y", Title: "other", Duration: ToastForever, OnDismiss: func() { dismissed = true }}) + r.settle() + r.state.Dismiss("y") + r.frame() + r.state.mu.Lock() + e := r.state.stack.live("x") + button := e.view.actionAt.Add(e.view.bodyAt) + origin := e.view.place.Transform(f32.Point{}) + r.state.mu.Unlock() + base := f32.Pt(float32(r.size.X-int(toastOffsetSide)-int(toastWidth)), float32(toastOffsetTop)).Add(origin) + center := base.Add(f32.Pt(float32(button.Min.X+button.Dx()/2), float32(button.Min.Y+button.Dy()/2))) + r.pointer(pointer.Press, center) + r.pointer(pointer.Release, center) + r.frame() + if r.live("x") || dismissed { + t.Fatalf("live %v, OnDismiss ran %v: want the button to answer without OnDismiss", r.live("x"), dismissed) + } +} + func TestToasterShortSlowSwipeSpringsBack(t *testing.T) { r := newToasterRig(t) r.state.Show(Toast{ID: "x", Title: "hello", Duration: ToastForever}) diff --git a/native/ui/toast_view.go b/native/ui/toast_view.go index 795c514bb..7bd578a8d 100644 --- a/native/ui/toast_view.go +++ b/native/ui/toast_view.go @@ -215,12 +215,16 @@ func (t *Toaster) readHover(gtx C) { } // readEntries takes each toast's swipes and button clicks, and returns the -// OnClick handlers to run once the lock is released. +// OnClick and OnDismiss handlers to run once the lock is released. func (s ToasterStyle) readEntries(gtx C) []func() { var clicked []func() stack := &s.State.stack for _, e := range stack.entries { + was := e.removed() s.readDrag(gtx, e) + if !was && e.removed() && e.OnDismiss != nil { + clicked = append(clicked, e.OnDismiss) + } for _, b := range []struct { click *widget.Clickable button *ToastButton