detached acba2a5

Scope browser windows and stabilize tab geometry

Make browser windows the runtime source of truth for tab state, active panes, navigation, omnibox actions, floating sessions, and snapshots.

Stabilize global shortcut dispatch and route tab/webview actions through explicit window targets to avoid cross-window bleed.

Keep the tab bar as a non-measured overlay while reserving bottom content inset only when the bottom tab bar is visible, preserving OSR WebView geometry during tab switches without leaving a blank strip for single-tab windows.
commit
acba2a525802aea35744c14ad8146a091e2a6f55
author
brice · · 2026-05-05 12:24 UTC
committer
brice · · 2026-05-05 12:47 UTC
parent
6066b2a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +5371 -863
M .mockery.yaml +1 -0
@@ -16,6 +16,7 @@ packages:
1616 SessionLogger: {}
1717 SessionSpawner: {}
1818 TabListProvider: {}
19+ WindowStateProvider: {}
1920 WebView: {}
2021 WebViewFactory: {}
2122 WebViewPool: {}
M cmd/dumber/main.go +1 -1
@@ -845,7 +845,7 @@ func buildUIDependencies(
845845 SessionStateRepo: repos.sessionState,
846846 CurrentSessionID: currentSessionID,
847847 SnapshotUC: uc.snapshot,
848- SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService {
848+ SnapshotServiceFactory: func(provider port.WindowStateProvider, intervalMs int) port.SnapshotService {
849849 return snapshot.NewService(uc.snapshot, provider, intervalMs)
850850 },
851851 CheckUpdateUC: uc.checkUpdate,
M internal/application/port/mocks/mocks.go +216 -0
@@ -10792,6 +10792,222 @@ func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.T
1079210792 return _c
1079310793 }
1079410794
10795+// NewMockWindowStateProvider creates a new instance of MockWindowStateProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10796+// The first argument is typically a *testing.T value.
10797+func NewMockWindowStateProvider(t interface {
10798+ mock.TestingT
10799+ Cleanup(func())
10800+}) *MockWindowStateProvider {
10801+ mock := &MockWindowStateProvider{}
10802+ mock.Mock.Test(t)
10803+
10804+ t.Cleanup(func() { mock.AssertExpectations(t) })
10805+
10806+ return mock
10807+}
10808+
10809+// MockWindowStateProvider is an autogenerated mock type for the WindowStateProvider type
10810+type MockWindowStateProvider struct {
10811+ mock.Mock
10812+}
10813+
10814+type MockWindowStateProvider_Expecter struct {
10815+ mock *mock.Mock
10816+}
10817+
10818+func (_m *MockWindowStateProvider) EXPECT() *MockWindowStateProvider_Expecter {
10819+ return &MockWindowStateProvider_Expecter{mock: &_m.Mock}
10820+}
10821+
10822+// GetActiveWindowIndex provides a mock function for the type MockWindowStateProvider
10823+func (_mock *MockWindowStateProvider) GetActiveWindowIndex() int {
10824+ ret := _mock.Called()
10825+
10826+ if len(ret) == 0 {
10827+ panic("no return value specified for GetActiveWindowIndex")
10828+ }
10829+
10830+ var r0 int
10831+ if returnFunc, ok := ret.Get(0).(func() int); ok {
10832+ r0 = returnFunc()
10833+ } else {
10834+ r0 = ret.Get(0).(int)
10835+ }
10836+ return r0
10837+}
10838+
10839+// MockWindowStateProvider_GetActiveWindowIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActiveWindowIndex'
10840+type MockWindowStateProvider_GetActiveWindowIndex_Call struct {
10841+ *mock.Call
10842+}
10843+
10844+// GetActiveWindowIndex is a helper method to define mock.On call
10845+func (_e *MockWindowStateProvider_Expecter) GetActiveWindowIndex() *MockWindowStateProvider_GetActiveWindowIndex_Call {
10846+ return &MockWindowStateProvider_GetActiveWindowIndex_Call{Call: _e.mock.On("GetActiveWindowIndex")}
10847+}
10848+
10849+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Run(run func()) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10850+ _c.Call.Run(func(args mock.Arguments) {
10851+ run()
10852+ })
10853+ return _c
10854+}
10855+
10856+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Return(n int) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10857+ _c.Call.Return(n)
10858+ return _c
10859+}
10860+
10861+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) RunAndReturn(run func() int) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10862+ _c.Call.Return(run)
10863+ return _c
10864+}
10865+
10866+// GetSessionID provides a mock function for the type MockWindowStateProvider
10867+func (_mock *MockWindowStateProvider) GetSessionID() entity.SessionID {
10868+ ret := _mock.Called()
10869+
10870+ if len(ret) == 0 {
10871+ panic("no return value specified for GetSessionID")
10872+ }
10873+
10874+ var r0 entity.SessionID
10875+ if returnFunc, ok := ret.Get(0).(func() entity.SessionID); ok {
10876+ r0 = returnFunc()
10877+ } else {
10878+ r0 = ret.Get(0).(entity.SessionID)
10879+ }
10880+ return r0
10881+}
10882+
10883+// MockWindowStateProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID'
10884+type MockWindowStateProvider_GetSessionID_Call struct {
10885+ *mock.Call
10886+}
10887+
10888+// GetSessionID is a helper method to define mock.On call
10889+func (_e *MockWindowStateProvider_Expecter) GetSessionID() *MockWindowStateProvider_GetSessionID_Call {
10890+ return &MockWindowStateProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")}
10891+}
10892+
10893+func (_c *MockWindowStateProvider_GetSessionID_Call) Run(run func()) *MockWindowStateProvider_GetSessionID_Call {
10894+ _c.Call.Run(func(args mock.Arguments) {
10895+ run()
10896+ })
10897+ return _c
10898+}
10899+
10900+func (_c *MockWindowStateProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockWindowStateProvider_GetSessionID_Call {
10901+ _c.Call.Return(sessionID)
10902+ return _c
10903+}
10904+
10905+func (_c *MockWindowStateProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockWindowStateProvider_GetSessionID_Call {
10906+ _c.Call.Return(run)
10907+ return _c
10908+}
10909+
10910+// GetWindowSnapshotState provides a mock function for the type MockWindowStateProvider
10911+func (_mock *MockWindowStateProvider) GetWindowSnapshotState() ([]entity.WindowTabListState, int) {
10912+ ret := _mock.Called()
10913+
10914+ if len(ret) == 0 {
10915+ panic("no return value specified for GetWindowSnapshotState")
10916+ }
10917+
10918+ var r0 []entity.WindowTabListState
10919+ var r1 int
10920+ if returnFunc, ok := ret.Get(0).(func() ([]entity.WindowTabListState, int)); ok {
10921+ return returnFunc()
10922+ }
10923+ if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok {
10924+ r0 = returnFunc()
10925+ } else {
10926+ if ret.Get(0) != nil {
10927+ r0 = ret.Get(0).([]entity.WindowTabListState)
10928+ }
10929+ }
10930+ if returnFunc, ok := ret.Get(1).(func() int); ok {
10931+ r1 = returnFunc()
10932+ } else {
10933+ r1 = ret.Get(1).(int)
10934+ }
10935+ return r0, r1
10936+}
10937+
10938+// MockWindowStateProvider_GetWindowSnapshotState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowSnapshotState'
10939+type MockWindowStateProvider_GetWindowSnapshotState_Call struct {
10940+ *mock.Call
10941+}
10942+
10943+// GetWindowSnapshotState is a helper method to define mock.On call
10944+func (_e *MockWindowStateProvider_Expecter) GetWindowSnapshotState() *MockWindowStateProvider_GetWindowSnapshotState_Call {
10945+ return &MockWindowStateProvider_GetWindowSnapshotState_Call{Call: _e.mock.On("GetWindowSnapshotState")}
10946+}
10947+
10948+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Run(run func()) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10949+ _c.Call.Run(func(args mock.Arguments) {
10950+ run()
10951+ })
10952+ return _c
10953+}
10954+
10955+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Return(windowTabListStates []entity.WindowTabListState, n int) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10956+ _c.Call.Return(windowTabListStates, n)
10957+ return _c
10958+}
10959+
10960+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) RunAndReturn(run func() ([]entity.WindowTabListState, int)) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10961+ _c.Call.Return(run)
10962+ return _c
10963+}
10964+
10965+// GetWindowTabLists provides a mock function for the type MockWindowStateProvider
10966+func (_mock *MockWindowStateProvider) GetWindowTabLists() []entity.WindowTabListState {
10967+ ret := _mock.Called()
10968+
10969+ if len(ret) == 0 {
10970+ panic("no return value specified for GetWindowTabLists")
10971+ }
10972+
10973+ var r0 []entity.WindowTabListState
10974+ if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok {
10975+ r0 = returnFunc()
10976+ } else {
10977+ if ret.Get(0) != nil {
10978+ r0 = ret.Get(0).([]entity.WindowTabListState)
10979+ }
10980+ }
10981+ return r0
10982+}
10983+
10984+// MockWindowStateProvider_GetWindowTabLists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowTabLists'
10985+type MockWindowStateProvider_GetWindowTabLists_Call struct {
10986+ *mock.Call
10987+}
10988+
10989+// GetWindowTabLists is a helper method to define mock.On call
10990+func (_e *MockWindowStateProvider_Expecter) GetWindowTabLists() *MockWindowStateProvider_GetWindowTabLists_Call {
10991+ return &MockWindowStateProvider_GetWindowTabLists_Call{Call: _e.mock.On("GetWindowTabLists")}
10992+}
10993+
10994+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Run(run func()) *MockWindowStateProvider_GetWindowTabLists_Call {
10995+ _c.Call.Run(func(args mock.Arguments) {
10996+ run()
10997+ })
10998+ return _c
10999+}
11000+
11001+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Return(windowTabListStates []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call {
11002+ _c.Call.Return(windowTabListStates)
11003+ return _c
11004+}
11005+
11006+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) RunAndReturn(run func() []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call {
11007+ _c.Call.Return(run)
11008+ return _c
11009+}
11010+
1079511011 // NewMockSessionSpawner creates a new instance of MockSessionSpawner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1079611012 // The first argument is typically a *testing.T value.
1079711013 func NewMockSessionSpawner(t interface {
M internal/application/port/session_state.go +17 -0
@@ -8,6 +8,8 @@ import (
88
99 // TabListProvider provides access to the current tab list state.
1010 // Implemented by the UI layer to allow the snapshot service to read state.
11+//
12+// Deprecated: use WindowStateProvider for v2 window-scoped snapshots.
1113 type TabListProvider interface {
1214 // GetTabList returns the current tab list state.
1315 GetTabList() *entity.TabList
@@ -15,6 +17,21 @@ type TabListProvider interface {
1517 GetSessionID() entity.SessionID
1618 }
1719
20+// WindowStateProvider provides window-scoped browser state for v2 snapshots.
21+// Implemented by the UI layer and consumed by the snapshot service.
22+type WindowStateProvider interface {
23+ // GetWindowSnapshotState returns the ordered window tab lists and active window index atomically.
24+ GetWindowSnapshotState() ([]entity.WindowTabListState, int)
25+ // GetWindowTabLists returns the ordered list of windows with their tab lists.
26+ // For consistent reads alongside GetActiveWindowIndex, prefer GetWindowSnapshotState.
27+ GetWindowTabLists() []entity.WindowTabListState
28+ // GetActiveWindowIndex returns the index of the currently focused window.
29+ // For consistent reads alongside GetWindowTabLists, prefer GetWindowSnapshotState.
30+ GetActiveWindowIndex() int
31+ // GetSessionID returns the current session ID.
32+ GetSessionID() entity.SessionID
33+}
34+
1835 // SessionSpawner spawns a new dumber instance for resurrection.
1936 type SessionSpawner interface {
2037 // SpawnWithSession starts a new dumber instance to restore a session.
M internal/application/usecase/snapshot_session.go +28 -6
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "time"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/dumber/internal/domain/repository"
@@ -23,23 +24,44 @@ func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *Sna
2324 type SnapshotInput struct {
2425 SessionID entity.SessionID
2526 TabList *entity.TabList
27+
28+ // v2: window-scoped snapshot. A non-nil slice, including an empty one,
29+ // requests the v2 path; leave nil when using the legacy TabList path.
30+ Windows []entity.WindowTabListState
31+ ActiveWindowIndex int
2632 }
2733
2834 // Execute creates a snapshot of the current session state and saves it.
35+// When Windows is non-nil, uses the v2 window-scoped path, including explicit
36+// zero-window snapshots. Otherwise falls back to the legacy flat TabList path.
2937 func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {
3038 log := logging.FromContext(ctx)
3139
3240 if input.SessionID == "" {
3341 return fmt.Errorf("session id required")
3442 }
43+ if input.Windows != nil && input.TabList != nil {
44+ return fmt.Errorf("windows and tab list are mutually exclusive")
45+ }
3546
36- state := entity.SnapshotFromTabList(input.SessionID, input.TabList)
47+ var state *entity.SessionState
3748
38- log.Debug().
39- Str("session_id", string(input.SessionID)).
40- Int("tab_count", len(state.Tabs)).
41- Int("pane_count", state.CountPanes()).
42- Msg("creating session snapshot")
49+ if input.Windows != nil {
50+ state = entity.SnapshotFromWindowTabLists(input.SessionID, input.Windows, input.ActiveWindowIndex, time.Now())
51+ log.Debug().
52+ Str("session_id", string(input.SessionID)).
53+ Int("window_count", len(state.Windows)).
54+ Int("active_window", state.ActiveWindowIndex).
55+ Int("pane_count", state.CountPanes()).
56+ Msg("creating window-scoped session snapshot")
57+ } else {
58+ state = entity.SnapshotFromTabList(input.SessionID, input.TabList)
59+ log.Debug().
60+ Str("session_id", string(input.SessionID)).
61+ Int("tab_count", len(state.Tabs)).
62+ Int("pane_count", state.CountPanes()).
63+ Msg("creating session snapshot")
64+ }
4365
4466 if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {
4567 return fmt.Errorf("save session snapshot: %w", err)
M internal/application/usecase/snapshot_session_test.go +102 -2
@@ -34,7 +34,7 @@ func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) {
3434 require.Equal(t, sessionID, state.SessionID)
3535 require.Len(t, state.Tabs, 1)
3636 require.Equal(t, "Test Tab", state.Tabs[0].Name)
37- require.Equal(t, entity.SessionStateVersion, state.Version)
37+ require.Equal(t, entity.LegacySessionStateVersion, state.Version)
3838 }).
3939 Return(nil)
4040
@@ -85,6 +85,47 @@ func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
8585 assert.Contains(t, err.Error(), "session id required")
8686 }
8787
88+func TestSnapshotSessionUseCase_Execute_RejectsAmbiguousWindowAndTabListInput(t *testing.T) {
89+ ctx := testContext()
90+
91+ stateRepo := repomocks.NewMockSessionStateRepository(t)
92+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
93+
94+ err := uc.Execute(ctx, usecase.SnapshotInput{
95+ SessionID: entity.SessionID("20251224_120000_ambiguous"),
96+ TabList: entity.NewTabList(),
97+ Windows: []entity.WindowTabListState{},
98+ })
99+ require.Error(t, err)
100+ assert.Contains(t, err.Error(), "windows and tab list are mutually exclusive")
101+}
102+
103+func TestSnapshotSessionUseCase_Execute_SavesEmptyWindowSnapshot(t *testing.T) {
104+ ctx := testContext()
105+
106+ stateRepo := repomocks.NewMockSessionStateRepository(t)
107+ sessionID := entity.SessionID("20251224_120000_empty_v2_windows")
108+
109+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
110+ Run(func(_ context.Context, state *entity.SessionState) {
111+ require.Equal(t, sessionID, state.SessionID)
112+ require.Equal(t, entity.SessionStateVersion, state.Version)
113+ require.Empty(t, state.Windows)
114+ require.Empty(t, state.Tabs)
115+ assert.Equal(t, 0, state.ActiveWindowIndex)
116+ }).
117+ Return(nil)
118+
119+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
120+
121+ err := uc.Execute(ctx, usecase.SnapshotInput{
122+ SessionID: sessionID,
123+ Windows: []entity.WindowTabListState{},
124+ ActiveWindowIndex: 7,
125+ })
126+ require.NoError(t, err)
127+}
128+
88129 func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
89130 sessionID := entity.SessionID("20251224_120000_snap")
90131
@@ -108,7 +149,7 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
108149 state := entity.SnapshotFromTabList(sessionID, tabList)
109150
110151 require.NotNil(t, state)
111- assert.Equal(t, entity.SessionStateVersion, state.Version)
152+ assert.Equal(t, entity.LegacySessionStateVersion, state.Version)
112153 assert.Equal(t, sessionID, state.SessionID)
113154 assert.Len(t, state.Tabs, 1)
114155 assert.Equal(t, "Dev Tab", state.Tabs[0].Name)
@@ -119,6 +160,65 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
119160 assert.False(t, state.SavedAt.IsZero())
120161 }
121162
163+func TestSnapshotSessionUseCase_Execute_SavesWindowSnapshots(t *testing.T) {
164+ ctx := testContext()
165+
166+ stateRepo := repomocks.NewMockSessionStateRepository(t)
167+
168+ // Create two windows, each with tabs
169+ pane1_1 := entity.NewPane("p1_w1")
170+ pane1_1.URI = "https://window1.com"
171+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
172+ tab1_1.Name = "Window1 Tab1"
173+
174+ pane1_2 := entity.NewPane("p1_w2")
175+ pane1_2.URI = "https://window2.com"
176+ tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
177+ tab1_2.Name = "Window2 Tab1"
178+
179+ tabs1 := entity.NewTabList()
180+ tabs1.Add(tab1_1)
181+ tabs2 := entity.NewTabList()
182+ tabs2.Add(tab1_2)
183+
184+ sessionID := entity.SessionID("20251224_120000_v2_windows")
185+
186+ windows := []entity.WindowTabListState{
187+ {WindowID: "w1", Tabs: tabs1},
188+ {WindowID: "w2", Tabs: tabs2},
189+ }
190+
191+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
192+ Run(func(_ context.Context, state *entity.SessionState) {
193+ require.Equal(t, sessionID, state.SessionID)
194+ require.Equal(t, 2, state.Version)
195+ // Windows field populated
196+ require.Len(t, state.Windows, 2)
197+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
198+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
199+ // Window 1 has 1 tab
200+ require.Len(t, state.Windows[0].Tabs, 1)
201+ assert.Equal(t, "Window1 Tab1", state.Windows[0].Tabs[0].Name)
202+ // Window 2 has 1 tab
203+ require.Len(t, state.Windows[1].Tabs, 1)
204+ assert.Equal(t, "Window2 Tab1", state.Windows[1].Tabs[0].Name)
205+ // Active window index set
206+ assert.Equal(t, 0, state.ActiveWindowIndex)
207+ // Legacy Tabs empty when windows are provided
208+ assert.Empty(t, state.Tabs)
209+ }).
210+ Return(nil)
211+
212+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
213+
214+ err := uc.Execute(ctx, usecase.SnapshotInput{
215+ SessionID: sessionID,
216+ Windows: windows,
217+ ActiveWindowIndex: 0,
218+ })
219+ require.NoError(t, err)
220+}
221+
122222 func TestSessionState_CountPanes(t *testing.T) {
123223 state := &entity.SessionState{
124224 Tabs: []entity.TabSnapshot{
M internal/domain/entity/session_state.go +67 -16
@@ -2,18 +2,24 @@ package entity
22
33 import "time"
44
5+const LegacySessionStateVersion = 1
6+
57 // SessionStateVersion is the current schema version for session state.
68 // Increment when making breaking changes to the serialization format.
7-const SessionStateVersion = 1
9+// Version 2: added window-scoped sessions (Windows, ActiveWindowIndex).
10+const SessionStateVersion = 2
811
912 // SessionState represents a complete snapshot of a browser session.
1013 // This is serialized to JSON and stored in the database.
1114 type SessionState struct {
1215 Version int `json:"version"`
1316 SessionID SessionID `json:"session_id"`
14- Tabs []TabSnapshot `json:"tabs"`
15- ActiveTabIndex int `json:"active_tab_index"`
16- SavedAt time.Time `json:"saved_at"`
17+ Tabs []TabSnapshot `json:"tabs,omitempty"`
18+ ActiveTabIndex int `json:"active_tab_index,omitempty"`
19+ // v2: window-scoped sessions
20+ Windows []WindowSnapshot `json:"windows,omitempty"`
21+ ActiveWindowIndex int `json:"active_window_index,omitempty"`
22+ SavedAt time.Time `json:"saved_at"`
1723 }
1824
1925 // TabSnapshot captures the state of a single tab.
@@ -55,7 +61,7 @@ type PaneSnapshot struct {
5561 func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
5662 if tabs == nil {
5763 return &SessionState{
58- Version: SessionStateVersion,
64+ Version: LegacySessionStateVersion,
5965 SessionID: sessionID,
6066 Tabs: []TabSnapshot{},
6167 SavedAt: time.Now(),
@@ -73,7 +79,7 @@ func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
7379 }
7480
7581 return &SessionState{
76- Version: SessionStateVersion,
82+ Version: LegacySessionStateVersion,
7783 SessionID: sessionID,
7884 Tabs: snapTabs,
7985 ActiveTabIndex: activeTabIndex,
@@ -147,6 +153,17 @@ type SessionInfo struct {
147153
148154 // CountPanes returns the total number of panes in the session state.
149155 func (s *SessionState) CountPanes() int {
156+ // v2: count across windows
157+ if len(s.Windows) > 0 {
158+ count := 0
159+ for _, w := range s.Windows {
160+ for _, tab := range w.Tabs {
161+ count += countPanesInNode(tab.Workspace.Root)
162+ }
163+ }
164+ return count
165+ }
166+ // v1: legacy flat tabs
150167 count := 0
151168 for _, tab := range s.Tabs {
152169 count += countPanesInNode(tab.Workspace.Root)
@@ -171,17 +188,12 @@ func countPanesInNode(node *PaneNodeSnapshot) int {
171188 // IDGenerator is a function that generates unique IDs.
172189 type IDGenerator func() string
173190
174-// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
175-// Generates new IDs for all entities using the provided generator.
176-// This is the inverse of SnapshotFromTabList.
177-func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
178- if state == nil {
179- return NewTabList()
180- }
181-
191+// tabListFromSnapshots converts a slice of TabSnapshots to a live TabList.
192+// activeIndex is the index in snapshots that should become the active tab.
193+func tabListFromSnapshots(snaps []TabSnapshot, activeIndex int, idGen IDGenerator) *TabList {
182194 tabs := NewTabList()
183195
184- for i, tabSnap := range state.Tabs {
196+ for i, tabSnap := range snaps {
185197 tab := tabFromSnapshot(&tabSnap, idGen)
186198 if tab == nil {
187199 continue
@@ -189,7 +201,7 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
189201 tabs.Tabs = append(tabs.Tabs, tab)
190202 tab.Position = len(tabs.Tabs) - 1
191203
192- if i == state.ActiveTabIndex {
204+ if i == activeIndex {
193205 tabs.ActiveTabID = tab.ID
194206 }
195207 }
@@ -202,6 +214,45 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
202214 return tabs
203215 }
204216
217+// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
218+// Generates new IDs for all entities using the provided generator.
219+// This is the inverse of SnapshotFromTabList.
220+func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
221+ if state == nil {
222+ return NewTabList()
223+ }
224+ if state.Version >= SessionStateVersion {
225+ tabs, activeIndex := flattenWindowTabSnapshots(state.Windows, state.ActiveWindowIndex)
226+ return tabListFromSnapshots(tabs, activeIndex, idGen)
227+ }
228+
229+ return tabListFromSnapshots(state.Tabs, state.ActiveTabIndex, idGen)
230+}
231+
232+func flattenWindowTabSnapshots(windows []WindowSnapshot, activeWindowIndex int) ([]TabSnapshot, int) {
233+ if activeWindowIndex < 0 || activeWindowIndex >= len(windows) {
234+ activeWindowIndex = 0
235+ }
236+ flattened := make([]TabSnapshot, 0)
237+ activeTabIndex := 0
238+ for i, win := range windows {
239+ if i == activeWindowIndex {
240+ if len(win.Tabs) == 0 {
241+ // Active window has zero tabs; activeTabIndex stays at 0
242+ // (first tab overall if any), not len(flattened).
243+ } else {
244+ winActiveIndex := win.ActiveTabIndex
245+ if winActiveIndex < 0 || winActiveIndex >= len(win.Tabs) {
246+ winActiveIndex = 0
247+ }
248+ activeTabIndex = len(flattened) + winActiveIndex
249+ }
250+ }
251+ flattened = append(flattened, win.Tabs...)
252+ }
253+ return flattened, activeTabIndex
254+}
255+
205256 func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
206257 if snap == nil {
207258 return nil
M internal/domain/entity/session_state_test.go +323 -0
@@ -1,6 +1,7 @@
11 package entity_test
22
33 import (
4+ "encoding/json"
45 "sync/atomic"
56 "testing"
67 "time"
@@ -380,6 +381,252 @@ func TestFindPaneAcrossTabs(t *testing.T) {
380381 assert.Nil(t, notFound)
381382 }
382383
384+// --- Window-scoped snapshot tests ---
385+
386+func TestSnapshotFromWindowTabLists_V2State(t *testing.T) {
387+ // Create two windows, each with tabs
388+ pane1_1 := entity.NewPane("p1_w1")
389+ pane1_1.URI = "https://window1.com"
390+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
391+ tab1_1.Name = "Window1 Tab1"
392+
393+ pane1_2 := entity.NewPane("p1_w2")
394+ pane1_2.URI = "https://window2.com"
395+ tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
396+ tab1_2.Name = "Window2 Tab1"
397+
398+ pane1_2b := entity.NewPane("p2_w2")
399+ pane1_2b.URI = "https://window2-tab2.com"
400+ tab1_2b := entity.NewTab("t2_w2", "ws2_w2", pane1_2b)
401+ tab1_2b.Name = "Window2 Tab2"
402+
403+ tabs1 := entity.NewTabList()
404+ tabs1.Add(tab1_1)
405+
406+ tabs2 := entity.NewTabList()
407+ tabs2.Add(tab1_2)
408+ tabs2.Add(tab1_2b)
409+ tabs2.SetActive("t2_w2")
410+
411+ windows := []entity.WindowTabListState{
412+ {WindowID: "w1", Tabs: tabs1},
413+ {WindowID: "w2", Tabs: tabs2},
414+ }
415+
416+ sessionID := entity.SessionID("test_v2")
417+ state := entity.SnapshotFromWindowTabLists(sessionID, windows, 1, time.Unix(123, 0))
418+
419+ require.NotNil(t, state)
420+ assert.Equal(t, 2, state.Version)
421+ assert.Equal(t, sessionID, state.SessionID)
422+
423+ // Windows field populated
424+ require.Len(t, state.Windows, 2)
425+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
426+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
427+
428+ // Active window index set
429+ assert.Equal(t, 1, state.ActiveWindowIndex)
430+
431+ // No legacy Tabs when using windows
432+ assert.Empty(t, state.Tabs)
433+
434+ // CountPanes across windows
435+ assert.Equal(t, 3, state.CountPanes())
436+}
437+
438+func TestWindowTabListsFromSnapshot_V2Restore(t *testing.T) {
439+ // Create a v2 snapshot from windows, then restore
440+ pane := entity.NewPane("p_w1_t1")
441+ pane.URI = "https://example.com"
442+ tab := entity.NewTab("t_w1_t1", "ws_w1", pane)
443+ tab.Name = "MyTab"
444+
445+ tabs := entity.NewTabList()
446+ tabs.Add(tab)
447+
448+ windows := []entity.WindowTabListState{
449+ {WindowID: "window-1", Tabs: tabs},
450+ }
451+
452+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
453+
454+ idGen := mockIDGenerator()
455+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
456+
457+ require.Len(t, restored, 1)
458+ assert.Equal(t, entity.WindowID("window-1"), restored[0].WindowID)
459+ require.NotNil(t, restored[0].Tabs)
460+ require.Len(t, restored[0].Tabs.Tabs, 1)
461+ assert.Equal(t, "MyTab", restored[0].Tabs.Tabs[0].Name)
462+ assert.Equal(t, "https://example.com", restored[0].Tabs.Tabs[0].Workspace.Root.Pane.URI)
463+}
464+
465+func TestWindowTabListsFromSnapshot_V1LegacySingleWindow(t *testing.T) {
466+ // Legacy v1 flat tabs should be restored as a single window with empty WindowID
467+ state := &entity.SessionState{
468+ Version: 1,
469+ SessionID: "old_sess",
470+ ActiveTabIndex: 1,
471+ Tabs: []entity.TabSnapshot{
472+ {
473+ ID: "tab1",
474+ Name: "First",
475+ Workspace: entity.WorkspaceSnapshot{
476+ Root: &entity.PaneNodeSnapshot{
477+ Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.com"},
478+ },
479+ },
480+ },
481+ {
482+ ID: "tab2",
483+ Name: "Second",
484+ Workspace: entity.WorkspaceSnapshot{
485+ Root: &entity.PaneNodeSnapshot{
486+ Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.com"},
487+ },
488+ },
489+ },
490+ },
491+ }
492+
493+ idGen := mockIDGenerator()
494+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
495+
496+ require.Len(t, restored, 1)
497+ // Legacy window gets empty WindowID
498+ assert.Equal(t, entity.WindowID(""), restored[0].WindowID)
499+ require.NotNil(t, restored[0].Tabs)
500+ require.Len(t, restored[0].Tabs.Tabs, 2)
501+ assert.Equal(t, "Second", restored[0].Tabs.Tabs[1].Name)
502+ // Active tab should be second (index 1)
503+ assert.Equal(t, restored[0].Tabs.Tabs[1].ID, restored[0].Tabs.ActiveTabID)
504+}
505+
506+func TestWindowTabListsFromSnapshot_NilState(t *testing.T) {
507+ idGen := mockIDGenerator()
508+ restored := entity.WindowTabListsFromSnapshot(nil, idGen)
509+ assert.Nil(t, restored)
510+}
511+
512+func TestWindowTabListsFromSnapshot_InvalidIndexesFallback(t *testing.T) {
513+ // Tab with active tab index out of range should fall back to first tab
514+ state := &entity.SessionState{
515+ Version: 1,
516+ ActiveTabIndex: 999, // beyond bounds
517+ Tabs: []entity.TabSnapshot{
518+ {
519+ ID: "tab_a",
520+ Name: "TabA",
521+ Workspace: entity.WorkspaceSnapshot{
522+ Root: &entity.PaneNodeSnapshot{
523+ Pane: &entity.PaneSnapshot{ID: "pa", URI: "https://a.com"},
524+ },
525+ },
526+ },
527+ },
528+ }
529+
530+ idGen := mockIDGenerator()
531+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
532+
533+ require.Len(t, restored, 1)
534+ require.NotNil(t, restored[0].Tabs)
535+ // Should have fallen back to first tab
536+ assert.Equal(t, restored[0].Tabs.Tabs[0].ID, restored[0].Tabs.ActiveTabID)
537+}
538+
539+func TestTabListFromSnapshot_ActiveWindowWithZeroTabsFallsBackToFirstOverallTab(t *testing.T) {
540+ // Regression: when the active window has zero tabs, the active tab should
541+ // be the first tab overall (window 0's tab), not the third window's tab.
542+ state := &entity.SessionState{
543+ Version: entity.SessionStateVersion,
544+ ActiveWindowIndex: 1,
545+ Windows: []entity.WindowSnapshot{
546+ {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
547+ {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
548+ }},
549+ {ID: "w2", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{}},
550+ {ID: "w3", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
551+ {Name: "Third", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://third.example"}}}},
552+ }},
553+ },
554+ }
555+
556+ tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
557+
558+ require.Len(t, tabs.Tabs, 2)
559+ assert.Equal(t, "First", tabs.ActiveTab().Name, "active tab should be the first overall tab, not the third window's tab")
560+}
561+
562+func TestTabListFromSnapshot_FlattensV2WindowSnapshots(t *testing.T) {
563+ state := &entity.SessionState{
564+ Version: entity.SessionStateVersion,
565+ ActiveWindowIndex: 1,
566+ Windows: []entity.WindowSnapshot{
567+ {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
568+ {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
569+ }},
570+ {ID: "w2", ActiveTabIndex: 1, Tabs: []entity.TabSnapshot{
571+ {Name: "Second", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.example"}}}},
572+ {Name: "Active", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://active.example"}}}},
573+ }},
574+ },
575+ }
576+
577+ tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
578+
579+ require.Len(t, tabs.Tabs, 3)
580+ assert.Equal(t, "Active", tabs.ActiveTab().Name)
581+}
582+
583+func TestWindowTabListsFromSnapshot_FutureV2VersionWithEmptyWindowsStaysEmpty(t *testing.T) {
584+ state := &entity.SessionState{Version: entity.SessionStateVersion + 1, Windows: []entity.WindowSnapshot{}}
585+
586+ restored := entity.WindowTabListsFromSnapshot(state, mockIDGenerator())
587+
588+ assert.Empty(t, restored)
589+}
590+
591+func TestSnapshotFromWindowTabLists_NilTabs(t *testing.T) {
592+ windows := []entity.WindowTabListState{
593+ {WindowID: "w1", Tabs: nil},
594+ }
595+
596+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
597+
598+ require.Len(t, state.Windows, 1)
599+ assert.Empty(t, state.Windows[0].Tabs)
600+ assert.Equal(t, 0, state.CountPanes())
601+}
602+
603+func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) {
604+ state := entity.SnapshotFromWindowTabLists("sess", nil, 0, time.Unix(123, 0))
605+
606+ require.NotNil(t, state)
607+ assert.Equal(t, 2, state.Version)
608+ assert.Empty(t, state.Windows)
609+ assert.Equal(t, 0, state.ActiveWindowIndex)
610+}
611+
612+func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {
613+ pane := entity.NewPane("p1")
614+ pane.URI = "https://x.com"
615+ tab := entity.NewTab("t1", "ws1", pane)
616+ tabs := entity.NewTabList()
617+ tabs.Add(tab)
618+
619+ windows := []entity.WindowTabListState{
620+ {WindowID: "w1", Tabs: tabs},
621+ }
622+
623+ // Active window index out of range
624+ state := entity.SnapshotFromWindowTabLists("sess", windows, 999, time.Unix(123, 0))
625+
626+ // Should clamp to 0
627+ assert.Equal(t, 0, state.ActiveWindowIndex)
628+}
629+
383630 func TestFindPaneInNestedStructure(t *testing.T) {
384631 // Create a tab with split panes
385632 pane1 := entity.NewPane("left_pane")
@@ -428,3 +675,79 @@ func TestFindPaneInNestedStructure(t *testing.T) {
428675 rightNodeAgain := tab.Workspace.FindPane("right_pane")
429676 assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430677 }
678+
679+func TestV2SnapshotJSON_OmitEmptyLegacyFields(t *testing.T) {
680+ // When using SnapshotFromWindowTabLists (v2 path), the JSON output must
681+ // NOT include top-level legacy "tabs" or "active_tab_index" fields.
682+ // They should be omitted by omitempty.
683+ pane := entity.NewPane(entity.PaneID("p1"))
684+ pane.URI = "https://example.com"
685+ tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
686+ tabs := entity.NewTabList()
687+ tabs.Add(tab)
688+
689+ windows := []entity.WindowTabListState{
690+ {WindowID: entity.WindowID("w1"), Tabs: tabs},
691+ }
692+
693+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
694+
695+ data, err := json.Marshal(state)
696+ require.NoError(t, err)
697+
698+ var raw map[string]any
699+ err = json.Unmarshal(data, &raw)
700+ require.NoError(t, err)
701+
702+ // v2 JSON must not contain top-level legacy fields.
703+ _, hasTabs := raw["tabs"]
704+ _, hasActiveTabIdx := raw["active_tab_index"]
705+ assert.False(t, hasTabs, "v2 JSON must not include legacy 'tabs' field")
706+ assert.False(t, hasActiveTabIdx, "v2 JSON must not include legacy 'active_tab_index' field")
707+
708+ // But must still have the v2 windows field.
709+ windowsRaw, ok := raw["windows"]
710+ require.True(t, ok, "v2 JSON must include 'windows' field")
711+ windowsArr, _ := windowsRaw.([]any)
712+ require.Len(t, windowsArr, 1)
713+}
714+
715+func TestV2SnapshotJSON_CanStillReadV1(t *testing.T) {
716+ // When reading a v1 JSON with the legacy fields, they must still be
717+ // populated correctly (omitempty is only for writes).
718+ v1JSON := `{
719+ "version": 1,
720+ "session_id": "old-sess",
721+ "tabs": [{
722+ "id": "tab1",
723+ "name": "MyTab",
724+ "position": 0,
725+ "is_pinned": false,
726+ "workspace": {
727+ "id": "ws1",
728+ "root": {
729+ "id": "node1",
730+ "pane": {"id": "p1", "uri": "https://example.com", "title": "", "zoom_factor": 0},
731+ "split_dir": 0,
732+ "split_ratio": 0,
733+ "is_stacked": false,
734+ "active_stack_index": 0
735+ },
736+ "active_pane_id": ""
737+ }
738+ }],
739+ "active_tab_index": 0,
740+ "saved_at": "2026-01-01T00:00:00Z"
741+ }`
742+
743+ var state entity.SessionState
744+ err := json.Unmarshal([]byte(v1JSON), &state)
745+ require.NoError(t, err)
746+
747+ assert.Equal(t, 1, state.Version)
748+ assert.Equal(t, entity.SessionID("old-sess"), state.SessionID)
749+ require.Len(t, state.Tabs, 1)
750+ assert.Equal(t, entity.TabID("tab1"), state.Tabs[0].ID)
751+ assert.Equal(t, "MyTab", state.Tabs[0].Name)
752+ assert.Equal(t, 0, state.ActiveTabIndex)
753+}
M internal/domain/entity/tab.go +17 -0
@@ -161,6 +161,23 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
161161 return true
162162 }
163163
164+// Snapshot returns a shallow copy of the TabList safe for concurrent read.
165+// The returned TabList shares the underlying Tab pointers but has its own
166+// slice header and scalar fields, protecting against races from concurrent
167+// structural mutations (add/remove) and ActiveTabID changes on the original.
168+func (tl *TabList) Snapshot() *TabList {
169+ if tl == nil {
170+ return nil
171+ }
172+ tabs := make([]*Tab, len(tl.Tabs))
173+ copy(tabs, tl.Tabs)
174+ return &TabList{
175+ Tabs: tabs,
176+ ActiveTabID: tl.ActiveTabID,
177+ PreviousActiveTabID: tl.PreviousActiveTabID,
178+ }
179+}
180+
164181 // ReplaceFrom replaces this TabList's contents with those from another TabList.
165182 // This modifies in-place so existing references to this TabList remain valid.
166183 func (tl *TabList) ReplaceFrom(other *TabList) {
A internal/domain/entity/window_state.go +103 -0
@@ -0,0 +1,103 @@
1+package entity
2+
3+import "time"
4+
5+// WindowID uniquely identifies a browser window.
6+type WindowID string
7+
8+// WindowSnapshot captures the state of a single browser window.
9+type WindowSnapshot struct {
10+ ID WindowID `json:"id"`
11+ Tabs []TabSnapshot `json:"tabs"`
12+ ActiveTabIndex int `json:"active_tab_index"`
13+}
14+
15+// WindowTabListState pairs a window ID with its live TabList.
16+type WindowTabListState struct {
17+ WindowID WindowID
18+ Tabs *TabList
19+}
20+
21+// SnapshotFromWindowTabLists creates a v2 SessionState from ordered windows.
22+func SnapshotFromWindowTabLists(sessionID SessionID, windows []WindowTabListState, activeWindowIndex int, savedAt time.Time) *SessionState {
23+ // Clamp active window index
24+ if activeWindowIndex < 0 || activeWindowIndex >= len(windows) {
25+ activeWindowIndex = 0
26+ }
27+
28+ snapWindows := make([]WindowSnapshot, 0, len(windows))
29+ for _, w := range windows {
30+ snapWindows = append(snapWindows, windowToSnapshot(w.WindowID, w.Tabs))
31+ }
32+
33+ return &SessionState{
34+ Version: SessionStateVersion,
35+ SessionID: sessionID,
36+ Windows: snapWindows,
37+ ActiveWindowIndex: activeWindowIndex,
38+ SavedAt: savedAt,
39+ }
40+}
41+
42+// windowToSnapshot converts a live TabList to a WindowSnapshot.
43+func windowToSnapshot(id WindowID, tabs *TabList) WindowSnapshot {
44+ if tabs == nil {
45+ return WindowSnapshot{
46+ ID: id,
47+ Tabs: []TabSnapshot{},
48+ }
49+ }
50+
51+ snapTabs := make([]TabSnapshot, 0, len(tabs.Tabs))
52+ activeTabIndex := 0
53+
54+ for i, tab := range tabs.Tabs {
55+ if tab.ID == tabs.ActiveTabID {
56+ activeTabIndex = i
57+ }
58+ snapTabs = append(snapTabs, snapshotTab(tab))
59+ }
60+
61+ return WindowSnapshot{
62+ ID: id,
63+ Tabs: snapTabs,
64+ ActiveTabIndex: activeTabIndex,
65+ }
66+}
67+
68+// WindowTabListsFromSnapshot restores window/tab lists from a SessionState.
69+// For v2 states (with Windows), each WindowSnapshot is restored independently.
70+// For legacy v1 states (flat Tabs), returns a single window with empty WindowID.
71+func WindowTabListsFromSnapshot(state *SessionState, idGen IDGenerator) []WindowTabListState {
72+ if state == nil {
73+ return nil
74+ }
75+
76+ // v2+: restore from Windows, including valid empty-window snapshots.
77+ if state.Version >= SessionStateVersion {
78+ result := make([]WindowTabListState, 0, len(state.Windows))
79+ for _, wSnap := range state.Windows {
80+ tabs := tabListFromWindowSnapshot(&wSnap, idGen)
81+ result = append(result, WindowTabListState{
82+ WindowID: wSnap.ID,
83+ Tabs: tabs,
84+ })
85+ }
86+ return result
87+ }
88+
89+ // v1: legacy flat tabs wrapped in a single window
90+ tabs := TabListFromSnapshot(state, idGen)
91+ return []WindowTabListState{
92+ {WindowID: "", Tabs: tabs},
93+ }
94+}
95+
96+// tabListFromWindowSnapshot converts a single WindowSnapshot to a live TabList.
97+func tabListFromWindowSnapshot(snap *WindowSnapshot, idGen IDGenerator) *TabList {
98+ if snap == nil {
99+ return NewTabList()
100+ }
101+
102+ return tabListFromSnapshots(snap.Tabs, snap.ActiveTabIndex, idGen)
103+}
M internal/infrastructure/snapshot/service.go +20 -7
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/entity"
1314 "github.com/bnema/dumber/internal/logging"
1415 )
1516
@@ -25,7 +26,7 @@ var _ port.SnapshotService = (*Service)(nil)
2526 // Service handles debounced session state snapshots.
2627 type Service struct {
2728 snapshotUC *usecase.SnapshotSessionUseCase
28- provider port.TabListProvider
29+ provider port.WindowStateProvider
2930 interval time.Duration
3031 retries int
3132 retryDelay time.Duration
@@ -41,7 +42,7 @@ type Service struct {
4142 // NewService creates a new snapshot service.
4243 func NewService(
4344 snapshotUC *usecase.SnapshotSessionUseCase,
44- provider port.TabListProvider,
45+ provider port.WindowStateProvider,
4546 intervalMs int,
4647 ) *Service {
4748 if intervalMs <= 0 {
@@ -163,7 +164,6 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
163164 s.dirty = false
164165 s.mu.Unlock()
165166
166- tabList := s.provider.GetTabList()
167167 sessionID := s.provider.GetSessionID()
168168
169169 if sessionID == "" {
@@ -171,10 +171,23 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
171171 return nil
172172 }
173173
174- if err := s.executeWithRetry(ctx, usecase.SnapshotInput{
175- SessionID: sessionID,
176- TabList: tabList,
177- }); err != nil {
174+ windows, activeWindowIndex := s.provider.GetWindowSnapshotState()
175+ if windows == nil {
176+ windows = make([]entity.WindowTabListState, 0)
177+ }
178+ input := usecase.SnapshotInput{
179+ SessionID: sessionID,
180+ Windows: windows,
181+ ActiveWindowIndex: activeWindowIndex,
182+ }
183+ // single window with WindowID == "" is legacy v1 sentinel; populate TabList and clear Windows/ActiveWindowIndex.
184+ if len(windows) == 1 && windows[0].WindowID == "" {
185+ input.Windows = nil
186+ input.ActiveWindowIndex = 0
187+ input.TabList = windows[0].Tabs
188+ }
189+
190+ if err := s.executeWithRetry(ctx, input); err != nil {
178191 s.markDirty()
179192 return err
180193 }
M internal/infrastructure/snapshot/service_test.go +123 -27
@@ -6,6 +6,7 @@ import (
66 "testing"
77 "time"
88
9+ "github.com/bnema/dumber/internal/application/port/mocks"
910 "github.com/bnema/dumber/internal/application/usecase"
1011 "github.com/bnema/dumber/internal/domain/entity"
1112 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -14,17 +15,16 @@ import (
1415 "github.com/stretchr/testify/require"
1516 )
1617
17-type testProvider struct {
18- sessionID entity.SessionID
19- tabList *entity.TabList
20-}
21-
22-func (p *testProvider) GetTabList() *entity.TabList {
23- return p.tabList
24-}
25-
26-func (p *testProvider) GetSessionID() entity.SessionID {
27- return p.sessionID
18+func newWindowStateProvider(
19+ t *testing.T,
20+ sessionID entity.SessionID,
21+ windows []entity.WindowTabListState,
22+ activeWindowIndex int,
23+) *mocks.MockWindowStateProvider {
24+ provider := mocks.NewMockWindowStateProvider(t)
25+ provider.EXPECT().GetSessionID().Return(sessionID).Once()
26+ provider.EXPECT().GetWindowSnapshotState().Return(windows, activeWindowIndex).Once()
27+ return provider
2828 }
2929
3030 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
@@ -41,10 +41,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
4141 })
4242
4343 uc := usecase.NewSnapshotSessionUseCase(repo)
44- svc := NewService(uc, &testProvider{
45- sessionID: "20260207_120000_fk_retry_ok",
46- tabList: entity.NewTabList(),
47- }, 1)
44+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_ok", nil, 0), 1)
4845 svc.retryDelay = time.Millisecond
4946 svc.ready = true
5047 svc.dirty = true
@@ -67,10 +64,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
6764 })
6865
6966 uc := usecase.NewSnapshotSessionUseCase(repo)
70- svc := NewService(uc, &testProvider{
71- sessionID: "20260207_120000_fk_retry_fail",
72- tabList: entity.NewTabList(),
73- }, 1)
67+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_fail", nil, 0), 1)
7468 svc.retryDelay = time.Millisecond
7569 svc.ready = true
7670 svc.dirty = true
@@ -94,10 +88,7 @@ func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
9488 })
9589
9690 uc := usecase.NewSnapshotSessionUseCase(repo)
97- svc := NewService(uc, &testProvider{
98- sessionID: "20260207_120000_non_fk",
99- tabList: entity.NewTabList(),
100- }, 1)
91+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_non_fk", nil, 0), 1)
10192 svc.retryDelay = time.Millisecond
10293 svc.ready = true
10394 svc.dirty = true
@@ -120,10 +111,7 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
120111 })
121112
122113 uc := usecase.NewSnapshotSessionUseCase(repo)
123- svc := NewService(uc, &testProvider{
124- sessionID: "20260207_120000_ready_flush",
125- tabList: entity.NewTabList(),
126- }, 1)
114+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_ready_flush", nil, 0), 1)
127115 svc.Start(context.Background())
128116 svc.dirty = true
129117
@@ -139,3 +127,111 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
139127 svc.mu.Unlock()
140128 assert.False(t, dirty)
141129 }
130+
131+func TestService_SaveNowPassesWindowSnapshots(t *testing.T) {
132+ repo := repomocks.NewMockSessionStateRepository(t)
133+
134+ pane1 := entity.NewPane("p_w1")
135+ pane1.URI = "https://win1.com"
136+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1)
137+ tab1_1.Name = "Win1Tab"
138+ tabs1 := entity.NewTabList()
139+ tabs1.Add(tab1_1)
140+
141+ pane2 := entity.NewPane("p_w2")
142+ pane2.URI = "https://win2.com"
143+ tab2_1 := entity.NewTab("t1_w2", "ws1_w2", pane2)
144+ tab2_1.Name = "Win2Tab"
145+ tabs2 := entity.NewTabList()
146+ tabs2.Add(tab2_1)
147+
148+ windows := []entity.WindowTabListState{
149+ {WindowID: "w1", Tabs: tabs1},
150+ {WindowID: "w2", Tabs: tabs2},
151+ }
152+
153+ sessionID := entity.SessionID("20260501_window_snap")
154+
155+ repo.EXPECT().
156+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
157+ Run(func(_ context.Context, state *entity.SessionState) {
158+ require.Equal(t, sessionID, state.SessionID)
159+ require.Equal(t, 2, state.Version)
160+ require.Len(t, state.Windows, 2)
161+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
162+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
163+ assert.Equal(t, 1, state.ActiveWindowIndex)
164+ assert.Empty(t, state.Tabs)
165+ }).
166+ Return(nil)
167+
168+ uc := usecase.NewSnapshotSessionUseCase(repo)
169+ provider := newWindowStateProvider(t, sessionID, windows, 1)
170+ svc := NewService(uc, provider, 1)
171+ svc.ready = true
172+ svc.dirty = true
173+
174+ err := svc.saveSnapshot(context.Background())
175+ require.NoError(t, err)
176+ assert.False(t, svc.dirty)
177+}
178+
179+func TestService_SaveNowPersistsEmptyWindowSnapshotAsV2(t *testing.T) {
180+ repo := repomocks.NewMockSessionStateRepository(t)
181+ sessionID := entity.SessionID("20260501_empty_window_snap")
182+
183+ repo.EXPECT().
184+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
185+ Run(func(_ context.Context, state *entity.SessionState) {
186+ require.Equal(t, sessionID, state.SessionID)
187+ require.Equal(t, entity.SessionStateVersion, state.Version)
188+ require.Empty(t, state.Windows)
189+ require.Empty(t, state.Tabs)
190+ assert.Equal(t, 0, state.ActiveWindowIndex)
191+ }).
192+ Return(nil)
193+
194+ uc := usecase.NewSnapshotSessionUseCase(repo)
195+ provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{}, 2)
196+ svc := NewService(uc, provider, 1)
197+ svc.ready = true
198+ svc.dirty = true
199+
200+ err := svc.saveSnapshot(context.Background())
201+ require.NoError(t, err)
202+ assert.False(t, svc.dirty)
203+}
204+
205+func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
206+ repo := repomocks.NewMockSessionStateRepository(t)
207+ sessionID := entity.SessionID("20260501_legacy_sentinel")
208+
209+ pane := entity.NewPane("p_legacy")
210+ pane.URI = "https://legacy.example"
211+ tab := entity.NewTab("t_legacy", "ws_legacy", pane)
212+ tab.Name = "Legacy Tab"
213+ tabs := entity.NewTabList()
214+ tabs.Add(tab)
215+
216+ repo.EXPECT().
217+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
218+ Run(func(_ context.Context, state *entity.SessionState) {
219+ require.Equal(t, sessionID, state.SessionID)
220+ require.Equal(t, entity.LegacySessionStateVersion, state.Version)
221+ require.Len(t, state.Tabs, 1)
222+ assert.Equal(t, "Legacy Tab", state.Tabs[0].Name)
223+ assert.Empty(t, state.Windows)
224+ assert.Equal(t, 0, state.ActiveWindowIndex)
225+ }).
226+ Return(nil)
227+
228+ uc := usecase.NewSnapshotSessionUseCase(repo)
229+ provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{{WindowID: "", Tabs: tabs}}, 3)
230+ svc := NewService(uc, provider, 1)
231+ svc.ready = true
232+ svc.dirty = true
233+
234+ err := svc.saveSnapshot(context.Background())
235+ require.NoError(t, err)
236+ assert.False(t, svc.dirty)
237+}
M internal/ui/app.go +1175 -280
@@ -7,6 +7,7 @@ import (
77 "io"
88 "os"
99 "path/filepath"
10+ "sort"
1011 "strings"
1112 "sync"
1213 "unsafe"
@@ -76,9 +77,9 @@ type App struct {
7677 mainWindow *window.MainWindow
7778
7879 browserWindows map[string]*browserWindow
80+ browserWindowOrder []string // registration order of window IDs
7981 lastFocusedWindowID string
8082 browserWindowFactory func(context.Context, string) (*browserWindow, error)
81- tabCreationWindow *browserWindow
8283 dispatchOnMainThread func(func())
8384
8485 // State
@@ -109,8 +110,7 @@ type App struct {
109110 resizeModeBorderTarget layout.Widget
110111
111112 // Omnibox configuration (omnibox is created per workspace view)
112- omniboxCfg component.OmniboxConfig
113- omniboxNavigateFn func(url string)
113+ omniboxCfg component.OmniboxConfig
114114 // Find bar configuration (find bar is created per workspace view)
115115 findBarCfg component.FindBarConfig
116116 // Floating pane sessions keyed by tab and profile session ID.
@@ -411,6 +411,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
411411 if err != nil {
412412 return nil, err
413413 }
414+ created.ensureTabs()
414415 a.wireBrowserWindowActivationTracking(created)
415416 created.initChrome(ctx, a)
416417 return created, nil
@@ -424,6 +425,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
424425 browserWindow := &browserWindow{
425426 id: a.generateWindowID(),
426427 initialURL: initialURL,
428+ tabs: entity.NewTabList(),
427429 mainWindow: mainWindow,
428430 }
429431 a.wireBrowserWindowActivationTracking(browserWindow)
@@ -500,56 +502,60 @@ func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browser
500502 }
501503
502504 func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
503- if a.windowForTab != nil {
504- if bw := a.windowForTab[tabID]; bw != nil {
505- return bw
506- }
507- }
508- if a.tabCreationWindow != nil {
509- return a.tabCreationWindow
505+ if a.windowForTab == nil {
506+ return nil
510507 }
511- return a.browserWindowForMainWindow(a.mainWindow)
508+ return a.windowForTab[tabID]
512509 }
513510
514-func (a *App) browserWindowHasLiveTab(bw *browserWindow, tabID entity.TabID) bool {
515- if a == nil || a.tabs == nil || bw == nil || tabID == "" {
516- return false
517- }
518- if a.tabs.Find(tabID) == nil {
519- return false
511+func (a *App) browserWindowForTabTarget(target coordinator.TabTarget) *browserWindow {
512+ if target.Tabs != nil {
513+ for _, bw := range a.browserWindows {
514+ if bw != nil && bw.tabs == target.Tabs {
515+ return bw
516+ }
517+ }
520518 }
521- if a.windowForTab[tabID] != bw {
522- return false
519+ if target.MainWindow != nil {
520+ return a.browserWindowForMainWindow(target.MainWindow)
523521 }
524- return true
522+ return nil
525523 }
526524
527525 func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {
528- if a.tabs == nil || bw == nil {
526+ if bw == nil || bw.tabs == nil {
529527 return 0
530528 }
531- count := 0
532- for _, tab := range a.tabs.Tabs {
533- if tab == nil {
534- continue
535- }
536- if owner := a.windowForTab[tab.ID]; owner == bw {
537- count++
538- }
539- }
540- return count
529+ return len(bw.tabs.Tabs)
541530 }
542531
543532 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
544- if paneID == "" || a.tabs == nil {
533+ if paneID == "" {
545534 return nil
546535 }
547- for _, tab := range a.tabs.Tabs {
548- if tab == nil || tab.Workspace == nil {
536+ for _, bw := range a.browserWindows {
537+ if bw == nil || bw.tabs == nil {
549538 continue
550539 }
551- if tab.Workspace.FindPane(paneID) != nil {
552- return a.browserWindowForTab(tab.ID)
540+ for _, tab := range bw.tabs.Tabs {
541+ if tab == nil || tab.Workspace == nil {
542+ continue
543+ }
544+ if tab.Workspace.FindPane(paneID) != nil {
545+ return bw
546+ }
547+ }
548+ }
549+ return nil
550+}
551+
552+func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow {
553+ if bw := a.browserWindowForPane(paneID); bw != nil {
554+ return bw
555+ }
556+ for key, session := range a.floatingSessions {
557+ if session != nil && session.paneID == paneID {
558+ return a.browserWindowForTab(key.tabID)
553559 }
554560 }
555561 return nil
@@ -598,6 +604,25 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.
598604 return a.lastFocusedBrowserWindow()
599605 }
600606
607+func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error {
608+ bw := a.browserWindowForAnyPane(popupInput.ParentPaneID)
609+ if bw == nil {
610+ return fmt.Errorf("popup owner window not found for parent pane %q", popupInput.ParentPaneID)
611+ }
612+ tab, err := a.tabCoord.CreateWithPane(
613+ ctx,
614+ a.ensureTabTargetForBrowserWindow(bw),
615+ popupInput.PopupPane,
616+ popupInput.WebView,
617+ popupInput.TargetURI,
618+ )
619+ if err != nil {
620+ return err
621+ }
622+ logging.FromContext(ctx).Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
623+ return nil
624+}
625+
601626 func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {
602627 a.updateWindowTitle(title, a.browserWindowForPane(paneID))
603628 }
@@ -609,8 +634,10 @@ func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) {
609634 }
610635 if entering {
611636 bw.mainWindow.TabBar().SetVisible(false)
637+ bw.mainWindow.SetTabBarContentInsetVisible(false)
612638 return
613639 }
640+ bw.mainWindow.TabBar().SetVisible(true)
614641 a.updateBrowserWindowTabBarVisibility(bw)
615642 }
616643
@@ -619,11 +646,13 @@ func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) {
619646 return
620647 }
621648 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
622- bw.mainWindow.TabBar().SetVisible(true)
649+ bw.mainWindow.TabBar().SetAutoHidden(false)
650+ bw.mainWindow.SetTabBarContentInsetVisible(true)
623651 return
624652 }
625653 shouldShow := bw.mainWindow.TabBar().Count() > 1
626- bw.mainWindow.TabBar().SetVisible(shouldShow)
654+ bw.mainWindow.TabBar().SetAutoHidden(!shouldShow)
655+ bw.mainWindow.SetTabBarContentInsetVisible(shouldShow)
627656 }
628657
629658 func (a *App) activateBrowserWindow(bw *browserWindow) {
@@ -636,13 +665,30 @@ func (a *App) activateBrowserWindow(bw *browserWindow) {
636665 }
637666 a.keyboardHandler = bw.keyboardHandler
638667 a.globalShortcutHandler = bw.globalShortcutHandler
639- if a.tabCoord != nil && bw.mainWindow != nil {
640- a.tabCoord.SetMainWindow(bw.mainWindow)
668+ if a.tabCoord != nil {
669+ a.tabCoord.SetCurrentTarget(a.tabTargetForBrowserWindow(bw))
641670 }
642- if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil {
643- a.tabs.SetActive(bw.activeTabID)
671+ // Sync the derived global App.tabs mirror from the active browser window.
672+ // Some usecase and snapshot/export boundaries still accept a single TabList,
673+ // so the mirror follows per-window active tab state without selecting runtime targets.
674+ activeTab := a.activeTabForBrowserWindow(bw)
675+ activeID := entity.TabID("")
676+ if activeTab != nil {
677+ activeID = activeTab.ID
678+ }
679+ perWinTabs := a.tabListForBrowserWindow(bw)
680+ if a.tabs != nil && activeID != "" {
681+ if a.tabs.Find(activeID) == nil {
682+ if tab := perWinTabs.Find(activeID); tab != nil {
683+ tabSnapshot := *tab
684+ a.tabs.Add(&tabSnapshot)
685+ }
686+ }
687+ if a.tabs.Find(activeID) != nil {
688+ a.tabs.SetActive(activeID)
689+ }
644690 }
645- a.updateWindowTitleFromActivePane(bw.activeTabID)
691+ a.updateWindowTitleFromActivePane(activeID)
646692 }
647693
648694 func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {
@@ -667,8 +713,9 @@ func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
667713 }
668714 a.ensureWindowForTabMap()
669715 a.windowForTab[tabID] = bw
670- if bw.activeTabID == "" {
671- bw.activeTabID = tabID
716+ tabs := a.ensureTabListForBrowserWindow(bw)
717+ if tabs.ActiveTabID == "" {
718+ tabs.SetActive(tabID)
672719 }
673720 }
674721
@@ -711,7 +758,7 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
711758 }
712759 a.registerBrowserWindow(created)
713760
714- if a.tabs == nil || a.tabs.Count() == 0 {
761+ if len(a.browserWindows) == 1 {
715762 a.activateBrowserWindow(created)
716763 return nil
717764 }
@@ -733,17 +780,13 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
733780 }
734781
735782 func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
736- previousMainWindow := a.mainWindow
737- if created.mainWindow != nil {
738- a.tabCoord.SetMainWindow(created.mainWindow)
739- }
740- a.tabCreationWindow = created
783+ previousTarget := a.tabCoord.CurrentTarget()
784+ a.tabCoord.SetCurrentTarget(a.ensureTabTargetForBrowserWindow(created))
741785 defer func() {
742- a.tabCreationWindow = nil
743- a.tabCoord.SetMainWindow(previousMainWindow)
786+ a.tabCoord.SetCurrentTarget(previousTarget)
744787 }()
745788
746- createdTab, createErr := a.tabCoord.Create(ctx, url)
789+ createdTab, createErr := a.tabCoord.Create(ctx, a.ensureTabTargetForBrowserWindow(created), url)
747790 if createErr != nil {
748791 a.removeBrowserWindow(created.id)
749792 if created.mainWindow != nil {
@@ -752,19 +795,17 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
752795 return createErr
753796 }
754797 if a.workspaceViews[createdTab.ID] == nil {
755- if a.tabs != nil {
756- a.tabs.Remove(createdTab.ID)
798+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
799+ createdTabs.Remove(createdTab.ID)
757800 }
758- tabBar := a.mainWindow.TabBar()
759- if created.mainWindow != nil && a.tabCoord != nil {
760- if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil {
761- tabBar = targetTabBar
801+ if created.mainWindow != nil {
802+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
803+ tabBar.RemoveTab(createdTab.ID)
804+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
805+ tabBar.SetActive(activeTab.ID)
806+ }
762807 }
763808 }
764- if tabBar != nil {
765- tabBar.RemoveTab(createdTab.ID)
766- tabBar.SetActive(a.tabs.ActiveTabID)
767- }
768809 a.removeBrowserWindow(created.id)
769810 if created.mainWindow != nil {
770811 created.mainWindow.Destroy()
@@ -772,7 +813,8 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
772813 return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
773814 }
774815 a.setBrowserWindowForTab(createdTab.ID, created)
775- created.activeTabID = createdTab.ID
816+ // Tab was already created in the per-window TabList via tabCoord.Create;
817+ // active state is managed by TabList.SetActive.
776818 if created.mainWindow != nil {
777819 created.mainWindow.Show()
778820 }
@@ -792,30 +834,27 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created
792834 return err
793835 }
794836 a.setBrowserWindowForTab(output.Tab.ID, created)
795- created.activeTabID = output.Tab.ID
837+ // Mirror the tabsUC-created tab into the new browser window's tab list.
838+ // This path uses the tabs usecase directly instead of tabCoord.Create,
839+ // so the per-window TabList must be populated explicitly.
840+ a.ensureTabListForBrowserWindow(created).Add(output.Tab)
796841 if a.widgetFactory != nil {
797- a.tabCreationWindow = created
798- defer func() {
799- a.tabCreationWindow = nil
800- }()
801842 a.createWorkspaceView(ctx, output.Tab)
802843 a.switchWorkspaceView(ctx, output.Tab.ID)
803844 }
804845 if a.workspaceViews[output.Tab.ID] == nil {
846+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
847+ createdTabs.Remove(output.Tab.ID)
848+ }
805849 if a.tabs != nil {
806850 a.tabs.Remove(output.Tab.ID)
807851 }
808- var tabBar *component.TabBar
809- if a.mainWindow != nil {
810- tabBar = a.mainWindow.TabBar()
811- }
812- if created.mainWindow != nil && created.mainWindow.TabBar() != nil {
813- tabBar = created.mainWindow.TabBar()
814- }
815- if tabBar != nil {
816- tabBar.RemoveTab(output.Tab.ID)
817- if a.tabs != nil {
818- tabBar.SetActive(a.tabs.ActiveTabID)
852+ if created.mainWindow != nil {
853+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
854+ tabBar.RemoveTab(output.Tab.ID)
855+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
856+ tabBar.SetActive(activeTab.ID)
857+ }
819858 }
820859 }
821860 a.removeBrowserWindow(created.id)
@@ -1118,7 +1157,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11181157 return nil
11191158 }
11201159 }
1121- return a.kbDispatcher.Dispatch(actionCtx, action)
1160+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
11221161 })
11231162 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
11241163 a.activateBrowserWindow(bw)
@@ -1165,7 +1204,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11651204 bw.keyboardHandler,
11661205 func(actionCtx context.Context, action input.Action) error {
11671206 a.activateBrowserWindow(bw)
1168- return a.kbDispatcher.Dispatch(actionCtx, action)
1207+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
11691208 },
11701209 )
11711210 if bw.globalShortcutHandler == nil {
@@ -1181,11 +1220,11 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
11811220 }
11821221 bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
11831222 a.activateBrowserWindow(bw)
1184- if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1223+ if err := a.tabCoord.Switch(ctx, a.ensureTabTargetForBrowserWindow(bw), tabID); err != nil {
11851224 logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")
11861225 return
11871226 }
1188- bw.activeTabID = tabID
1227+ // Active tab state is managed by TabCoordinator.Switch → TabList.SetActive.
11891228 })
11901229 }
11911230
@@ -1432,7 +1471,7 @@ func (a *App) attachTabPickerToActivePane() {
14321471 return
14331472 }
14341473
1435- activeTab := a.tabs.ActiveTab()
1474+ activeTab := a.activeTabForBrowserWindow(bw)
14361475 if activeTab == nil || activeTab.Workspace == nil {
14371476 return
14381477 }
@@ -1488,18 +1527,19 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
14881527 return nil
14891528 }
14901529
1491- sourceTab := a.tabs.ActiveTab()
1492- if sourceTab == nil || sourceTab.Workspace == nil {
1530+ sourceTabs := a.tabListForBrowserWindow(bw)
1531+ sourceTab := a.activeTabForBrowserWindow(bw)
1532+ if sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
14931533 return nil
14941534 }
14951535
14961536 // If there is only 1 tab, auto-create a new tab and move there.
1497- if a.tabs.Count() <= 1 {
1537+ if sourceTabs.Count() <= 1 {
14981538 return a.MoveActivePaneToTab(ctx, "")
14991539 }
15001540
1501- items := make([]component.TabPickerItem, 0, a.tabs.Count())
1502- for _, tab := range a.tabs.Tabs {
1541+ items := make([]component.TabPickerItem, 0, sourceTabs.Count())
1542+ for _, tab := range sourceTabs.Tabs {
15031543 if tab == nil {
15041544 continue
15051545 }
@@ -1524,17 +1564,19 @@ func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
15241564 if a.movePaneToTabUC == nil {
15251565 return nil
15261566 }
1527- active := a.tabs.ActiveTab()
1528- if active == nil {
1567+ bw := a.lastFocusedBrowserWindow()
1568+ sourceTabs := a.tabListForBrowserWindow(bw)
1569+ active := a.activeTabForBrowserWindow(bw)
1570+ if sourceTabs == nil || active == nil {
15291571 return nil
15301572 }
15311573
15321574 nextPos := active.Position + 1
1533- if nextPos >= a.tabs.Count() {
1575+ if nextPos >= sourceTabs.Count() {
15341576 // Create a new tab on the right.
15351577 return a.MoveActivePaneToTab(ctx, "")
15361578 }
1537- next := a.tabs.TabAt(nextPos)
1579+ next := sourceTabs.TabAt(nextPos)
15381580 if next == nil {
15391581 return nil
15401582 }
@@ -1568,10 +1610,23 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
15681610 if out.NewTabCreated && targetWindow != nil {
15691611 a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)
15701612 }
1613+ a.syncCrossWindowMovePaneTabLists(out, sourceTab, sourceWindow, targetWindow)
15711614
15721615 a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)
15731616 a.switchToTargetTabIfConfigured(ctx, out, targetWindow)
1574- a.updateTabBarVisibility(ctx)
1617+ if a.tabCoord != nil {
1618+ seen := map[*browserWindow]struct{}{}
1619+ for _, bw := range []*browserWindow{sourceWindow, targetWindow} {
1620+ if bw == nil {
1621+ continue
1622+ }
1623+ if _, ok := seen[bw]; ok {
1624+ continue
1625+ }
1626+ seen[bw] = struct{}{}
1627+ a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(bw))
1628+ }
1629+ }
15751630 a.MarkDirty()
15761631 return nil
15771632 }
@@ -1587,17 +1642,36 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
15871642 if a.movePaneToTabUC == nil {
15881643 return nil, nil
15891644 }
1590- activeTab := a.tabs.ActiveTab()
1591- if activeTab == nil || activeTab.Workspace == nil {
1645+ sourceWindow := a.lastFocusedBrowserWindow()
1646+ tabList := a.tabListForBrowserWindow(sourceWindow)
1647+ activeTab := a.activeTabForBrowserWindow(sourceWindow)
1648+ if tabList == nil || activeTab == nil || activeTab.Workspace == nil {
15921649 return nil, nil
15931650 }
1651+ if targetTabID != "" {
1652+ if targetWindow := a.browserWindowForTab(targetTabID); targetWindow != nil && targetWindow != sourceWindow {
1653+ // Cross-window pane moves still pass a single derived tab list because
1654+ // MovePaneToTabInput accepts one TabList. Ensure the derived global
1655+ // mirror contains source and target tabs before passing a.tabs.
1656+ a.syncDerivedGlobalTabMirror(activeTab)
1657+ if targetWindowTabs := a.tabListForBrowserWindow(targetWindow); targetWindowTabs != nil {
1658+ if targetTab := targetWindowTabs.Find(targetTabID); targetTab != nil {
1659+ a.syncDerivedGlobalTabMirror(targetTab)
1660+ }
1661+ }
1662+ if a.tabs == nil {
1663+ return nil, nil
1664+ }
1665+ tabList = a.tabs
1666+ }
1667+ }
15941668 sourcePaneID := activeTab.Workspace.ActivePaneID
15951669 if sourcePaneID == "" {
15961670 return nil, nil
15971671 }
15981672
15991673 in := &usecase.MovePaneToTabInput{
1600- TabList: a.tabs,
1674+ TabList: tabList,
16011675 SourceTabID: activeTab.ID,
16021676 SourcePaneID: sourcePaneID,
16031677 TargetTabID: targetTabID,
@@ -1605,6 +1679,41 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
16051679 return in, activeTab
16061680 }
16071681
1682+func (a *App) syncCrossWindowMovePaneTabLists(
1683+ out *usecase.MovePaneToTabOutput,
1684+ sourceTab *entity.Tab,
1685+ sourceWindow *browserWindow,
1686+ targetWindow *browserWindow,
1687+) {
1688+ if out == nil || out.TargetTab == nil || sourceWindow == nil || targetWindow == nil {
1689+ return
1690+ }
1691+
1692+ if out.NewTabCreated {
1693+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1694+ if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1695+ targetTabs.Add(out.TargetTab)
1696+ }
1697+ }
1698+
1699+ if sourceWindow == targetWindow {
1700+ return
1701+ }
1702+
1703+ if out.SourceTabClosed && sourceTab != nil {
1704+ if sourceTabs := a.tabListForBrowserWindow(sourceWindow); sourceTabs != nil {
1705+ sourceTabs.Remove(sourceTab.ID)
1706+ }
1707+ }
1708+
1709+ if owner := a.browserWindowForTab(out.TargetTab.ID); owner == targetWindow {
1710+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1711+ if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1712+ targetTabs.Add(out.TargetTab)
1713+ }
1714+ }
1715+}
1716+
16081717 func (a *App) applyMovePaneToTabUI(
16091718 ctx context.Context,
16101719 out *usecase.MovePaneToTabOutput,
@@ -1639,6 +1748,11 @@ func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserW
16391748 if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {
16401749 tabBar.RemoveTab(sourceTabID)
16411750 }
1751+ // Remove the tab from the derived global tab mirror after the per-window
1752+ // TabList is modified.
1753+ if a.tabs != nil {
1754+ a.tabs.Remove(sourceTabID)
1755+ }
16421756 }
16431757
16441758 func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {
@@ -1714,9 +1828,16 @@ func (a *App) switchToTargetTabIfConfigured(
17141828 return
17151829 }
17161830
1717- a.tabs.SetActive(out.TargetTab.ID)
1831+ if targetWindow == nil {
1832+ targetWindow = a.lastFocusedBrowserWindow()
1833+ }
17181834 if targetWindow != nil {
1719- targetWindow.activeTabID = out.TargetTab.ID
1835+ a.ensureTabListForBrowserWindow(targetWindow).SetActive(out.TargetTab.ID)
1836+ }
1837+ // Sync derived global active state after the target window is resolved
1838+ // and the per-window TabList has been updated.
1839+ if a.tabs != nil && a.tabs.Find(out.TargetTab.ID) != nil {
1840+ a.tabs.SetActive(out.TargetTab.ID)
17201841 }
17211842 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
17221843 tabBar.SetActive(out.TargetTab.ID)
@@ -1727,12 +1848,6 @@ func (a *App) switchToTargetTabIfConfigured(
17271848 a.switchWorkspaceView(ctx, out.TargetTab.ID)
17281849 }
17291850
1730-func (a *App) updateTabBarVisibility(ctx context.Context) {
1731- if a.tabCoord != nil {
1732- a.tabCoord.UpdateBarVisibility(ctx)
1733- }
1734-}
1735-
17361851 func (a *App) initSnapshotService(ctx context.Context) {
17371852 log := logging.FromContext(ctx)
17381853
@@ -1790,7 +1905,7 @@ func (a *App) GetTabList() *entity.TabList {
17901905 return a.tabs
17911906 }
17921907
1793-// GetSessionID implements port.TabListProvider.
1908+// GetSessionID implements port.TabListProvider and port.WindowStateProvider.
17941909 func (a *App) GetSessionID() entity.SessionID {
17951910 if a.deps == nil {
17961911 return ""
@@ -1798,6 +1913,532 @@ func (a *App) GetSessionID() entity.SessionID {
17981913 return a.deps.CurrentSessionID
17991914 }
18001915
1916+// GetWindowSnapshotState implements port.WindowStateProvider.
1917+// Returns per-window tab lists and active window index from one main-thread capture.
1918+func (a *App) GetWindowSnapshotState() ([]entity.WindowTabListState, int) {
1919+ var (
1920+ browserTabs map[string]*entity.TabList
1921+ windowOrder []string
1922+ globalTabs *entity.TabList
1923+ tabOwners map[entity.TabID]string
1924+ lastFocusedWindowID string
1925+ )
1926+
1927+ capture := func() {
1928+ globalTabs = entity.NewTabList()
1929+ if a.tabs != nil {
1930+ globalTabs = a.tabs.Snapshot()
1931+ }
1932+
1933+ browserTabs = make(map[string]*entity.TabList, len(a.browserWindows))
1934+ for id, bw := range a.browserWindows {
1935+ if bw == nil {
1936+ continue
1937+ }
1938+ if bw.tabs != nil {
1939+ browserTabs[id] = bw.tabs.Snapshot()
1940+ } else {
1941+ browserTabs[id] = nil
1942+ }
1943+ }
1944+
1945+ windowOrder = append([]string(nil), a.browserWindowOrder...)
1946+ tabOwners = make(map[entity.TabID]string, len(a.windowForTab))
1947+ for tabID, bw := range a.windowForTab {
1948+ if bw != nil {
1949+ tabOwners[tabID] = bw.id
1950+ }
1951+ }
1952+ lastFocusedWindowID = a.lastFocusedWindowID
1953+ }
1954+ if a.dispatchOnMainThread != nil {
1955+ a.dispatchOnMainThread(capture)
1956+ } else {
1957+ // nil dispatchOnMainThread is for single-threaded tests only;
1958+ // production should marshal capture to GTK main thread.
1959+ capture()
1960+ }
1961+
1962+ if len(browserTabs) == 0 {
1963+ return []entity.WindowTabListState{
1964+ {WindowID: "", Tabs: globalTabs},
1965+ }, 0
1966+ }
1967+
1968+ windowIDs := windowOrderFrom(browserTabs, windowOrder)
1969+ result := make([]entity.WindowTabListState, 0, len(windowIDs))
1970+ activeWindowIndex := 0
1971+ anyOwnedWindow := false
1972+ for i, wid := range windowIDs {
1973+ if wid == lastFocusedWindowID {
1974+ activeWindowIndex = i
1975+ }
1976+
1977+ tabs := browserTabs[wid]
1978+ if tabs == nil {
1979+ tabs = entity.NewTabList()
1980+ for _, tab := range globalTabs.Tabs {
1981+ if tabOwners[tab.ID] == wid {
1982+ tabs.Add(tab)
1983+ }
1984+ }
1985+
1986+ // Derive active tab from the global list when no per-window TabList
1987+ // exists only for snapshot/export compatibility with older flat snapshot state.
1988+ globalActive := globalTabs.ActiveTabID
1989+ if globalActive != "" && tabs.Find(globalActive) != nil {
1990+ tabs.SetActive(globalActive)
1991+ }
1992+ }
1993+ if tabs.Count() > 0 {
1994+ anyOwnedWindow = true
1995+ }
1996+
1997+ result = append(result, entity.WindowTabListState{
1998+ WindowID: entity.WindowID(wid),
1999+ Tabs: tabs,
2000+ })
2001+ }
2002+ if !anyOwnedWindow && globalTabs.Count() > 0 {
2003+ // No window ownership populated; return the flat global list for
2004+ // snapshot/export compatibility rather than snapshotting empty windows.
2005+ return []entity.WindowTabListState{{WindowID: "", Tabs: globalTabs}}, 0
2006+ }
2007+
2008+ return result, activeWindowIndex
2009+}
2010+
2011+// GetWindowTabLists implements port.WindowStateProvider.
2012+// Returns per-window tab lists in browser window registration order.
2013+// Falls back to a single window with the shared tab list when no browser windows exist.
2014+func (a *App) GetWindowTabLists() []entity.WindowTabListState {
2015+ windows, _ := a.GetWindowSnapshotState()
2016+ return windows
2017+}
2018+
2019+// GetActiveWindowIndex implements port.WindowStateProvider.
2020+// Returns the index of the last focused window in registration order.
2021+func (a *App) GetActiveWindowIndex() int {
2022+ _, activeWindowIndex := a.GetWindowSnapshotState()
2023+ return activeWindowIndex
2024+}
2025+
2026+// tabListForBrowserWindow returns the per-window TabList for a browser window.
2027+// Returns nil when bw is nil or bw.tabs has not been initialized.
2028+func (a *App) tabListForBrowserWindow(bw *browserWindow) *entity.TabList {
2029+ if bw == nil {
2030+ return nil
2031+ }
2032+ return bw.tabs
2033+}
2034+
2035+// ensureTabListForBrowserWindow returns the per-window TabList for a browser window,
2036+// lazy-initializing bw.tabs when nil. Returns nil when bw is nil.
2037+func (a *App) ensureTabListForBrowserWindow(bw *browserWindow) *entity.TabList {
2038+ if bw == nil {
2039+ return nil
2040+ }
2041+ if bw.tabs == nil {
2042+ bw.tabs = entity.NewTabList()
2043+ }
2044+ return bw.tabs
2045+}
2046+
2047+// activeTabForBrowserWindow returns the active tab for a browser window.
2048+// Returns nil when bw is nil or the window has no tabs.
2049+func (a *App) activeTabForBrowserWindow(bw *browserWindow) *entity.Tab {
2050+ tabs := a.tabListForBrowserWindow(bw)
2051+ if tabs == nil {
2052+ return nil
2053+ }
2054+ return tabs.ActiveTab()
2055+}
2056+
2057+// activeWorkspaceForBrowserWindow returns the active workspace for a browser window.
2058+// Returns nil when bw is nil or the active tab has no workspace.
2059+func (a *App) activeWorkspaceForBrowserWindow(bw *browserWindow) *entity.Workspace {
2060+ tab := a.activeTabForBrowserWindow(bw)
2061+ if tab == nil {
2062+ return nil
2063+ }
2064+ return tab.Workspace
2065+}
2066+
2067+func (a *App) activeWorkspaceViewForBrowserWindow(bw *browserWindow) *component.WorkspaceView {
2068+ tab := a.activeTabForBrowserWindow(bw)
2069+ if tab == nil {
2070+ return nil
2071+ }
2072+ return a.workspaceViews[tab.ID]
2073+}
2074+
2075+// tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window.
2076+// It does not allocate a TabList; callers that mutate tabs should use
2077+// ensureTabTargetForBrowserWindow instead.
2078+func (a *App) tabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2079+ if bw == nil {
2080+ return coordinator.TabTarget{}
2081+ }
2082+ return coordinator.TabTarget{
2083+ Tabs: a.tabListForBrowserWindow(bw),
2084+ MainWindow: bw.mainWindow,
2085+ }
2086+}
2087+
2088+func (a *App) ensureTabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2089+ if bw == nil {
2090+ return coordinator.TabTarget{}
2091+ }
2092+ return coordinator.TabTarget{
2093+ Tabs: a.ensureTabListForBrowserWindow(bw),
2094+ MainWindow: bw.mainWindow,
2095+ }
2096+}
2097+
2098+// activePaneIDForBrowserWindow resolves the active pane ID for a browser window.
2099+// A content-coordinator override wins so floating panes can receive navigation.
2100+// Returns the empty string when bw is nil or the active tab has no workspace.
2101+func (a *App) activePaneIDForBrowserWindow(bw *browserWindow) entity.PaneID {
2102+ if a.contentCoord != nil && bw != nil {
2103+ if paneID, ok := a.contentCoord.ActivePaneOverrideID(); ok && a.browserWindowForAnyPane(paneID) == bw {
2104+ return paneID
2105+ }
2106+ }
2107+ ws := a.activeWorkspaceForBrowserWindow(bw)
2108+ if ws == nil {
2109+ return ""
2110+ }
2111+ return ws.ActivePaneID
2112+}
2113+
2114+// activeWebViewForBrowserWindow resolves the active WebView for a browser window.
2115+// Uses the active pane ID and contentCoord.GetWebView (public getter) — never
2116+// contentCoord.ActiveWebView — so that stale global focus cannot be picked.
2117+func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, port.WebView) {
2118+ paneID := a.activePaneIDForBrowserWindow(bw)
2119+ if paneID == "" {
2120+ return "", nil
2121+ }
2122+ if a.contentCoord == nil {
2123+ return paneID, nil
2124+ }
2125+ return paneID, a.contentCoord.GetWebView(paneID)
2126+}
2127+
2128+// omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2129+// navigation through navigate for the supplied browser window. The closure captures
2130+// bw at creation time, so navigation targets the owning window even when another
2131+// window is globally focused.
2132+func omniboxNavigateForBrowserWindow(
2133+ ctx context.Context,
2134+ bw *browserWindow,
2135+ navigate func(context.Context, *browserWindow, string) error,
2136+) func(string) {
2137+ return func(url string) {
2138+ if err := navigate(ctx, bw, url); err != nil {
2139+ logging.FromContext(ctx).Error().
2140+ Err(err).
2141+ Str("url", url).
2142+ Str("window_id", bw.id).
2143+ Msg("omnibox navigation failed")
2144+ }
2145+ }
2146+}
2147+
2148+// navigateFromBrowserWindow loads a URL in the active pane of the given browser window.
2149+// Uses NavigationCoordinator.NavigateWebView for explicit target resolution.
2150+func (a *App) navigateFromBrowserWindow(ctx context.Context, bw *browserWindow, rawURL string) error {
2151+ if a.navCoord == nil {
2152+ return fmt.Errorf("navigation coordinator not initialized")
2153+ }
2154+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2155+ return a.navCoord.NavigateWebView(ctx, rawURL, paneID, wv)
2156+}
2157+
2158+// withBrowserWindowWebView resolves the active WebView for bw, checks that navCoord
2159+// is initialized and wv is not nil, then runs fn with the WebView. Avoids nil bw
2160+// panic in error formatting by falling back to empty window id.
2161+func (a *App) withBrowserWindowWebView(_ context.Context, bw *browserWindow, fn func(wv port.WebView) error) error {
2162+ if a.navCoord == nil {
2163+ return fmt.Errorf("navigation coordinator not initialized")
2164+ }
2165+ _, wv := a.activeWebViewForBrowserWindow(bw)
2166+ if wv == nil {
2167+ windowID := ""
2168+ if bw != nil {
2169+ windowID = bw.id
2170+ }
2171+ return fmt.Errorf("no active webview for browser window %s", windowID)
2172+ }
2173+ return fn(wv)
2174+}
2175+
2176+// reloadBrowserWindow reloads the active page in the given browser window.
2177+// When bypassCache is true, bypasses the cache (hard reload).
2178+func (a *App) reloadBrowserWindow(ctx context.Context, bw *browserWindow, bypassCache bool) error {
2179+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2180+ return a.navCoord.ReloadWebView(ctx, wv, bypassCache)
2181+ })
2182+}
2183+
2184+// stopBrowserWindow stops loading in the active pane of the given browser window.
2185+func (a *App) stopBrowserWindow(ctx context.Context, bw *browserWindow) error {
2186+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2187+ return a.navCoord.StopWebView(ctx, wv)
2188+ })
2189+}
2190+
2191+// goBackBrowserWindow navigates back in history for the active pane of the given browser window.
2192+func (a *App) goBackBrowserWindow(ctx context.Context, bw *browserWindow) error {
2193+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2194+ return a.navCoord.GoBackWebView(ctx, wv)
2195+ })
2196+}
2197+
2198+// goForwardBrowserWindow navigates forward in history for the active pane of the given browser window.
2199+func (a *App) goForwardBrowserWindow(ctx context.Context, bw *browserWindow) error {
2200+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2201+ return a.navCoord.GoForwardWebView(ctx, wv)
2202+ })
2203+}
2204+
2205+func (a *App) printBrowserWindow(ctx context.Context, bw *browserWindow) error {
2206+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2207+ return a.navCoord.PrintWebView(ctx, wv)
2208+ })
2209+}
2210+
2211+func (a *App) openDevToolsBrowserWindow(ctx context.Context, bw *browserWindow) error {
2212+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2213+ return a.navCoord.OpenDevToolsWebView(ctx, wv)
2214+ })
2215+}
2216+
2217+func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action string) error {
2218+ if a.deps == nil || a.deps.ZoomUC == nil {
2219+ logging.FromContext(ctx).Warn().Msg("zoom use case not available")
2220+ return nil
2221+ }
2222+
2223+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2224+ if wv == nil {
2225+ logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for zoom")
2226+ return nil
2227+ }
2228+
2229+ domain, err := usecase.ExtractDomain(wv.URI())
2230+ if err != nil {
2231+ logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
2232+ return nil
2233+ }
2234+
2235+ current := wv.GetZoomLevel()
2236+ var newZoom *entity.ZoomLevel
2237+ switch action {
2238+ case "in":
2239+ newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, domain, current)
2240+ case "out":
2241+ newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, domain, current)
2242+ case "reset":
2243+ err = a.deps.ZoomUC.ResetZoom(ctx, domain)
2244+ if err == nil {
2245+ newZoom = entity.NewZoomLevel(domain, a.deps.ZoomUC.DefaultZoom())
2246+ }
2247+ default:
2248+ return fmt.Errorf("unknown zoom action %q", action)
2249+ }
2250+ if err != nil {
2251+ return err
2252+ }
2253+ if newZoom == nil {
2254+ return nil
2255+ }
2256+ if err := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); err != nil {
2257+ return err
2258+ }
2259+ if a.navCoord != nil {
2260+ a.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
2261+ }
2262+ if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2263+ if paneView := wsView.GetPaneView(paneID); paneView != nil {
2264+ paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100))
2265+ }
2266+ }
2267+ return nil
2268+}
2269+
2270+const (
2271+ tabSwitchIndex0 = iota // 0
2272+ tabSwitchIndex1 // 1
2273+ tabSwitchIndex2 // 2
2274+ tabSwitchIndex3 // 3
2275+ tabSwitchIndex4 // 4
2276+ tabSwitchIndex5 // 5
2277+ tabSwitchIndex6 // 6
2278+ tabSwitchIndex7 // 7
2279+ tabSwitchIndex8 // 8
2280+ tabSwitchIndex9 // 9
2281+)
2282+
2283+// switchTabIndexActionIndex maps ActionSwitchTabIndex1..10 to 0..9.
2284+func switchTabIndexActionIndex(action input.Action) (int, bool) {
2285+ switch action {
2286+ case input.ActionSwitchTabIndex1:
2287+ return tabSwitchIndex0, true
2288+ case input.ActionSwitchTabIndex2:
2289+ return tabSwitchIndex1, true
2290+ case input.ActionSwitchTabIndex3:
2291+ return tabSwitchIndex2, true
2292+ case input.ActionSwitchTabIndex4:
2293+ return tabSwitchIndex3, true
2294+ case input.ActionSwitchTabIndex5:
2295+ return tabSwitchIndex4, true
2296+ case input.ActionSwitchTabIndex6:
2297+ return tabSwitchIndex5, true
2298+ case input.ActionSwitchTabIndex7:
2299+ return tabSwitchIndex6, true
2300+ case input.ActionSwitchTabIndex8:
2301+ return tabSwitchIndex7, true
2302+ case input.ActionSwitchTabIndex9:
2303+ return tabSwitchIndex8, true
2304+ case input.ActionSwitchTabIndex10:
2305+ return tabSwitchIndex9, true
2306+ default:
2307+ return 0, false
2308+ }
2309+}
2310+
2311+// dispatchBrowserWindowAction routes keyboard/global shortcut actions through the
2312+// source browser window helpers instead of falling back to the global active
2313+// webview/tab. Browser-window-owning callbacks (keyboardHandler, globalShortcutHandler)
2314+// must call this instead of activating the window then dispatching globally.
2315+func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow, action input.Action) error {
2316+ switch action {
2317+ case input.ActionReload:
2318+ return a.reloadBrowserWindow(ctx, bw, false)
2319+ case input.ActionHardReload:
2320+ return a.reloadBrowserWindow(ctx, bw, true)
2321+ case input.ActionStop:
2322+ return a.stopBrowserWindow(ctx, bw)
2323+ case input.ActionGoBack:
2324+ return a.goBackBrowserWindow(ctx, bw)
2325+ case input.ActionGoForward:
2326+ return a.goForwardBrowserWindow(ctx, bw)
2327+ case input.ActionPrintPage:
2328+ return a.printBrowserWindow(ctx, bw)
2329+ case input.ActionOpenDevTools:
2330+ return a.openDevToolsBrowserWindow(ctx, bw)
2331+ case input.ActionZoomIn:
2332+ return a.zoomBrowserWindow(ctx, bw, "in")
2333+ case input.ActionZoomOut:
2334+ return a.zoomBrowserWindow(ctx, bw, "out")
2335+ case input.ActionZoomReset:
2336+ return a.zoomBrowserWindow(ctx, bw, "reset")
2337+ case input.ActionSwitchTabIndex1, input.ActionSwitchTabIndex2, input.ActionSwitchTabIndex3,
2338+ input.ActionSwitchTabIndex4, input.ActionSwitchTabIndex5, input.ActionSwitchTabIndex6,
2339+ input.ActionSwitchTabIndex7, input.ActionSwitchTabIndex8, input.ActionSwitchTabIndex9,
2340+ input.ActionSwitchTabIndex10:
2341+ index, ok := switchTabIndexActionIndex(action)
2342+ if !ok {
2343+ return nil
2344+ }
2345+ return a.switchBrowserWindowTabIndex(ctx, bw, index)
2346+ default:
2347+ if a.kbDispatcher != nil {
2348+ return a.kbDispatcher.Dispatch(ctx, action)
2349+ }
2350+ return nil
2351+ }
2352+}
2353+
2354+// switchBrowserWindowTabIndex activates the given browser window and switches to
2355+// the tab at the given 0-based index in that window, creating tabs as needed.
2356+func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error {
2357+ if a.tabCoord == nil {
2358+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil")
2359+ return nil
2360+ }
2361+ if bw == nil {
2362+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window")
2363+ return nil
2364+ }
2365+ a.activateBrowserWindow(bw)
2366+ target := a.ensureTabTargetForBrowserWindow(bw)
2367+ initialURL := ""
2368+ if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" {
2369+ initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)
2370+ }
2371+ return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL)
2372+}
2373+
2374+// windowOrder returns window IDs in registration order.
2375+// Falls back to sorted map keys when no registration order is recorded
2376+// (e.g., in old tests or direct map manipulation).
2377+func (a *App) windowOrder() []string {
2378+ if len(a.browserWindowOrder) > 0 {
2379+ // Deduplicate against browserWindows, skipping stale entries.
2380+ seen := make(map[string]bool, len(a.browserWindowOrder))
2381+ result := make([]string, 0, len(a.browserWindowOrder))
2382+ for _, wid := range a.browserWindowOrder {
2383+ if seen[wid] || a.browserWindows[wid] == nil {
2384+ continue
2385+ }
2386+ seen[wid] = true
2387+ result = append(result, wid)
2388+ }
2389+ // Include any live browserWindows not yet tracked in order
2390+ // (inserted via direct map manipulation) deterministically.
2391+ untracked := make([]string, 0)
2392+ for id, bw := range a.browserWindows {
2393+ if bw != nil && !seen[id] {
2394+ untracked = append(untracked, id)
2395+ }
2396+ }
2397+ sort.Strings(untracked)
2398+ return append(result, untracked...)
2399+ }
2400+
2401+ // Fallback: sort map keys for deterministic output.
2402+ windowIDs := make([]string, 0, len(a.browserWindows))
2403+ for id := range a.browserWindows {
2404+ windowIDs = append(windowIDs, id)
2405+ }
2406+ sort.Strings(windowIDs)
2407+ return windowIDs
2408+}
2409+
2410+func windowOrderFrom(browserTabs map[string]*entity.TabList, browserWindowOrder []string) []string {
2411+ if len(browserWindowOrder) > 0 {
2412+ seen := make(map[string]bool, len(browserWindowOrder))
2413+ result := make([]string, 0, len(browserWindowOrder))
2414+ for _, wid := range browserWindowOrder {
2415+ if seen[wid] {
2416+ continue
2417+ }
2418+ if _, ok := browserTabs[wid]; !ok {
2419+ continue
2420+ }
2421+ seen[wid] = true
2422+ result = append(result, wid)
2423+ }
2424+ untracked := make([]string, 0)
2425+ for id := range browserTabs {
2426+ if !seen[id] {
2427+ untracked = append(untracked, id)
2428+ }
2429+ }
2430+ sort.Strings(untracked)
2431+ return append(result, untracked...)
2432+ }
2433+
2434+ windowIDs := make([]string, 0, len(browserTabs))
2435+ for id := range browserTabs {
2436+ windowIDs = append(windowIDs, id)
2437+ }
2438+ sort.Strings(windowIDs)
2439+ return windowIDs
2440+}
2441+
18012442 // MarkDirty signals that session state has changed and should be saved.
18022443 func (a *App) MarkDirty() {
18032444 if a.snapshotService != nil {
@@ -1823,23 +2464,43 @@ func (a *App) createInitialTab(ctx context.Context) {
18232464 }
18242465
18252466 // Create an initial tab using coordinator.
1826- if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil {
2467+ target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow())
2468+ if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
18272469 log.Error().Err(err).Msg("failed to create initial tab")
18282470 }
18292471 }
18302472
2473+func (a *App) clearRuntimeWindowUI(runtimeWindows []*browserWindow) {
2474+ for _, bw := range runtimeWindows {
2475+ if bw == nil || bw.mainWindow == nil {
2476+ continue
2477+ }
2478+ if tabBar := bw.mainWindow.TabBar(); tabBar != nil {
2479+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
2480+ for _, tab := range tabs.Tabs {
2481+ if tab != nil {
2482+ tabBar.RemoveTab(tab.ID)
2483+ }
2484+ }
2485+ }
2486+ }
2487+ bw.mainWindow.SetContent(nil)
2488+ }
2489+}
2490+
18312491 func (a *App) clearSessionRestoreUIState(ctx context.Context) {
18322492 if a == nil {
18332493 return
18342494 }
18352495
18362496 tabIDs := make(map[entity.TabID]struct{})
1837- if a.tabs != nil {
1838- for _, tab := range a.tabs.Tabs {
1839- if tab == nil {
1840- continue
2497+ for _, bw := range a.browserWindows {
2498+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
2499+ for _, tab := range tabs.Tabs {
2500+ if tab != nil {
2501+ tabIDs[tab.ID] = struct{}{}
2502+ }
18412503 }
1842- tabIDs[tab.ID] = struct{}{}
18432504 }
18442505 }
18452506 for tabID := range a.workspaceViews {
@@ -1867,94 +2528,290 @@ func (a *App) clearSessionRestoreUIState(ctx context.Context) {
18672528 }
18682529
18692530 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1870- log := logging.FromContext(ctx)
2531+ state, restoredWindows, err := a.loadRestoredWindowStates(ctx, sessionID)
2532+ if err != nil {
2533+ return err
2534+ }
2535+
2536+ logging.FromContext(ctx).Info().
2537+ Int("windows", len(restoredWindows)).
2538+ Int("panes", state.CountPanes()).
2539+ Msg("restoring session state")
2540+
2541+ if len(restoredWindows) == 0 {
2542+ if state.Version >= entity.SessionStateVersion {
2543+ // v2+ empty-window snapshots are valid: replace with a single empty window.
2544+ restoredWindows = []entity.WindowTabListState{{
2545+ WindowID: "",
2546+ Tabs: entity.NewTabList(),
2547+ }}
2548+ } else {
2549+ return fmt.Errorf("restored session contains no windows")
2550+ }
2551+ }
2552+
2553+ runtimeWindows, firstBWReused, err := a.prepareRuntimeWindows(ctx, len(restoredWindows))
2554+ if err != nil {
2555+ return err
2556+ }
2557+ a.clearSessionRestoreUIState(ctx)
2558+ a.clearRuntimeWindowUI(runtimeWindows)
2559+ a.assignRuntimeWindowTabLists(runtimeWindows, restoredWindows, firstBWReused)
2560+ a.pruneStaleBrowserWindows(runtimeWindows)
2561+
2562+ activeBW := runtimeWindows[safeWindowIndex(state.ActiveWindowIndex, len(runtimeWindows))]
2563+ a.activateBrowserWindow(activeBW)
2564+ a.replaceGlobalTabsFromRuntimeWindows(runtimeWindows, activeBW)
2565+ a.buildRestoredWindowUI(ctx, runtimeWindows)
2566+ a.showRestoredRuntimeWindows(runtimeWindows)
2567+
2568+ return nil
2569+}
18712570
2571+func (a *App) loadRestoredWindowStates(
2572+ ctx context.Context,
2573+ sessionID entity.SessionID,
2574+) (*entity.SessionState, []entity.WindowTabListState, error) {
18722575 if a.deps == nil || a.deps.SessionStateRepo == nil {
1873- return fmt.Errorf("session state repo not available")
2576+ return nil, nil, fmt.Errorf("session state repo not available")
18742577 }
18752578
1876- // Load session state
18772579 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
18782580 if err != nil {
1879- return fmt.Errorf("load session state: %w", err)
2581+ return nil, nil, fmt.Errorf("load session state: %w", err)
18802582 }
18812583 if state == nil {
1882- return fmt.Errorf("session state not found")
2584+ return nil, nil, fmt.Errorf("session state not found")
18832585 }
18842586
1885- log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
2587+ restoredWindows := entity.WindowTabListsFromSnapshot(state, a.generateID)
2588+ return state, restoredWindows, nil
2589+}
18862590
1887- // Build complete TabList from snapshot (state-first approach)
1888- // This reconstructs the full pane tree with new IDs
1889- restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
1890- if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
1891- return fmt.Errorf("failed to build tab list from snapshot")
2591+func (a *App) prepareRuntimeWindows(ctx context.Context, windowCount int) ([]*browserWindow, bool, error) {
2592+ firstBW := a.browserWindowForMainWindow(a.mainWindow)
2593+ if firstBW == nil {
2594+ firstBW = a.lastFocusedBrowserWindow()
18922595 }
2596+ firstBWReused := firstBW != nil
2597+ createdWindows := make([]*browserWindow, 0)
18932598
1894- a.clearSessionRestoreUIState(ctx)
2599+ if firstBW == nil {
2600+ if a.mainWindow != nil {
2601+ firstBW = a.wrapExistingMainWindowForRestore(ctx, a.mainWindow)
2602+ } else {
2603+ created, err := a.createBrowserWindow(ctx, "")
2604+ if err != nil {
2605+ return nil, false, fmt.Errorf("create browser window for restore: %w", err)
2606+ }
2607+ firstBW = created
2608+ createdWindows = append(createdWindows, created)
2609+ }
2610+ }
18952611
1896- // Replace tabs in-place to preserve references held by TabCoordinator
1897- a.tabs.ReplaceFrom(restoredTabs)
2612+ runtimeWindows := make([]*browserWindow, windowCount)
2613+ runtimeWindows[0] = firstBW
2614+ for i := 1; i < windowCount; i++ {
2615+ extraBW, err := a.createBrowserWindow(ctx, "")
2616+ if err != nil {
2617+ a.cleanupCreatedBrowserWindows(createdWindows)
2618+ return nil, false, fmt.Errorf("create browser window %d for restore: %w", i, err)
2619+ }
2620+ createdWindows = append(createdWindows, extraBW)
2621+ runtimeWindows[i] = extraBW
2622+ }
18982623
1899- a.assignRestoredTabsToCurrentWindow()
2624+ return runtimeWindows, firstBWReused, nil
2625+}
19002626
1901- // Create UI for each restored tab and add to tab bar
1902- // Don't attach to content area yet - only the active tab should be attached
1903- tabBar := a.mainWindow.TabBar()
1904- for _, tab := range a.tabs.Tabs {
1905- a.createWorkspaceViewWithoutAttach(ctx, tab)
1906- wsView := a.workspaceViews[tab.ID]
1907- if a.wsCoord != nil && wsView != nil {
1908- a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
2627+func (a *App) wrapExistingMainWindowForRestore(ctx context.Context, mainWindow *window.MainWindow) *browserWindow {
2628+ bw := &browserWindow{id: a.generateWindowID(), tabs: entity.NewTabList(), mainWindow: mainWindow}
2629+ a.wireBrowserWindowActivationTracking(bw)
2630+ if a.kbDispatcher != nil {
2631+ a.initBrowserWindowInput(ctx, bw)
2632+ }
2633+ if a.tabCoord != nil {
2634+ a.wireBrowserWindowTabBar(ctx, bw)
2635+ }
2636+ bw.initChrome(ctx, a)
2637+ return bw
2638+}
2639+
2640+func (a *App) cleanupCreatedBrowserWindows(createdWindows []*browserWindow) {
2641+ for _, bw := range createdWindows {
2642+ if bw == nil {
2643+ continue
19092644 }
1910- if tabBar != nil {
1911- tabBar.AddTab(tab)
2645+ mainWindow := bw.mainWindow
2646+ a.removeBrowserWindow(bw.id)
2647+ if mainWindow != nil {
2648+ if a.mainWindow == mainWindow {
2649+ a.mainWindow = nil
2650+ }
2651+ mainWindow.Destroy()
19122652 }
1913- log.Debug().
1914- Str("tab_id", string(tab.ID)).
1915- Str("name", tab.Name).
1916- Int("panes", tab.Workspace.PaneCount()).
1917- Msg("restored tab with workspace")
19182653 }
2654+}
19192655
1920- // Switch to active tab - this attaches the active workspace view to content area
1921- activeTab := a.tabs.ActiveTab()
1922- if activeTab != nil {
1923- a.switchWorkspaceView(ctx, activeTab.ID)
1924- if tabBar != nil {
1925- tabBar.SetActive(activeTab.ID)
2656+func (a *App) assignRuntimeWindowTabLists(
2657+ runtimeWindows []*browserWindow,
2658+ restoredWindows []entity.WindowTabListState,
2659+ firstBWReused bool,
2660+) {
2661+ for i, bw := range runtimeWindows {
2662+ if i > 0 || !firstBWReused {
2663+ a.registerBrowserWindow(bw)
19262664 }
2665+ a.assignRestoredWindowTabList(bw, restoredWindows[i])
19272666 }
2667+}
19282668
1929- return nil
2669+func (a *App) pruneStaleBrowserWindows(runtimeWindows []*browserWindow) {
2670+ runtimeSet := make(map[string]bool, len(runtimeWindows))
2671+ for _, bw := range runtimeWindows {
2672+ runtimeSet[bw.id] = true
2673+ }
2674+ for id, bw := range a.browserWindows {
2675+ if !runtimeSet[id] {
2676+ if bw != nil && bw.mainWindow != nil {
2677+ bw.mainWindow.Destroy()
2678+ }
2679+ a.removeBrowserWindow(id)
2680+ }
2681+ }
2682+
2683+ a.browserWindowOrder = make([]string, len(runtimeWindows))
2684+ for i, bw := range runtimeWindows {
2685+ a.browserWindowOrder[i] = bw.id
2686+ }
19302687 }
19312688
1932-func (a *App) assignRestoredTabsToCurrentWindow() {
1933- if a == nil || a.tabs == nil {
1934- return
2689+func safeWindowIndex(index, count int) int {
2690+ if index < 0 || index >= count {
2691+ return 0
19352692 }
2693+ return index
2694+}
19362695
1937- restoreWindow := a.browserWindowForMainWindow(a.mainWindow)
1938- if restoreWindow == nil {
1939- restoreWindow = a.lastFocusedBrowserWindow()
2696+func (a *App) syncDerivedGlobalTabMirror(tab *entity.Tab) {
2697+ if a.tabs != nil && tab != nil && a.tabs.Find(tab.ID) == nil {
2698+ a.tabs.Add(cloneTabForGlobalList(tab))
19402699 }
1941- if restoreWindow == nil {
1942- return
2700+}
2701+
2702+// workspace pointer is intentionally shared because App.tabs is a derived
2703+// snapshot/export mirror, not runtime command target; runtime mutations must
2704+// target browserWindow tabs.
2705+func cloneTabForGlobalList(tab *entity.Tab) *entity.Tab {
2706+ if tab == nil {
2707+ return nil
19432708 }
2709+ return &entity.Tab{
2710+ ID: tab.ID,
2711+ Name: tab.Name,
2712+ Workspace: tab.Workspace,
2713+ IsPinned: tab.IsPinned,
2714+ CreatedAt: tab.CreatedAt,
2715+ }
2716+}
19442717
1945- for _, tab := range a.tabs.Tabs {
1946- if tab != nil {
1947- a.setBrowserWindowForTab(tab.ID, restoreWindow)
2718+func (a *App) replaceGlobalTabsFromRuntimeWindows(runtimeWindows []*browserWindow, activeBW *browserWindow) {
2719+ globalTabs := entity.NewTabList()
2720+ for _, bw := range runtimeWindows {
2721+ perWinTabs := a.tabListForBrowserWindow(bw)
2722+ if perWinTabs == nil {
2723+ continue
2724+ }
2725+ for _, tab := range perWinTabs.Tabs {
2726+ if cloned := cloneTabForGlobalList(tab); cloned != nil {
2727+ globalTabs.Add(cloned)
2728+ }
2729+ }
2730+ }
2731+
2732+ if activeTabs := a.tabListForBrowserWindow(activeBW); activeTabs != nil {
2733+ activeID := activeTabs.ActiveTabID
2734+ if activeID != "" && globalTabs.Find(activeID) != nil {
2735+ globalTabs.SetActive(activeID)
2736+ }
2737+ }
2738+ a.tabs.ReplaceFrom(globalTabs)
2739+}
2740+
2741+func (a *App) showRestoredRuntimeWindows(runtimeWindows []*browserWindow) {
2742+ for _, bw := range runtimeWindows {
2743+ if bw != nil && bw.mainWindow != nil {
2744+ bw.mainWindow.Show()
19482745 }
19492746 }
2747+}
2748+
2749+func (a *App) buildRestoredWindowUI(ctx context.Context, runtimeWindows []*browserWindow) {
2750+ for _, bw := range runtimeWindows {
2751+ perWinTabs := a.tabListForBrowserWindow(bw)
2752+ if perWinTabs == nil || perWinTabs.Count() == 0 {
2753+ a.updateBrowserWindowTabBarVisibility(bw)
2754+ continue
2755+ }
2756+
2757+ tabBar := bw.mainWindow.TabBar()
2758+ activeTab := perWinTabs.ActiveTab()
2759+ for _, tab := range perWinTabs.Tabs {
2760+ a.buildRestoredTabUI(ctx, bw, tabBar, tab)
2761+ }
2762+
2763+ if activeTab != nil {
2764+ a.switchWorkspaceView(ctx, activeTab.ID)
2765+ if tabBar != nil {
2766+ tabBar.SetActive(activeTab.ID)
2767+ }
2768+ }
2769+ a.updateBrowserWindowTabBarVisibility(bw)
2770+ }
2771+}
2772+
2773+func (a *App) buildRestoredTabUI(ctx context.Context, bw *browserWindow, tabBar *component.TabBar, tab *entity.Tab) {
2774+ if tab == nil {
2775+ return
2776+ }
2777+ a.createWorkspaceViewWithoutAttach(ctx, tab)
2778+ wsView := a.workspaceViews[tab.ID]
2779+ if a.wsCoord != nil && wsView != nil {
2780+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
2781+ }
2782+ if tabBar != nil {
2783+ tabBar.AddTab(tab)
2784+ }
2785+ logging.FromContext(ctx).Debug().
2786+ Str("tab_id", string(tab.ID)).
2787+ Str("name", tab.Name).
2788+ Str("window_id", bw.id).
2789+ Int("panes", tab.Workspace.PaneCount()).
2790+ Msg("restored tab with workspace")
2791+}
2792+
2793+// assignRestoredWindowTabList assigns a restored WindowTabListState to a runtime browser window.
2794+// It sets bw.tabs to the restored TabList (or a new empty one if nil) and registers each
2795+// tab's ownership via windowForTab. It never inserts saved window IDs into browserWindows;
2796+// the runtime window ID is owned by the caller.
2797+func (a *App) assignRestoredWindowTabList(bw *browserWindow, restored entity.WindowTabListState) {
2798+ if bw == nil {
2799+ return
2800+ }
19502801
1951- restoreWindow.activeTabID = a.tabs.ActiveTabID
1952- previousID := a.tabs.PreviousActiveTabID
1953- if previousID != "" && previousID != a.tabs.ActiveTabID && a.tabs.Find(previousID) != nil {
1954- restoreWindow.prevActiveTabID = previousID
2802+ a.ensureWindowForTabMap()
2803+
2804+ if restored.Tabs == nil {
2805+ bw.tabs = entity.NewTabList()
19552806 return
19562807 }
1957- restoreWindow.prevActiveTabID = ""
2808+
2809+ bw.tabs = restored.Tabs
2810+ for _, tab := range restored.Tabs.Tabs {
2811+ if tab != nil {
2812+ a.windowForTab[tab.ID] = bw
2813+ }
2814+ }
19582815 }
19592816
19602817 func (a *App) finalizeActivation(ctx context.Context) {
@@ -2126,14 +2983,12 @@ func (a *App) initContentCoordinator(
21262983 func (a *App) initTabCoordinator(ctx context.Context) {
21272984 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
21282985 TabsUC: a.tabsUC,
2129- Tabs: a.tabs,
21302986 MainWindow: a.mainWindow,
21312987 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
21322988 })
2133- a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
2134- // Assign ownership BEFORE creating workspace view so windowForTab is set
2135- // for scope filtering and browserWindowForTab resolution.
2136- bw := a.browserWindowForTab(tab.ID)
2989+ a.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
2990+ // Assign ownership from the callback target, not focus/global state.
2991+ bw := a.browserWindowForTabTarget(target)
21372992 if bw != nil {
21382993 a.setBrowserWindowForTab(tab.ID, bw)
21392994 }
@@ -2147,59 +3002,38 @@ func (a *App) initTabCoordinator(ctx context.Context) {
21473002 }
21483003 tab.Name = defaultTabName(count)
21493004 }
3005+ // Keep the derived global App.tabs mirror populated from per-window
3006+ // tab creation for usecase and snapshot/export boundaries.
3007+ // This must happen AFTER default tab name assignment so the clone
3008+ // has the final window-scoped default name.
3009+ if a.tabs != nil && a.tabs.Find(tab.ID) == nil {
3010+ a.tabs.Add(cloneTabForGlobalList(tab))
3011+ }
21503012 a.createWorkspaceView(ctx, tab)
21513013 })
2152- a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
2153- if bw := a.browserWindowForTab(tab.ID); bw != nil {
2154- oldActiveID := bw.activeTabID
2155- if oldActiveID != "" && oldActiveID != tab.ID && a.tabs.Find(oldActiveID) != nil {
2156- bw.prevActiveTabID = oldActiveID
2157- } else if bw.prevActiveTabID == tab.ID || a.tabs.Find(bw.prevActiveTabID) == nil {
2158- bw.prevActiveTabID = ""
2159- }
2160- bw.activeTabID = tab.ID
3014+ a.tabCoord.SetOnTabSwitched(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
3015+ // TabList.SetActive (called by TabCoordinator.Switch → tabsUC.Switch) already
3016+ // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
3017+ if bw := a.browserWindowForTabTarget(target); bw != nil {
21613018 a.activateBrowserWindow(bw)
21623019 }
21633020 a.switchWorkspaceView(ctx, tab.ID)
21643021 })
2165- a.tabCoord.SetOnQuit(a.Quit)
21663022 a.tabCoord.SetOnStateChanged(a.MarkDirty)
2167- // Wire per-window tab scoping
2168- a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool {
2169- if tab == nil {
2170- return false
2171- }
2172- bw := a.browserWindowForMainWindow(mainWindow)
2173- if bw == nil {
2174- return true // backward-compatible fallback for single-window / test scenarios
2175- }
2176- owner := a.windowForTab[tab.ID]
2177- if owner == nil {
2178- return false // unowned tabs are out of scope when a real window exists
2179- }
2180- return owner == bw
2181- })
2182- // Wire per-window previous active tab ID provider for scoped Alt+Tab switching
2183- a.tabCoord.SetPreviousActiveTabIDProvider(func(mainWindow *window.MainWindow) entity.TabID {
2184- bw := a.browserWindowForMainWindow(mainWindow)
2185- if bw == nil {
2186- return ""
2187- }
2188- prevID := bw.prevActiveTabID
2189- if !a.browserWindowHasLiveTab(bw, prevID) {
2190- bw.prevActiveTabID = ""
2191- return ""
2192- }
2193- return prevID
2194- })
2195- a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, mainWindow *window.MainWindow) {
2196- bw := a.browserWindowForMainWindow(mainWindow)
3023+ // Per-window tab ownership is now enforced through explicit TabTarget.
3024+ // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.
3025+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, target coordinator.TabTarget) {
3026+ bw := a.browserWindowForTabTarget(target)
21973027 if bw != nil {
21983028 a.removeBrowserWindow(bw.id)
21993029 if bw.mainWindow != nil {
22003030 bw.mainWindow.Destroy()
22013031 }
22023032 }
3033+ // Quit the app only when all browser windows are gone.
3034+ if len(a.browserWindows) == 0 {
3035+ a.Quit()
3036+ }
22033037 })
22043038 // Wire popup tab WebView attachment
22053039 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
@@ -2216,7 +3050,11 @@ func (a *App) initCoordinators(ctx context.Context) {
22163050 log := logging.FromContext(ctx)
22173051 log.Debug().Msg("initializing coordinators")
22183052
2219- // Helper to get active workspace and view
3053+ // Coordinators resolve the workspace and view through
3054+ // lastFocusedBrowserWindow via activeWorkspace helpers.
3055+ // Shared coordinator instances defer to window-scoped resolution;
3056+ // the startup edge case (no focused window yet) is handled inside
3057+ // activeWorkspace/activeWorkspaceView.
22203058 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
22213059 return a.activeWorkspace(), a.activeWorkspaceView()
22223060 }
@@ -2252,7 +3090,8 @@ func (a *App) initCoordinators(ctx context.Context) {
22523090 ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
22533091 })
22543092 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
2255- return a.tabCoord.Close(ctx)
3093+ bw := a.lastFocusedBrowserWindow()
3094+ return a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw))
22563095 })
22573096 a.wsCoord.SetOnStateChanged(a.MarkDirty)
22583097
@@ -2273,16 +3112,8 @@ func (a *App) initCoordinators(ctx context.Context) {
22733112 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
22743113 return a.wsCoord.ClosePaneByID(ctx, paneID)
22753114 })
2276- // Wire tabbed popup behavior to create new tabs
2277- a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {
2278- // Create a new tab with the popup pane
2279- tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
2280- if err != nil {
2281- return err
2282- }
2283- log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
2284- return nil
2285- })
3115+ // Wire tabbed popup behavior to create new tabs in the originating window.
3116+ a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
22863117
22873118 // Move pane use case (cross-tab)
22883119 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
@@ -2338,7 +3169,10 @@ func (a *App) initCoordinators(ctx context.Context) {
23383169 })
23393170 }
23403171
2341- // Set the WebView as the focused input for accent picker (if this is the active pane)
3172+ // This only updates focus when the shown pane belongs to the last-focused
3173+ // window's active workspace. activeWorkspace() delegates to
3174+ // lastFocusedBrowserWindow, so the accent-picker does not activate for
3175+ // background windows.
23423176 ws := a.activeWorkspace()
23433177 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
23443178 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
@@ -2346,14 +3180,16 @@ func (a *App) initCoordinators(ctx context.Context) {
23463180 })
23473181
23483182 // 5. Keyboard Dispatcher
3183+ // Dispatcher receives an app-level active pane provider (contentCoord.ActivePaneID)
3184+ // and per-window handlers route source-window actions through
3185+ // dispatchBrowserWindowAction for proper multi-window isolation.
23493186 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
23503187 ctx,
2351- a.tabCoord,
23523188 a.wsCoord,
23533189 a.navCoord,
23543190 a.deps.ZoomUC,
23553191 a.deps.CopyURLUC,
2356- a.deps.Config.Workspace.NewPaneURL,
3192+ a.keyboardActions(),
23573193 a.contentCoord.ActivePaneID,
23583194 )
23593195 a.wireKeyboardActions()
@@ -2364,6 +3200,65 @@ func (a *App) initCoordinators(ctx context.Context) {
23643200 log.Debug().Msg("coordinators initialized")
23653201 }
23663202
3203+func (a *App) keyboardActions() dispatcher.KeyboardActions {
3204+ return dispatcher.KeyboardActions{
3205+ NewTab: func(ctx context.Context) error {
3206+ if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" {
3207+ logging.FromContext(ctx).Warn().Msg("new tab ignored: new pane URL is not configured")
3208+ return fmt.Errorf("newPaneURL is not configured")
3209+ }
3210+ return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3211+ _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL))
3212+ return err
3213+ })
3214+ },
3215+ CloseTab: func(ctx context.Context) error {
3216+ return a.withFocusedTabTarget(ctx, "close tab", false, func(target coordinator.TabTarget) error {
3217+ return a.tabCoord.Close(ctx, target)
3218+ })
3219+ },
3220+ NextTab: func(ctx context.Context) error {
3221+ return a.withFocusedTabTarget(ctx, "next tab", false, func(target coordinator.TabTarget) error {
3222+ return a.tabCoord.SwitchNext(ctx, target)
3223+ })
3224+ },
3225+ PreviousTab: func(ctx context.Context) error {
3226+ return a.withFocusedTabTarget(ctx, "previous tab", false, func(target coordinator.TabTarget) error {
3227+ return a.tabCoord.SwitchPrev(ctx, target)
3228+ })
3229+ },
3230+ SwitchLastTab: func(ctx context.Context) error {
3231+ return a.withFocusedTabTarget(ctx, "switch last tab", false, func(target coordinator.TabTarget) error {
3232+ return a.tabCoord.SwitchToLastActive(ctx, target)
3233+ })
3234+ },
3235+ SwitchTabIndex: func(ctx context.Context, index int) error {
3236+ return a.switchBrowserWindowTabIndex(ctx, a.lastFocusedBrowserWindow(), index)
3237+ },
3238+ ActiveWebView: func(context.Context) port.WebView {
3239+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
3240+ return wv
3241+ },
3242+ }
3243+}
3244+
3245+func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bool, fn func(coordinator.TabTarget) error) error {
3246+ if a.tabCoord == nil {
3247+ logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: tab coordinator is nil")
3248+ return nil
3249+ }
3250+ bw := a.lastFocusedBrowserWindow()
3251+ if bw == nil {
3252+ logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: no focused browser window")
3253+ return nil
3254+ }
3255+ target := a.tabTargetForBrowserWindow(bw)
3256+ if ensure {
3257+ target = a.ensureTabTargetForBrowserWindow(bw)
3258+ }
3259+ return fn(target)
3260+}
3261+
23673262 func (a *App) wireKeyboardActions() {
23683263 a.kbDispatcher.SetOnQuit(a.Quit)
23693264 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
@@ -2631,13 +3526,13 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
26313526 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
26323527 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
26333528 var ws *entity.Workspace
2634- if a.tabs != nil {
2635- if tab := a.tabs.Find(tabID); tab != nil {
3529+ bw := a.browserWindowForTab(tabID)
3530+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
3531+ if tab := tabs.Find(tabID); tab != nil {
26363532 ws = tab.Workspace
2637- } else {
2638- ws = a.activeWorkspace()
26393533 }
2640- } else {
3534+ }
3535+ if ws == nil {
26413536 ws = a.activeWorkspace()
26423537 }
26433538 if ws == nil || a.contentCoord == nil {
@@ -2657,6 +3552,7 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
26573552 a.clearResizeModeBorder()
26583553 }
26593554 if to == input.ModeResize {
3555+ // Resize mode targets the last-focused browser window's active workspace.
26603556 a.applyResizeModeBorder(ctx, a.activeWorkspace())
26613557 }
26623558
@@ -2722,6 +3618,8 @@ func (a *App) clearResizeModeBorder() {
27223618 }
27233619
27243620 func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
3621+ // activeWorkspaceView resolves via lastFocusedBrowserWindow; resize mode
3622+ // always operates on the last-focused window.
27253623 wsView := a.activeWorkspaceView()
27263624 if wsView == nil || ws == nil {
27273625 a.clearResizeModeBorder()
@@ -2809,8 +3707,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
28093707 // Note: Mode borders for tab/pane/session are attached to MainWindow.
28103708 // Resize mode border is attached to the active pane's stack container.
28113709
2812- // Set omnibox config for this workspace view
2813- wsView.SetOmniboxConfig(a.omniboxCfg)
3710+ // Set omnibox config for this workspace view.
3711+ // When the owning browser window is known, bind navigation to the
3712+ // owning window so that the omnibox always navigates in the correct
3713+ // window, regardless of global focus.
3714+ if owner := a.browserWindowForTab(tab.ID); owner != nil {
3715+ cfg := a.omniboxCfg
3716+ cfg.OnNavigate = omniboxNavigateForBrowserWindow(ctx, owner, a.navigateFromBrowserWindow)
3717+ wsView.SetOmniboxConfig(cfg)
3718+ } else {
3719+ wsView.SetOmniboxConfig(a.omniboxCfg)
3720+ }
28143721 // Set find bar config for this workspace view
28153722 wsView.SetFindBarConfig(a.findBarCfg)
28163723 // Set auto-open omnibox on new pane
@@ -2820,6 +3727,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
28203727
28213728 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
28223729 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
3730+ // Resize-mode pane focus tracks the last-focused window's workspace.
28233731 ws := a.activeWorkspace()
28243732 if ws != nil {
28253733 ws.ActivePaneID = paneID
@@ -2842,50 +3750,40 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
28423750 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
28433751 }
28443752
2845-// activeWorkspace returns the workspace of the active tab.
3753+// activeWorkspace returns the workspace of the focused browser window's active tab.
28463754 func (a *App) activeWorkspace() *entity.Workspace {
2847- if a.tabs == nil {
2848- return nil
2849- }
2850- activeTab := a.tabs.ActiveTab()
2851- if activeTab == nil {
2852- return nil
2853- }
2854- return activeTab.Workspace
3755+ return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
28553756 }
28563757
28573758 // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
28583759 // This is necessary because panes in inactive tabs also need URI updates for session snapshots.
28593760 func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
2860- for _, tab := range a.tabs.Tabs {
2861- if tab.Workspace == nil {
3761+ for _, bw := range a.browserWindows {
3762+ if bw == nil || bw.tabs == nil {
28623763 continue
28633764 }
2864- paneNode := tab.Workspace.FindPane(paneID)
2865- if paneNode != nil && paneNode.Pane != nil {
2866- paneNode.Pane.URI = url
2867- return // Pane IDs are unique, no need to continue
3765+ for _, tab := range bw.tabs.Tabs {
3766+ if tab == nil || tab.Workspace == nil {
3767+ continue
3768+ }
3769+ paneNode := tab.Workspace.FindPane(paneID)
3770+ if paneNode != nil && paneNode.Pane != nil {
3771+ paneNode.Pane.URI = url
3772+ return // Pane IDs are unique, no need to continue
3773+ }
28683774 }
28693775 }
28703776 }
28713777
2872-// activeWorkspaceView returns the workspace view for the active tab.
3778+// activeWorkspaceView returns the workspace view for the focused browser window's active tab.
28733779 func (a *App) activeWorkspaceView() *component.WorkspaceView {
2874- activeTab := a.tabs.ActiveTab()
2875- if activeTab == nil {
2876- return nil
2877- }
2878- return a.workspaceViews[activeTab.ID]
3780+ return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow())
28793781 }
28803782
28813783 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
28823784 // Used by the accent picker to insert accented characters into web content.
28833785 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2884- if a.contentCoord == nil {
2885- return nil
2886- }
2887-
2888- wv := a.contentCoord.ActiveWebView(context.Background())
3786+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
28893787 if wv == nil {
28903788 return nil
28913789 }
@@ -2952,9 +3850,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
29523850 return
29533851 }
29543852
2955- // Swap content (MainWindow.SetContent now properly removes old content)
3853+ // Swap content (MainWindow.SetContent now properly removes old content).
3854+ // Active tab state is managed by TabList.SetActive; no per-window field needed.
29563855 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
2957- target.activeTabID = tabID
29583856 target.mainWindow.SetContent(gtkWidget)
29593857 } else if a.mainWindow != nil {
29603858 a.mainWindow.SetContent(gtkWidget)
@@ -3292,7 +4190,7 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta
32924190 }
32934191
32944192 func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
3295- activeTab := a.tabs.ActiveTab()
4193+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
32964194 if activeTab == nil {
32974195 return floatingSessionKey{}, nil, false
32984196 }
@@ -3377,7 +4275,7 @@ func (a *App) syncFloatingFocus() {
33774275 return
33784276 }
33794277
3380- activeTab := a.tabs.ActiveTab()
4278+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
33814279 if activeTab == nil {
33824280 a.contentCoord.ClearActivePaneOverride()
33834281 return
@@ -3402,6 +4300,14 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
34024300
34034301 if session.omnibox == nil {
34044302 cfg := a.omniboxCfg
4303+ cfg.OnNavigate = func(url string) {
4304+ if session.pane == nil {
4305+ return
4306+ }
4307+ if err := session.pane.Navigate(ctx, url); err != nil {
4308+ logging.FromContext(ctx).Error().Err(err).Str("url", url).Msg("floating omnibox navigation failed")
4309+ }
4310+ }
34054311 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
34064312 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
34074313 }
@@ -3619,7 +4525,7 @@ func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
36194525
36204526 // ToggleFloatingPane toggles the active workspace floating pane visibility.
36214527 func (a *App) ToggleFloatingPane(ctx context.Context) error {
3622- activeTab := a.tabs.ActiveTab()
4528+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
36234529 if activeTab == nil {
36244530 return nil
36254531 }
@@ -3640,7 +4546,7 @@ func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url str
36404546 }
36414547
36424548 func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
3643- activeTab := a.tabs.ActiveTab()
4549+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
36444550 if activeTab == nil {
36454551 return nil
36464552 }
@@ -3694,14 +4600,10 @@ func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
36944600 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
36954601 return session.pane.Navigate(ctx, url)
36964602 }
3697- if a.omniboxNavigateFn != nil {
3698- a.omniboxNavigateFn(url)
3699- return nil
4603+ if bw := a.lastFocusedBrowserWindow(); bw != nil {
4604+ return a.navigateFromBrowserWindow(ctx, bw, url)
37004605 }
3701- if a.navCoord == nil {
3702- return fmt.Errorf("navigation coordinator not initialized")
3703- }
3704- return a.navCoord.Navigate(ctx, url)
4606+ return fmt.Errorf("no focused browser window for omnibox navigation")
37054607 }
37064608
37074609 // ToggleOmnibox implements OmniboxProvider.
@@ -3802,13 +4704,6 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
38024704 }
38034705 }
38044706
3805-// SetOmniboxOnNavigate implements OmniboxProvider.
3806-// This is called to set the navigation callback on new omniboxes.
3807-// Since omniboxes are created per-pane, we store the config with the callback.
3808-func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
3809- a.omniboxNavigateFn = fn
3810-}
3811-
38124707 // initFilteringAsync starts background filter loading with toast feedback.
38134708 func (a *App) initConfigWatcher(ctx context.Context) {
38144709 log := logging.FromContext(ctx)
M internal/ui/app_browser_launch_test.go +1462 -47
@@ -2,12 +2,15 @@ package ui
22
33 import (
44 "context"
5+ "errors"
56 "io"
67 "os"
78 "path/filepath"
89 "reflect"
10+ "strconv"
911 "strings"
1012 "testing"
13+ "time"
1114 "unsafe"
1215
1316 "github.com/bnema/dumber/internal/application/port"
@@ -153,6 +156,39 @@ func windowForTabCount(t *testing.T, app *App) int {
153156 return fv.Len()
154157 }
155158
159+func assertWindowOwnershipInvariant(t *testing.T, app *App) {
160+ t.Helper()
161+
162+ seen := make(map[entity.TabID]string)
163+ for windowID, bw := range app.browserWindows {
164+ if bw == nil {
165+ continue
166+ }
167+ if bw.tabs == nil {
168+ t.Errorf("browser window %s has nil tabs", windowID)
169+ continue
170+ }
171+ for _, tab := range bw.tabs.Tabs {
172+ if tab == nil {
173+ continue
174+ }
175+ if previous, exists := seen[tab.ID]; exists {
176+ t.Errorf("tab %s is owned by both %s and %s", tab.ID, previous, windowID)
177+ continue
178+ }
179+ seen[tab.ID] = windowID
180+ if got := app.windowForTab[tab.ID]; got != bw {
181+ t.Errorf("windowForTab[%s] = %p, want owner window %s (%p)", tab.ID, got, windowID, bw)
182+ }
183+ }
184+ }
185+ for tabID, bw := range app.windowForTab {
186+ if bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
187+ t.Errorf("windowForTab[%s] points to stale or non-owning window %p", tabID, bw)
188+ }
189+ }
190+}
191+
156192 // tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.
157193 // Keep this test-only so production visibility stays narrow.
158194 func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {
@@ -177,6 +213,24 @@ func setWindowTabBar(t *testing.T, mw *window.MainWindow, tabBar *component.TabB
177213 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))
178214 }
179215
216+func omniboxNavigateCallbackForTest(t *testing.T, omnibox *component.Omnibox) func(string) {
217+ t.Helper()
218+ if omnibox == nil {
219+ t.Fatal("omnibox is nil")
220+ }
221+
222+ rv := reflect.ValueOf(omnibox).Elem()
223+ fv := rv.FieldByName("onNavigate")
224+ if !fv.IsValid() {
225+ t.Fatal("Omnibox missing onNavigate field")
226+ }
227+ cb, ok := reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Interface().(func(string))
228+ if !ok || cb == nil {
229+ t.Fatal("omnibox onNavigate callback is nil or has unexpected type")
230+ }
231+ return cb
232+}
233+
180234 func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {
181235 t.Helper()
182236
@@ -220,6 +274,32 @@ func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
220274 return mw.TabBar().Box().GetVisible()
221275 }
222276
277+func assertWindowTabBarAutoHidden(t *testing.T, mw *window.MainWindow, wantHidden bool) {
278+ t.Helper()
279+ if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
280+ t.Fatal("missing tab bar")
281+ }
282+ box := mw.TabBar().Box()
283+ if got := box.GetVisible(); !got {
284+ t.Fatalf("tab bar visible = %v, want true so allocation is preserved", got)
285+ }
286+ if wantHidden {
287+ if got := box.GetOpacity(); got != 0.0 {
288+ t.Fatalf("tab bar opacity = %v, want 0", got)
289+ }
290+ if got := box.GetCanTarget(); got {
291+ t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
292+ }
293+ return
294+ }
295+ if got := box.GetOpacity(); got != 1.0 {
296+ t.Fatalf("tab bar opacity = %v, want 1", got)
297+ }
298+ if got := box.GetCanTarget(); !got {
299+ t.Fatalf("tab bar can target = %v, want true while shown", got)
300+ }
301+}
302+
223303 func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
224304 t.Helper()
225305 if sv == nil {
@@ -320,23 +400,102 @@ func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t
320400 }
321401
322402 func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
323- created := &browserWindow{id: "window-1"}
403+ existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
404+ existingTabs := entity.NewTabList()
405+ existingTabs.Add(existingTab)
406+ existingTabs.SetActive(existingTab.ID)
407+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs}
408+ created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
324409 app := &App{
325410 tabs: entity.NewTabList(),
326411 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
327- browserWindows: make(map[string]*browserWindow),
412+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
413+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
328414 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
329415 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
330416 return created, nil
331417 },
332418 }
333- app.tabs.Add(entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
334419
335420 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
336421 t.Fatalf("OpenFreshWindow returned error: %v", err)
337422 }
338- if got := windowForTabCount(t, app); got != 1 {
339- t.Fatalf("windowForTab length = %d, want 1", got)
423+ if got := windowForTabCount(t, app); got != 2 {
424+ t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
425+ }
426+ assertWindowOwnershipInvariant(t, app)
427+}
428+
429+func TestApp_RestoreSessionHandlesEmptyWindowSnapshotAsSuccess(t *testing.T) {
430+ ctx := context.Background()
431+ sessionID := entity.SessionID("session-empty-windows")
432+ staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
433+ staleTabs := entity.NewTabList()
434+ staleTabs.Add(staleTab)
435+ staleWindow := &browserWindow{id: "window-stale", tabs: staleTabs}
436+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
437+
438+ app := &App{
439+ deps: &Dependencies{
440+ Config: &config.Config{},
441+ SessionStateRepo: &fakeSessionStateRepo{state: state},
442+ },
443+ tabs: entity.NewTabList(),
444+ browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
445+ lastFocusedWindowID: staleWindow.id,
446+ workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: {}},
447+ windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
448+ }
449+ app.tabs.Add(staleTab)
450+
451+ err := app.restoreSession(ctx, sessionID)
452+ if err != nil {
453+ t.Fatalf("restoreSession returned error for valid v2 empty window snapshot: %v", err)
454+ }
455+ // Stale state should be cleared and stale window should have an empty tab list.
456+ if _, ok := app.workspaceViews[staleTab.ID]; ok {
457+ t.Fatal("stale workspace view was not cleared")
458+ }
459+ if _, ok := app.windowForTab[staleTab.ID]; ok {
460+ t.Fatal("stale windowForTab entry was not cleared")
461+ }
462+ if got := app.tabs.Count(); got != 0 {
463+ t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
464+ }
465+ if got := staleWindow.tabs.Count(); got != 0 {
466+ t.Fatalf("staleWindow.tabs.Count() = %d, want 0 after empty restore", got)
467+ }
468+}
469+
470+func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
471+ ctx := context.Background()
472+ sessionID := entity.SessionID("session-empty-windows-fallback")
473+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
474+ tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" })
475+ windowTabs := entity.NewTabList()
476+ bw := &browserWindow{id: "window-1", tabs: windowTabs}
477+ app := &App{
478+ deps: &Dependencies{
479+ Config: &config.Config{},
480+ RestoreSessionID: string(sessionID),
481+ InitialURL: "https://fallback.example",
482+ SessionStateRepo: &fakeSessionStateRepo{state: state},
483+ },
484+ tabs: entity.NewTabList(),
485+ tabsUC: tabsUC,
486+ tabCoord: coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: tabsUC, Tabs: entity.NewTabList()}),
487+ browserWindows: map[string]*browserWindow{bw.id: bw},
488+ lastFocusedWindowID: bw.id,
489+ }
490+
491+ app.createInitialTab(ctx)
492+
493+ // For valid v2 empty-window restore, no fallback tab should be created.
494+ if created := windowTabs.Find(entity.TabID("fallback-tab")); created != nil {
495+ t.Fatal("fallback tab was created despite valid v2 empty-window restore")
496+ }
497+ if got := app.tabs.Count(); got != 0 {
498+ t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
340499 }
341500 }
342501
@@ -394,6 +553,7 @@ func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testi
394553 if _, ok := app.floatingSessions[staleSessionKey]; ok {
395554 t.Fatalf("stale floating session was not removed")
396555 }
556+ assertWindowOwnershipInvariant(t, app)
397557 }
398558
399559 func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
@@ -489,40 +649,53 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T
489649 }
490650
491651 func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
492- created := &browserWindow{id: "window-1"}
652+ created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
493653 originalWindow := &window.MainWindow{}
494654 tabBar := &component.TabBar{}
495655 setWindowTabBar(t, originalWindow, tabBar)
496656 existingTabID := entity.TabID("existing-tab")
497657 staleTabID := entity.TabID("stale-tab")
498658 tabBar.SetActive(staleTabID)
659+
660+ existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
661+ existingTabs := entity.NewTabList()
662+ existingTabs.Add(existingTab)
663+ existingTabs.SetActive(existingTab.ID)
664+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: originalWindow}
665+
499666 app := &App{
500667 tabs: entity.NewTabList(),
501668 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
502- browserWindows: make(map[string]*browserWindow),
669+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
670+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
503671 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
504672 return created, nil
505673 },
506674 mainWindow: originalWindow,
507675 }
508- app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
509676 app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
510677 TabsUC: app.tabsUC,
511- Tabs: app.tabs,
678+ Tabs: entity.NewTabList(),
512679 MainWindow: &window.MainWindow{},
513680 })
514681
515682 if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
516683 t.Fatalf("OpenFreshWindow = nil error, want failure")
517684 }
518- if got := len(app.browserWindows); got != 0 {
519- t.Fatalf("browserWindows length = %d, want 0", got)
685+ if got := len(app.browserWindows); got != 1 {
686+ t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
687+ }
688+ if app.browserWindows[existingWindow.id] == nil {
689+ t.Fatal("existing window should remain after rollback")
520690 }
521- if got := windowForTabCount(t, app); got != 0 {
522- t.Fatalf("windowForTab length = %d, want 0", got)
691+ if app.browserWindows["window-1"] != nil {
692+ t.Fatal("created window should be removed after rollback")
693+ }
694+ if got := windowForTabCount(t, app); got != 1 {
695+ t.Fatalf("windowForTab length = %d, want 1 (existing tab only)", got)
523696 }
524- if got := windowTabBarActiveID(t, originalWindow); got != existingTabID {
525- t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID)
697+ if got := windowTabBarActiveID(t, originalWindow); got != staleTabID {
698+ t.Fatalf("tab bar active tab = %q, want %q (original window unchanged)", got, staleTabID)
526699 }
527700 }
528701
@@ -536,10 +709,17 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
536709 tabs := entity.NewTabList()
537710 tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })
538711
712+ existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
713+ existingTabs := entity.NewTabList()
714+ existingTabs.Add(existingTab)
715+ existingTabs.SetActive(existingTab.ID)
716+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: oldWindow}
717+
539718 app := &App{
540719 tabs: tabs,
541720 tabsUC: tabsUC,
542- browserWindows: make(map[string]*browserWindow),
721+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
722+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
543723 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
544724 return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
545725 },
@@ -552,8 +732,7 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
552732 }),
553733 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
554734 }
555- app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
556- app.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
735+ app.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
557736 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
558737 })
559738
@@ -571,9 +750,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
571750 if got := windowTabBarActiveID(t, oldWindow); got != "" {
572751 t.Fatalf("old window tab bar active tab = %q, want empty", got)
573752 }
574- if got := windowTabBarVisible(t, newWindow); got {
575- t.Fatalf("new window tab bar visible = %v, want false", got)
576- }
753+ // The lightweight test tab bar shell has no GTK box, so allocation state is
754+ // covered by TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation.
577755 }
578756
579757 func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
@@ -584,8 +762,14 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
584762 tabs.Add(tab2)
585763 ws1 := &component.WorkspaceView{}
586764 ws2 := &component.WorkspaceView{}
587- first := &browserWindow{id: "window-1", activeTabID: tab1.ID, mainWindow: &window.MainWindow{}}
588- second := &browserWindow{id: "window-2", activeTabID: tab2.ID, mainWindow: &window.MainWindow{}}
765+ firstTabs := entity.NewTabList()
766+ firstTabs.Add(tab1)
767+ firstTabs.SetActive(tab1.ID)
768+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
769+ secondTabs := entity.NewTabList()
770+ secondTabs.Add(tab2)
771+ secondTabs.SetActive(tab2.ID)
772+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
589773 kh := &input.KeyboardHandler{}
590774 gs := &input.GlobalShortcutHandler{}
591775 second.keyboardHandler = kh
@@ -603,8 +787,8 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
603787 if app.mainWindow != second.mainWindow {
604788 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)
605789 }
606- if app.tabs.ActiveTabID != tab2.ID {
607- t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab2.ID)
790+ if second.tabs.ActiveTabID != tab2.ID {
791+ t.Fatalf("active tab = %q, want %q", second.tabs.ActiveTabID, tab2.ID)
608792 }
609793 if app.activeWorkspaceView() != ws2 {
610794 t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)
@@ -623,8 +807,12 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
623807 tabs := entity.NewTabList()
624808 tabs.Add(tab1)
625809 tabs.Add(tab2)
626- first := &browserWindow{id: "window-1"}
627- second := &browserWindow{id: "window-2"}
810+ firstTabs := entity.NewTabList()
811+ firstTabs.Add(tab1)
812+ secondTabs := entity.NewTabList()
813+ secondTabs.Add(tab2)
814+ first := &browserWindow{id: "window-1", tabs: firstTabs}
815+ second := &browserWindow{id: "window-2", tabs: secondTabs}
628816 app := &App{
629817 tabs: tabs,
630818 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -644,8 +832,12 @@ func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
644832 tabs := entity.NewTabList()
645833 tabs.Add(tab1)
646834 tabs.Add(tab2)
647- first := &browserWindow{id: "window-1"}
648- second := &browserWindow{id: "window-2"}
835+ firstTabs := entity.NewTabList()
836+ firstTabs.Add(tab1)
837+ secondTabs := entity.NewTabList()
838+ secondTabs.Add(tab2)
839+ first := &browserWindow{id: "window-1", tabs: firstTabs}
840+ second := &browserWindow{id: "window-2", tabs: secondTabs}
649841 app := &App{
650842 tabs: tabs,
651843 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -680,8 +872,14 @@ func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
680872 tabs := entity.NewTabList()
681873 tabs.Add(tab1)
682874 tabs.Add(tab2)
683- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
684- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
875+ firstTabs := entity.NewTabList()
876+ firstTabs.Add(tab1)
877+ firstTabs.SetActive(tab1.ID)
878+ secondTabs := entity.NewTabList()
879+ secondTabs.Add(tab2)
880+ secondTabs.SetActive(tab2.ID)
881+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
882+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
685883 app := &App{
686884 tabs: tabs,
687885 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -719,8 +917,14 @@ func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
719917 tabs := entity.NewTabList()
720918 tabs.Add(tab1)
721919 tabs.Add(tab2)
722- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID}
723- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID}
920+ firstTabs := entity.NewTabList()
921+ firstTabs.Add(tab1)
922+ firstTabs.SetActive(tab1.ID)
923+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
924+ secondTabs := entity.NewTabList()
925+ secondTabs.Add(tab2)
926+ secondTabs.SetActive(tab2.ID)
927+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
724928 contentCoord := &contentcoord.Coordinator{}
725929 contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
726930 reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
@@ -766,8 +970,14 @@ func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
766970 tabs.Add(tab2)
767971 secondMainWindow.TabBar().AddTab(tab1)
768972 secondMainWindow.TabBar().AddTab(tab2)
769- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
770- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
973+ firstTabs := entity.NewTabList()
974+ firstTabs.Add(tab1)
975+ firstTabs.SetActive(tab1.ID)
976+ secondTabs := entity.NewTabList()
977+ secondTabs.Add(tab2)
978+ secondTabs.SetActive(tab2.ID)
979+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
980+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
771981 app := &App{
772982 tabs: tabs,
773983 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -851,8 +1061,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.
8511061 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
8521062 tabs.Add(tab1)
8531063 tabs.Add(tab2)
854- first := &browserWindow{id: "window-1", activeTabID: tab1.ID}
855- second := &browserWindow{id: "window-2", activeTabID: tab2.ID}
1064+ firstTabs := entity.NewTabList()
1065+ firstTabs.Add(tab1)
1066+ firstTabs.SetActive(tab1.ID)
1067+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1068+ secondTabs := entity.NewTabList()
1069+ secondTabs.Add(tab2)
1070+ secondTabs.SetActive(tab2.ID)
1071+ second := &browserWindow{id: "window-2", tabs: secondTabs}
8561072 app := &App{
8571073 tabs: tabs,
8581074 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
@@ -882,8 +1098,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWin
8821098 tabs.Add(tab2)
8831099 tabs.Add(tab3)
8841100 tabs.SetActive(tab1.ID)
885- first := &browserWindow{id: "window-1", activeTabID: tab1.ID}
886- second := &browserWindow{id: "window-2", activeTabID: tab2.ID}
1101+ firstTabs := entity.NewTabList()
1102+ firstTabs.Add(tab1)
1103+ firstTabs.SetActive(tab1.ID)
1104+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1105+ secondTabs := entity.NewTabList()
1106+ secondTabs.Add(tab2)
1107+ secondTabs.SetActive(tab2.ID)
1108+ second := &browserWindow{id: "window-2", tabs: secondTabs}
8871109 app := &App{
8881110 tabs: tabs,
8891111 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
@@ -914,6 +1136,65 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWin
9141136 }
9151137 }
9161138
1139+func TestApp_MoveActivePaneToTabFromBrowserWindowCrossWindowDerivedMirror(t *testing.T) {
1140+ // app.tabs initially contains ONLY source tab (not target tab) to simulate
1141+ // a target created only in browserWindow.tabs (the real-world scenario that
1142+ // CodeRabbit flagged). The fix ensures buildMovePaneToTabInput syncs the
1143+ // derived global mirror before passing a.tabs to the move usecase.
1144+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1145+ tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1146+
1147+ // app.tabs contains only tab1 — tab3 is absent from the mirror.
1148+ tabs := entity.NewTabList()
1149+ tabs.Add(tab1)
1150+ tabs.SetActive(tab1.ID)
1151+
1152+ firstTabs := entity.NewTabList()
1153+ firstTabs.Add(tab1)
1154+ firstTabs.SetActive(tab1.ID)
1155+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1156+
1157+ secondTabs := entity.NewTabList()
1158+ secondTabs.Add(tab3)
1159+ secondTabs.SetActive(tab3.ID)
1160+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1161+
1162+ initialPaneCount := tab3.Workspace.PaneCount()
1163+
1164+ app := &App{
1165+ tabs: tabs,
1166+ movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1167+ deps: &Dependencies{Config: &config.Config{}},
1168+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1169+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab3.ID: second},
1170+ lastFocusedWindowID: first.id,
1171+ }
1172+
1173+ if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1174+ t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1175+ }
1176+
1177+ // The derived mirror must now contain tab3 (synced by the fix).
1178+ if app.tabs.Find(tab3.ID) == nil {
1179+ t.Fatalf("tab-3 should have been synced into the derived global mirror")
1180+ }
1181+
1182+ // No new tab should have been generated — the move should target tab3.
1183+ if app.tabs.Find(entity.TabID("generated")) != nil {
1184+ t.Fatalf("a new tab was incorrectly generated; the move should target tab-3")
1185+ }
1186+
1187+ // The moved pane should be in tab3's workspace.
1188+ if got := tab3.Workspace.PaneCount(); got != initialPaneCount+1 {
1189+ t.Fatalf("tab-3 pane count = %d, want %d (original + moved pane)", got, initialPaneCount+1)
1190+ }
1191+
1192+ // windowForTab for tab3 must remain second.
1193+ if app.windowForTab[tab3.ID] != second {
1194+ t.Fatalf("windowForTab[tab-3] = %p, want second window %p", app.windowForTab[tab3.ID], second)
1195+ }
1196+}
1197+
9171198 func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
9181199 gtkApp := requireGTKDisplayApp(t)
9191200 defer gtkApp.Unref()
@@ -936,7 +1217,7 @@ func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T)
9361217 deps: &Dependencies{Config: &config.Config{}},
9371218 mainWindow: mainWindow,
9381219 browserWindows: map[string]*browserWindow{bw.id: bw},
939- kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }),
1220+ kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, dispatcher.KeyboardActions{}, func(context.Context) entity.PaneID { return "" }),
9401221 }
9411222
9421223 app.initKeyboardHandler(context.Background())
@@ -974,7 +1255,7 @@ type testFocusedInputProvider struct{}
9741255 func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
9751256 func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}
9761257
977-func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
1258+func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation(t *testing.T) {
9781259 gtkApp := requireGTKDisplayApp(t)
9791260 defer gtkApp.Unref()
9801261
@@ -983,7 +1264,48 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(
9831264 if tabBar == nil {
9841265 t.Fatal("tab bar creation failed")
9851266 }
1267+ setWindowTabBar(t, mainWindow, tabBar)
1268+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1269+ app := &App{
1270+ deps: &Dependencies{Config: &config.Config{}},
1271+ }
1272+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1273+
1274+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1275+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1276+ tabBar.AddTab(tab1)
1277+
1278+ app.updateBrowserWindowTabBarVisibility(bw)
1279+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1280+
1281+ tabBar.AddTab(tab2)
1282+ app.updateBrowserWindowTabBarVisibility(bw)
1283+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1284+
9861285 tabBar.SetVisible(false)
1286+ app.updateBrowserWindowTabBarVisibility(bw)
1287+ if got := tabBar.Box().GetVisible(); got {
1288+ t.Fatalf("tab bar visible = %v, want false while explicitly hidden", got)
1289+ }
1290+
1291+ tabBar.SetVisible(true)
1292+ app.updateBrowserWindowTabBarVisibility(bw)
1293+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1294+
1295+ tabBar.RemoveTab(tab2.ID)
1296+ app.updateBrowserWindowTabBarVisibility(bw)
1297+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1298+}
1299+
1300+func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
1301+ gtkApp := requireGTKDisplayApp(t)
1302+ defer gtkApp.Unref()
1303+
1304+ mainWindow := &window.MainWindow{}
1305+ tabBar := component.NewTabBar()
1306+ if tabBar == nil {
1307+ t.Fatal("tab bar creation failed")
1308+ }
9871309 setWindowTabBar(t, mainWindow, tabBar)
9881310 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
9891311 app := &App{
@@ -993,8 +1315,141 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(
9931315
9941316 app.updateBrowserWindowTabBarVisibility(bw)
9951317
996- if got := windowTabBarVisible(t, mainWindow); !got {
997- t.Fatalf("tab bar visible = %v, want true", got)
1318+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1319+}
1320+
1321+func TestApp_ActivePaneIDForNilBrowserWindowIgnoresStaleOverride(t *testing.T) {
1322+ ctx := context.Background()
1323+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1324+ contentCoord.SetActivePaneOverride(entity.PaneID("stale-pane"))
1325+ app := &App{contentCoord: contentCoord}
1326+
1327+ if got := app.activePaneIDForBrowserWindow(nil); got != "" {
1328+ t.Fatalf("active pane for nil browser window = %q, want empty", got)
1329+ }
1330+}
1331+
1332+func TestApp_BuildRestoredWindowUIUpdatesEmptyWindowTabBarVisibility(t *testing.T) {
1333+ gtkApp := requireGTKDisplayApp(t)
1334+ defer gtkApp.Unref()
1335+
1336+ mw, err := window.New(context.Background(), gtkApp, "bottom")
1337+ if err != nil {
1338+ t.Fatalf("window creation failed: %v", err)
1339+ }
1340+ defer mw.Destroy()
1341+
1342+ mw.SetTabBarContentInsetVisible(true)
1343+ bw := &browserWindow{id: "window-1", mainWindow: mw, tabs: entity.NewTabList()}
1344+ app := &App{deps: &Dependencies{Config: &config.Config{}}}
1345+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1346+
1347+ app.buildRestoredWindowUI(context.Background(), []*browserWindow{bw})
1348+
1349+ assertWindowTabBarAutoHidden(t, mw, true)
1350+ if mw.HasTabBarContentInset() {
1351+ t.Fatal("empty restored bottom window should not keep tab bar content inset")
1352+ }
1353+}
1354+
1355+func TestApp_UpdateBrowserWindowTabBarVisibility_BottomInset(t *testing.T) {
1356+ gtkApp := requireGTKDisplayApp(t)
1357+ defer gtkApp.Unref()
1358+
1359+ // Use a real bottom MainWindow so SetTabBarContentInsetVisible is effective.
1360+ mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1361+ if err != nil {
1362+ t.Fatalf("window creation failed: %v", err)
1363+ }
1364+ defer mainWindow.Destroy()
1365+
1366+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1367+ app := &App{
1368+ deps: &Dependencies{Config: &config.Config{}},
1369+ }
1370+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1371+
1372+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1373+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1374+
1375+ // One tab + hide enabled: auto-hidden and no inset
1376+ mainWindow.TabBar().AddTab(tab1)
1377+ app.updateBrowserWindowTabBarVisibility(bw)
1378+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1379+ if mainWindow.HasTabBarContentInset() {
1380+ t.Fatal("one tab: expected no content area inset")
1381+ }
1382+
1383+ // Two tabs: visible and inset present
1384+ mainWindow.TabBar().AddTab(tab2)
1385+ app.updateBrowserWindowTabBarVisibility(bw)
1386+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1387+ if !mainWindow.HasTabBarContentInset() {
1388+ t.Fatal("two tabs: expected content area inset")
1389+ }
1390+
1391+ // Remove back to one tab: auto-hidden and no inset
1392+ mainWindow.TabBar().RemoveTab(tab2.ID)
1393+ app.updateBrowserWindowTabBarVisibility(bw)
1394+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1395+ if mainWindow.HasTabBarContentInset() {
1396+ t.Fatal("back to one tab: expected no content area inset")
1397+ }
1398+}
1399+
1400+func TestApp_HandlePaneFullscreenChanged_ClearsBottomInset(t *testing.T) {
1401+ gtkApp := requireGTKDisplayApp(t)
1402+ defer gtkApp.Unref()
1403+
1404+ // Use a bottom MainWindow so SetTabBarContentInsetVisible is effective.
1405+ mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1406+ if err != nil {
1407+ t.Fatalf("window creation failed: %v", err)
1408+ }
1409+ defer mainWindow.Destroy()
1410+
1411+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1412+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1413+ mainWindow.TabBar().AddTab(tab1)
1414+ mainWindow.TabBar().AddTab(tab2)
1415+
1416+ tabs := entity.NewTabList()
1417+ tabs.Add(tab1)
1418+ tabs.Add(tab2)
1419+ tabs.SetActive(tab2.ID)
1420+
1421+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow, tabs: tabs}
1422+ app := &App{
1423+ tabs: tabs,
1424+ deps: &Dependencies{Config: &config.Config{}},
1425+ browserWindows: map[string]*browserWindow{bw.id: bw},
1426+ lastFocusedWindowID: bw.id,
1427+ }
1428+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1429+
1430+ // Start with two tabs: inset should be present
1431+ app.updateBrowserWindowTabBarVisibility(bw)
1432+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1433+ if !mainWindow.HasTabBarContentInset() {
1434+ t.Fatal("two tabs: expected content area inset before fullscreen")
1435+ }
1436+
1437+ // Enter fullscreen: tab bar hidden, inset cleared
1438+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
1439+ if windowTabBarVisible(t, mainWindow) {
1440+ t.Fatal("fullscreen: tab bar should be not visible")
1441+ }
1442+ if mainWindow.HasTabBarContentInset() {
1443+ t.Fatal("fullscreen: expected no content area inset")
1444+ }
1445+
1446+ // Exit fullscreen: tab bar restored, inset restored
1447+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
1448+ if !windowTabBarVisible(t, mainWindow) {
1449+ t.Fatal("exited fullscreen: tab bar should be visible")
1450+ }
1451+ if !mainWindow.HasTabBarContentInset() {
1452+ t.Fatal("exited fullscreen: expected content area inset to be restored")
9981453 }
9991454 }
10001455
@@ -1012,16 +1467,17 @@ func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *t
10121467 firstValid.id: firstValid,
10131468 secondValid.id: secondValid,
10141469 },
1470+ browserWindowOrder: []string{removed.id, nilWindow.id, secondValid.id, firstValid.id},
10151471 lastFocusedWindowID: removed.id,
10161472 }
10171473
10181474 app.removeBrowserWindow(removed.id)
10191475
1020- if app.mainWindow != firstValid.mainWindow {
1021- t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow)
1476+ if app.mainWindow != secondValid.mainWindow {
1477+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow)
10221478 }
1023- if app.lastFocusedWindowID != firstValid.id {
1024- t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id)
1479+ if app.lastFocusedWindowID != secondValid.id {
1480+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, secondValid.id)
10251481 }
10261482 }
10271483 }
@@ -1070,6 +1526,52 @@ func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
10701526 }
10711527 }
10721528
1529+func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
1530+ ctx := context.Background()
1531+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1532+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1533+ globalTabs := entity.NewTabList()
1534+ globalTabs.Add(firstTab)
1535+ globalTabs.Add(secondTab)
1536+ firstTabs := entity.NewTabList()
1537+ firstTabs.Add(firstTab)
1538+ secondTabs := entity.NewTabList()
1539+ secondTabs.Add(secondTab)
1540+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1541+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1542+ app := &App{
1543+ deps: &Dependencies{Config: &config.Config{}},
1544+ tabs: globalTabs,
1545+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }),
1546+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1547+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1548+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1549+ lastFocusedWindowID: first.id,
1550+ mainWindow: first.mainWindow,
1551+ }
1552+ app.initTabCoordinator(ctx)
1553+
1554+ err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
1555+ ParentPaneID: secondTab.Workspace.ActivePaneID,
1556+ PopupPane: entity.NewPane(entity.PaneID("popup-pane")),
1557+ TargetURI: "https://example.com/popup",
1558+ })
1559+ if err != nil {
1560+ t.Fatalf("createPopupTab returned error: %v", err)
1561+ }
1562+
1563+ if first.tabs.Find(entity.TabID("popup-tab")) != nil {
1564+ t.Fatalf("popup tab was added to stale focused window")
1565+ }
1566+ created := second.tabs.Find(entity.TabID("popup-tab"))
1567+ if created == nil {
1568+ t.Fatalf("popup tab was not added to parent pane owner window")
1569+ }
1570+ if got := app.windowForTab[created.ID]; got != second {
1571+ t.Fatalf("popup tab owner = %p, want %p", got, second)
1572+ }
1573+}
1574+
10731575 func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
10741576 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
10751577 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
@@ -1085,3 +1587,916 @@ func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
10851587 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
10861588 }
10871589 }
1590+
1591+func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T) {
1592+ ctx := context.Background()
1593+ firstTabs := entity.NewTabList()
1594+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1595+ firstTabs.Add(firstTab)
1596+ secondTabs := entity.NewTabList()
1597+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1598+ secondTabs.Add(secondTab)
1599+
1600+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1601+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1602+ app := &App{
1603+ deps: &Dependencies{Config: &config.Config{}},
1604+ tabs: entity.NewTabList(),
1605+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }),
1606+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1607+ lastFocusedWindowID: first.id,
1608+ mainWindow: first.mainWindow,
1609+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1610+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1611+ }
1612+ app.tabs.Add(firstTab)
1613+ app.tabs.Add(secondTab)
1614+ app.initTabCoordinator(ctx)
1615+
1616+ created, err := app.tabCoord.Create(ctx, app.tabTargetForBrowserWindow(second), "https://example.com")
1617+ if err != nil {
1618+ t.Fatalf("Create returned error: %v", err)
1619+ }
1620+ if got := app.windowForTab[created.ID]; got != second {
1621+ t.Fatalf("created tab owner = %p, want second window %p", got, second)
1622+ }
1623+ if app.mainWindow != second.mainWindow {
1624+ t.Fatalf("mainWindow after create switch = %p, want %p", app.mainWindow, second.mainWindow)
1625+ }
1626+
1627+ app.mainWindow = first.mainWindow
1628+ app.lastFocusedWindowID = first.id
1629+ if err := app.tabCoord.Switch(ctx, app.tabTargetForBrowserWindow(second), secondTab.ID); err != nil {
1630+ t.Fatalf("Switch returned error: %v", err)
1631+ }
1632+ if app.mainWindow != second.mainWindow {
1633+ t.Fatalf("mainWindow after target switch = %p, want %p", app.mainWindow, second.mainWindow)
1634+ }
1635+}
1636+
1637+func TestApp_ActivateBrowserWindowAddsGlobalSnapshotWithoutChangingWindowTabPosition(t *testing.T) {
1638+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1639+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1640+ firstTabs := entity.NewTabList()
1641+ firstTabs.Add(firstTab)
1642+ secondTabs := entity.NewTabList()
1643+ secondTabs.Add(secondTab)
1644+ secondTabs.SetActive(secondTab.ID)
1645+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1646+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1647+ globalTabs := entity.NewTabList()
1648+ globalTabs.Add(firstTab)
1649+ app := &App{
1650+ tabs: globalTabs,
1651+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1652+ }
1653+
1654+ app.activateBrowserWindow(second)
1655+
1656+ if secondTab.Position != 0 {
1657+ t.Fatalf("window tab position = %d, want 0", secondTab.Position)
1658+ }
1659+ globalSecond := app.tabs.Find(secondTab.ID)
1660+ if globalSecond == nil {
1661+ t.Fatalf("global tab snapshot was not added")
1662+ }
1663+ if globalSecond == secondTab {
1664+ t.Fatalf("global tab should be a snapshot, not the live per-window tab")
1665+ }
1666+ if globalSecond.Position != 1 {
1667+ t.Fatalf("global tab position = %d, want 1", globalSecond.Position)
1668+ }
1669+}
1670+
1671+func TestApp_ActiveWorkspaceRequiresFocusedBrowserWindow(t *testing.T) {
1672+ globalTab := entity.NewTab(entity.TabID("global-tab"), entity.WorkspaceID("ws-global"), entity.NewPane(entity.PaneID("pane-global")))
1673+ globalTabs := entity.NewTabList()
1674+ globalTabs.Add(globalTab)
1675+ globalTabs.SetActive(globalTab.ID)
1676+ app := &App{
1677+ tabs: globalTabs,
1678+ browserWindows: map[string]*browserWindow{},
1679+ workspaceViews: map[entity.TabID]*component.WorkspaceView{globalTab.ID: {}},
1680+ }
1681+
1682+ if got := app.activeWorkspace(); got != nil {
1683+ t.Fatalf("activeWorkspace() = %p, want nil without focused browser window", got)
1684+ }
1685+ if got := app.activeWorkspaceView(); got != nil {
1686+ t.Fatalf("activeWorkspaceView() = %p, want nil without focused browser window", got)
1687+ }
1688+}
1689+
1690+func TestApp_ActiveWorkspaceUsesLastFocusedWindowTabsNotGlobalActiveTab(t *testing.T) {
1691+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1692+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1693+ firstTabs := entity.NewTabList()
1694+ firstTabs.Add(firstTab)
1695+ firstTabs.SetActive(firstTab.ID)
1696+ secondTabs := entity.NewTabList()
1697+ secondTabs.Add(secondTab)
1698+ secondTabs.SetActive(secondTab.ID)
1699+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1700+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1701+ globalTabs := entity.NewTabList()
1702+ globalTabs.Add(firstTab)
1703+ globalTabs.Add(secondTab)
1704+ globalTabs.SetActive(firstTab.ID)
1705+ secondView := &component.WorkspaceView{}
1706+ app := &App{
1707+ tabs: globalTabs,
1708+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1709+ lastFocusedWindowID: second.id,
1710+ workspaceViews: map[entity.TabID]*component.WorkspaceView{secondTab.ID: secondView},
1711+ }
1712+
1713+ if got := app.activeWorkspace(); got != secondTab.Workspace {
1714+ t.Fatalf("activeWorkspace() = %p, want second workspace %p", got, secondTab.Workspace)
1715+ }
1716+ if got := app.activeWorkspaceView(); got != secondView {
1717+ t.Fatalf("activeWorkspaceView() = %p, want second view %p", got, secondView)
1718+ }
1719+}
1720+
1721+// fakeRecordingWebView is a complete port.WebView that records which navigation
1722+// methods were called and with what arguments. It does not use reflect or unsafe.
1723+type fakeRecordingWebView struct {
1724+ id port.WebViewID
1725+
1726+ loadURICalled bool
1727+ loadURILastURI string
1728+ reloadCalls int
1729+ reloadBypassCacheCalls int
1730+ stopCalls int
1731+ goBackCalls int
1732+ goForwardCalls int
1733+ loadHTMLCalls int
1734+ setZoomLevelCalls int
1735+ setZoomLevelLastLevel float64
1736+ openDevToolsCalls int
1737+ printPageCalls int
1738+}
1739+
1740+func (f *fakeRecordingWebView) ID() port.WebViewID { return f.id }
1741+
1742+func (f *fakeRecordingWebView) LoadURI(_ context.Context, uri string) error {
1743+ f.loadURICalled = true
1744+ f.loadURILastURI = uri
1745+ return nil
1746+}
1747+
1748+func (f *fakeRecordingWebView) LoadHTML(_ context.Context, _, _ string) error {
1749+ f.loadHTMLCalls++
1750+ return nil
1751+}
1752+
1753+func (f *fakeRecordingWebView) Reload(_ context.Context) error {
1754+ f.reloadCalls++
1755+ return nil
1756+}
1757+
1758+func (f *fakeRecordingWebView) ReloadBypassCache(_ context.Context) error {
1759+ f.reloadBypassCacheCalls++
1760+ return nil
1761+}
1762+
1763+func (f *fakeRecordingWebView) Stop(_ context.Context) error {
1764+ f.stopCalls++
1765+ return nil
1766+}
1767+
1768+func (f *fakeRecordingWebView) GoBack(_ context.Context) error {
1769+ f.goBackCalls++
1770+ return nil
1771+}
1772+
1773+func (f *fakeRecordingWebView) GoForward(_ context.Context) error {
1774+ f.goForwardCalls++
1775+ return nil
1776+}
1777+
1778+func (f *fakeRecordingWebView) State() port.WebViewState { return port.WebViewState{} }
1779+
1780+func (f *fakeRecordingWebView) URI() string { return f.loadURILastURI }
1781+func (f *fakeRecordingWebView) Title() string { return "" }
1782+
1783+func (f *fakeRecordingWebView) IsLoading() bool { return false }
1784+func (f *fakeRecordingWebView) EstimatedProgress() float64 { return 0 }
1785+func (f *fakeRecordingWebView) CanGoBack() bool { return false }
1786+func (f *fakeRecordingWebView) CanGoForward() bool { return false }
1787+
1788+func (f *fakeRecordingWebView) SetZoomLevel(_ context.Context, level float64) error {
1789+ f.setZoomLevelCalls++
1790+ f.setZoomLevelLastLevel = level
1791+ return nil
1792+}
1793+
1794+func (f *fakeRecordingWebView) OpenDevTools() { f.openDevToolsCalls++ }
1795+func (f *fakeRecordingWebView) PrintPage() { f.printPageCalls++ }
1796+
1797+func (f *fakeRecordingWebView) GetZoomLevel() float64 { return 1.0 }
1798+func (f *fakeRecordingWebView) GetFindController() port.FindController { return nil }
1799+func (f *fakeRecordingWebView) SetCallbacks(_ *port.WebViewCallbacks) {}
1800+func (f *fakeRecordingWebView) RunJavaScript(_ context.Context, _ string) {}
1801+func (f *fakeRecordingWebView) SetBackgroundColor(_, _, _, _ float64) {}
1802+func (f *fakeRecordingWebView) ResetBackgroundToDefault() {}
1803+func (f *fakeRecordingWebView) Favicon() port.Texture { return nil }
1804+func (f *fakeRecordingWebView) Generation() uint64 { return 0 }
1805+func (f *fakeRecordingWebView) IsFullscreen() bool { return false }
1806+func (f *fakeRecordingWebView) IsPlayingAudio() bool { return false }
1807+func (f *fakeRecordingWebView) IsDestroyed() bool { return false }
1808+func (f *fakeRecordingWebView) Destroy() {}
1809+
1810+type fakeZoomRepo struct{}
1811+
1812+func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil }
1813+func (f *fakeZoomRepo) Set(context.Context, *entity.ZoomLevel) error { return nil }
1814+func (f *fakeZoomRepo) Delete(context.Context, string) error { return nil }
1815+func (f *fakeZoomRepo) GetAll(context.Context) ([]*entity.ZoomLevel, error) { return nil, nil }
1816+
1817+func TestApp_BrowserWindowWebViewActionsIgnoreStaleFocusedWindow(t *testing.T) {
1818+ ctx := context.Background()
1819+
1820+ // Build two browser windows with independent bw.tabs and active tabs/panes.
1821+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1822+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
1823+
1824+ firstTabs := entity.NewTabList()
1825+ firstTabs.Add(tab1)
1826+ firstTabs.SetActive(tab1.ID)
1827+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1828+
1829+ secondTabs := entity.NewTabList()
1830+ secondTabs.Add(tab2)
1831+ secondTabs.SetActive(tab2.ID)
1832+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1833+
1834+ // Create fake recording webviews.
1835+ fakeWv1 := &fakeRecordingWebView{id: 1}
1836+ fakeWv2 := &fakeRecordingWebView{id: 2}
1837+
1838+ // Create content coordinator with initialized internal maps to avoid
1839+ // nil-map panics in SetNavigationOrigin during NavigateWebView calls.
1840+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1841+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
1842+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
1843+
1844+ // Create NavigationCoordinator (no navigateUC needed for direct webview calls).
1845+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1846+
1847+ app := &App{
1848+ tabs: entity.NewTabList(),
1849+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1850+ lastFocusedWindowID: first.id, // stale! should NOT be used
1851+ contentCoord: contentCoord,
1852+ navCoord: navCoord,
1853+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
1854+ tab1.ID: &component.WorkspaceView{},
1855+ tab2.ID: &component.WorkspaceView{},
1856+ },
1857+ }
1858+ app.tabs.Add(tab1)
1859+ app.tabs.Add(tab2)
1860+
1861+ // Call window-scoped app helpers for the second window.
1862+ if err := app.navigateFromBrowserWindow(ctx, second, "https://google.com"); err != nil {
1863+ t.Fatalf("navigateFromBrowserWindow returned error: %v", err)
1864+ }
1865+ if err := app.reloadBrowserWindow(ctx, second, false); err != nil {
1866+ t.Fatalf("reloadBrowserWindow (soft) returned error: %v", err)
1867+ }
1868+ if err := app.reloadBrowserWindow(ctx, second, true); err != nil {
1869+ t.Fatalf("reloadBrowserWindow (hard) returned error: %v", err)
1870+ }
1871+ if err := app.stopBrowserWindow(ctx, second); err != nil {
1872+ t.Fatalf("stopBrowserWindow returned error: %v", err)
1873+ }
1874+ if err := app.goBackBrowserWindow(ctx, second); err != nil {
1875+ t.Fatalf("goBackBrowserWindow returned error: %v", err)
1876+ }
1877+ if err := app.goForwardBrowserWindow(ctx, second); err != nil {
1878+ t.Fatalf("goForwardBrowserWindow returned error: %v", err)
1879+ }
1880+
1881+ assertRecordingWebViewIdle(t, "first", fakeWv1)
1882+ assertRecordingWebViewActions(t, "second", fakeWv2, "https://google.com")
1883+}
1884+
1885+func assertRecordingWebViewIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
1886+ t.Helper()
1887+ if wv.loadURICalled || wv.loadURILastURI != "" {
1888+ t.Errorf("%s webview LoadURI was called, expected zero calls", name)
1889+ }
1890+ if wv.reloadCalls > 0 {
1891+ t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
1892+ }
1893+ if wv.reloadBypassCacheCalls > 0 {
1894+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
1895+ }
1896+ if wv.stopCalls > 0 {
1897+ t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
1898+ }
1899+ if wv.goBackCalls > 0 {
1900+ t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
1901+ }
1902+ if wv.goForwardCalls > 0 {
1903+ t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
1904+ }
1905+}
1906+
1907+func assertRecordingWebViewActions(t *testing.T, name string, wv *fakeRecordingWebView, wantURI string) {
1908+ t.Helper()
1909+ if !wv.loadURICalled {
1910+ t.Errorf("%s webview LoadURI was not called", name)
1911+ }
1912+ if wv.loadURILastURI != wantURI {
1913+ t.Errorf("%s webview LoadURI URI = %q, want %q", name, wv.loadURILastURI, wantURI)
1914+ }
1915+ if wv.reloadCalls != 1 {
1916+ t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
1917+ }
1918+ if wv.reloadBypassCacheCalls != 1 {
1919+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
1920+ }
1921+ if wv.stopCalls != 1 {
1922+ t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
1923+ }
1924+ if wv.goBackCalls != 1 {
1925+ t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
1926+ }
1927+ if wv.goForwardCalls != 1 {
1928+ t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
1929+ }
1930+}
1931+
1932+func assertRecordingWebViewBrowserActionsIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
1933+ t.Helper()
1934+ if wv.reloadCalls > 0 {
1935+ t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
1936+ }
1937+ if wv.reloadBypassCacheCalls > 0 {
1938+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
1939+ }
1940+ if wv.stopCalls > 0 {
1941+ t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
1942+ }
1943+ if wv.goBackCalls > 0 {
1944+ t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
1945+ }
1946+ if wv.goForwardCalls > 0 {
1947+ t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
1948+ }
1949+ if wv.printPageCalls > 0 {
1950+ t.Errorf("%s webview PrintPage calls = %d, want 0", name, wv.printPageCalls)
1951+ }
1952+ if wv.openDevToolsCalls > 0 {
1953+ t.Errorf("%s webview OpenDevTools calls = %d, want 0", name, wv.openDevToolsCalls)
1954+ }
1955+ if wv.setZoomLevelCalls > 0 {
1956+ t.Errorf("%s webview SetZoomLevel calls = %d, want 0", name, wv.setZoomLevelCalls)
1957+ }
1958+}
1959+
1960+func assertRecordingWebViewBrowserActionsCalledOnce(t *testing.T, name string, wv *fakeRecordingWebView) {
1961+ t.Helper()
1962+ if wv.reloadCalls != 1 {
1963+ t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
1964+ }
1965+ if wv.reloadBypassCacheCalls != 1 {
1966+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
1967+ }
1968+ if wv.stopCalls != 1 {
1969+ t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
1970+ }
1971+ if wv.goBackCalls != 1 {
1972+ t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
1973+ }
1974+ if wv.goForwardCalls != 1 {
1975+ t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
1976+ }
1977+ if wv.printPageCalls != 1 {
1978+ t.Errorf("%s webview PrintPage calls = %d, want 1", name, wv.printPageCalls)
1979+ }
1980+ if wv.openDevToolsCalls != 1 {
1981+ t.Errorf("%s webview OpenDevTools calls = %d, want 1", name, wv.openDevToolsCalls)
1982+ }
1983+ if wv.setZoomLevelCalls != 1 {
1984+ t.Errorf("%s webview SetZoomLevel calls = %d, want 1", name, wv.setZoomLevelCalls)
1985+ }
1986+}
1987+
1988+func TestApp_OmniboxNavigateCallbackCapturesBrowserWindow(t *testing.T) {
1989+ second := &browserWindow{id: "window-2"}
1990+
1991+ var capturedBW *browserWindow
1992+ var capturedURL string
1993+ testNavigate := func(_ context.Context, bw *browserWindow, url string) error {
1994+ capturedBW = bw
1995+ capturedURL = url
1996+ return nil
1997+ }
1998+
1999+ ctx := context.Background()
2000+ cb := omniboxNavigateForBrowserWindow(ctx, second, testNavigate)
2001+ cb("https://google.com")
2002+
2003+ if capturedBW != second {
2004+ capturedID := "<nil>"
2005+ if capturedBW != nil {
2006+ capturedID = capturedBW.id
2007+ }
2008+ t.Errorf("captured browser window = %p (id=%s), want %p (id=%s)", capturedBW, capturedID, second, second.id)
2009+ }
2010+ if capturedURL != "https://google.com" {
2011+ t.Errorf("captured URL = %q, want %q", capturedURL, "https://google.com")
2012+ }
2013+}
2014+
2015+func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
2016+ ctx := context.Background()
2017+
2018+ // Build two browser windows with independent bw.tabs and active panes.
2019+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2020+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2021+
2022+ firstTabs := entity.NewTabList()
2023+ firstTabs.Add(tab1)
2024+ firstTabs.SetActive(tab1.ID)
2025+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2026+
2027+ secondTabs := entity.NewTabList()
2028+ secondTabs.Add(tab2)
2029+ secondTabs.SetActive(tab2.ID)
2030+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2031+
2032+ // Create fake recording webviews.
2033+ fakeWv1 := &fakeRecordingWebView{id: 1, loadURILastURI: "https://first.example"}
2034+ fakeWv2 := &fakeRecordingWebView{id: 2, loadURILastURI: "https://second.example"}
2035+
2036+ // Register fake webviews in content coordinator.
2037+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2038+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2039+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2040+
2041+ // Create navCoord (no navigateUC needed for direct webview calls).
2042+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2043+
2044+ app := &App{
2045+ tabs: entity.NewTabList(),
2046+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2047+ lastFocusedWindowID: first.id, // stale! should NOT be used
2048+ contentCoord: contentCoord,
2049+ navCoord: navCoord,
2050+ deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)},
2051+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
2052+ tab1.ID: &component.WorkspaceView{},
2053+ tab2.ID: &component.WorkspaceView{},
2054+ },
2055+ }
2056+ app.tabs.Add(tab1)
2057+ app.tabs.Add(tab2)
2058+
2059+ // Dispatch browser window actions targeting the second window.
2060+ for _, a := range []input.Action{
2061+ input.ActionReload,
2062+ input.ActionHardReload,
2063+ input.ActionStop,
2064+ input.ActionGoBack,
2065+ input.ActionGoForward,
2066+ input.ActionPrintPage,
2067+ input.ActionOpenDevTools,
2068+ input.ActionZoomIn,
2069+ } {
2070+ if err := app.dispatchBrowserWindowAction(ctx, second, a); err != nil {
2071+ t.Fatalf("dispatchBrowserWindowAction (%s) returned error: %v", a, err)
2072+ }
2073+ }
2074+
2075+ // Assert first fake webview receives zero calls (stale focus is ignored).
2076+ assertRecordingWebViewBrowserActionsIdle(t, "first", fakeWv1)
2077+
2078+ // Assert second fake webview receives exactly one call per action.
2079+ assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2)
2080+}
2081+
2082+func counterIDGen() func() string {
2083+ var counter int
2084+ return func() string {
2085+ counter++
2086+ return strconv.Itoa(counter)
2087+ }
2088+}
2089+
2090+func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *testing.T) {
2091+ ctx := context.Background()
2092+
2093+ // Two windows, each with one tab.
2094+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2095+ firstTabs := entity.NewTabList()
2096+ firstTabs.Add(firstTab)
2097+ firstTabs.SetActive(firstTab.ID)
2098+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2099+
2100+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2101+ secondTabs := entity.NewTabList()
2102+ secondTabs.Add(secondTab)
2103+ secondTabs.SetActive(secondTab.ID)
2104+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2105+
2106+ app := &App{
2107+ deps: &Dependencies{
2108+ Config: &config.Config{
2109+ Workspace: config.WorkspaceConfig{
2110+ NewPaneURL: "about:blank",
2111+ },
2112+ },
2113+ },
2114+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2115+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2116+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2117+ lastFocusedWindowID: first.id, // stale — should NOT be used
2118+ }
2119+ app.initTabCoordinator(ctx)
2120+
2121+ // Dispatch ActionSwitchTabIndex4 targeting the second window.
2122+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2123+ if err != nil {
2124+ t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2125+ }
2126+
2127+ // secondTabs should have 4 tabs (1 existing + 3 new).
2128+ if got := secondTabs.Count(); got != 4 {
2129+ t.Fatalf("second target tab count = %d, want 4", got)
2130+ }
2131+ if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[3].ID {
2132+ t.Fatalf("active tab = %q, want %q (index 3)", got, secondTabs.Tabs[3].ID)
2133+ }
2134+
2135+ // firstTabs must be completely unchanged.
2136+ if got := firstTabs.Count(); got != 1 {
2137+ t.Fatalf("first target tab count = %d, want 1", got)
2138+ }
2139+
2140+ // windowForTab should map all second window tabs to second.
2141+ for _, tab := range secondTabs.Tabs {
2142+ if got := app.windowForTab[tab.ID]; got != second {
2143+ t.Fatalf("windowForTab[%s] = %p, want second window %p", tab.ID, got, second)
2144+ }
2145+ }
2146+}
2147+
2148+func TestApp_WorkspaceOmniboxNavigateUsesOwnerWindow(t *testing.T) {
2149+ gtkApp := requireGTKDisplayApp(t)
2150+ defer gtkApp.Unref()
2151+
2152+ ctx := context.Background()
2153+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2154+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2155+ firstTabs := entity.NewTabList()
2156+ firstTabs.Add(tab1)
2157+ firstTabs.SetActive(tab1.ID)
2158+ secondTabs := entity.NewTabList()
2159+ secondTabs.Add(tab2)
2160+ secondTabs.SetActive(tab2.ID)
2161+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2162+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2163+
2164+ fakeWv1 := &fakeRecordingWebView{id: 1}
2165+ fakeWv2 := &fakeRecordingWebView{id: 2}
2166+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2167+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2168+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2169+
2170+ app := &App{
2171+ deps: &Dependencies{Config: &config.Config{}},
2172+ widgetFactory: layout.NewGtkWidgetFactory(),
2173+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2174+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
2175+ lastFocusedWindowID: first.id,
2176+ contentCoord: contentCoord,
2177+ navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
2178+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
2179+ }
2180+
2181+ app.createWorkspaceViewWithoutAttach(ctx, tab2)
2182+ wsView := app.workspaceViews[tab2.ID]
2183+ if wsView == nil {
2184+ t.Fatal("workspace view was not created for second tab")
2185+ }
2186+ wsView.ShowOmnibox(ctx, "")
2187+ cb := omniboxNavigateCallbackForTest(t, wsView.GetOmnibox())
2188+ cb("https://example.com")
2189+
2190+ if fakeWv1.loadURICalled {
2191+ t.Errorf("first window webview navigated to %q, want no navigation", fakeWv1.loadURILastURI)
2192+ }
2193+ if !fakeWv2.loadURICalled || fakeWv2.loadURILastURI != "https://example.com" {
2194+ t.Errorf("second window webview navigation = called %v uri %q, want https://example.com", fakeWv2.loadURICalled, fakeWv2.loadURILastURI)
2195+ }
2196+}
2197+
2198+func TestApp_FloatingOmniboxNavigateUsesSessionPane(t *testing.T) {
2199+ gtkApp := requireGTKDisplayApp(t)
2200+ defer gtkApp.Unref()
2201+
2202+ ctx := context.Background()
2203+ factory := layout.NewGtkWidgetFactory()
2204+ overlay := factory.NewOverlay()
2205+
2206+ loadedURL := ""
2207+ pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
2208+ OnNavigate: func(_ context.Context, url string) error {
2209+ loadedURL = url
2210+ return nil
2211+ },
2212+ })
2213+ session := &floatingWorkspaceSession{
2214+ paneID: entity.PaneID("floating-pane"),
2215+ pane: pane,
2216+ overlay: overlay,
2217+ }
2218+ app := &App{widgetFactory: factory}
2219+
2220+ app.showFloatingOmnibox(ctx, session)
2221+ cb := omniboxNavigateCallbackForTest(t, session.omnibox)
2222+ cb("https://example.com")
2223+
2224+ if loadedURL != "https://example.com" {
2225+ t.Fatalf("floating pane loaded URL = %q, want https://example.com", loadedURL)
2226+ }
2227+}
2228+
2229+// TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge verifies that existing
2230+// browserWindows not participating in the restore do not leak their tabs into the
2231+// restored global tab list or UI aggregation. Only runtimeWindows (the windows
2232+// actually restored) contribute.
2233+func TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge(t *testing.T) {
2234+ // Two stale browserWindows exist in the map; only one window is restored.
2235+ staleBW1 := &browserWindow{id: "stale-w1"}
2236+ staleBW2 := &browserWindow{id: "stale-w2"}
2237+
2238+ staleTab1 := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-ws"), entity.NewPane(entity.PaneID("stale-pane")))
2239+ staleTab1.Name = "StaleTab"
2240+ staleBW1.tabs = entity.NewTabList()
2241+ staleBW1.tabs.Add(staleTab1)
2242+
2243+ staleBW2.tabs = entity.NewTabList()
2244+
2245+ // One restored window with its own tab. Tab names survive the
2246+ // snapshot/restore cycle even though IDs are regenerated.
2247+ restoredTabs := entity.NewTabList()
2248+ restoredTab := entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-ws"), entity.NewPane(entity.PaneID("restored-pane")))
2249+ restoredTab.Name = "RestoredTab"
2250+ restoredTabs.Add(restoredTab)
2251+
2252+ sessionID := entity.SessionID("test-session")
2253+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2254+ {WindowID: "saved-w1", Tabs: restoredTabs},
2255+ }, 0, time.Unix(123, 0))
2256+
2257+ mainWindow := &window.MainWindow{}
2258+ runtimeBW := &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2259+
2260+ app := &App{
2261+ deps: &Dependencies{
2262+ Config: &config.Config{},
2263+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2264+ },
2265+ mainWindow: mainWindow,
2266+ browserWindows: map[string]*browserWindow{
2267+ staleBW1.id: staleBW1,
2268+ staleBW2.id: staleBW2,
2269+ runtimeBW.id: runtimeBW,
2270+ },
2271+ tabs: entity.NewTabList(),
2272+ }
2273+ // Seed global tabs with a stale tab to verify it is replaced.
2274+ app.tabs.Add(staleTab1)
2275+
2276+ if err := app.restoreSession(context.Background(), sessionID); err != nil {
2277+ t.Fatalf("restoreSession returned error: %v", err)
2278+ }
2279+
2280+ // Global tabs must only contain the restored tab, not stale tabs.
2281+ if got := app.tabs.Count(); got != 1 {
2282+ t.Fatalf("tabs.Count() = %d, want 1 (only restored tab)", got)
2283+ }
2284+ // Verify by name since IDs are regenerated during restore.
2285+ restored := app.tabs.Tabs[0]
2286+ if restored == nil || restored.Name != "RestoredTab" {
2287+ t.Fatalf("restored tab name = %q, want RestoredTab", firstTabName(app.tabs))
2288+ }
2289+ if app.tabs.Find(staleTab1.ID) != nil {
2290+ t.Fatal("stale tab leaked into global tabs")
2291+ }
2292+}
2293+
2294+func firstTabName(tl *entity.TabList) string {
2295+ if len(tl.Tabs) == 0 || tl.Tabs[0] == nil {
2296+ return ""
2297+ }
2298+ return tl.Tabs[0].Name
2299+}
2300+
2301+// TestApp_RestoreSessionHonorsActiveWindowIndex verifies that when restoring a
2302+// v2 multi-window session, the ActiveWindowIndex from the snapshot is used to
2303+// determine the focused window and global active tab.
2304+func TestApp_RestoreSessionHonorsActiveWindowIndex(t *testing.T) {
2305+ gtkApp := requireGTKDisplayApp(t)
2306+ defer gtkApp.Unref()
2307+
2308+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
2309+ if err != nil {
2310+ t.Fatalf("window creation failed: %v", err)
2311+ }
2312+ defer mainWindow.Destroy()
2313+
2314+ // Window 0 has a tab named "Tab-W0", window 1 has "Tab-W1" (active).
2315+ // Tab names survive snapshot/restore; IDs are regenerated.
2316+ pane0 := entity.NewPane("pane-w0")
2317+ tabW0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2318+ tabW0.Name = "Tab-W0"
2319+ tabs0 := entity.NewTabList()
2320+ tabs0.Add(tabW0)
2321+
2322+ pane1 := entity.NewPane("pane-w1")
2323+ tabW1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2324+ tabW1.Name = "Tab-W1"
2325+ tabs1 := entity.NewTabList()
2326+ tabs1.Add(tabW1)
2327+ tabs1.SetActive("tab-w1")
2328+
2329+ sessionID := entity.SessionID("test-sess")
2330+ // ActiveWindowIndex = 1: second window should be focused.
2331+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2332+ {WindowID: "saved-w0", Tabs: tabs0},
2333+ {WindowID: "saved-w1", Tabs: tabs1},
2334+ }, 1, time.Unix(123, 0))
2335+
2336+ firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2337+ // Factory creates the second runtime window; share the mainWindow so the UI
2338+ // restore loop can walk its tab bar without needing a second GTK window.
2339+ var runtimeW1 *browserWindow
2340+ app := &App{
2341+ deps: &Dependencies{
2342+ Config: &config.Config{},
2343+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2344+ },
2345+ mainWindow: mainWindow,
2346+ widgetFactory: layout.NewGtkWidgetFactory(),
2347+ browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2348+ tabs: entity.NewTabList(),
2349+ windowForTab: map[entity.TabID]*browserWindow{},
2350+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
2351+ browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2352+ runtimeW1 = &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2353+ return runtimeW1, nil
2354+ },
2355+ }
2356+
2357+ if err := app.restoreSession(context.Background(), sessionID); err != nil {
2358+ t.Fatalf("restoreSession returned error: %v", err)
2359+ }
2360+
2361+ // lastFocusedWindowID must point to the active window (index 1).
2362+ if app.lastFocusedWindowID != "runtime-w1" {
2363+ t.Errorf("lastFocusedWindowID = %s, want runtime-w1 (active window index 1)", app.lastFocusedWindowID)
2364+ }
2365+
2366+ // Global active tab must come from the active window (Tab-W1), not the first (Tab-W0).
2367+ activeTab := app.tabs.ActiveTab()
2368+ if activeTab == nil {
2369+ t.Fatal("active tab is nil")
2370+ }
2371+ if activeTab.Name != "Tab-W1" {
2372+ t.Errorf("active tab name = %q, want Tab-W1 (from active window)", activeTab.Name)
2373+ }
2374+}
2375+
2376+// TestApp_RestoreSessionFailsOnAdditionalWindowCreationError verifies that
2377+// restoreSession returns an error (not silent partial restore) when creating
2378+// a browser window for an additional restored window fails.
2379+func TestApp_RestoreSessionFailsOnAdditionalWindowCreationError(t *testing.T) {
2380+ gtkApp := requireGTKDisplayApp(t)
2381+ defer gtkApp.Unref()
2382+
2383+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
2384+ if err != nil {
2385+ t.Fatalf("window creation failed: %v", err)
2386+ }
2387+ defer mainWindow.Destroy()
2388+
2389+ pane0 := entity.NewPane("pane-w0")
2390+ tab0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2391+ tabs0 := entity.NewTabList()
2392+ tabs0.Add(tab0)
2393+
2394+ pane1 := entity.NewPane("pane-w1")
2395+ tab1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2396+ tabs1 := entity.NewTabList()
2397+ tabs1.Add(tab1)
2398+
2399+ sessionID := entity.SessionID("test-sess")
2400+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2401+ {WindowID: "saved-w0", Tabs: tabs0},
2402+ {WindowID: "saved-w1", Tabs: tabs1},
2403+ }, 0, time.Unix(123, 0))
2404+
2405+ firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2406+ factoryErr := errors.New("window creation failed")
2407+ app := &App{
2408+ deps: &Dependencies{
2409+ Config: &config.Config{},
2410+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2411+ },
2412+ mainWindow: mainWindow,
2413+ browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2414+ tabs: entity.NewTabList(),
2415+ windowForTab: map[entity.TabID]*browserWindow{},
2416+ browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2417+ return nil, factoryErr
2418+ },
2419+ }
2420+
2421+ gotErr := app.restoreSession(context.Background(), sessionID)
2422+ if gotErr == nil {
2423+ t.Fatal("restoreSession returned nil error, want failure when additional window creation fails")
2424+ }
2425+ if !strings.Contains(gotErr.Error(), "create browser window 1 for restore") {
2426+ t.Errorf("error message should mention window creation for restore, got: %v", gotErr)
2427+ }
2428+ if !errors.Is(gotErr, factoryErr) {
2429+ t.Errorf("error should wrap factoryErr, got: %v", gotErr)
2430+ }
2431+}
2432+
2433+func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
2434+ gtkApp := requireGTKDisplayApp(t)
2435+ defer gtkApp.Unref()
2436+
2437+ mw, err := window.New(context.Background(), gtkApp, "bottom")
2438+ if err != nil {
2439+ t.Fatalf("window creation failed: %v", err)
2440+ }
2441+ defer mw.Destroy()
2442+
2443+ // Bottom window should start WITHOUT the inset
2444+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2445+ t.Fatal("bottom-tab-bar content area: should NOT have inset at window creation")
2446+ }
2447+
2448+ // HasTabBarContentInset must return false initially
2449+ if mw.HasTabBarContentInset() {
2450+ t.Fatal("HasTabBarContentInset should return false initially")
2451+ }
2452+
2453+ // SetTabBarContentInsetVisible(true) must add the class
2454+ mw.SetTabBarContentInsetVisible(true)
2455+ if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2456+ t.Fatal("bottom-tab-bar content area: expected content-area-tabbar-inset-bottom CSS class after SetTabBarContentInsetVisible(true)")
2457+ }
2458+ if !mw.HasTabBarContentInset() {
2459+ t.Fatal("HasTabBarContentInset should return true after SetTabBarContentInsetVisible(true)")
2460+ }
2461+
2462+ // SetTabBarContentInsetVisible(false) must remove the class
2463+ mw.SetTabBarContentInsetVisible(false)
2464+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2465+ t.Fatal("bottom-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
2466+ }
2467+ if mw.HasTabBarContentInset() {
2468+ t.Fatal("HasTabBarContentInset should return false after SetTabBarContentInsetVisible(false)")
2469+ }
2470+
2471+ // Verify tab bar remains a non-measured overlay regardless of inset state
2472+ if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
2473+ t.Fatal("bottom-tab-bar: expected tab bar to remain a non-measured overlay")
2474+ }
2475+}
2476+
2477+func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) {
2478+ gtkApp := requireGTKDisplayApp(t)
2479+ defer gtkApp.Unref()
2480+
2481+ mw, err := window.New(context.Background(), gtkApp, "top")
2482+ if err != nil {
2483+ t.Fatalf("window creation failed: %v", err)
2484+ }
2485+ defer mw.Destroy()
2486+
2487+ // Top should never have the bottom inset class
2488+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2489+ t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
2490+ }
2491+
2492+ // Even after SetTabBarContentInsetVisible(true), top must not get bottom inset
2493+ mw.SetTabBarContentInsetVisible(true)
2494+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2495+ t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(true)")
2496+ }
2497+
2498+ // HasTabBarContentInset must return false for top
2499+ if mw.HasTabBarContentInset() {
2500+ t.Fatal("HasTabBarContentInset should return false for top tab bar position")
2501+ }
2502+}
M internal/ui/browser_window.go +33 -3
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/coordinator"
1213 "github.com/bnema/dumber/internal/ui/focus"
1314 "github.com/bnema/dumber/internal/ui/input"
1415 "github.com/bnema/dumber/internal/ui/layout"
@@ -19,8 +20,7 @@ import (
1920 type browserWindow struct {
2021 id string
2122 initialURL string
22- activeTabID entity.TabID
23- prevActiveTabID entity.TabID // per-window previous active tab (for Alt+Tab behavior)
23+ tabs *entity.TabList // per-window tab list (single source of truth for tab state)
2424 mainWindow *window.MainWindow
2525 appToaster *component.Toaster
2626 modeToaster *component.Toaster
@@ -231,14 +231,29 @@ func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
231231 log.Debug().Msg("tab picker initialized")
232232 }
233233
234+func (bw *browserWindow) ensureTabs() {
235+ if bw != nil && bw.tabs == nil {
236+ bw.tabs = entity.NewTabList()
237+ }
238+}
239+
234240 func (a *App) registerBrowserWindow(bw *browserWindow) {
235241 if bw == nil {
236242 return
237243 }
244+ bw.ensureTabs()
238245 if a.browserWindows == nil {
239246 a.browserWindows = make(map[string]*browserWindow)
240247 }
241248 a.browserWindows[bw.id] = bw
249+
250+ // Track registration order, guarding against duplicates.
251+ for _, id := range a.browserWindowOrder {
252+ if id == bw.id {
253+ return
254+ }
255+ }
256+ a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
242257 }
243258
244259 func (a *App) removeBrowserWindow(id string) {
@@ -246,6 +261,14 @@ func (a *App) removeBrowserWindow(id string) {
246261 return
247262 }
248263 removed := a.browserWindows[id]
264+
265+ // Remove from registration order
266+ for i, wid := range a.browserWindowOrder {
267+ if wid == id {
268+ a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
269+ break
270+ }
271+ }
249272 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
250273 ownedTabIDs := make([]entity.TabID, 0)
251274 for tabID, bw := range a.windowForTab {
@@ -284,7 +307,7 @@ func (a *App) removeBrowserWindow(id string) {
284307 a.keyboardHandler = nil
285308 a.globalShortcutHandler = nil
286309 if a.tabCoord != nil {
287- a.tabCoord.SetMainWindow(nil)
310+ a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
288311 }
289312 }
290313 }
@@ -293,6 +316,13 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow {
293316 if a.browserWindows == nil {
294317 return nil
295318 }
319+ for _, id := range a.browserWindowOrder {
320+ bw := a.browserWindows[id]
321+ if id == "" || bw == nil || bw.mainWindow == nil {
322+ continue
323+ }
324+ return bw
325+ }
296326 ids := make([]string, 0, len(a.browserWindows))
297327 for id, bw := range a.browserWindows {
298328 if id == "" || bw == nil || bw.mainWindow == nil {
M internal/ui/browser_window_test.go +543 -2
@@ -14,6 +14,8 @@ import (
1414 "github.com/bnema/dumber/internal/ui/layout"
1515 "github.com/bnema/dumber/internal/ui/window"
1616 "github.com/bnema/puregotk/v4/gtk"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
1719 )
1820
1921 func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
@@ -66,8 +68,12 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
6668 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
6769 removedMainWindow := &window.MainWindow{}
6870 remainingMainWindow := &window.MainWindow{}
69- removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow}
70- remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow}
71+ removedTabs := entity.NewTabList()
72+ removedTabs.Add(ownedTab)
73+ remainingTabs := entity.NewTabList()
74+ remainingTabs.Add(otherTab)
75+ removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
76+ remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
7177 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
7278 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
7379 app := &App{
@@ -110,6 +116,7 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
110116 if _, ok := app.floatingSessions[otherSessionKey]; !ok {
111117 t.Fatalf("other floating session should remain")
112118 }
119+ assertWindowOwnershipInvariant(t, app)
113120 }
114121
115122 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
@@ -129,6 +136,9 @@ func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
129136 if _, ok := app.browserWindows[second.id]; !ok {
130137 t.Fatalf("second browser window was not registered")
131138 }
139+ if first.tabs == nil || second.tabs == nil {
140+ t.Fatalf("registered browser windows must have non-nil tab lists")
141+ }
132142
133143 app.removeBrowserWindow(first.id)
134144
@@ -309,3 +319,534 @@ type testPermissionDialogPresenter struct{}
309319
310320 func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
311321 }
322+
323+// --- Window tab list tests ---
324+
325+func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
326+ // Register windows in non-lexicographic order: window-2 first, then window-1.
327+ // The result should preserve creation order, not sorted order.
328+ app := &App{
329+ browserWindows: make(map[string]*browserWindow),
330+ }
331+
332+ first := &browserWindow{id: "window-2"}
333+ second := &browserWindow{id: "window-1"}
334+ app.registerBrowserWindow(first)
335+ app.registerBrowserWindow(second)
336+
337+ // Give each window a tab so they show up in results.
338+ pane1 := entity.NewPane(entity.PaneID("p-w2"))
339+ tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
340+ pane2 := entity.NewPane(entity.PaneID("p-w1"))
341+ tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
342+
343+ app.tabs = entity.NewTabList()
344+ app.tabs.Add(tab1)
345+ app.tabs.Add(tab2)
346+ first.tabs.Add(tab1)
347+ second.tabs.Add(tab2)
348+ app.windowForTab = map[entity.TabID]*browserWindow{
349+ tab1.ID: first,
350+ tab2.ID: second,
351+ }
352+
353+ result := app.GetWindowTabLists()
354+
355+ require.Len(t, result, 2)
356+ assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
357+ assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
358+}
359+
360+func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
361+ pane := entity.NewPane(entity.PaneID("p1"))
362+ tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
363+ tabs := entity.NewTabList()
364+ tabs.Add(tab)
365+
366+ app := &App{tabs: tabs}
367+
368+ result := app.GetWindowTabLists()
369+
370+ require.Len(t, result, 1)
371+ assert.Equal(t, entity.WindowID(""), result[0].WindowID)
372+ // Snapshot returns a copy so pointer identity is not expected; verify
373+ // structural equality instead.
374+ assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
375+ assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
376+}
377+
378+func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
379+ // A window with its own TabList where the second tab is active.
380+ app := &App{
381+ browserWindows: make(map[string]*browserWindow),
382+ }
383+
384+ pane1 := entity.NewPane(entity.PaneID("p1"))
385+ tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
386+ pane2 := entity.NewPane(entity.PaneID("p2"))
387+ tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
388+
389+ app.tabs = entity.NewTabList()
390+ app.tabs.Add(tab1)
391+ app.tabs.Add(tab2)
392+
393+ bwTabs := entity.NewTabList()
394+ bwTabs.Add(tab1)
395+ bwTabs.Add(tab2)
396+ bwTabs.SetActive(entity.TabID("t2"))
397+ bw := &browserWindow{id: "w1", tabs: bwTabs}
398+ app.registerBrowserWindow(bw)
399+ app.windowForTab = map[entity.TabID]*browserWindow{
400+ tab1.ID: bw,
401+ tab2.ID: bw,
402+ }
403+
404+ result := app.GetWindowTabLists()
405+
406+ require.Len(t, result, 1)
407+ assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
408+}
409+
410+func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
411+ app := &App{
412+ browserWindows: make(map[string]*browserWindow),
413+ }
414+
415+ pane1 := entity.NewPane(entity.PaneID("p1"))
416+ tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
417+ pane2 := entity.NewPane(entity.PaneID("p2"))
418+ tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
419+
420+ app.tabs = entity.NewTabList()
421+ app.tabs.Add(tab1)
422+ app.tabs.Add(tab2)
423+
424+ bwTabs := entity.NewTabList()
425+ bwTabs.Add(tab1)
426+ bwTabs.Add(tab2)
427+ bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
428+
429+ bw := &browserWindow{id: "w1", tabs: bwTabs}
430+ app.registerBrowserWindow(bw)
431+ app.windowForTab = map[entity.TabID]*browserWindow{
432+ tab1.ID: bw,
433+ tab2.ID: bw,
434+ }
435+
436+ result := app.GetWindowTabLists()
437+
438+ require.Len(t, result, 1)
439+ assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
440+}
441+
442+func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
443+ // Register windows in non-lexicographic order; lastFocusedWindowID should
444+ // map to the correct index in registration order.
445+ app := &App{
446+ browserWindows: make(map[string]*browserWindow),
447+ }
448+
449+ app.registerBrowserWindow(&browserWindow{id: "z-window"})
450+ app.registerBrowserWindow(&browserWindow{id: "a-window"})
451+ app.lastFocusedWindowID = "a-window"
452+
453+ idx := app.GetActiveWindowIndex()
454+ assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
455+}
456+
457+func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
458+ // The order of windows in GetWindowTabLists must match the indices returned
459+ // by GetActiveWindowIndex.
460+ app := &App{
461+ browserWindows: make(map[string]*browserWindow),
462+ }
463+
464+ w1 := &browserWindow{id: "w1"}
465+ w2 := &browserWindow{id: "w2"}
466+ w3 := &browserWindow{id: "w3"}
467+ app.registerBrowserWindow(w1)
468+ app.registerBrowserWindow(w2)
469+ app.registerBrowserWindow(w3)
470+
471+ // Give each window a tab.
472+ p1 := entity.NewPane(entity.PaneID("p1"))
473+ t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
474+ p2 := entity.NewPane(entity.PaneID("p2"))
475+ t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
476+ p3 := entity.NewPane(entity.PaneID("p3"))
477+ t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
478+ app.tabs = entity.NewTabList()
479+ app.tabs.Add(t1)
480+ app.tabs.Add(t2)
481+ app.tabs.Add(t3)
482+ w1.tabs.Add(t1)
483+ w2.tabs.Add(t2)
484+ w3.tabs.Add(t3)
485+ app.windowForTab = map[entity.TabID]*browserWindow{
486+ t1.ID: w1,
487+ t2.ID: w2,
488+ t3.ID: w3,
489+ }
490+
491+ app.lastFocusedWindowID = "w2"
492+
493+ lists := app.GetWindowTabLists()
494+ idx := app.GetActiveWindowIndex()
495+
496+ require.Len(t, lists, 3)
497+ assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
498+}
499+
500+func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
501+ // When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
502+ // windowOrder must fall back to a deterministic (sorted) order.
503+ app := &App{
504+ browserWindows: map[string]*browserWindow{
505+ "z-window": {id: "z-window"},
506+ "a-window": {id: "a-window"},
507+ },
508+ }
509+
510+ order := app.windowOrder()
511+
512+ require.Len(t, order, 2)
513+ // Sorted order: "a-window" < "z-window"
514+ assert.Equal(t, "a-window", order[0])
515+ assert.Equal(t, "z-window", order[1])
516+}
517+
518+func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
519+ app := &App{
520+ browserWindows: make(map[string]*browserWindow),
521+ }
522+
523+ w1 := &browserWindow{id: "w1"}
524+ w2 := &browserWindow{id: "w2"}
525+ w3 := &browserWindow{id: "w3"}
526+ app.registerBrowserWindow(w1)
527+ app.registerBrowserWindow(w2)
528+ app.registerBrowserWindow(w3)
529+
530+ app.removeBrowserWindow("w2")
531+
532+ require.Len(t, app.browserWindowOrder, 2)
533+ assert.Equal(t, "w1", app.browserWindowOrder[0])
534+ assert.Equal(t, "w3", app.browserWindowOrder[1])
535+}
536+
537+func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
538+ app := &App{
539+ browserWindows: make(map[string]*browserWindow),
540+ }
541+
542+ bw := &browserWindow{id: "w1"}
543+ app.registerBrowserWindow(bw)
544+ app.registerBrowserWindow(bw) // duplicate
545+
546+ require.Len(t, app.browserWindowOrder, 1)
547+}
548+
549+func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
550+ // Create two windows with per-window bw.tabs as the real source of truth.
551+ // Ensure GetWindowTabLists returns them in registration order, with correct
552+ // WindowID, and that the returned Tabs pointers are the per-window bw.tabs
553+ // (not copies or derived from global App.tabs).
554+ firstTabs := entity.NewTabList()
555+ firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
556+ firstTabs.Add(firstTab)
557+ firstTabs.SetActive(entity.TabID("tab-1"))
558+
559+ secondTabs := entity.NewTabList()
560+ secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
561+ secondTabs.Add(secondTab)
562+
563+ first := &browserWindow{id: "window-1", tabs: firstTabs}
564+ second := &browserWindow{id: "window-2", tabs: secondTabs}
565+
566+ app := &App{
567+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
568+ browserWindowOrder: []string{"window-1", "window-2"},
569+ lastFocusedWindowID: "window-2",
570+ }
571+
572+ result := app.GetWindowTabLists()
573+
574+ require.Len(t, result, 2)
575+ assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
576+ assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
577+
578+ // The returned Tabs are snapshot copies of per-window bw.tabs.
579+ // Verify structural equality (not pointer identity, since
580+ // GetWindowTabLists returns a snapshot for safe concurrent read).
581+ assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
582+ assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
583+ assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
584+ assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
585+
586+ // Active state reflects per-window bw.tabs.
587+ assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
588+ assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
589+
590+ // GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
591+ idx := app.GetActiveWindowIndex()
592+ assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
593+}
594+
595+func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
596+ // Create two restored TabLists with distinct tabs.
597+ firstTabs := entity.NewTabList()
598+ firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
599+ firstTabs.Add(firstTab)
600+ firstTabs.SetActive(entity.TabID("tab-1"))
601+
602+ secondTabs := entity.NewTabList()
603+ secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
604+ secondTabs.Add(secondTab)
605+
606+ // Create two runtime browser windows with fresh IDs distinct from saved IDs.
607+ firstBW := &browserWindow{id: "runtime-w1"}
608+ secondBW := &browserWindow{id: "runtime-w2"}
609+
610+ app := &App{
611+ browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
612+ }
613+
614+ // Assign restored state using saved window IDs (simulating session restore).
615+ app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
616+ WindowID: "saved-w1",
617+ Tabs: firstTabs,
618+ })
619+ app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
620+ WindowID: "saved-w2",
621+ Tabs: secondTabs,
622+ })
623+
624+ // Tabs must be owned by runtime windows, not saved.
625+ require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
626+ require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
627+
628+ // windowForTab must map tabs to runtime windows.
629+ require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
630+ require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
631+
632+ // Saved window IDs must never leak into browserWindows.
633+ require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
634+ require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
635+ assertWindowOwnershipInvariant(t, app)
636+}
637+
638+func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
639+ // Construct first browser window with its own TabList and distinct tabs.
640+ firstTabs := entity.NewTabList()
641+ firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
642+ firstTabs.Add(firstTab)
643+
644+ first := &browserWindow{
645+ id: "window-first",
646+ tabs: firstTabs,
647+ }
648+
649+ // Construct second browser window with its own TabList and distinct tabs.
650+ secondTabs := entity.NewTabList()
651+ secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
652+ secondTabs.Add(secondTab)
653+
654+ second := &browserWindow{
655+ id: "window-second",
656+ tabs: secondTabs,
657+ }
658+
659+ app := &App{
660+ browserWindows: map[string]*browserWindow{
661+ first.id: first,
662+ second.id: second,
663+ },
664+ }
665+
666+ // tabListForBrowserWindow returns the correct TabList for each window.
667+ got := app.tabListForBrowserWindow(first)
668+ if got != firstTabs {
669+ t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
670+ }
671+ got = app.tabListForBrowserWindow(second)
672+ if got != secondTabs {
673+ t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
674+ }
675+
676+ // Each window's tabs do not contain the other window's tabs.
677+ if firstTabs.Find(secondTab.ID) != nil {
678+ t.Fatalf("first window tabs unexpectedly contain second tab")
679+ }
680+ if secondTabs.Find(firstTab.ID) != nil {
681+ t.Fatalf("second window tabs unexpectedly contain first tab")
682+ }
683+
684+ // activeTabForBrowserWindow returns the correct active tab for each window.
685+ gotTab := app.activeTabForBrowserWindow(first)
686+ if gotTab != firstTab {
687+ t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
688+ }
689+ gotTab = app.activeTabForBrowserWindow(second)
690+ if gotTab != secondTab {
691+ t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
692+ }
693+
694+ app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
695+ assertWindowOwnershipInvariant(t, app)
696+}
697+
698+func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
699+ // After restore, only runtime windows should remain in browserWindows
700+ // and browserWindowOrder. Stale pre-existing windows must be removed.
701+
702+ // Runtime windows with tabs (what restoreSession produces).
703+ runtime1Tabs := entity.NewTabList()
704+ r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
705+ runtime1Tabs.Add(r1Tab)
706+ runtime1Tabs.SetActive(entity.TabID("r1-t1"))
707+
708+ runtime2Tabs := entity.NewTabList()
709+ r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
710+ runtime2Tabs.Add(r2Tab)
711+ runtime2Tabs.SetActive(entity.TabID("r2-t1"))
712+
713+ runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
714+ runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
715+
716+ // Stale pre-existing browser window (no longer in runtime set).
717+ staleTabs := entity.NewTabList()
718+ staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
719+ staleTabs.Add(staleTab)
720+ stale := &browserWindow{id: "stale-window", tabs: staleTabs}
721+
722+ app := &App{
723+ browserWindows: map[string]*browserWindow{
724+ "stale-window": stale,
725+ "runtime-w1": runtime1,
726+ "runtime-w2": runtime2,
727+ },
728+ tabs: entity.NewTabList(),
729+ windowForTab: map[entity.TabID]*browserWindow{
730+ staleTab.ID: stale,
731+ r1Tab.ID: runtime1,
732+ r2Tab.ID: runtime2,
733+ },
734+ }
735+
736+ // Simulate post-restore cleanup: remove stale windows not in runtime set.
737+ runtimeIDs := []string{"runtime-w1", "runtime-w2"}
738+ app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
739+
740+ // After cleanup, browserWindows contains only runtime windows.
741+ require.Len(t, app.browserWindows, 2)
742+ require.NotNil(t, app.browserWindows["runtime-w1"])
743+ require.NotNil(t, app.browserWindows["runtime-w2"])
744+ require.Nil(t, app.browserWindows["stale-window"])
745+
746+ // browserWindowOrder matches runtime order.
747+ require.Equal(t, runtimeIDs, app.browserWindowOrder)
748+
749+ // GetWindowTabLists returns only runtime windows.
750+ result := app.GetWindowTabLists()
751+ require.Len(t, result, 2)
752+ assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
753+ assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
754+
755+ // No stale tabs leak into the result.
756+ // Verify structural equality (snapshot copies, not live pointers).
757+ assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
758+ assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
759+ assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
760+ assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
761+ assertWindowOwnershipInvariant(t, app)
762+}
763+
764+func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
765+ // When the first restored window reuses a browser window that was not
766+ // already in browserWindowOrder, the restored order must start with
767+ // the reused window ID followed by newly-created window IDs.
768+
769+ reuseTabs := entity.NewTabList()
770+ reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
771+ reuseTabs.Add(reuseTab)
772+ reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
773+
774+ createdTabs := entity.NewTabList()
775+ createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
776+ createdTabs.Add(createdTab)
777+ createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
778+
779+ // Pre-existing browserWindowOrder does NOT contain the reused window.
780+ app := &App{
781+ browserWindows: map[string]*browserWindow{
782+ "reused-window": reuseBW,
783+ "created-window": createdBW,
784+ },
785+ browserWindowOrder: []string{"old-stale-window"}, // reused window not present
786+ tabs: entity.NewTabList(),
787+ windowForTab: map[entity.TabID]*browserWindow{},
788+ }
789+
790+ // Exercise the helper used by restoreSession to prune stale windows and
791+ // replace browserWindowOrder with the runtime restore order.
792+ runtimeWindows := []*browserWindow{reuseBW, createdBW}
793+ app.pruneStaleBrowserWindows(runtimeWindows)
794+
795+ require.Len(t, app.browserWindowOrder, 2)
796+ assert.Equal(t, "reused-window", app.browserWindowOrder[0],
797+ "first reused window must be first in order")
798+ assert.Equal(t, "created-window", app.browserWindowOrder[1],
799+ "newly-created window must follow the reused window")
800+
801+ // GetWindowTabLists respects the order.
802+ result := app.GetWindowTabLists()
803+ require.Len(t, result, 2)
804+ assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
805+ assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
806+}
807+
808+func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
809+ // After restore, GetActiveWindowIndex must return the restored active index.
810+ // The focused window state must be consistent: lastFocusedWindowID matches
811+ // the window at that index, and the active window's tab target aligns.
812+
813+ runtime1Tabs := entity.NewTabList()
814+ r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
815+ runtime1Tabs.Add(r1Tab)
816+ runtime1Tabs.SetActive(entity.TabID("a1-t1"))
817+
818+ runtime2Tabs := entity.NewTabList()
819+ r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
820+ runtime2Tabs.Add(r2Tab)
821+ runtime2Tabs.SetActive(entity.TabID("a2-t1"))
822+
823+ runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
824+ runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
825+
826+ app := &App{
827+ browserWindows: map[string]*browserWindow{
828+ "active-w1": runtime1,
829+ "active-w2": runtime2,
830+ },
831+ browserWindowOrder: []string{"active-w1", "active-w2"},
832+ lastFocusedWindowID: "active-w2",
833+ tabs: entity.NewTabList(),
834+ windowForTab: map[entity.TabID]*browserWindow{},
835+ }
836+
837+ // Active index should be 1 (second window in order).
838+ idx := app.GetActiveWindowIndex()
839+ assert.Equal(t, 1, idx)
840+
841+ // The focused window is the one at that index.
842+ focused := app.lastFocusedBrowserWindow()
843+ require.NotNil(t, focused)
844+ assert.Equal(t, "active-w2", focused.id)
845+
846+ // Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
847+ // the window at the active index matches the focused window.
848+ result := app.GetWindowTabLists()
849+ require.Len(t, result, 2)
850+ assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
851+ "window at active index must match focused window ID")
852+}
M internal/ui/component/tab_bar.go +38 -3
@@ -12,6 +12,8 @@ type TabBar struct {
1212 box *gtk.Box
1313 buttons map[entity.TabID]*TabButton
1414
15+ explicitlyVisible bool
16+
1517 // Active tab tracking
1618 activeTabID entity.TabID
1719
@@ -26,7 +28,8 @@ type TabBar struct {
2628 // NewTabBar creates a new tab bar widget.
2729 func NewTabBar() *TabBar {
2830 tb := &TabBar{
29- buttons: make(map[entity.TabID]*TabButton),
31+ buttons: make(map[entity.TabID]*TabButton),
32+ explicitlyVisible: true,
3033 }
3134
3235 // Create horizontal box container
@@ -157,12 +160,44 @@ func (tb *TabBar) ActiveTabID() entity.TabID {
157160 return tb.activeTabID
158161 }
159162
160-// SetVisible shows or hides the tab bar.
163+// SetVisible explicitly shows or hides the tab bar.
161164 func (tb *TabBar) SetVisible(visible bool) {
162- if tb.box == nil {
165+ if tb == nil || tb.box == nil {
163166 return
164167 }
168+ tb.explicitlyVisible = visible
165169 tb.box.SetVisible(visible)
170+ if !visible {
171+ tb.box.SetFocusable(false)
172+ tb.box.SetCanTarget(false)
173+ } else {
174+ tb.box.SetFocusable(true)
175+ tb.box.SetCanTarget(true)
176+ }
177+}
178+
179+// SetAutoHidden hides the tab bar visually while keeping the overlay widget
180+// visible to GTK when the bar has not been explicitly hidden. It is used for
181+// hide_tab_bar_when_single_tab; WebView viewport height stays stable because the
182+// tab bar is mounted as a non-measured overlay above the content.
183+func (tb *TabBar) SetAutoHidden(hidden bool) {
184+ if tb == nil || tb.box == nil {
185+ return
186+ }
187+
188+ // Fullscreen and other explicit hide states must win over auto-hide updates.
189+ tb.box.SetVisible(tb.explicitlyVisible)
190+
191+ if hidden {
192+ tb.box.SetOpacity(0.0)
193+ tb.box.SetCanTarget(false)
194+ tb.box.SetFocusable(false)
195+ return
196+ }
197+
198+ tb.box.SetOpacity(1.0)
199+ tb.box.SetCanTarget(tb.explicitlyVisible)
200+ tb.box.SetFocusable(tb.explicitlyVisible)
166201 }
167202
168203 // Destroy cleans up all tab bar resources.
M internal/ui/coordinator/content/coordinator.go +5 -0
@@ -275,6 +275,11 @@ func (c *Coordinator) ClearActivePaneOverride() {
275275 c.activePaneOverrideMu.Unlock()
276276 }
277277
278+// ActivePaneOverrideID returns the currently forced active pane override, if any.
279+func (c *Coordinator) ActivePaneOverrideID() (entity.PaneID, bool) {
280+ return c.activePaneOverrideID()
281+}
282+
278283 func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
279284 c.activePaneOverrideMu.RLock()
280285 defer c.activePaneOverrideMu.RUnlock()
M internal/ui/coordinator/navigation.go +78 -85
@@ -16,7 +16,6 @@ import (
1616 type OmniboxProvider interface {
1717 ToggleOmnibox(ctx context.Context)
1818 UpdateOmniboxZoom(factor float64)
19- SetOmniboxOnNavigate(fn func(url string))
2019 }
2120
2221 // NavigationCoordinator handles URL navigation, history, and browser controls.
@@ -51,137 +50,138 @@ func NewNavigationCoordinator(
5150 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.
5251 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {
5352 c.omniboxProvider = provider
54- if c.omniboxProvider != nil {
55- c.omniboxProvider.SetOmniboxOnNavigate(func(url string) {
56- navCtx := context.Background()
57- if c.contextProvider != nil {
58- navCtx = c.contextProvider()
59- }
60- if err := c.Navigate(navCtx, url); err != nil {
61- logging.FromContext(navCtx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed")
62- }
63- })
64- }
6553 }
6654
67-// Navigate loads a URL in the active pane using NavigateUseCase.
68-// This properly handles history recording and zoom application.
69-func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {
70- log := logging.FromContext(ctx)
71- if c.contentCoord == nil {
72- log.Warn().Str("url", url).Msg("content coordinator not initialized")
73- return fmt.Errorf("content coordinator not initialized")
55+// requireWebView returns an error if wv is nil, preserving stable error text.
56+func requireWebView(wv port.WebView) error {
57+ if wv == nil {
58+ return fmt.Errorf("no webview provided")
7459 }
60+ return nil
61+}
7562
76- wv := c.contentCoord.ActiveWebView(ctx)
77- if wv == nil {
78- log.Warn().Str("url", url).Msg("no active webview for navigation")
79- return fmt.Errorf("no active webview")
63+func (c *NavigationCoordinator) trackNavigationOrigin(ctx context.Context, paneID entity.PaneID, url string) {
64+ if c.contentCoord == nil || paneID == "" {
65+ return
8066 }
67+ c.contentCoord.SetNavigationOrigin(paneID, url)
68+ go func() {
69+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
70+ defer cancelPreload()
71+ c.contentCoord.PreloadCachedFavicon(preloadCtx, paneID, url)
72+ }()
73+}
8174
82- // Get active pane ID for tracking
83- activePaneID := c.contentCoord.ActivePaneID(ctx)
84- paneID := string(activePaneID)
85- if activePaneID != "" {
86- // Track original URL for cross-domain redirect favicon caching
87- c.contentCoord.SetNavigationOrigin(activePaneID, url)
88- // Pre-load cached favicon asynchronously (don't block navigation start)
89- go func() {
90- preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
91- defer cancelPreload()
92- c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
93- }()
75+// NavigateWebView loads a URL in the specified pane using the provided WebView.
76+// This is the explicit-target equivalent of Navigate: it uses NavigateUseCase if
77+// available, falling back to direct LoadURI. Origin tracking via contentCoord is
78+// performed when contentCoord and paneID are available.
79+func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, paneID entity.PaneID, wv port.WebView) error {
80+ log := logging.FromContext(ctx)
81+
82+ if err := requireWebView(wv); err != nil {
83+ log.Warn().Str("url", url).Msg("NavigateWebView called with nil webview")
84+ return err
9485 }
9586
96- // Use NavigateUseCase which handles history + zoom
87+ c.trackNavigationOrigin(ctx, paneID, url)
88+
89+ paneIDStr := string(paneID)
90+
9791 if c.navigateUC != nil {
9892 input := usecase.NavigateInput{
9993 URL: url,
100- PaneID: paneID,
101- WebView: wv, // webkit.WebView implements port.WebView
94+ PaneID: paneIDStr,
95+ WebView: wv,
10296 }
10397 output, err := c.navigateUC.Execute(ctx, input)
10498 if err != nil {
105- log.Error().Err(err).Str("url", url).Msg("navigation failed")
99+ log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed")
106100 return err
107101 }
108102 log.Debug().
109103 Str("url", url).
104+ Str("pane_id", paneIDStr).
110105 Float64("zoom", output.AppliedZoom).
106+ Uint64("webview_id", uint64(wv.ID())).
111107 Msg("navigation initiated via usecase")
112108 return nil
113109 }
114110
115- // Fallback: direct navigation without usecase
116111 log.Warn().Msg("navigateUC not available, using direct LoadURI")
117112 if err := wv.LoadURI(ctx, url); err != nil {
118113 log.Error().Err(err).Str("url", url).Msg("failed to navigate")
119114 return err
120115 }
121116
122- log.Debug().Str("url", url).Msg("navigated active pane (direct)")
117+ log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)")
123118 return nil
124119 }
125120
126-// Reload reloads the current page.
127-func (c *NavigationCoordinator) Reload(ctx context.Context) error {
121+// ReloadWebView reloads the page in the provided WebView, optionally bypassing cache.
122+func (c *NavigationCoordinator) ReloadWebView(ctx context.Context, wv port.WebView, bypassCache bool) error {
128123 log := logging.FromContext(ctx)
129124
130- wv := c.contentCoord.ActiveWebView(ctx)
131- if wv == nil {
132- log.Debug().Msg("no active webview for reload")
133- return nil
125+ if err := requireWebView(wv); err != nil {
126+ log.Debug().Msg("ReloadWebView called with nil webview")
127+ return err
134128 }
135129
136130 if c.navigateUC != nil {
137- return c.navigateUC.Reload(ctx, wv, false)
131+ return c.navigateUC.Reload(ctx, wv, bypassCache)
138132 }
139133
134+ if bypassCache {
135+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview bypassing cache")
136+ return wv.ReloadBypassCache(ctx)
137+ }
138+
139+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview")
140140 return wv.Reload(ctx)
141141 }
142142
143-// HardReload reloads the current page bypassing cache.
144-func (c *NavigationCoordinator) HardReload(ctx context.Context) error {
143+// StopWebView stops loading in the provided WebView.
144+func (c *NavigationCoordinator) StopWebView(ctx context.Context, wv port.WebView) error {
145145 log := logging.FromContext(ctx)
146146
147- wv := c.contentCoord.ActiveWebView(ctx)
148- if wv == nil {
149- log.Debug().Msg("no active webview for hard reload")
150- return nil
147+ if err := requireWebView(wv); err != nil {
148+ log.Debug().Msg("StopWebView called with nil webview")
149+ return err
151150 }
152151
153152 if c.navigateUC != nil {
154- return c.navigateUC.Reload(ctx, wv, true)
153+ return c.navigateUC.Stop(ctx, wv)
155154 }
156155
157- return wv.ReloadBypassCache(ctx)
156+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("stopping explicit webview")
157+ return wv.Stop(ctx)
158158 }
159159
160-// GoBack navigates back in history.
160+// GoBackWebView navigates back in history for the provided WebView.
161161 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.
162-func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
162+func (c *NavigationCoordinator) GoBackWebView(ctx context.Context, wv port.WebView) error {
163163 log := logging.FromContext(ctx)
164164
165- wv := c.contentCoord.ActiveWebView(ctx)
166- if wv == nil {
167- log.Debug().Msg("no active webview for go back")
168- return nil
165+ if err := requireWebView(wv); err != nil {
166+ log.Debug().Msg("GoBackWebView called with nil webview")
167+ return err
169168 }
170169
170+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going back in explicit webview")
171171 return wv.GoBack(ctx)
172172 }
173173
174-// GoForward navigates forward in history.
174+// GoForwardWebView navigates forward in history for the provided WebView.
175175 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.
176-func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
176+func (c *NavigationCoordinator) GoForwardWebView(ctx context.Context, wv port.WebView) error {
177177 log := logging.FromContext(ctx)
178178
179- wv := c.contentCoord.ActiveWebView(ctx)
180- if wv == nil {
181- log.Debug().Msg("no active webview for go forward")
182- return nil
179+ if err := requireWebView(wv); err != nil {
180+ log.Debug().Msg("GoForwardWebView called with nil webview")
181+ return err
183182 }
184183
184+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going forward in explicit webview")
185185 return wv.GoForward(ctx)
186186 }
187187
@@ -199,14 +199,13 @@ func (c *NavigationCoordinator) OpenOmnibox(ctx context.Context) error {
199199 return nil
200200 }
201201
202-// OpenDevTools opens the WebKit inspector for the active WebView.
203-func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
202+// OpenDevToolsWebView opens the WebKit inspector for the provided WebView.
203+func (c *NavigationCoordinator) OpenDevToolsWebView(ctx context.Context, wv port.WebView) error {
204204 log := logging.FromContext(ctx)
205205
206- wv := c.contentCoord.ActiveWebView(ctx)
207- if wv == nil {
208- log.Warn().Msg("no active webview for devtools")
209- return fmt.Errorf("no active webview")
206+ if err := requireWebView(wv); err != nil {
207+ log.Warn().Msg("OpenDevToolsWebView called with nil webview")
208+ return err
210209 }
211210
212211 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
@@ -219,14 +218,13 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
219218 return fmt.Errorf("webview does not support devtools")
220219 }
221220
222-// PrintPage opens the print dialog for the active WebView.
223-func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {
221+// PrintWebView opens the print dialog for the provided WebView.
222+func (c *NavigationCoordinator) PrintWebView(ctx context.Context, wv port.WebView) error {
224223 log := logging.FromContext(ctx)
225224
226- wv := c.contentCoord.ActiveWebView(ctx)
227- if wv == nil {
228- log.Warn().Msg("no active webview for print")
229- return fmt.Errorf("no active webview")
225+ if err := requireWebView(wv); err != nil {
226+ log.Warn().Msg("PrintWebView called with nil webview")
227+ return err
230228 }
231229
232230 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
@@ -265,11 +263,6 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
265263 c.navigateUC.ClearPaneHistory(string(paneID))
266264 }
267265
268-// ActiveWebView returns the WebView for the active pane (for zoom operations).
269-func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) port.WebView {
270- return c.contentCoord.ActiveWebView(ctx)
271-}
272-
273266 // NotifyZoomChanged updates the omnibox zoom indicator.
274267 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
275268 if c.omniboxProvider != nil {
M internal/ui/coordinator/navigation_test.go +10 -20
@@ -4,38 +4,28 @@ import (
44 "context"
55 "testing"
66
7- "github.com/stretchr/testify/assert"
87 "github.com/stretchr/testify/require"
98 )
109
1110 type fakeOmniboxProvider struct {
12- onNavigate func(string)
11+ toggled bool
12+ zoomUpdates []float64
1313 }
1414
15-func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) {}
15+func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) { f.toggled = true }
1616
17-func (f *fakeOmniboxProvider) UpdateOmniboxZoom(float64) {}
18-
19-func (f *fakeOmniboxProvider) SetOmniboxOnNavigate(fn func(url string)) {
20- f.onNavigate = fn
21-}
22-
23-func TestNavigationCoordinator_NavigateWithoutContentCoordinatorReturnsError(t *testing.T) {
24- c := &NavigationCoordinator{}
25-
26- err := c.Navigate(context.Background(), "https://example.com")
27- require.Error(t, err)
28- assert.Contains(t, err.Error(), "content coordinator")
17+func (f *fakeOmniboxProvider) UpdateOmniboxZoom(factor float64) {
18+ f.zoomUpdates = append(f.zoomUpdates, factor)
2919 }
3020
31-func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *testing.T) {
21+func TestNavigationCoordinator_OmniboxProviderOpenAndZoom(t *testing.T) {
3222 c := &NavigationCoordinator{}
3323 provider := &fakeOmniboxProvider{}
3424
3525 c.SetOmniboxProvider(provider)
36- require.NotNil(t, provider.onNavigate)
26+ require.NoError(t, c.OpenOmnibox(context.Background()))
27+ c.NotifyZoomChanged(context.Background(), 1.25)
3728
38- assert.NotPanics(t, func() {
39- provider.onNavigate("https://example.com")
40- })
29+ require.True(t, provider.toggled)
30+ require.Equal(t, []float64{1.25}, provider.zoomUpdates)
4131 }
A internal/ui/coordinator/navigation_webview_targeted_test.go +168 -0
@@ -0,0 +1,168 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that
13+// the explicit WebView-targeted navigation methods call the correct methods on the
14+// provided WebView rather than resolving through the content coordinator.
15+func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testing.T) {
16+ ctx := context.Background()
17+
18+ t.Run("NavigateWebView with nil webview returns error", func(t *testing.T) {
19+ testNavigateWebViewNilWebView(t, ctx)
20+ })
21+ t.Run("NavigateWebView targets provided webview", func(t *testing.T) {
22+ testNavigateWebViewTargetsProvidedWebView(t, ctx)
23+ })
24+ t.Run("ReloadWebView with nil webview returns error", func(t *testing.T) {
25+ testReloadWebViewNilWebView(t, ctx)
26+ })
27+ t.Run("ReloadWebView bypassCache=false calls Reload once", func(t *testing.T) {
28+ testReloadWebViewCallsReload(t, ctx)
29+ })
30+ t.Run("ReloadWebView bypassCache=true calls ReloadBypassCache once", func(t *testing.T) {
31+ testReloadWebViewCallsReloadBypassCache(t, ctx)
32+ })
33+ t.Run("StopWebView with nil webview returns error", func(t *testing.T) {
34+ testStopWebViewNilWebView(t, ctx)
35+ })
36+ t.Run("StopWebView calls Stop once", func(t *testing.T) {
37+ testStopWebViewCallsStop(t, ctx)
38+ })
39+ t.Run("GoBackWebView with nil webview returns error", func(t *testing.T) {
40+ testGoBackWebViewNilWebView(t, ctx)
41+ })
42+ t.Run("GoBackWebView calls GoBack once", func(t *testing.T) {
43+ testGoBackWebViewCallsGoBack(t, ctx)
44+ })
45+ t.Run("GoForwardWebView with nil webview returns error", func(t *testing.T) {
46+ testGoForwardWebViewNilWebView(t, ctx)
47+ })
48+ t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) {
49+ testGoForwardWebViewCallsGoForward(t, ctx)
50+ })
51+}
52+
53+func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
54+ t.Helper()
55+ c := &NavigationCoordinator{}
56+ err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), nil)
57+ if err == nil {
58+ t.Fatal("expected error for nil webview, got nil")
59+ }
60+}
61+
62+func testNavigateWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
63+ t.Helper()
64+ wv := mocks.NewMockWebView(t)
65+ wv.EXPECT().LoadURI(ctx, "https://example.com").Return(nil).Once()
66+ wv.EXPECT().ID().Return(port.WebViewID(42)).Once()
67+ c := &NavigationCoordinator{}
68+ err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), wv)
69+ if err != nil {
70+ t.Fatalf("unexpected error: %v", err)
71+ }
72+}
73+
74+func testReloadWebViewNilWebView(t *testing.T, ctx context.Context) {
75+ t.Helper()
76+ c := &NavigationCoordinator{}
77+ err := c.ReloadWebView(ctx, nil, false)
78+ if err == nil {
79+ t.Fatal("expected error for nil webview, got nil")
80+ }
81+}
82+
83+func testReloadWebViewCallsReload(t *testing.T, ctx context.Context) {
84+ t.Helper()
85+ wv := mocks.NewMockWebView(t)
86+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
87+ wv.EXPECT().Reload(ctx).Return(nil).Once()
88+ c := &NavigationCoordinator{}
89+ err := c.ReloadWebView(ctx, wv, false)
90+ if err != nil {
91+ t.Fatalf("unexpected error: %v", err)
92+ }
93+}
94+
95+func testReloadWebViewCallsReloadBypassCache(t *testing.T, ctx context.Context) {
96+ t.Helper()
97+ wv := mocks.NewMockWebView(t)
98+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
99+ wv.EXPECT().ReloadBypassCache(ctx).Return(nil).Once()
100+ c := &NavigationCoordinator{}
101+ err := c.ReloadWebView(ctx, wv, true)
102+ if err != nil {
103+ t.Fatalf("unexpected error: %v", err)
104+ }
105+}
106+
107+func testStopWebViewNilWebView(t *testing.T, ctx context.Context) {
108+ t.Helper()
109+ c := &NavigationCoordinator{}
110+ err := c.StopWebView(ctx, nil)
111+ if err == nil {
112+ t.Fatal("expected error for nil webview, got nil")
113+ }
114+}
115+
116+func testStopWebViewCallsStop(t *testing.T, ctx context.Context) {
117+ t.Helper()
118+ wv := mocks.NewMockWebView(t)
119+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
120+ wv.EXPECT().Stop(ctx).Return(nil).Once()
121+ c := &NavigationCoordinator{}
122+ err := c.StopWebView(ctx, wv)
123+ if err != nil {
124+ t.Fatalf("unexpected error: %v", err)
125+ }
126+}
127+
128+func testGoBackWebViewNilWebView(t *testing.T, ctx context.Context) {
129+ t.Helper()
130+ c := &NavigationCoordinator{}
131+ err := c.GoBackWebView(ctx, nil)
132+ if err == nil {
133+ t.Fatal("expected error for nil webview, got nil")
134+ }
135+}
136+
137+func testGoBackWebViewCallsGoBack(t *testing.T, ctx context.Context) {
138+ t.Helper()
139+ wv := mocks.NewMockWebView(t)
140+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
141+ wv.EXPECT().GoBack(ctx).Return(nil).Once()
142+ c := &NavigationCoordinator{}
143+ err := c.GoBackWebView(ctx, wv)
144+ if err != nil {
145+ t.Fatalf("unexpected error: %v", err)
146+ }
147+}
148+
149+func testGoForwardWebViewNilWebView(t *testing.T, ctx context.Context) {
150+ t.Helper()
151+ c := &NavigationCoordinator{}
152+ err := c.GoForwardWebView(ctx, nil)
153+ if err == nil {
154+ t.Fatal("expected error for nil webview, got nil")
155+ }
156+}
157+
158+func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) {
159+ t.Helper()
160+ wv := mocks.NewMockWebView(t)
161+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
162+ wv.EXPECT().GoForward(ctx).Return(nil).Once()
163+ c := &NavigationCoordinator{}
164+ err := c.GoForwardWebView(ctx, wv)
165+ if err != nil {
166+ t.Fatalf("unexpected error: %v", err)
167+ }
168+}
M internal/ui/coordinator/tab.go +241 -224
@@ -12,37 +12,36 @@ import (
1212 "github.com/bnema/dumber/internal/ui/window"
1313 )
1414
15-// TabScopeFunc filters whether a tab belongs to the current window scope.
16-// Returns true if the tab should be considered for window-local operations.
17-type TabScopeFunc func(tab *entity.Tab, mainWindow *window.MainWindow) bool
15+// TabTarget identifies which TabList and MainWindow a tab operation applies to.
16+// Each browser window owns a dedicated TabTarget; operations are scoped to that target.
17+type TabTarget struct {
18+ Tabs *entity.TabList
19+ MainWindow *window.MainWindow
20+}
1821
19-// TabCoordinator manages tab lifecycle operations.
22+// TabCoordinator manages tab lifecycle operations within a target window.
23+// Public methods accept an explicit TabTarget so callers control which window's
24+// tabs are mutated. The currentTarget field provides a fallback for callers that
25+// operate on the "currently active" browser window (e.g., the keyboard dispatcher).
2026 type TabCoordinator struct {
2127 tabsUC *usecase.ManageTabsUseCase
22- tabs *entity.TabList
23- mainWindow *window.MainWindow
28+ currentTarget TabTarget
29+ mainWindow *window.MainWindow // retained for GetTabBar and SetMainWindow
2430 hideTabBarWhenSingleTab bool
2531
26- // Window-scoped tab filtering (per-window tab ownership)
27- tabScope TabScopeFunc
28-
2932 // Callbacks to avoid circular dependencies
30- onTabCreated func(ctx context.Context, tab *entity.Tab)
31- onTabSwitched func(ctx context.Context, tab *entity.Tab)
33+ onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab)
34+ onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab)
3235 onQuit func()
33- onCurrentWindowEmpty func(ctx context.Context, mainWindow *window.MainWindow)
36+ onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
3437 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
3538 onStateChanged func() // For session snapshots
36-
37- // previousActiveTabID provides the per-window previous active tab ID for Alt+Tab style switching.
38- // When set and tabScope is active, SwitchToLastActive prefers this over the global PreviousActiveTabID.
39- previousActiveTabID func(mainWindow *window.MainWindow) entity.TabID
4039 }
4140
4241 // TabCoordinatorConfig holds configuration for TabCoordinator.
4342 type TabCoordinatorConfig struct {
4443 TabsUC *usecase.ManageTabsUseCase
45- Tabs *entity.TabList
44+ Tabs *entity.TabList // Initial tab list for the default target
4645 MainWindow *window.MainWindow
4746 HideTabBarWhenSingleTab bool
4847 }
@@ -53,21 +52,38 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi
5352 log.Debug().Msg("creating tab coordinator")
5453
5554 return &TabCoordinator{
56- tabsUC: cfg.TabsUC,
57- tabs: cfg.Tabs,
55+ tabsUC: cfg.TabsUC,
56+ currentTarget: TabTarget{
57+ Tabs: cfg.Tabs,
58+ MainWindow: cfg.MainWindow,
59+ },
5860 mainWindow: cfg.MainWindow,
5961 hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
6062 }
6163 }
6264
65+// SetCurrentTarget updates the fallback target used by methods that don't receive
66+// an explicit TabTarget. This should be called whenever the active browser window changes.
67+func (c *TabCoordinator) SetCurrentTarget(target TabTarget) {
68+ c.currentTarget = target
69+ if target.MainWindow != nil {
70+ c.mainWindow = target.MainWindow
71+ }
72+}
73+
74+// CurrentTarget returns the currently active fallback target.
75+func (c *TabCoordinator) CurrentTarget() TabTarget {
76+ return c.currentTarget
77+}
78+
6379 // SetOnTabCreated sets the callback for when a tab is created.
64-func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entity.Tab)) {
80+func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
6581 c.onTabCreated = fn
6682 }
6783
6884 // SetOnTabSwitched sets the callback for when a tab switch occurs.
6985 // This is used to swap workspace views in the content area.
70-func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, tab *entity.Tab)) {
86+func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
7187 c.onTabSwitched = fn
7288 }
7389
@@ -81,44 +97,44 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
8197 c.onStateChanged = fn
8298 }
8399
84-// SetPreviousActiveTabIDProvider sets the callback for retrieving the per-window
85-// previous active tab ID. When set and tabScope is active, SwitchToLastActive
86-// prefers this provider over the global PreviousActiveTabID.
87-func (c *TabCoordinator) SetPreviousActiveTabIDProvider(fn func(mainWindow *window.MainWindow) entity.TabID) {
88- c.previousActiveTabID = fn
89-}
90-
91-// SetTabScope sets the function used to filter tabs to the current window.
92-// When set, tab operations (switch by index, next/prev, close) only consider
93-// tabs for which the scope function returns true.
94-func (c *TabCoordinator) SetTabScope(fn TabScopeFunc) {
95- c.tabScope = fn
96-}
97-
98100 // SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
99101 // window is closed. This allows the app to close the window gracefully.
100-func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, mainWindow *window.MainWindow)) {
102+func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, target TabTarget)) {
101103 c.onCurrentWindowEmpty = fn
102104 }
103105
104-// scopedTabs returns tabs filtered by the tabScope function.
105-// When no scope function is set, returns all tabs (backward compatible).
106-func (c *TabCoordinator) scopedTabs() []*entity.Tab {
107- if c.tabs == nil {
108- return nil
106+// SetMainWindow updates only the main window reference for coordinator setup.
107+//
108+// Deprecated: Use SetCurrentTarget for runtime targeting. This method preserves
109+// currentTarget.Tabs and is safe only during app-level setup.
110+func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
111+ oldMainWindow := c.mainWindow
112+ c.mainWindow = mainWindow
113+ if c.currentTarget.MainWindow != mainWindow {
114+ c.currentTarget = TabTarget{
115+ Tabs: c.currentTarget.Tabs,
116+ MainWindow: mainWindow,
117+ }
109118 }
110-
111- if c.tabScope == nil {
112- return c.tabs.Tabs
119+ if c.currentTarget.Tabs != nil && c.currentTarget.Tabs.Count() > 0 && oldMainWindow != mainWindow {
120+ logging.FromContext(context.Background()).Debug().
121+ Int("tab_count", c.currentTarget.Tabs.Count()).
122+ Str("old_main_window", fmt.Sprintf("%p", oldMainWindow)).
123+ Str("new_main_window", fmt.Sprintf("%p", mainWindow)).
124+ Msg("SetMainWindow: changing mainWindow while tabs exist")
113125 }
126+}
114127
115- scoped := make([]*entity.Tab, 0, len(c.tabs.Tabs))
116- for _, tab := range c.tabs.Tabs {
117- if tab != nil && c.tabScope(tab, c.mainWindow) {
118- scoped = append(scoped, tab)
119- }
128+// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
129+func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
130+ c.onAttachPopupToTab = fn
131+}
132+
133+// notifyStateChanged triggers the state changed callback if set.
134+func (c *TabCoordinator) notifyStateChanged() {
135+ if c.onStateChanged != nil {
136+ c.onStateChanged()
120137 }
121- return scoped
122138 }
123139
124140 // indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
@@ -132,28 +148,20 @@ func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
132148 return -1
133149 }
134150
135-// SetMainWindow updates the window targeted by tab UI operations.
136-func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
137- c.mainWindow = mainWindow
138-}
139-
140-// notifyStateChanged triggers the state changed callback if set.
141-func (c *TabCoordinator) notifyStateChanged() {
142- if c.onStateChanged != nil {
143- c.onStateChanged()
144- }
145-}
146-
147-// Create creates a new tab with the given initial URL.
148-func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
149- return c.create(ctx, initialURL, true)
151+// Create creates a new tab in the given target with the given initial URL.
152+func (c *TabCoordinator) Create(ctx context.Context, target TabTarget, initialURL string) (*entity.Tab, error) {
153+ return c.create(ctx, target, initialURL, true)
150154 }
151155
152-func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate bool) (*entity.Tab, error) {
156+func (c *TabCoordinator) create(ctx context.Context, target TabTarget, initialURL string, activate bool) (*entity.Tab, error) {
153157 log := logging.FromContext(ctx)
154158
159+ if target.Tabs == nil {
160+ return nil, fmt.Errorf("target.Tabs is nil")
161+ }
162+
155163 output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
156- TabList: c.tabs,
164+ TabList: target.Tabs,
157165 Name: "",
158166 InitialURL: initialURL,
159167 })
@@ -164,30 +172,30 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
164172
165173 if activate {
166174 // Set new tab as active (updates domain state and tracks previous)
167- c.tabs.SetActive(output.Tab.ID)
175+ target.Tabs.SetActive(output.Tab.ID)
168176 }
169177
170178 // Notify app before adding the tab to the visible tab bar.
171179 // The app assigns window ownership and the window-scoped default name here,
172180 // and TabBar.AddTab reads tab.Title() immediately when creating the button.
173181 if c.onTabCreated != nil {
174- c.onTabCreated(ctx, output.Tab)
182+ c.onTabCreated(ctx, target, output.Tab)
175183 }
176184
177185 // Update tab bar
178- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
179- c.mainWindow.TabBar().AddTab(output.Tab)
186+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
187+ target.MainWindow.TabBar().AddTab(output.Tab)
180188 if activate {
181- c.mainWindow.TabBar().SetActive(output.Tab.ID)
189+ target.MainWindow.TabBar().SetActive(output.Tab.ID)
182190 }
183191 }
184192
185193 // Update tab bar visibility
186- c.UpdateBarVisibility(ctx)
194+ c.UpdateBarVisibility(ctx, target)
187195
188196 // Switch to the new tab's workspace view when activation is requested.
189197 if activate && c.onTabSwitched != nil {
190- c.onTabSwitched(ctx, output.Tab)
198+ c.onTabSwitched(ctx, target, output.Tab)
191199 }
192200
193201 // Notify state change for session snapshots
@@ -197,269 +205,280 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
197205 return output.Tab, nil
198206 }
199207
200-// Close closes the active tab.
201-// When scoped, prefers switching to a sibling tab in the same window.
202-// If this was the last tab in the window, fires onCurrentWindowEmpty.
203-// If this was the last tab globally, fires onQuit.
204-func (c *TabCoordinator) Close(ctx context.Context) error {
208+// Close closes the active tab in the given target.
209+// When the target's TabList becomes empty, fires onCurrentWindowEmpty.
210+// The caller (App) is responsible for deciding whether all windows are empty
211+// and the process should quit. onQuit is NOT fired by Close.
212+func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
205213 log := logging.FromContext(ctx)
206214
207- activeID := c.tabs.ActiveTabID
215+ if target.Tabs == nil {
216+ return fmt.Errorf("target.Tabs is nil")
217+ }
218+
219+ activeID := target.Tabs.ActiveTabID
208220 if activeID == "" {
209221 log.Debug().Msg("no active tab to close")
210222 return nil
211223 }
212224
213- // Compute the next scoped tab to activate BEFORE closing
214- scoped := c.scopedTabs()
215- activeIdx := indexOfTab(scoped, activeID)
216- if activeIdx < 0 && len(scoped) > 0 {
217- log.Debug().Str("active_tab_id", string(activeID)).Int("scoped_count", len(scoped)).Msg("active tab not in scoped list")
218- }
225+ tabs := target.Tabs.Tabs
226+
227+ // Compute the next tab to activate BEFORE closing
228+ activeIdx := indexOfTab(tabs, activeID)
219229
220- var nextScopedID entity.TabID
221- if activeIdx >= 0 && len(scoped) > 1 {
222- if activeIdx < len(scoped)-1 {
223- nextScopedID = scoped[activeIdx+1].ID
230+ var nextID entity.TabID
231+ if activeIdx >= 0 && len(tabs) > 1 {
232+ if activeIdx < len(tabs)-1 {
233+ nextID = tabs[activeIdx+1].ID
224234 } else {
225- nextScopedID = scoped[activeIdx-1].ID
235+ nextID = tabs[activeIdx-1].ID
226236 }
227237 }
228238
229- wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)
239+ wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
230240 if err != nil {
231241 log.Error().Err(err).Msg("failed to close tab")
232242 return err
233243 }
234244
235- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
236- c.mainWindow.TabBar().RemoveTab(activeID)
245+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
246+ target.MainWindow.TabBar().RemoveTab(activeID)
237247 }
238248
239249 // Update tab bar visibility
240- c.UpdateBarVisibility(ctx)
250+ c.UpdateBarVisibility(ctx, target)
241251
242- // Quit if no tabs left globally
252+ // Target TabList became empty — signal window-empty.
253+ // App decides whether to quit (all windows empty) or only close this window.
243254 if wasLast {
244- if c.onQuit != nil {
245- c.onQuit()
255+ c.notifyStateChanged()
256+ if c.onCurrentWindowEmpty != nil {
257+ c.onCurrentWindowEmpty(ctx, target)
246258 }
247- log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed, last globally")
259+ log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, target window empty")
248260 return nil
249261 }
250262
251263 // If there's a sibling tab in the same window, switch to it
252- if nextScopedID != "" {
253- if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil {
254- log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling")
264+ if nextID != "" {
265+ if err := c.tabsUC.Switch(ctx, target.Tabs, nextID); err != nil {
266+ log.Error().Err(err).Str("tab_id", string(nextID)).Msg("failed to switch to sibling")
255267 return err
256268 }
257269
258- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
259- c.mainWindow.TabBar().SetActive(nextScopedID)
270+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
271+ target.MainWindow.TabBar().SetActive(nextID)
260272 }
261273
262274 if c.onTabSwitched != nil {
263- if tab := c.tabs.Find(nextScopedID); tab != nil {
264- c.onTabSwitched(ctx, tab)
275+ if tab := target.Tabs.Find(nextID); tab != nil {
276+ c.onTabSwitched(ctx, target, tab)
265277 }
266278 }
267279
268280 // Notify state change for session snapshots
269281 c.notifyStateChanged()
270282
271- log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextScopedID)).Msg("tab closed, switched to scoped sibling")
283+ log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextID)).Msg("tab closed, switched to sibling")
272284 return nil
273285 }
274286
275- // Last tab in this window but not globally last — close the window
287+ return c.recoverCloseWithoutSibling(ctx, target, activeID)
288+}
289+
290+func (c *TabCoordinator) recoverCloseWithoutSibling(ctx context.Context, target TabTarget, closedID entity.TabID) error {
291+ log := logging.FromContext(ctx)
292+ // Defensive corrupted-state guard: only treat the target as empty if it is
293+ // actually empty after close; otherwise recover by activating a surviving tab.
276294 c.notifyStateChanged()
277- if c.onCurrentWindowEmpty != nil {
278- c.onCurrentWindowEmpty(ctx, c.mainWindow)
295+ if len(target.Tabs.Tabs) == 0 {
296+ if c.onCurrentWindowEmpty != nil {
297+ c.onCurrentWindowEmpty(ctx, target)
298+ }
299+ log.Debug().Str("tab_id", string(closedID)).Msg("tab closed, target window empty")
300+ return nil
279301 }
280-
281- log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window")
282- return nil
302+ for _, tab := range target.Tabs.Tabs {
303+ if tab != nil {
304+ return c.Switch(ctx, target, tab.ID)
305+ }
306+ }
307+ return fmt.Errorf("tab closed but no surviving tab could be activated")
283308 }
284309
285-// Switch switches to a specific tab by ID.
286-func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {
310+// Switch switches to a specific tab by ID in the given target.
311+func (c *TabCoordinator) Switch(ctx context.Context, target TabTarget, tabID entity.TabID) error {
287312 log := logging.FromContext(ctx)
288313
314+ if target.Tabs == nil {
315+ return fmt.Errorf("target.Tabs is nil")
316+ }
317+
289318 // Skip if already active
290- if tabID == c.tabs.ActiveTabID {
319+ if tabID == target.Tabs.ActiveTabID {
291320 return nil
292321 }
293322
294- // Reject switching to a tab outside the current window scope
295- if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
296- log.Debug().
297- Str("tab_id", string(tabID)).
298- Msg("ignoring switch to tab outside current window scope")
323+ // Verify the tab exists in this target
324+ tab := target.Tabs.Find(tabID)
325+ if tab == nil {
326+ log.Debug().Str("tab_id", string(tabID)).Msg("tab not found in target")
299327 return nil
300328 }
301329
302330 log.Debug().
303- Str("from", string(c.tabs.ActiveTabID)).
331+ Str("from", string(target.Tabs.ActiveTabID)).
304332 Str("to", string(tabID)).
305333 Msg("switching tab")
306334
307335 // Update domain state
308- if err := c.tabsUC.Switch(ctx, c.tabs, tabID); err != nil {
336+ if err := c.tabsUC.Switch(ctx, target.Tabs, tabID); err != nil {
309337 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
310338 return err
311339 }
312340
313341 // Update tab bar UI
314- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
315- c.mainWindow.TabBar().SetActive(tabID)
342+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
343+ target.MainWindow.TabBar().SetActive(tabID)
316344 }
317345
318346 // Invoke callback for workspace view switching
319347 if c.onTabSwitched != nil {
320- if tab := c.tabs.Find(tabID); tab != nil {
321- c.onTabSwitched(ctx, tab)
322- }
348+ c.onTabSwitched(ctx, target, tab)
323349 }
324350
351+ c.notifyStateChanged()
352+
325353 return nil
326354 }
327355
328-// switchRelative switches to the tab delta positions away within the current window scope.
329-// delta of +1 switches next, -1 switches previous, wrapping within the scoped list.
330-func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error {
331- scoped := c.scopedTabs()
332- if len(scoped) <= 1 {
356+// switchRelative switches to the tab delta positions away within the target.
357+// delta of +1 switches next, -1 switches previous, wrapping within the list.
358+func (c *TabCoordinator) switchRelative(ctx context.Context, target TabTarget, delta int) error {
359+ if target.Tabs == nil {
360+ return fmt.Errorf("target.Tabs is nil")
361+ }
362+ tabs := target.Tabs.Tabs
363+ if len(tabs) <= 1 {
333364 return nil
334365 }
335366
336- current := indexOfTab(scoped, c.tabs.ActiveTabID)
367+ current := indexOfTab(tabs, target.Tabs.ActiveTabID)
337368 if current < 0 {
338369 current = 0
339370 } else {
340- current = (current + delta + len(scoped)) % len(scoped)
371+ current = (current + delta + len(tabs)) % len(tabs)
341372 }
342373
343- return c.Switch(ctx, scoped[current].ID)
374+ return c.Switch(ctx, target, tabs[current].ID)
344375 }
345376
346-// SwitchNext switches to the next tab within the current window scope.
347-func (c *TabCoordinator) SwitchNext(ctx context.Context) error {
348- return c.switchRelative(ctx, 1)
377+// SwitchNext switches to the next tab within the given target.
378+func (c *TabCoordinator) SwitchNext(ctx context.Context, target TabTarget) error {
379+ return c.switchRelative(ctx, target, 1)
349380 }
350381
351-// SwitchPrev switches to the previous tab within the current window scope.
352-func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {
353- return c.switchRelative(ctx, -1)
382+// SwitchPrev switches to the previous tab within the given target.
383+func (c *TabCoordinator) SwitchPrev(ctx context.Context, target TabTarget) error {
384+ return c.switchRelative(ctx, target, -1)
354385 }
355386
356-// SwitchByIndex switches to a tab by 0-based index within the current window scope.
357-func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {
387+// SwitchByIndexCreating switches to a tab by 0-based index within the given target,
388+// creating new tabs as needed to reach the requested index.
389+// If the requested index is beyond the current tab count and initialURL is empty,
390+// it returns an error without mutating tabs. If index < 0, it logs debug and returns nil.
391+func (c *TabCoordinator) SwitchByIndexCreating(ctx context.Context, target TabTarget, index int, initialURL string) error {
358392 log := logging.FromContext(ctx)
359393
360- scoped := c.scopedTabs()
361- if index < 0 || index >= len(scoped) {
362- log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index")
363- return nil
394+ if target.Tabs == nil {
395+ return fmt.Errorf("target.Tabs is nil")
364396 }
365-
366- return c.Switch(ctx, scoped[index].ID)
367-}
368-
369-// EnsureTabByIndex creates tabs until the requested index exists within the current
370-// window scope, then switches to it.
371-func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
372- log := logging.FromContext(ctx)
373-
374397 if index < 0 {
375- log.Debug().Int("index", index).Msg("invalid tab index")
398+ log.Debug().Int("index", index).Msg("invalid tab index, ignoring")
376399 return nil
377400 }
378-
379- if c.tabs == nil {
380- return fmt.Errorf("tab list is required")
401+ if target.Tabs.Count() <= index && initialURL == "" {
402+ return fmt.Errorf("initial URL is required to create missing tabs")
381403 }
382-
383- // Create tabs within the current window scope until we have enough
384- scoped := c.scopedTabs()
385- if len(scoped) <= index && initialURL == "" {
386- log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("cannot auto-create missing tabs without initial URL")
387- return fmt.Errorf("initial URL is required to auto-create missing tabs")
404+ // Intermediate tabs are created inactive (create(..., false)) and the final
405+ // missing tab is created active (create(..., true)) before SwitchByIndex.
406+ for target.Tabs.Count() < index {
407+ if _, err := c.create(ctx, target, initialURL, false); err != nil {
408+ return err
409+ }
388410 }
389-
390- for len(c.scopedTabs()) <= index {
391- if _, err := c.create(ctx, initialURL, false); err != nil {
392- log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
411+ if target.Tabs.Count() <= index {
412+ if _, err := c.create(ctx, target, initialURL, true); err != nil {
393413 return err
394414 }
395415 }
396-
397- return c.SwitchByIndex(ctx, index)
416+ return c.SwitchByIndex(ctx, target, index)
398417 }
399418
400-// SwitchToLastActive switches to the previously active tab (Alt+Tab style).
401-// When window-scoped (tabScope is set and previousActiveTabID provider is available),
402-// it uses the per-window previous active tab ID. Falls back to the global
403-// PreviousActiveTabID otherwise.
404-func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
419+// SwitchByIndex switches to a tab by 0-based index within the given target.
420+func (c *TabCoordinator) SwitchByIndex(ctx context.Context, target TabTarget, index int) error {
405421 log := logging.FromContext(ctx)
406422
407- var prevID entity.TabID
408- if c.tabScope != nil && c.previousActiveTabID != nil {
409- // Use per-window previous active tab ID when scoped
410- prevID = c.previousActiveTabID(c.mainWindow)
411- } else {
412- // Fall back to global PreviousActiveTabID when not scoped or no provider
413- prevID = c.tabs.PreviousActiveTabID
423+ if target.Tabs == nil {
424+ return fmt.Errorf("target.Tabs is nil")
425+ }
426+ tabs := target.Tabs.Tabs
427+ if index < 0 || index >= len(tabs) {
428+ log.Debug().Int("index", index).Int("count", len(tabs)).Msg("invalid tab index")
429+ return nil
414430 }
415431
416- if prevID == "" || prevID == c.tabs.ActiveTabID {
432+ return c.Switch(ctx, target, tabs[index].ID)
433+}
434+
435+// SwitchToLastActive switches to the previously active tab (Alt+Tab style)
436+// within the given target.
437+func (c *TabCoordinator) SwitchToLastActive(ctx context.Context, target TabTarget) error {
438+ if target.Tabs == nil {
417439 return nil
418440 }
419441
420- tab := c.tabs.Find(prevID)
421- if tab == nil {
422- // Only clear the global previous active tab ID if that's what we used
423- if c.tabScope == nil || c.previousActiveTabID == nil {
424- c.tabs.PreviousActiveTabID = ""
425- }
442+ prevID := target.Tabs.PreviousActiveTabID
443+ if prevID == "" || prevID == target.Tabs.ActiveTabID {
426444 return nil
427445 }
428446
429- // With a scope function, only allow switching to the previous tab if it's in scope.
430- if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {
431- log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope")
447+ tab := target.Tabs.Find(prevID)
448+ if tab == nil {
449+ target.Tabs.PreviousActiveTabID = ""
432450 return nil
433451 }
434452
435- return c.Switch(ctx, prevID)
453+ return c.Switch(ctx, target, prevID)
436454 }
437455
438-// UpdateBarVisibility shows or hides the tab bar based on tab count.
439-func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
456+// UpdateBarVisibility shows or hides the tab bar based on tab count in the target.
457+func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context, target TabTarget) {
440458 log := logging.FromContext(ctx)
441459
442- // Check if feature is enabled
443- hideEnabled := c.hideTabBarWhenSingleTab
444-
445- if !hideEnabled {
446- log.Debug().Msg("tab bar auto-hide disabled by config")
460+ if target.MainWindow == nil || target.MainWindow.TabBar() == nil {
461+ log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
447462 return
448463 }
449464
450- if c.mainWindow == nil || c.mainWindow.TabBar() == nil {
451- log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
465+ // Check if feature is enabled
466+ if !c.hideTabBarWhenSingleTab {
467+ log.Debug().Msg("tab bar auto-hide disabled by config")
468+ target.MainWindow.TabBar().SetAutoHidden(false)
469+ target.MainWindow.SetTabBarContentInsetVisible(true)
452470 return
453471 }
454472
455- tabCount := c.mainWindow.TabBar().Count()
473+ tabCount := target.MainWindow.TabBar().Count()
456474 shouldShow := tabCount > 1
457475
458476 log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
459- c.mainWindow.TabBar().SetVisible(shouldShow)
477+ target.MainWindow.TabBar().SetAutoHidden(!shouldShow)
478+ target.MainWindow.SetTabBarContentInsetVisible(shouldShow)
460479 }
461480
462-// GetTabBar returns the tab bar component.
481+// GetTabBar returns the tab bar component from the current main window.
463482 func (c *TabCoordinator) GetTabBar() *component.TabBar {
464483 if c.mainWindow != nil {
465484 return c.mainWindow.TabBar()
@@ -467,23 +486,23 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar {
467486 return nil
468487 }
469488
470-// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
471-func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
472- c.onAttachPopupToTab = fn
473-}
474-
475-// CreateWithPane creates a new tab with a pre-created pane and WebView.
489+// CreateWithPane creates a new tab with a pre-created pane and WebView in the given target.
476490 // This is used for tabbed popup behavior where the popup pane already exists.
477491 func (c *TabCoordinator) CreateWithPane(
478492 ctx context.Context,
493+ target TabTarget,
479494 pane *entity.Pane,
480495 wv port.WebView,
481496 initialURL string,
482497 ) (*entity.Tab, error) {
483498 log := logging.FromContext(ctx)
484499
500+ if target.Tabs == nil {
501+ return nil, fmt.Errorf("target.Tabs is nil")
502+ }
503+
485504 output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
486- TabList: c.tabs,
505+ TabList: target.Tabs,
487506 Name: pane.Title,
488507 Pane: pane,
489508 InitialURL: initialURL,
@@ -494,23 +513,21 @@ func (c *TabCoordinator) CreateWithPane(
494513 }
495514
496515 // Set new tab as active
497- c.tabs.SetActive(output.Tab.ID)
516+ target.Tabs.SetActive(output.Tab.ID)
498517
499518 // Notify app before adding tab to the visible tab bar.
500- // The app assigns window ownership and the scoped default name here,
501- // and TabBar.AddTab reads tab.Title() immediately when creating the button.
502519 if c.onTabCreated != nil {
503- c.onTabCreated(ctx, output.Tab)
520+ c.onTabCreated(ctx, target, output.Tab)
504521 }
505522
506523 // Update tab bar
507- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {
508- c.mainWindow.TabBar().AddTab(output.Tab)
509- c.mainWindow.TabBar().SetActive(output.Tab.ID)
524+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
525+ target.MainWindow.TabBar().AddTab(output.Tab)
526+ target.MainWindow.TabBar().SetActive(output.Tab.ID)
510527 }
511528
512529 // Update tab bar visibility
513- c.UpdateBarVisibility(ctx)
530+ c.UpdateBarVisibility(ctx, target)
514531
515532 // Attach the popup WebView to the new tab's workspace
516533 if c.onAttachPopupToTab != nil {
@@ -519,7 +536,7 @@ func (c *TabCoordinator) CreateWithPane(
519536
520537 // Switch to the new tab's workspace view
521538 if c.onTabSwitched != nil {
522- c.onTabSwitched(ctx, output.Tab)
539+ c.onTabSwitched(ctx, target, output.Tab)
523540 }
524541
525542 // Notify state change for session snapshots
A internal/ui/coordinator/tab_test.go +273 -0
@@ -0,0 +1,273 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "strconv"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/window"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally verifies that
16+// closing the only tab in one window fires onCurrentWindowEmpty and does NOT
17+// call onQuit when another browser window still has tabs.
18+func TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally(t *testing.T) {
19+ ctx := context.Background()
20+
21+ // Two independent TabLists, each with one tab.
22+ firstTabs := entity.NewTabList()
23+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
24+
25+ secondTabs := entity.NewTabList()
26+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))
27+
28+ gen := counterIDGen()
29+ tabsUC := usecase.NewManageTabsUseCase(gen)
30+
31+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
32+ TabsUC: tabsUC,
33+ })
34+
35+ firstMainWindow := &window.MainWindow{}
36+ secondMainWindow := &window.MainWindow{}
37+ firstTarget := TabTarget{Tabs: firstTabs, MainWindow: firstMainWindow}
38+ secondTarget := TabTarget{Tabs: secondTabs, MainWindow: secondMainWindow}
39+
40+ var (
41+ quitCalled bool
42+ windowEmptyCalled bool
43+ windowEmptyTarget *window.MainWindow
44+ )
45+
46+ coord.SetOnQuit(func() {
47+ quitCalled = true
48+ })
49+ coord.SetOnCurrentWindowEmpty(func(_ context.Context, target TabTarget) {
50+ windowEmptyCalled = true
51+ windowEmptyTarget = target.MainWindow
52+ })
53+
54+ // Close the only tab in the first window.
55+ err := coord.Close(ctx, firstTarget)
56+ require.NoError(t, err, "Close on first target returned error")
57+
58+ // onQuit must NOT be called (second window still has tabs).
59+ assert.False(t, quitCalled, "onQuit was called, but second window still has tabs")
60+
61+ // onCurrentWindowEmpty must be called for the first window.
62+ assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the emptied window")
63+ assert.Same(t, firstMainWindow, windowEmptyTarget)
64+
65+ // firstTabs must be empty.
66+ assert.Equal(t, 0, firstTabs.Count())
67+
68+ // secondTabs must be unchanged (still has its tab).
69+ assert.Equal(t, 1, secondTabs.Count())
70+ assert.Equal(t, entity.TabID("second-tab"), secondTabs.ActiveTabID)
71+
72+ // Closing the last tab globally (second window's only tab) should also fire window-empty.
73+ windowEmptyCalled = false
74+ windowEmptyTarget = nil
75+ err = coord.Close(ctx, secondTarget)
76+ require.NoError(t, err, "Close on second target returned error")
77+
78+ // onQuit must still NOT be called (App decides when to quit).
79+ assert.False(t, quitCalled, "onQuit was called on last window; onQuit is no longer used by Close")
80+
81+ // onCurrentWindowEmpty must be called for the second window.
82+ assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the last window")
83+ assert.Same(t, secondMainWindow, windowEmptyTarget)
84+}
85+
86+// counterIDGen returns a function that generates sequential IDs.
87+func counterIDGen() func() string {
88+ var counter int
89+ return func() string {
90+ counter++
91+ return strconv.Itoa(counter)
92+ }
93+}
94+
95+func TestTabCoordinator_SetMainWindowNilClearsCurrentTargetWindowPreservingTabs(t *testing.T) {
96+ tabs := entity.NewTabList()
97+ mainWindow := &window.MainWindow{}
98+ coord := NewTabCoordinator(context.Background(), TabCoordinatorConfig{
99+ Tabs: tabs,
100+ MainWindow: mainWindow,
101+ })
102+
103+ coord.SetMainWindow(nil)
104+
105+ got := coord.CurrentTarget()
106+ if got.MainWindow != nil {
107+ t.Fatalf("current target main window = %p, want nil", got.MainWindow)
108+ }
109+ if got.Tabs != tabs {
110+ t.Fatalf("current target tabs = %p, want %p", got.Tabs, tabs)
111+ }
112+}
113+
114+func TestTabCoordinator_SwitchByIndexUsesTargetTabListOnly(t *testing.T) {
115+ ctx := context.Background()
116+
117+ // Create two independent TabLists, each with two tabs.
118+ firstTabs := entity.NewTabList()
119+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab-1"), entity.WorkspaceID("ws-first-1"), entity.NewPane(entity.PaneID("pane-first-1"))))
120+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab-2"), entity.WorkspaceID("ws-first-2"), entity.NewPane(entity.PaneID("pane-first-2"))))
121+
122+ secondTabs := entity.NewTabList()
123+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
124+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
125+
126+ gen := counterIDGen()
127+ tabsUC := usecase.NewManageTabsUseCase(gen)
128+
129+ // Create TabCoordinator without global tabs (target-driven).
130+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
131+ TabsUC: tabsUC,
132+ // No Tabs, no MainWindow — target is provided per-call.
133+ })
134+
135+ firstTarget := TabTarget{Tabs: firstTabs}
136+ secondTarget := TabTarget{Tabs: secondTabs}
137+
138+ // Confirm initial active tab IDs are as expected (first tab in each list).
139+ if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
140+ t.Fatalf("firstTabs.ActiveTabID = %q, want %q", got, "first-tab-1")
141+ }
142+ if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-1") {
143+ t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-1")
144+ }
145+
146+ // Switch second window to its second tab (index 1).
147+ err := coord.SwitchByIndex(ctx, secondTarget, 1)
148+ if err != nil {
149+ t.Fatalf("SwitchByIndex on second target failed: %v", err)
150+ }
151+
152+ _ = firstTarget // first target is deliberately not passed to SwitchByIndex below
153+
154+ // Assert firstTabs.ActiveTabID is unchanged.
155+ if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
156+ t.Fatalf("firstTabs.ActiveTabID = %q, want %q (should be unchanged)", got, "first-tab-1")
157+ }
158+
159+ if got := secondTabs.PreviousActiveTabID; got != entity.TabID("second-tab-1") {
160+ t.Fatalf("secondTabs.PreviousActiveTabID = %q, want %q", got, "second-tab-1")
161+ }
162+
163+ // Assert secondTabs.ActiveTabID is now the second window's tab 2.
164+ if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-2") {
165+ t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-2")
166+ }
167+}
168+
169+func TestTabCoordinator_CreateAndSwitchCallbacksCarryTarget(t *testing.T) {
170+ ctx := context.Background()
171+
172+ firstTabs := entity.NewTabList()
173+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
174+ secondTabs := entity.NewTabList()
175+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
176+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
177+
178+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
179+ coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})
180+
181+ secondTarget := TabTarget{Tabs: secondTabs}
182+ var createdTarget TabTarget
183+ var switchedTargets []TabTarget
184+ coord.SetOnTabCreated(func(_ context.Context, target TabTarget, tab *entity.Tab) {
185+ createdTarget = target
186+ })
187+ coord.SetOnTabSwitched(func(_ context.Context, target TabTarget, tab *entity.Tab) {
188+ switchedTargets = append(switchedTargets, target)
189+ })
190+
191+ if _, err := coord.Create(ctx, secondTarget, "https://example.com"); err != nil {
192+ t.Fatalf("Create returned error: %v", err)
193+ }
194+ if createdTarget.Tabs != secondTabs {
195+ t.Fatalf("created callback target tabs = %p, want %p", createdTarget.Tabs, secondTabs)
196+ }
197+ if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
198+ t.Fatalf("create switch callback targets = %#v, want second target", switchedTargets)
199+ }
200+
201+ switchedTargets = nil
202+ if err := coord.Switch(ctx, secondTarget, entity.TabID("second-tab-1")); err != nil {
203+ t.Fatalf("Switch returned error: %v", err)
204+ }
205+ if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
206+ t.Fatalf("switch callback targets = %#v, want second target", switchedTargets)
207+ }
208+ if firstTabs.ActiveTabID != entity.TabID("first-tab") {
209+ t.Fatalf("first target active tab changed to %q", firstTabs.ActiveTabID)
210+ }
211+}
212+
213+// TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs verifies that
214+// SwitchByIndexCreating creates new tabs when the requested index exceeds
215+// the current tab count, and only modifies the specified target.
216+func TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs(t *testing.T) {
217+ ctx := context.Background()
218+
219+ firstTabs := entity.NewTabList()
220+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
221+
222+ secondTabs := entity.NewTabList()
223+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
224+
225+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
226+
227+ secondTarget := TabTarget{Tabs: secondTabs}
228+
229+ // Call SwitchByIndexCreating with index 3 (0-based), which is beyond the
230+ // current 1 tab. This should create 3 new tabs (indices 1, 2, 3) and
231+ // activate index 3.
232+ err := coord.SwitchByIndexCreating(ctx, secondTarget, 3, "about:blank")
233+ require.NoError(t, err, "SwitchByIndexCreating should not return error")
234+
235+ // secondTabs should have 4 tabs now (1 existing + 3 created).
236+ require.Equal(t, 4, secondTabs.Count(), "second target should have 4 tabs after creating up to index 3")
237+ require.Greater(t, len(secondTabs.Tabs), 3, "secondTabs.Tabs must have at least 4 elements")
238+ assert.Equal(t, secondTabs.Tabs[3].ID, secondTabs.ActiveTabID, "active tab should be at index 3")
239+
240+ // Verify created tabs/panes have URI "about:blank".
241+ for i := 1; i <= 3; i++ {
242+ require.Less(t, i, len(secondTabs.Tabs), "tab index %d out of range", i)
243+ require.NotNil(t, secondTabs.Tabs[i].Workspace, "tab %d workspace should not be nil", i)
244+ pane := secondTabs.Tabs[i].Workspace.ActivePane()
245+ require.NotNil(t, pane, "tab %d ActivePane should not be nil", i)
246+ require.NotNil(t, pane.Pane, "tab %d pane.Pane should not be nil", i)
247+ assert.Equal(t, "about:blank", pane.Pane.URI, "tab %d pane URI should be about:blank", i)
248+ }
249+
250+ // firstTabs must be completely unchanged.
251+ assert.Equal(t, 1, firstTabs.Count(), "first target should still have 1 tab")
252+ assert.Equal(t, entity.TabID("first-tab"), firstTabs.ActiveTabID, "first target active tab should be unchanged")
253+}
254+
255+// TestTabCoordinator_SwitchByIndexCreatingMissingURLError verifies that
256+// SwitchByIndexCreating returns an error when the initialURL is empty.
257+func TestTabCoordinator_SwitchByIndexCreatingMissingURLError(t *testing.T) {
258+ ctx := context.Background()
259+
260+ targetTabs := entity.NewTabList()
261+ targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))
262+
263+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
264+
265+ target := TabTarget{Tabs: targetTabs}
266+
267+ // Call with empty URL — should return an error.
268+ err := coord.SwitchByIndexCreating(ctx, target, 1, "")
269+ require.Error(t, err, "SwitchByIndexCreating with empty URL should return error")
270+
271+ // Tab count should remain 1.
272+ assert.Equal(t, 1, targetTabs.Count(), "tab count should remain 1 after error")
273+}
M internal/ui/deps.go +2 -2
@@ -77,9 +77,9 @@ type Dependencies struct {
7777 SessionRepo repository.SessionRepository
7878 CurrentSessionID entity.SessionID
7979 SnapshotUC *usecase.SnapshotSessionUseCase
80- // SnapshotServiceFactory creates a snapshot service bound to the given tab-list provider.
80+ // SnapshotServiceFactory creates a snapshot service bound to the given window state provider.
8181 // Called after the App is initialized so the App can serve as the provider.
82- SnapshotServiceFactory func(provider port.TabListProvider, intervalMs int) port.SnapshotService
82+ SnapshotServiceFactory func(provider port.WindowStateProvider, intervalMs int) port.SnapshotService
8383 // SessionSpawner spawns a new dumber instance for session restoration.
8484 SessionSpawner port.SessionSpawner
8585 // FileSystem provides file operations (e.g., for download deduplication).
M internal/ui/dispatcher/keyboard.go +96 -27
@@ -4,9 +4,9 @@ import (
44 "context"
55 "fmt"
66
7+ "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/bnema/dumber/internal/domain/entity"
9- domainurl "github.com/bnema/dumber/internal/domain/url"
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/bnema/dumber/internal/ui/component"
1212 "github.com/bnema/dumber/internal/ui/coordinator"
@@ -20,14 +20,23 @@ const (
2020 configSystemViewURL = "dumb://config"
2121 )
2222
23+type KeyboardActions struct {
24+ NewTab func(context.Context) error
25+ CloseTab func(context.Context) error
26+ NextTab func(context.Context) error
27+ PreviousTab func(context.Context) error
28+ SwitchLastTab func(context.Context) error
29+ SwitchTabIndex func(context.Context, int) error
30+ ActiveWebView func(context.Context) port.WebView
31+}
32+
2333 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
2434 type KeyboardDispatcher struct {
25- tabCoord *coordinator.TabCoordinator
35+ actions KeyboardActions
2636 wsCoord *coordinator.WorkspaceCoordinator
2737 navCoord *coordinator.NavigationCoordinator
2838 zoomUC *usecase.ManageZoomUseCase
2939 copyURLUC *usecase.CopyURLUseCase
30- newPaneURL string
3140 actionHandlers map[input.Action]func(ctx context.Context) error
3241 onQuit func()
3342 onFindOpen func(ctx context.Context) error
@@ -45,24 +54,22 @@ type KeyboardDispatcher struct {
4554 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
4655 func NewKeyboardDispatcher(
4756 ctx context.Context,
48- tabCoord *coordinator.TabCoordinator,
4957 wsCoord *coordinator.WorkspaceCoordinator,
5058 navCoord *coordinator.NavigationCoordinator,
5159 zoomUC *usecase.ManageZoomUseCase,
5260 copyURLUC *usecase.CopyURLUseCase,
53- newPaneURL string,
61+ actions KeyboardActions,
5462 activePaneID func(context.Context) entity.PaneID,
5563 ) *KeyboardDispatcher {
5664 log := logging.FromContext(ctx)
5765 log.Debug().Msg("creating keyboard dispatcher")
5866
5967 dispatcher := &KeyboardDispatcher{
60- tabCoord: tabCoord,
68+ actions: actions,
6169 wsCoord: wsCoord,
6270 navCoord: navCoord,
6371 zoomUC: zoomUC,
6472 copyURLUC: copyURLUC,
65- newPaneURL: newPaneURL,
6673 activePaneID: activePaneID,
6774 }
6875 dispatcher.initActionHandlers()
@@ -140,18 +147,15 @@ func (d *KeyboardDispatcher) initActionHandlers() {
140147 )
141148 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
142149 // Tab actions
143- input.ActionNewTab: func(ctx context.Context) error {
144- if d.newPaneURL == "" {
145- logging.FromContext(ctx).Warn().Msg("ActionNewTab: newPaneURL is empty, cannot open new tab")
146- return fmt.Errorf("newPaneURL is not configured")
147- }
148- _, err := d.tabCoord.Create(ctx, domainurl.Normalize(d.newPaneURL))
149- return err
150+ input.ActionNewTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
151+ input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
152+ input.ActionNextTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
153+ input.ActionPreviousTab: func(ctx context.Context) error {
154+ return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
155+ },
156+ input.ActionSwitchLastTab: func(ctx context.Context) error {
157+ return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
150158 },
151- input.ActionCloseTab: d.tabCoord.Close,
152- input.ActionNextTab: d.tabCoord.SwitchNext,
153- input.ActionPreviousTab: d.tabCoord.SwitchPrev,
154- input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive,
155159 input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
156160 input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
157161 input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
@@ -209,11 +213,11 @@ func (d *KeyboardDispatcher) initActionHandlers() {
209213 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
210214 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
211215 // Navigation
212- input.ActionGoBack: d.navCoord.GoBack,
213- input.ActionGoForward: d.navCoord.GoForward,
214- input.ActionReload: d.navCoord.Reload,
215- input.ActionHardReload: d.navCoord.HardReload,
216- input.ActionPrintPage: d.navCoord.PrintPage,
216+ input.ActionGoBack: d.handleGoBack,
217+ input.ActionGoForward: d.handleGoForward,
218+ input.ActionReload: d.handleReload,
219+ input.ActionHardReload: d.handleHardReload,
220+ input.ActionPrintPage: d.handlePrintPage,
217221 // Zoom actions
218222 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
219223 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
@@ -224,7 +228,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
224228 input.ActionFindNext: d.handleFindNext,
225229 input.ActionFindPrev: d.handleFindPrev,
226230 input.ActionCloseFind: d.handleFindClose,
227- input.ActionOpenDevTools: d.navCoord.OpenDevTools,
231+ input.ActionOpenDevTools: d.handleOpenDevTools,
228232 input.ActionToggleFloatingPane: func(ctx context.Context) error {
229233 if d.onToggleFloating != nil {
230234 return d.onToggleFloating(ctx)
@@ -330,7 +334,27 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error
330334 }
331335
332336 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
333- return d.tabCoord.EnsureTabByIndex(ctx, index, domainurl.Normalize(d.newPaneURL))
337+ if d.actions.SwitchTabIndex == nil {
338+ logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
339+ return nil
340+ }
341+ return d.actions.SwitchTabIndex(ctx, index)
342+}
343+
344+func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
345+ if fn == nil {
346+ logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
347+ return nil
348+ }
349+ return fn(ctx)
350+}
351+
352+func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
353+ if d.actions.ActiveWebView == nil {
354+ logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
355+ return nil
356+ }
357+ return d.actions.ActiveWebView(ctx)
334358 }
335359
336360 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
@@ -351,6 +375,51 @@ func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
351375 return nil
352376 }
353377
378+func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
379+ wv := d.activeWebView(ctx)
380+ if wv == nil {
381+ logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
382+ return nil
383+ }
384+ return fn(wv)
385+}
386+
387+func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
388+ return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
389+ return d.navCoord.ReloadWebView(ctx, wv, false)
390+ })
391+}
392+
393+func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
394+ return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
395+ return d.navCoord.ReloadWebView(ctx, wv, true)
396+ })
397+}
398+
399+func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
400+ return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
401+ return d.navCoord.GoBackWebView(ctx, wv)
402+ })
403+}
404+
405+func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
406+ return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
407+ return d.navCoord.GoForwardWebView(ctx, wv)
408+ })
409+}
410+
411+func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
412+ return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
413+ return d.navCoord.PrintWebView(ctx, wv)
414+ })
415+}
416+
417+func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
418+ return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
419+ return d.navCoord.OpenDevToolsWebView(ctx, wv)
420+ })
421+}
422+
354423 // handleZoom processes zoom in/out/reset actions for the active WebView.
355424 func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
356425 log := logging.FromContext(ctx)
@@ -360,7 +429,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
360429 return nil
361430 }
362431
363- wv := d.navCoord.ActiveWebView(ctx)
432+ wv := d.activeWebView(ctx)
364433 if wv == nil {
365434 log.Debug().Msg("no active webview for zoom")
366435 return nil
@@ -424,7 +493,7 @@ func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
424493 return nil
425494 }
426495
427- wv := d.navCoord.ActiveWebView(ctx)
496+ wv := d.activeWebView(ctx)
428497 if wv == nil {
429498 log.Debug().Msg("no active webview for copy URL")
430499 return nil
M internal/ui/dispatcher/keyboard_test.go +50 -101
@@ -6,7 +6,6 @@ import (
66
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
9- domainurl "github.com/bnema/dumber/internal/domain/url"
109 "github.com/bnema/dumber/internal/ui/component"
1110 "github.com/bnema/dumber/internal/ui/coordinator"
1211 "github.com/bnema/dumber/internal/ui/input"
@@ -14,105 +13,55 @@ import (
1413 "github.com/stretchr/testify/require"
1514 )
1615
17-func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *testing.T) {
16+func TestKeyboardDispatcher_TabActionsUseInjectedKeyboardActions(t *testing.T) {
1817 ctx := context.Background()
19- ids := []string{
20- "tab-1", "ws-1", "pane-1",
21- "tab-2", "ws-2", "pane-2",
22- "tab-3", "ws-3", "pane-3",
23- }
24- idx := 0
25- tabsUC := usecase.NewManageTabsUseCase(func() string {
26- id := ids[idx]
27- idx++
28- return id
29- })
30- tabs := entity.NewTabList()
31- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
32- TabsUC: tabsUC,
33- Tabs: tabs,
34- })
35-
36- _, err := tabCoord.Create(ctx, "https://initial.example")
37- require.NoError(t, err)
38-
39- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" })
40-
41- err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)
42- require.NoError(t, err)
43-
44- require.Len(t, tabs.Tabs, 3)
45- require.NotNil(t, tabs.ActiveTab())
46- assert.Equal(t, tabs.TabAt(2).ID, tabs.ActiveTabID)
47- assert.Equal(t, tabs.TabAt(0).ID, tabs.PreviousActiveTabID)
48- assert.Equal(t, 2, tabs.ActiveTab().Position)
49- assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
50- assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(2).Workspace.Root.Pane.URI)
51-
52- err = tabCoord.SwitchToLastActive(ctx)
53- require.NoError(t, err)
54- assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
55-}
56-
57-func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *testing.T) {
58- ctx := context.Background()
59- ids := []string{
60- "tab-1", "ws-1", "pane-1",
61- "tab-2", "ws-2", "pane-2",
62- }
63- idx := 0
64- tabsUC := usecase.NewManageTabsUseCase(func() string {
65- id := ids[idx]
66- idx++
67- return id
68- })
69- tabs := entity.NewTabList()
70- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
71- TabsUC: tabsUC,
72- Tabs: tabs,
73- })
74-
75- _, err := tabCoord.Create(ctx, "https://first.example")
76- require.NoError(t, err)
77- _, err = tabCoord.Create(ctx, "https://second.example")
78- require.NoError(t, err)
79-
80- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
81-
82- err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
83- require.NoError(t, err)
84-
85- require.Len(t, tabs.Tabs, 2)
86- assert.Equal(t, tabs.TabAt(1).ID, tabs.ActiveTabID)
87- assert.Equal(t, domainurl.Normalize("https://second.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
88-}
89-
90-func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithoutNewPaneURL(t *testing.T) {
91- ctx := context.Background()
92- ids := []string{
93- "tab-1", "ws-1", "pane-1",
94- }
95- idx := 0
96- tabsUC := usecase.NewManageTabsUseCase(func() string {
97- id := ids[idx]
98- idx++
99- return id
100- })
101- tabs := entity.NewTabList()
102- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
103- TabsUC: tabsUC,
104- Tabs: tabs,
105- })
106-
107- _, err := tabCoord.Create(ctx, "https://first.example")
108- require.NoError(t, err)
109-
110- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
111-
112- err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
113- require.Error(t, err)
114- require.Len(t, tabs.Tabs, 1)
115- assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
18+ called := make([]string, 0)
19+ var switchedIndex int
20+ d := NewKeyboardDispatcher(
21+ ctx,
22+ &coordinator.WorkspaceCoordinator{},
23+ &coordinator.NavigationCoordinator{},
24+ nil,
25+ nil,
26+ KeyboardActions{
27+ NewTab: func(context.Context) error {
28+ called = append(called, "new")
29+ return nil
30+ },
31+ CloseTab: func(context.Context) error {
32+ called = append(called, "close")
33+ return nil
34+ },
35+ NextTab: func(context.Context) error {
36+ called = append(called, "next")
37+ return nil
38+ },
39+ PreviousTab: func(context.Context) error {
40+ called = append(called, "previous")
41+ return nil
42+ },
43+ SwitchLastTab: func(context.Context) error {
44+ called = append(called, "last")
45+ return nil
46+ },
47+ SwitchTabIndex: func(_ context.Context, index int) error {
48+ called = append(called, "index")
49+ switchedIndex = index
50+ return nil
51+ },
52+ },
53+ func(context.Context) entity.PaneID { return "" },
54+ )
55+
56+ require.NoError(t, d.Dispatch(ctx, input.ActionNewTab))
57+ require.NoError(t, d.Dispatch(ctx, input.ActionCloseTab))
58+ require.NoError(t, d.Dispatch(ctx, input.ActionNextTab))
59+ require.NoError(t, d.Dispatch(ctx, input.ActionPreviousTab))
60+ require.NoError(t, d.Dispatch(ctx, input.ActionSwitchLastTab))
61+ require.NoError(t, d.Dispatch(ctx, input.ActionSwitchTabIndex5))
62+
63+ assert.Equal(t, []string{"new", "close", "next", "previous", "last", "index"}, called)
64+ assert.Equal(t, 4, switchedIndex)
11665 }
11766
11867 func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {
@@ -135,7 +84,7 @@ func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T)
13584 },
13685 })
13786
138- d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
87+ d := NewKeyboardDispatcher(ctx, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
13988
14089 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
14190 require.NoError(t, err)
@@ -151,7 +100,7 @@ func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T)
151100 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
152101 ctx := context.Background()
153102 activePaneID := entity.PaneID("pane-1")
154- d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID {
103+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID {
155104 return activePaneID
156105 })
157106
M internal/ui/floating_pane_test.go +9 -3
@@ -365,13 +365,19 @@ func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspac
365365 tabID := entity.TabID("tab-1")
366366 workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
367367 tabs := entity.NewTabList()
368- tabs.Add(&entity.Tab{ID: tabID, Workspace: workspace})
368+ tab := &entity.Tab{ID: tabID, Workspace: workspace}
369+ tabs.Add(tab)
370+ tabs.SetActive(tabID)
371+ bw := &browserWindow{id: "window-1", tabs: tabs}
369372
370373 session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
371374
372375 app := &App{
373- tabs: tabs,
374- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
376+ tabs: tabs,
377+ browserWindows: map[string]*browserWindow{bw.id: bw},
378+ lastFocusedWindowID: bw.id,
379+ windowForTab: map[entity.TabID]*browserWindow{tabID: bw},
380+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
375381 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
376382 {tabID: tabID, sessionID: floatingSessionIDDefault}: session,
377383 },
M internal/ui/input/global_shortcuts.go +28 -1
@@ -201,6 +201,12 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
201201 Msg("stale global shortcut callback ignored")
202202 return true
203203 }
204+ if !h.isActiveWindowShortcutHandler() {
205+ log.Trace().
206+ Str("action", string(actionToDispatch)).
207+ Msg("inactive window global shortcut callback ignored")
208+ return false
209+ }
204210 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
205211 log.Trace().
206212 Str("action", string(actionToDispatch)).
@@ -388,6 +394,10 @@ func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
388394 return h == nil || generation != h.generation
389395 }
390396
397+func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
398+ return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
399+}
400+
391401 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
392402 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {
393403 return false
@@ -406,7 +416,24 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
406416
407417 func isRepeatedGlobalShortcutSuppressed(action Action) bool {
408418 switch action {
409- case ActionZoomReset, ActionClosePane, ActionCloseTab, ActionQuit, ActionOpenSessionManager:
419+ case ActionGoBack,
420+ ActionGoForward,
421+ ActionZoomReset,
422+ ActionClosePane,
423+ ActionCloseTab,
424+ ActionQuit,
425+ ActionOpenSessionManager,
426+ ActionSwitchTabIndex1,
427+ ActionSwitchTabIndex2,
428+ ActionSwitchTabIndex3,
429+ ActionSwitchTabIndex4,
430+ ActionSwitchTabIndex5,
431+ ActionSwitchTabIndex6,
432+ ActionSwitchTabIndex7,
433+ ActionSwitchTabIndex8,
434+ ActionSwitchTabIndex9,
435+ ActionSwitchTabIndex10,
436+ ActionSwitchLastTab:
410437 return true
411438 default:
412439 return false
M internal/ui/input/global_shortcuts_test.go +77 -1
@@ -20,7 +20,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
2020 }
2121 }
2222
23-func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {
23+func TestGlobalShortcutHandlerAllowsRepeatingFocusNavigationActions(t *testing.T) {
2424 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
2525 now := time.Unix(100, 0)
2626
@@ -32,6 +32,47 @@ func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {
3232 }
3333 }
3434
35+func TestGlobalShortcutHandlerSuppressesRepeatedBrowserNavigationActions(t *testing.T) {
36+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
37+ now := time.Unix(100, 0)
38+
39+ if h.suppressRepeatedShortcut(ActionGoBack, now) {
40+ t.Fatal("first go-back dispatch was suppressed")
41+ }
42+ if !h.suppressRepeatedShortcut(ActionGoBack, now.Add(time.Millisecond)) {
43+ t.Fatal("repeated go-back dispatch was not suppressed")
44+ }
45+}
46+
47+func TestGlobalShortcutHandlerSuppressesRepeatedTabIndexSwitchActions(t *testing.T) {
48+ actions := []Action{
49+ ActionSwitchTabIndex1,
50+ ActionSwitchTabIndex2,
51+ ActionSwitchTabIndex3,
52+ ActionSwitchTabIndex4,
53+ ActionSwitchTabIndex5,
54+ ActionSwitchTabIndex6,
55+ ActionSwitchTabIndex7,
56+ ActionSwitchTabIndex8,
57+ ActionSwitchTabIndex9,
58+ ActionSwitchTabIndex10,
59+ }
60+
61+ for _, action := range actions {
62+ t.Run(string(action), func(t *testing.T) {
63+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
64+ now := time.Unix(100, 0)
65+
66+ if h.suppressRepeatedShortcut(action, now) {
67+ t.Fatalf("first %s dispatch was suppressed", action)
68+ }
69+ if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
70+ t.Fatalf("repeated %s dispatch was not suppressed", action)
71+ }
72+ })
73+ }
74+}
75+
3576 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {
3677 h := &GlobalShortcutHandler{
3778 registered: make(map[KeyBinding]Action),
@@ -59,3 +100,38 @@ func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) {
59100 t.Fatal("old generation was not marked stale")
60101 }
61102 }
103+
104+func TestGlobalShortcutHandlerReturnsInactiveForNilOrDetached(t *testing.T) {
105+ var nilHandler *GlobalShortcutHandler
106+ if nilHandler.isActiveWindowShortcutHandler() {
107+ t.Fatal("nil handler should return inactive")
108+ }
109+ if (&GlobalShortcutHandler{}).isActiveWindowShortcutHandler() {
110+ t.Fatal("handler with nil window should return inactive")
111+ }
112+
113+ h := &GlobalShortcutHandler{
114+ registered: make(map[KeyBinding]Action),
115+ lastDispatchAt: make(map[Action]time.Time),
116+ }
117+ h.Detach()
118+ if h.isActiveWindowShortcutHandler() {
119+ t.Fatal("detached handler with nil controller should return inactive")
120+ }
121+}
122+
123+func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
124+ if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {
125+ t.Fatal("ActionSwitchLastTab should be suppressed")
126+ }
127+
128+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
129+ now := time.Unix(100, 0)
130+
131+ if h.suppressRepeatedShortcut(ActionSwitchLastTab, now) {
132+ t.Fatal("first ActionSwitchLastTab dispatch was suppressed")
133+ }
134+ if !h.suppressRepeatedShortcut(ActionSwitchLastTab, now.Add(time.Millisecond)) {
135+ t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
136+ }
137+}
M internal/ui/theme/css.go +6 -0
@@ -144,6 +144,12 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
144144 // Uses em units for scalable UI.
145145 func generateTabBarCSS(p Palette) string {
146146 return `/* Tab bar styling */
147+/* Content area inset for bottom-positioned tab bar — uses the same 2em token as
148+ .tab-bar min-height and remains static to avoid OSR vertical reallocations. */
149+.content-area-tabbar-inset-bottom {
150+ padding-bottom: 2em;
151+}
152+
147153 .tab-bar {
148154 background-color: var(--surface);
149155 border-top: 0.125em solid var(--border);
M internal/ui/theme/css_test.go +17 -0
@@ -185,6 +185,23 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi
185185 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
186186 }
187187
188+func TestGenerateCSS_ContentAreaTabBarInsetBottom(t *testing.T) {
189+ css := GenerateCSS(DefaultDarkPalette())
190+
191+ if !strings.Contains(css, ".content-area-tabbar-inset-bottom") {
192+ t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS")
193+ }
194+ if !strings.Contains(css, "padding-bottom: 2em;") {
195+ t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block")
196+ }
197+ if !strings.Contains(css, ".tab-bar") {
198+ t.Fatal("expected .tab-bar selector in generated CSS")
199+ }
200+ if !strings.Contains(css, "min-height: 2em;") {
201+ t.Fatal("expected min-height: 2em in .tab-bar block")
202+ }
203+}
204+
188205 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
189206 css := GenerateCSS(DefaultDarkPalette())
190207
M internal/ui/window/main_window.go +42 -5
@@ -104,13 +104,23 @@ func (mw *MainWindow) assembleLayout() {
104104 tabBarPos = mw.tabBarPosition
105105 }
106106
107+ mw.rootBox.Append(&mw.contentOverlay.Widget)
108+
109+ tabBarWidget := mw.tabBar.Widget()
110+ tabBarWidget.SetHalign(gtk.AlignFillValue)
111+ tabBarWidget.SetValign(gtk.AlignStartValue)
107112 if tabBarPos == "bottom" {
108- mw.rootBox.Append(&mw.contentOverlay.Widget)
109- mw.rootBox.Append(mw.tabBar.Widget())
110- } else {
111- mw.rootBox.Append(mw.tabBar.Widget())
112- mw.rootBox.Append(&mw.contentOverlay.Widget)
113+ tabBarWidget.SetValign(gtk.AlignEndValue)
114+ // Inset is NOT applied unconditionally. See SetTabBarContentInsetVisible
115+ // which is called when the bottom tab bar is actually visible.
113116 }
117+ mw.contentOverlay.AddOverlay(tabBarWidget)
118+ mw.contentOverlay.SetClipOverlay(tabBarWidget, false)
119+ // Keep the tab bar out of measurement so showing/hiding it never changes the
120+ // WebView content allocation; the content area reserves a stable inset for
121+ // bottom-position bars while the overlay remains non-measured to avoid
122+ // WebView allocation changes.
123+ mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
114124
115125 mw.logger.Debug().
116126 Str("tab_bar_position", tabBarPos).
@@ -191,6 +201,33 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191201 return mw.contentOverlay
192202 }
193203
204+// SetTabBarContentInsetVisible adds or removes the bottom tab bar inset CSS class
205+// on the content area. Only effective when tabBarPosition is "bottom".
206+// Avoids duplicate add/remove if already in the desired state.
207+func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
208+ if mw.tabBarPosition != "bottom" || mw.contentArea == nil {
209+ return
210+ }
211+ if visible {
212+ if !mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {
213+ mw.contentArea.AddCssClass("content-area-tabbar-inset-bottom")
214+ }
215+ } else {
216+ if mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {
217+ mw.contentArea.RemoveCssClass("content-area-tabbar-inset-bottom")
218+ }
219+ }
220+}
221+
222+// HasTabBarContentInset returns whether the bottom tab bar inset CSS class
223+// is currently applied to the content area.
224+func (mw *MainWindow) HasTabBarContentInset() bool {
225+ if mw.tabBarPosition != "bottom" || mw.contentArea == nil {
226+ return false
227+ }
228+ return mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom")
229+}
230+
194231 // AddOverlay adds a widget as an overlay above the content area.
195232 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
196233 if mw.contentOverlay != nil && widget != nil {

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