detached f6fdf4d

fix(ui): reuse the relay pane for repeated external URL requests

Desktop apps retry the same OAuth URL through the browser launch relay. Each retry used to stack a new pane, leaving failed or blank ones behind. A request for the same URL within two minutes now reloads and focuses the pane opened by the first request.
commit
f6fdf4db512f681fd4bf3696861216383cfe68b0
author
brice · · 2026-10-15 06:06 UTC
parent
2baceed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +423 -1
A internal/domain/url/relay_reuse.go +33 -0
@@ -0,0 +1,33 @@
1+package url
2+
3+import (
4+ "strings"
5+ "time"
6+)
7+
8+// RelayReuseWindow is how long a pane opened for an externally relayed URL
9+// remains eligible to be reused when the same URL is relayed again.
10+const RelayReuseWindow = 2 * time.Minute
11+
12+// RelayURLKey returns the canonical key identifying a relayed URL for reuse
13+// decisions.
14+func RelayURLKey(raw string) string {
15+ return Normalize(strings.TrimSpace(raw))
16+}
17+
18+// ShouldReuseRelayPane reports whether a pane that was opened for recordedURL
19+// at openedAt should be reused for requestedURL at now, instead of creating a
20+// new pane. It is true only when both URLs are non-empty and identical after
21+// normalization and the pane is no older than window. A pane recorded in the
22+// future (clock anomaly) or with a non-positive window is never reused.
23+func ShouldReuseRelayPane(recordedURL string, openedAt time.Time, requestedURL string, now time.Time, window time.Duration) bool {
24+ if window <= 0 {
25+ return false
26+ }
27+ requested := RelayURLKey(requestedURL)
28+ if requested == "" || requested != RelayURLKey(recordedURL) {
29+ return false
30+ }
31+ age := now.Sub(openedAt)
32+ return age >= 0 && age <= window
33+}
@@ -0,0 +1,33 @@
1+package url
2+
3+import (
4+ "strings"
5+ "time"
6+)
7+
8+// RelayReuseWindow is how long a pane opened for an externally relayed URL
9+// remains eligible to be reused when the same URL is relayed again.
10+const RelayReuseWindow = 2 * time.Minute
11+
12+// RelayURLKey returns the canonical key identifying a relayed URL for reuse
13+// decisions.
14+func RelayURLKey(raw string) string {
15+ return Normalize(strings.TrimSpace(raw))
16+}
17+
18+// ShouldReuseRelayPane reports whether a pane that was opened for recordedURL
19+// at openedAt should be reused for requestedURL at now, instead of creating a
20+// new pane. It is true only when both URLs are non-empty and identical after
21+// normalization and the pane is no older than window. A pane recorded in the
22+// future (clock anomaly) or with a non-positive window is never reused.
23+func ShouldReuseRelayPane(recordedURL string, openedAt time.Time, requestedURL string, now time.Time, window time.Duration) bool {
24+ if window <= 0 {
25+ return false
26+ }
27+ requested := RelayURLKey(requestedURL)
28+ if requested == "" || requested != RelayURLKey(recordedURL) {
29+ return false
30+ }
31+ age := now.Sub(openedAt)
32+ return age >= 0 && age <= window
33+}
A internal/domain/url/relay_reuse_test.go +45 -0
@@ -0,0 +1,45 @@
1+package url
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestShouldReuseRelayPane(t *testing.T) {
11+ const u = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ"
12+ opened := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
13+
14+ tests := []struct {
15+ name string
16+ recorded string
17+ requested string
18+ now time.Time
19+ window time.Duration
20+ want bool
21+ }{
22+ {"same url within window", u, u, opened.Add(30 * time.Second), RelayReuseWindow, true},
23+ {"same url at exact window boundary", u, u, opened.Add(RelayReuseWindow), RelayReuseWindow, true},
24+ {"same url just after window", u, u, opened.Add(RelayReuseWindow + time.Nanosecond), RelayReuseWindow, false},
25+ {"different nonce", u, "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=ABC", opened.Add(time.Second), RelayReuseWindow, false},
26+ {"different host", u, "https://example.com/", opened.Add(time.Second), RelayReuseWindow, false},
27+ {"equivalent after normalization", "example.com/path", "https://example.com/path", opened.Add(time.Second), RelayReuseWindow, true},
28+ {"surrounding whitespace ignored", u, " " + u + "\n", opened.Add(time.Second), RelayReuseWindow, true},
29+ {"empty requested", "", "", opened, RelayReuseWindow, false},
30+ {"empty recorded", "", u, opened, RelayReuseWindow, false},
31+ {"recorded in the future", u, u, opened.Add(-time.Second), RelayReuseWindow, false},
32+ {"zero window", u, u, opened, 0, false},
33+ {"negative window", u, u, opened, -time.Second, false},
34+ }
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ assert.Equal(t, tt.want, ShouldReuseRelayPane(tt.recorded, opened, tt.requested, tt.now, tt.window))
38+ })
39+ }
40+}
41+
42+func TestRelayURLKey_Normalizes(t *testing.T) {
43+ assert.Equal(t, "https://example.com", RelayURLKey(" example.com "))
44+ assert.Empty(t, RelayURLKey(" "))
45+}
@@ -0,0 +1,45 @@
1+package url
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestShouldReuseRelayPane(t *testing.T) {
11+ const u = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ"
12+ opened := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
13+
14+ tests := []struct {
15+ name string
16+ recorded string
17+ requested string
18+ now time.Time
19+ window time.Duration
20+ want bool
21+ }{
22+ {"same url within window", u, u, opened.Add(30 * time.Second), RelayReuseWindow, true},
23+ {"same url at exact window boundary", u, u, opened.Add(RelayReuseWindow), RelayReuseWindow, true},
24+ {"same url just after window", u, u, opened.Add(RelayReuseWindow + time.Nanosecond), RelayReuseWindow, false},
25+ {"different nonce", u, "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=ABC", opened.Add(time.Second), RelayReuseWindow, false},
26+ {"different host", u, "https://example.com/", opened.Add(time.Second), RelayReuseWindow, false},
27+ {"equivalent after normalization", "example.com/path", "https://example.com/path", opened.Add(time.Second), RelayReuseWindow, true},
28+ {"surrounding whitespace ignored", u, " " + u + "\n", opened.Add(time.Second), RelayReuseWindow, true},
29+ {"empty requested", "", "", opened, RelayReuseWindow, false},
30+ {"empty recorded", "", u, opened, RelayReuseWindow, false},
31+ {"recorded in the future", u, u, opened.Add(-time.Second), RelayReuseWindow, false},
32+ {"zero window", u, u, opened, 0, false},
33+ {"negative window", u, u, opened, -time.Second, false},
34+ }
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ assert.Equal(t, tt.want, ShouldReuseRelayPane(tt.recorded, opened, tt.requested, tt.now, tt.window))
38+ })
39+ }
40+}
41+
42+func TestRelayURLKey_Normalizes(t *testing.T) {
43+ assert.Equal(t, "https://example.com", RelayURLKey(" example.com "))
44+ assert.Empty(t, RelayURLKey(" "))
45+}
M internal/ui/app.go +1 -0
@@ -92,6 +92,7 @@ type App struct {
9292 browserWindows map[string]*browserWindow
9393 browserWindowOrder []string // registration order of window IDs
9494 instanceWindows map[string]string // logical instance name -> browser window ID
95+ externalRelayPanes relayPaneRegistry // panes opened by the external URL relay (main thread only)
9596 lastFocusedWindowID string
9697 nativePopupWindows map[port.WebViewID]*nativePopupWindow
9798 browserWindowFactory func(context.Context, string) (*browserWindow, error)
@@ -92,6 +92,7 @@ type App struct {
92 browserWindows map[string]*browserWindow92 browserWindows map[string]*browserWindow
93 browserWindowOrder []string // registration order of window IDs93 browserWindowOrder []string // registration order of window IDs
94 instanceWindows map[string]string // logical instance name -> browser window ID94 instanceWindows map[string]string // logical instance name -> browser window ID
95+ externalRelayPanes relayPaneRegistry // panes opened by the external URL relay (main thread only)
95 lastFocusedWindowID string96 lastFocusedWindowID string
96 nativePopupWindows map[port.WebViewID]*nativePopupWindow97 nativePopupWindows map[port.WebViewID]*nativePopupWindow
97 browserWindowFactory func(context.Context, string) (*browserWindow, error)98 browserWindowFactory func(context.Context, string) (*browserWindow, error)
M internal/ui/browser_window.go +1 -1
@@ -634,7 +634,7 @@ func (a *App) dispatchWindowURLWork(ctx context.Context, label, url string, work
634634 func (a *App) OpenExternalURL(ctx context.Context, url string) error {
635635 url = domainurl.Normalize(url)
636636 return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error {
637- return a.openExternalURLOnMainThread(ctx, url, a.runtimeConfigSnapshot())
637+ return a.openRelayURLOnMainThread(ctx, url, a.runtimeConfigSnapshot())
638638 })
639639 }
640640
@@ -634,7 +634,7 @@ func (a *App) dispatchWindowURLWork(ctx context.Context, label, url string, work
634 func (a *App) OpenExternalURL(ctx context.Context, url string) error {634 func (a *App) OpenExternalURL(ctx context.Context, url string) error {
635 url = domainurl.Normalize(url)635 url = domainurl.Normalize(url)
636 return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error {636 return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error {
637- return a.openExternalURLOnMainThread(ctx, url, a.runtimeConfigSnapshot())637+ return a.openRelayURLOnMainThread(ctx, url, a.runtimeConfigSnapshot())
638 })638 })
639 }639 }
640 640
M internal/ui/coordinator/workspace.go +14 -0
@@ -304,6 +304,20 @@ func comparablePaneURL(raw string) string {
304304 return "dumb:" + page
305305 }
306306
307+// FocusPaneByID makes an existing pane the active pane of ws, revealing it in
308+// its stack when needed and giving it keyboard focus in wsView.
309+func (c *WorkspaceCoordinator) FocusPaneByID(
310+ ctx context.Context,
311+ ws *entity.Workspace,
312+ wsView *component.WorkspaceView,
313+ paneID entity.PaneID,
314+) {
315+ if ws == nil || ws.FindPane(paneID) == nil {
316+ return
317+ }
318+ c.focusExistingPane(ctx, ws, wsView, paneID)
319+}
320+
307321 func (c *WorkspaceCoordinator) focusExistingPane(
308322 ctx context.Context,
309323 ws *entity.Workspace,
@@ -304,6 +304,20 @@ func comparablePaneURL(raw string) string {
304 return "dumb:" + page304 return "dumb:" + page
305 }305 }
306 306
307+// FocusPaneByID makes an existing pane the active pane of ws, revealing it in
308+// its stack when needed and giving it keyboard focus in wsView.
309+func (c *WorkspaceCoordinator) FocusPaneByID(
310+ ctx context.Context,
311+ ws *entity.Workspace,
312+ wsView *component.WorkspaceView,
313+ paneID entity.PaneID,
314+) {
315+ if ws == nil || ws.FindPane(paneID) == nil {
316+ return
317+ }
318+ c.focusExistingPane(ctx, ws, wsView, paneID)
319+}
320+
307 func (c *WorkspaceCoordinator) focusExistingPane(321 func (c *WorkspaceCoordinator) focusExistingPane(
308 ctx context.Context,322 ctx context.Context,
309 ws *entity.Workspace,323 ws *entity.Workspace,
A internal/ui/external_url_relay_reuse.go +174 -0
@@ -0,0 +1,174 @@
1+package ui
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// relayPaneRecord remembers a pane created for an externally relayed URL.
13+type relayPaneRecord struct {
14+ paneID entity.PaneID
15+ openedAt time.Time
16+}
17+
18+// relayPaneRegistry tracks panes opened by the external URL relay so repeated
19+// identical relay requests can reuse the existing pane instead of stacking
20+// duplicates. It is only touched on the GTK main thread and needs no locking.
21+// The zero value is ready to use.
22+type relayPaneRegistry struct {
23+ entries map[string]relayPaneRecord
24+ now func() time.Time // test hook; defaults to time.Now
25+}
26+
27+func (r *relayPaneRegistry) clock() time.Time {
28+ if r.now != nil {
29+ return r.now()
30+ }
31+ return time.Now()
32+}
33+
34+// prune forgets entries that expired or whose pane no longer exists.
35+func (r *relayPaneRegistry) prune(exists func(entity.PaneID) bool) {
36+ now := r.clock()
37+ for key, rec := range r.entries {
38+ if now.Sub(rec.openedAt) > domainurl.RelayReuseWindow || (exists != nil && !exists(rec.paneID)) {
39+ delete(r.entries, key)
40+ }
41+ }
42+}
43+
44+// lookup returns the pane to reuse for url, if any.
45+func (r *relayPaneRegistry) lookup(url string, exists func(entity.PaneID) bool) (entity.PaneID, bool) {
46+ r.prune(exists)
47+ key := domainurl.RelayURLKey(url)
48+ rec, ok := r.entries[key]
49+ if !ok || !domainurl.ShouldReuseRelayPane(key, rec.openedAt, url, r.clock(), domainurl.RelayReuseWindow) {
50+ return "", false
51+ }
52+ return rec.paneID, true
53+}
54+
55+func (r *relayPaneRegistry) record(url string, paneID entity.PaneID) {
56+ key := domainurl.RelayURLKey(url)
57+ if key == "" || paneID == "" {
58+ return
59+ }
60+ if r.entries == nil {
61+ r.entries = make(map[string]relayPaneRecord)
62+ }
63+ r.entries[key] = relayPaneRecord{paneID: paneID, openedAt: r.clock()}
64+}
65+
66+func (r *relayPaneRegistry) forget(url string) {
67+ delete(r.entries, domainurl.RelayURLKey(url))
68+}
69+
70+// openRelayURLOnMainThread opens a relayed external URL, reusing the pane from
71+// a recent identical request when it still exists. Must run on the main thread.
72+func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error {
73+ if a.reuseRelayPane(ctx, url) {
74+ return nil
75+ }
76+ if err := a.openExternalURLOnMainThread(ctx, url, cfg); err != nil {
77+ return err
78+ }
79+ a.recordRelayPane(url)
80+ return nil
81+}
82+
83+// reuseRelayPane navigates and focuses the pane previously opened for url.
84+// It reports false when there is nothing reusable and a new pane should be created.
85+func (a *App) reuseRelayPane(ctx context.Context, url string) bool {
86+ paneID, ok := a.externalRelayPanes.lookup(url, func(id entity.PaneID) bool {
87+ return a.browserWindowForPane(id) != nil
88+ })
89+ if !ok {
90+ return false
91+ }
92+ log := logging.FromContext(ctx)
93+
94+ bw := a.browserWindowForPane(paneID)
95+ var tab *entity.Tab
96+ if bw != nil && bw.tabs != nil {
97+ for _, candidate := range bw.tabs.Tabs {
98+ if candidate != nil && candidate.Workspace != nil && candidate.Workspace.FindPane(paneID) != nil {
99+ tab = candidate
100+ break
101+ }
102+ }
103+ }
104+ if tab == nil || a.navCoord == nil || a.contentCoord == nil {
105+ a.externalRelayPanes.forget(url)
106+ return false
107+ }
108+
109+ // Reload the URL first: when the pane cannot be navigated, fall back to
110+ // creating a fresh pane instead of focusing a dead one.
111+ if err := a.navCoord.NavigateWebView(ctx, url, paneID, a.contentCoord.GetWebView(paneID)); err != nil {
112+ log.Warn().Err(err).
113+ Str("pane_id", string(paneID)).
114+ Str("url_host", logging.SafeURLHost(url)).
115+ Msg("ui: external URL relay pane could not be navigated; opening a new pane")
116+ a.externalRelayPanes.forget(url)
117+ return false
118+ }
119+
120+ a.focusRelayPane(ctx, bw, tab, paneID)
121+ log.Debug().
122+ Str("pane_id", string(paneID)).
123+ Str("url_host", logging.SafeURLHost(url)).
124+ Msg("ui: external URL reused relay pane")
125+ return true
126+}
127+
128+func (a *App) focusRelayPane(ctx context.Context, bw *browserWindow, tab *entity.Tab, paneID entity.PaneID) {
129+ if bw.tabs.ActiveTabID != tab.ID {
130+ if a.tabCoord != nil {
131+ if err := a.tabCoord.Switch(ctx, a.tabTargetForBrowserWindow(bw), tab.ID); err != nil {
132+ logging.FromContext(ctx).Warn().Err(err).
133+ Str("tab_id", string(tab.ID)).
134+ Msg("ui: failed to switch to relay pane tab")
135+ }
136+ } else {
137+ bw.tabs.SetActive(tab.ID)
138+ if bw.mainWindow != nil && bw.mainWindow.TabBar() != nil {
139+ bw.mainWindow.TabBar().SetActive(tab.ID)
140+ }
141+ a.switchWorkspaceView(ctx, tab.ID)
142+ }
143+ }
144+
145+ wsView := a.workspaceViews[tab.ID]
146+ if a.wsCoord != nil {
147+ a.wsCoord.FocusPaneByID(ctx, tab.Workspace, wsView, paneID)
148+ } else {
149+ tab.Workspace.ActivePaneID = paneID
150+ }
151+
152+ if bw.mainWindow != nil {
153+ bw.mainWindow.Show()
154+ }
155+ a.activateBrowserWindow(bw)
156+ if wsView != nil {
157+ wsView.FocusPane(paneID)
158+ }
159+}
160+
161+// recordRelayPane remembers the pane that was just created for url. After a
162+// successful placement the new pane is the active pane of the last-focused
163+// window's active tab (tabbed, split, stacked, and fresh window alike).
164+func (a *App) recordRelayPane(url string) {
165+ ws := a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
166+ if ws == nil {
167+ return
168+ }
169+ active := ws.ActivePane()
170+ if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) {
171+ return
172+ }
173+ a.externalRelayPanes.record(url, active.Pane.ID)
174+}
@@ -0,0 +1,174 @@
1+package ui
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// relayPaneRecord remembers a pane created for an externally relayed URL.
13+type relayPaneRecord struct {
14+ paneID entity.PaneID
15+ openedAt time.Time
16+}
17+
18+// relayPaneRegistry tracks panes opened by the external URL relay so repeated
19+// identical relay requests can reuse the existing pane instead of stacking
20+// duplicates. It is only touched on the GTK main thread and needs no locking.
21+// The zero value is ready to use.
22+type relayPaneRegistry struct {
23+ entries map[string]relayPaneRecord
24+ now func() time.Time // test hook; defaults to time.Now
25+}
26+
27+func (r *relayPaneRegistry) clock() time.Time {
28+ if r.now != nil {
29+ return r.now()
30+ }
31+ return time.Now()
32+}
33+
34+// prune forgets entries that expired or whose pane no longer exists.
35+func (r *relayPaneRegistry) prune(exists func(entity.PaneID) bool) {
36+ now := r.clock()
37+ for key, rec := range r.entries {
38+ if now.Sub(rec.openedAt) > domainurl.RelayReuseWindow || (exists != nil && !exists(rec.paneID)) {
39+ delete(r.entries, key)
40+ }
41+ }
42+}
43+
44+// lookup returns the pane to reuse for url, if any.
45+func (r *relayPaneRegistry) lookup(url string, exists func(entity.PaneID) bool) (entity.PaneID, bool) {
46+ r.prune(exists)
47+ key := domainurl.RelayURLKey(url)
48+ rec, ok := r.entries[key]
49+ if !ok || !domainurl.ShouldReuseRelayPane(key, rec.openedAt, url, r.clock(), domainurl.RelayReuseWindow) {
50+ return "", false
51+ }
52+ return rec.paneID, true
53+}
54+
55+func (r *relayPaneRegistry) record(url string, paneID entity.PaneID) {
56+ key := domainurl.RelayURLKey(url)
57+ if key == "" || paneID == "" {
58+ return
59+ }
60+ if r.entries == nil {
61+ r.entries = make(map[string]relayPaneRecord)
62+ }
63+ r.entries[key] = relayPaneRecord{paneID: paneID, openedAt: r.clock()}
64+}
65+
66+func (r *relayPaneRegistry) forget(url string) {
67+ delete(r.entries, domainurl.RelayURLKey(url))
68+}
69+
70+// openRelayURLOnMainThread opens a relayed external URL, reusing the pane from
71+// a recent identical request when it still exists. Must run on the main thread.
72+func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error {
73+ if a.reuseRelayPane(ctx, url) {
74+ return nil
75+ }
76+ if err := a.openExternalURLOnMainThread(ctx, url, cfg); err != nil {
77+ return err
78+ }
79+ a.recordRelayPane(url)
80+ return nil
81+}
82+
83+// reuseRelayPane navigates and focuses the pane previously opened for url.
84+// It reports false when there is nothing reusable and a new pane should be created.
85+func (a *App) reuseRelayPane(ctx context.Context, url string) bool {
86+ paneID, ok := a.externalRelayPanes.lookup(url, func(id entity.PaneID) bool {
87+ return a.browserWindowForPane(id) != nil
88+ })
89+ if !ok {
90+ return false
91+ }
92+ log := logging.FromContext(ctx)
93+
94+ bw := a.browserWindowForPane(paneID)
95+ var tab *entity.Tab
96+ if bw != nil && bw.tabs != nil {
97+ for _, candidate := range bw.tabs.Tabs {
98+ if candidate != nil && candidate.Workspace != nil && candidate.Workspace.FindPane(paneID) != nil {
99+ tab = candidate
100+ break
101+ }
102+ }
103+ }
104+ if tab == nil || a.navCoord == nil || a.contentCoord == nil {
105+ a.externalRelayPanes.forget(url)
106+ return false
107+ }
108+
109+ // Reload the URL first: when the pane cannot be navigated, fall back to
110+ // creating a fresh pane instead of focusing a dead one.
111+ if err := a.navCoord.NavigateWebView(ctx, url, paneID, a.contentCoord.GetWebView(paneID)); err != nil {
112+ log.Warn().Err(err).
113+ Str("pane_id", string(paneID)).
114+ Str("url_host", logging.SafeURLHost(url)).
115+ Msg("ui: external URL relay pane could not be navigated; opening a new pane")
116+ a.externalRelayPanes.forget(url)
117+ return false
118+ }
119+
120+ a.focusRelayPane(ctx, bw, tab, paneID)
121+ log.Debug().
122+ Str("pane_id", string(paneID)).
123+ Str("url_host", logging.SafeURLHost(url)).
124+ Msg("ui: external URL reused relay pane")
125+ return true
126+}
127+
128+func (a *App) focusRelayPane(ctx context.Context, bw *browserWindow, tab *entity.Tab, paneID entity.PaneID) {
129+ if bw.tabs.ActiveTabID != tab.ID {
130+ if a.tabCoord != nil {
131+ if err := a.tabCoord.Switch(ctx, a.tabTargetForBrowserWindow(bw), tab.ID); err != nil {
132+ logging.FromContext(ctx).Warn().Err(err).
133+ Str("tab_id", string(tab.ID)).
134+ Msg("ui: failed to switch to relay pane tab")
135+ }
136+ } else {
137+ bw.tabs.SetActive(tab.ID)
138+ if bw.mainWindow != nil && bw.mainWindow.TabBar() != nil {
139+ bw.mainWindow.TabBar().SetActive(tab.ID)
140+ }
141+ a.switchWorkspaceView(ctx, tab.ID)
142+ }
143+ }
144+
145+ wsView := a.workspaceViews[tab.ID]
146+ if a.wsCoord != nil {
147+ a.wsCoord.FocusPaneByID(ctx, tab.Workspace, wsView, paneID)
148+ } else {
149+ tab.Workspace.ActivePaneID = paneID
150+ }
151+
152+ if bw.mainWindow != nil {
153+ bw.mainWindow.Show()
154+ }
155+ a.activateBrowserWindow(bw)
156+ if wsView != nil {
157+ wsView.FocusPane(paneID)
158+ }
159+}
160+
161+// recordRelayPane remembers the pane that was just created for url. After a
162+// successful placement the new pane is the active pane of the last-focused
163+// window's active tab (tabbed, split, stacked, and fresh window alike).
164+func (a *App) recordRelayPane(url string) {
165+ ws := a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
166+ if ws == nil {
167+ return
168+ }
169+ active := ws.ActivePane()
170+ if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) {
171+ return
172+ }
173+ a.externalRelayPanes.record(url, active.Pane.ID)
174+}
A internal/ui/external_url_relay_reuse_test.go +155 -0
@@ -0,0 +1,155 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ domainurl "github.com/bnema/dumber/internal/domain/url"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
13+ "github.com/bnema/dumber/internal/ui/component"
14+ "github.com/bnema/dumber/internal/ui/coordinator"
15+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+const relayTestURL = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ"
21+
22+type relayReuseHarness struct {
23+ app *App
24+ tabs *entity.TabList
25+ window *browserWindow
26+ webview map[entity.PaneID]*recordingWebView
27+ clock *time.Time
28+}
29+
30+// newRelayReuseHarness builds an App with one window holding one tab, using the
31+// tabbed external-link behavior. New tabs get a recording webview registered so
32+// reuse can navigate them.
33+func newRelayReuseHarness(t *testing.T) *relayReuseHarness {
34+ t.Helper()
35+ ctx := context.Background()
36+
37+ original := entity.NewTab("tab-1", "ws-1", entity.NewPane("pane-1"))
38+ tabs := entity.NewTabList()
39+ tabs.Add(original)
40+ window := &browserWindow{id: "focused", tabs: tabs}
41+
42+ ids := []string{"tab-2", "ws-2", "pane-2", "tab-3", "ws-3", "pane-3"}
43+ index := 0
44+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
45+ h := &relayReuseHarness{tabs: tabs, window: window, webview: map[entity.PaneID]*recordingWebView{}, clock: &now}
46+
47+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
48+ app := &App{
49+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
50+ tabsUC: usecase.NewManageTabsUseCase(func() string { id := ids[index]; index++; return id }, nil),
51+ browserWindows: map[string]*browserWindow{
52+ window.id: window,
53+ },
54+ browserWindowOrder: []string{window.id},
55+ lastFocusedWindowID: window.id,
56+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
57+ windowForTab: map[entity.TabID]*browserWindow{},
58+ contentCoord: contentCoord,
59+ navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
60+ }
61+ app.externalRelayPanes.now = func() time.Time { return *h.clock }
62+ app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC})
63+ app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
64+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
65+ app.setBrowserWindowForTab(tab.ID, window)
66+ paneID := tab.Workspace.ActivePane().Pane.ID
67+ wv := &recordingWebView{id: port.WebViewID(100 + len(h.webview))}
68+ h.webview[paneID] = wv
69+ contentCoord.RegisterPopupWebView(paneID, wv)
70+ })
71+ h.app = app
72+ return h
73+}
74+
75+func TestApp_OpenExternalURLReusesRecentRelayPane(t *testing.T) {
76+ ctx := context.Background()
77+ h := newRelayReuseHarness(t)
78+
79+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
80+ require.Len(t, h.tabs.Tabs, 2)
81+ relayTab := h.tabs.ActiveTab()
82+ relayPaneID := relayTab.Workspace.ActivePane().Pane.ID
83+ assert.False(t, h.webview[relayPaneID].loadURICalled)
84+
85+ // The user moves on to another tab, then the app retries the same URL.
86+ h.tabs.SetActive("tab-1")
87+ *h.clock = h.clock.Add(30 * time.Second)
88+
89+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
90+
91+ assert.Len(t, h.tabs.Tabs, 2, "no new tab must be created")
92+ assert.Equal(t, relayTab.ID, h.tabs.ActiveTabID, "relay tab must be focused again")
93+ assert.True(t, h.webview[relayPaneID].loadURICalled)
94+ assert.Equal(t, relayTestURL, h.webview[relayPaneID].loadURILastURI)
95+}
96+
97+func TestApp_OpenExternalURLCreatesNewPaneAfterReuseWindowExpires(t *testing.T) {
98+ ctx := context.Background()
99+ h := newRelayReuseHarness(t)
100+
101+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
102+ require.Len(t, h.tabs.Tabs, 2)
103+
104+ *h.clock = h.clock.Add(domainurl.RelayReuseWindow + time.Second)
105+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
106+
107+ assert.Len(t, h.tabs.Tabs, 3)
108+}
109+
110+func TestApp_OpenExternalURLCreatesNewPaneForDifferentURL(t *testing.T) {
111+ ctx := context.Background()
112+ h := newRelayReuseHarness(t)
113+
114+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
115+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL+"2"))
116+
117+ assert.Len(t, h.tabs.Tabs, 3)
118+}
119+
120+func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneWasClosed(t *testing.T) {
121+ ctx := context.Background()
122+ h := newRelayReuseHarness(t)
123+
124+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
125+ relayTab := h.tabs.ActiveTab()
126+ h.tabs.Remove(relayTab.ID)
127+ require.Len(t, h.tabs.Tabs, 1)
128+
129+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
130+
131+ assert.Len(t, h.tabs.Tabs, 2)
132+ assert.NotEqual(t, relayTab.ID, h.tabs.ActiveTabID)
133+ assert.Len(t, h.app.externalRelayPanes.entries, 1, "stale entry replaced by the new pane")
134+}
135+
136+func TestRelayPaneRegistry_PrunesExpiredAndMissingPanes(t *testing.T) {
137+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
138+ reg := relayPaneRegistry{now: func() time.Time { return now }}
139+ reg.record("https://a.example/", "pane-a")
140+ reg.record("https://b.example/", "pane-b")
141+
142+ exists := func(id entity.PaneID) bool { return id != "pane-b" }
143+ _, ok := reg.lookup("https://b.example/", exists)
144+ assert.False(t, ok)
145+ assert.NotContains(t, reg.entries, "https://b.example/")
146+
147+ id, ok := reg.lookup("https://a.example/", exists)
148+ assert.True(t, ok)
149+ assert.Equal(t, entity.PaneID("pane-a"), id)
150+
151+ now = now.Add(domainurl.RelayReuseWindow + time.Second)
152+ _, ok = reg.lookup("https://a.example/", exists)
153+ assert.False(t, ok)
154+ assert.Empty(t, reg.entries)
155+}
@@ -0,0 +1,155 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ domainurl "github.com/bnema/dumber/internal/domain/url"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
13+ "github.com/bnema/dumber/internal/ui/component"
14+ "github.com/bnema/dumber/internal/ui/coordinator"
15+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+const relayTestURL = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ"
21+
22+type relayReuseHarness struct {
23+ app *App
24+ tabs *entity.TabList
25+ window *browserWindow
26+ webview map[entity.PaneID]*recordingWebView
27+ clock *time.Time
28+}
29+
30+// newRelayReuseHarness builds an App with one window holding one tab, using the
31+// tabbed external-link behavior. New tabs get a recording webview registered so
32+// reuse can navigate them.
33+func newRelayReuseHarness(t *testing.T) *relayReuseHarness {
34+ t.Helper()
35+ ctx := context.Background()
36+
37+ original := entity.NewTab("tab-1", "ws-1", entity.NewPane("pane-1"))
38+ tabs := entity.NewTabList()
39+ tabs.Add(original)
40+ window := &browserWindow{id: "focused", tabs: tabs}
41+
42+ ids := []string{"tab-2", "ws-2", "pane-2", "tab-3", "ws-3", "pane-3"}
43+ index := 0
44+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
45+ h := &relayReuseHarness{tabs: tabs, window: window, webview: map[entity.PaneID]*recordingWebView{}, clock: &now}
46+
47+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
48+ app := &App{
49+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
50+ tabsUC: usecase.NewManageTabsUseCase(func() string { id := ids[index]; index++; return id }, nil),
51+ browserWindows: map[string]*browserWindow{
52+ window.id: window,
53+ },
54+ browserWindowOrder: []string{window.id},
55+ lastFocusedWindowID: window.id,
56+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
57+ windowForTab: map[entity.TabID]*browserWindow{},
58+ contentCoord: contentCoord,
59+ navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
60+ }
61+ app.externalRelayPanes.now = func() time.Time { return *h.clock }
62+ app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC})
63+ app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
64+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
65+ app.setBrowserWindowForTab(tab.ID, window)
66+ paneID := tab.Workspace.ActivePane().Pane.ID
67+ wv := &recordingWebView{id: port.WebViewID(100 + len(h.webview))}
68+ h.webview[paneID] = wv
69+ contentCoord.RegisterPopupWebView(paneID, wv)
70+ })
71+ h.app = app
72+ return h
73+}
74+
75+func TestApp_OpenExternalURLReusesRecentRelayPane(t *testing.T) {
76+ ctx := context.Background()
77+ h := newRelayReuseHarness(t)
78+
79+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
80+ require.Len(t, h.tabs.Tabs, 2)
81+ relayTab := h.tabs.ActiveTab()
82+ relayPaneID := relayTab.Workspace.ActivePane().Pane.ID
83+ assert.False(t, h.webview[relayPaneID].loadURICalled)
84+
85+ // The user moves on to another tab, then the app retries the same URL.
86+ h.tabs.SetActive("tab-1")
87+ *h.clock = h.clock.Add(30 * time.Second)
88+
89+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
90+
91+ assert.Len(t, h.tabs.Tabs, 2, "no new tab must be created")
92+ assert.Equal(t, relayTab.ID, h.tabs.ActiveTabID, "relay tab must be focused again")
93+ assert.True(t, h.webview[relayPaneID].loadURICalled)
94+ assert.Equal(t, relayTestURL, h.webview[relayPaneID].loadURILastURI)
95+}
96+
97+func TestApp_OpenExternalURLCreatesNewPaneAfterReuseWindowExpires(t *testing.T) {
98+ ctx := context.Background()
99+ h := newRelayReuseHarness(t)
100+
101+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
102+ require.Len(t, h.tabs.Tabs, 2)
103+
104+ *h.clock = h.clock.Add(domainurl.RelayReuseWindow + time.Second)
105+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
106+
107+ assert.Len(t, h.tabs.Tabs, 3)
108+}
109+
110+func TestApp_OpenExternalURLCreatesNewPaneForDifferentURL(t *testing.T) {
111+ ctx := context.Background()
112+ h := newRelayReuseHarness(t)
113+
114+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
115+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL+"2"))
116+
117+ assert.Len(t, h.tabs.Tabs, 3)
118+}
119+
120+func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneWasClosed(t *testing.T) {
121+ ctx := context.Background()
122+ h := newRelayReuseHarness(t)
123+
124+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
125+ relayTab := h.tabs.ActiveTab()
126+ h.tabs.Remove(relayTab.ID)
127+ require.Len(t, h.tabs.Tabs, 1)
128+
129+ require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL))
130+
131+ assert.Len(t, h.tabs.Tabs, 2)
132+ assert.NotEqual(t, relayTab.ID, h.tabs.ActiveTabID)
133+ assert.Len(t, h.app.externalRelayPanes.entries, 1, "stale entry replaced by the new pane")
134+}
135+
136+func TestRelayPaneRegistry_PrunesExpiredAndMissingPanes(t *testing.T) {
137+ now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
138+ reg := relayPaneRegistry{now: func() time.Time { return now }}
139+ reg.record("https://a.example/", "pane-a")
140+ reg.record("https://b.example/", "pane-b")
141+
142+ exists := func(id entity.PaneID) bool { return id != "pane-b" }
143+ _, ok := reg.lookup("https://b.example/", exists)
144+ assert.False(t, ok)
145+ assert.NotContains(t, reg.entries, "https://b.example/")
146+
147+ id, ok := reg.lookup("https://a.example/", exists)
148+ assert.True(t, ok)
149+ assert.Equal(t, entity.PaneID("pane-a"), id)
150+
151+ now = now.Add(domainurl.RelayReuseWindow + time.Second)
152+ _, ok = reg.lookup("https://a.example/", exists)
153+ assert.False(t, ok)
154+ assert.Empty(t, reg.entries)
155+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close