detached 4069a5d

Merge pull request #242 from bnema/fix/window-tab-navigation-isolation

Refactor browser tabs to be window-scoped
commit
4069a5d3bce0a52d35e89adfc087d665b1f8149a
author
bnema · · 2026-05-05 17:57 UTC
committer
GitHub · · 2026-05-05 17:57 UTC
merge of
6066b2a + 3183bf0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
36 files · +5623 -923
M .github/workflows/aur.yml +3 -1
@@ -81,7 +81,9 @@ jobs:
8181 'build() {' \
8282 ' cd dumber' \
8383 ' export CGO_ENABLED=0' \
84- ' export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"' \
84+ ' # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.' \
85+ ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \
86+ ' export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"' \
8587 ' make build' \
8688 '}' \
8789 '' \
M .mockery.yaml +1 -1
@@ -15,7 +15,7 @@ packages:
1515 DiffFormatter: {}
1616 SessionLogger: {}
1717 SessionSpawner: {}
18- TabListProvider: {}
18+ WindowStateProvider: {}
1919 WebView: {}
2020 WebViewFactory: {}
2121 WebViewPool: {}
M Makefile +9 -3
@@ -22,6 +22,12 @@ VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0
2222 COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
2323 BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
2424
25+# Go flags. NATIVE_GOFLAGS can add native-only hardening such as
26+# -buildmode=pie without breaking js/wasm systemviews builds.
27+GOFLAGS?=-mod=mod
28+NATIVE_GOFLAGS?=$(GOFLAGS)
29+WASM_GOFLAGS?=$(GOFLAGS)
30+
2531 # Linker flags
2632 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
2733
@@ -42,7 +48,7 @@ help: ## Show this help message
4248 build: build-systemviews ## Build the application (pure Go, no CGO)
4349 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4450 @mkdir -p $(DIST_DIR)
45- GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
51+ GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
4652 @rm -f $(DIST_DIR)/cef-helper
4753 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4854
@@ -56,14 +62,14 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
5662 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
5763 @mkdir -p assets/systemviews
5864 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
59- GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
65+ GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
6066 brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
6167 @echo "Systemviews build complete"
6268
6369 build-quick: ## Build quickly for backend development
6470 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
6571 @mkdir -p $(DIST_DIR)
66- GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
72+ GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
6773 @rm -f $(DIST_DIR)/cef-helper
6874 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
6975
M aur/dumber-browser-git/.SRCINFO +4 -3
@@ -1,12 +1,13 @@
11 pkgbase = dumber-browser-git
22 pkgdesc = A minimal keyboard-driven browser for tiling WMs (git version)
3- pkgver = 0.23.1.r0.g6c872d5
4- pkgrel = 1
3+ pkgver = 0.28.0.r367.g6066b2a7
4+ pkgrel = 2
55 url = https://github.com/bnema/dumber
66 arch = x86_64
77 license = MIT
8- makedepends = go>=1.25
8+ makedepends = go>=1.26
99 makedepends = git
10+ makedepends = brotli
1011 depends = gtk4
1112 depends = webkitgtk-6.0
1213 optdepends = gst-plugins-base: Base media codecs
M aur/dumber-browser-git/PKGBUILD +5 -3
@@ -1,7 +1,7 @@
11 # Maintainer: bnema <b at bnema dot dev>
22 pkgname=dumber-browser-git
3-pkgver=0.23.1.r0.g6c872d5
4-pkgrel=1
3+pkgver=0.28.0.r367.g6066b2a7
4+pkgrel=2
55 pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
@@ -36,7 +36,9 @@ pkgver() {
3636 build() {
3737 cd dumber
3838 export CGO_ENABLED=0
39- export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"
39+ # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.
40+ export GOFLAGS="-trimpath -mod=readonly -modcacherw"
41+ export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"
4042 make build
4143 }
4244
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 +132 -33
@@ -10675,13 +10675,13 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context
1067510675 return _c
1067610676 }
1067710677
10678-// NewMockTabListProvider creates a new instance of MockTabListProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10678+// 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.
1067910679 // The first argument is typically a *testing.T value.
10680-func NewMockTabListProvider(t interface {
10680+func NewMockWindowStateProvider(t interface {
1068110681 mock.TestingT
1068210682 Cleanup(func())
10683-}) *MockTabListProvider {
10684- mock := &MockTabListProvider{}
10683+}) *MockWindowStateProvider {
10684+ mock := &MockWindowStateProvider{}
1068510685 mock.Mock.Test(t)
1068610686
1068710687 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -10689,21 +10689,65 @@ func NewMockTabListProvider(t interface {
1068910689 return mock
1069010690 }
1069110691
10692-// MockTabListProvider is an autogenerated mock type for the TabListProvider type
10693-type MockTabListProvider struct {
10692+// MockWindowStateProvider is an autogenerated mock type for the WindowStateProvider type
10693+type MockWindowStateProvider struct {
1069410694 mock.Mock
1069510695 }
1069610696
10697-type MockTabListProvider_Expecter struct {
10697+type MockWindowStateProvider_Expecter struct {
1069810698 mock *mock.Mock
1069910699 }
1070010700
10701-func (_m *MockTabListProvider) EXPECT() *MockTabListProvider_Expecter {
10702- return &MockTabListProvider_Expecter{mock: &_m.Mock}
10701+func (_m *MockWindowStateProvider) EXPECT() *MockWindowStateProvider_Expecter {
10702+ return &MockWindowStateProvider_Expecter{mock: &_m.Mock}
1070310703 }
1070410704
10705-// GetSessionID provides a mock function for the type MockTabListProvider
10706-func (_mock *MockTabListProvider) GetSessionID() entity.SessionID {
10705+// GetActiveWindowIndex provides a mock function for the type MockWindowStateProvider
10706+func (_mock *MockWindowStateProvider) GetActiveWindowIndex() int {
10707+ ret := _mock.Called()
10708+
10709+ if len(ret) == 0 {
10710+ panic("no return value specified for GetActiveWindowIndex")
10711+ }
10712+
10713+ var r0 int
10714+ if returnFunc, ok := ret.Get(0).(func() int); ok {
10715+ r0 = returnFunc()
10716+ } else {
10717+ r0 = ret.Get(0).(int)
10718+ }
10719+ return r0
10720+}
10721+
10722+// MockWindowStateProvider_GetActiveWindowIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActiveWindowIndex'
10723+type MockWindowStateProvider_GetActiveWindowIndex_Call struct {
10724+ *mock.Call
10725+}
10726+
10727+// GetActiveWindowIndex is a helper method to define mock.On call
10728+func (_e *MockWindowStateProvider_Expecter) GetActiveWindowIndex() *MockWindowStateProvider_GetActiveWindowIndex_Call {
10729+ return &MockWindowStateProvider_GetActiveWindowIndex_Call{Call: _e.mock.On("GetActiveWindowIndex")}
10730+}
10731+
10732+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Run(run func()) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10733+ _c.Call.Run(func(args mock.Arguments) {
10734+ run()
10735+ })
10736+ return _c
10737+}
10738+
10739+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Return(n int) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10740+ _c.Call.Return(n)
10741+ return _c
10742+}
10743+
10744+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) RunAndReturn(run func() int) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10745+ _c.Call.Return(run)
10746+ return _c
10747+}
10748+
10749+// GetSessionID provides a mock function for the type MockWindowStateProvider
10750+func (_mock *MockWindowStateProvider) GetSessionID() entity.SessionID {
1070710751 ret := _mock.Called()
1070810752
1070910753 if len(ret) == 0 {
@@ -10719,75 +10763,130 @@ func (_mock *MockTabListProvider) GetSessionID() entity.SessionID {
1071910763 return r0
1072010764 }
1072110765
10722-// MockTabListProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID'
10723-type MockTabListProvider_GetSessionID_Call struct {
10766+// MockWindowStateProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID'
10767+type MockWindowStateProvider_GetSessionID_Call struct {
1072410768 *mock.Call
1072510769 }
1072610770
1072710771 // GetSessionID is a helper method to define mock.On call
10728-func (_e *MockTabListProvider_Expecter) GetSessionID() *MockTabListProvider_GetSessionID_Call {
10729- return &MockTabListProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")}
10772+func (_e *MockWindowStateProvider_Expecter) GetSessionID() *MockWindowStateProvider_GetSessionID_Call {
10773+ return &MockWindowStateProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")}
1073010774 }
1073110775
10732-func (_c *MockTabListProvider_GetSessionID_Call) Run(run func()) *MockTabListProvider_GetSessionID_Call {
10776+func (_c *MockWindowStateProvider_GetSessionID_Call) Run(run func()) *MockWindowStateProvider_GetSessionID_Call {
1073310777 _c.Call.Run(func(args mock.Arguments) {
1073410778 run()
1073510779 })
1073610780 return _c
1073710781 }
1073810782
10739-func (_c *MockTabListProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockTabListProvider_GetSessionID_Call {
10783+func (_c *MockWindowStateProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockWindowStateProvider_GetSessionID_Call {
1074010784 _c.Call.Return(sessionID)
1074110785 return _c
1074210786 }
1074310787
10744-func (_c *MockTabListProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockTabListProvider_GetSessionID_Call {
10788+func (_c *MockWindowStateProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockWindowStateProvider_GetSessionID_Call {
10789+ _c.Call.Return(run)
10790+ return _c
10791+}
10792+
10793+// GetWindowSnapshotState provides a mock function for the type MockWindowStateProvider
10794+func (_mock *MockWindowStateProvider) GetWindowSnapshotState() ([]entity.WindowTabListState, int) {
10795+ ret := _mock.Called()
10796+
10797+ if len(ret) == 0 {
10798+ panic("no return value specified for GetWindowSnapshotState")
10799+ }
10800+
10801+ var r0 []entity.WindowTabListState
10802+ var r1 int
10803+ if returnFunc, ok := ret.Get(0).(func() ([]entity.WindowTabListState, int)); ok {
10804+ return returnFunc()
10805+ }
10806+ if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok {
10807+ r0 = returnFunc()
10808+ } else {
10809+ if ret.Get(0) != nil {
10810+ r0 = ret.Get(0).([]entity.WindowTabListState)
10811+ }
10812+ }
10813+ if returnFunc, ok := ret.Get(1).(func() int); ok {
10814+ r1 = returnFunc()
10815+ } else {
10816+ r1 = ret.Get(1).(int)
10817+ }
10818+ return r0, r1
10819+}
10820+
10821+// MockWindowStateProvider_GetWindowSnapshotState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowSnapshotState'
10822+type MockWindowStateProvider_GetWindowSnapshotState_Call struct {
10823+ *mock.Call
10824+}
10825+
10826+// GetWindowSnapshotState is a helper method to define mock.On call
10827+func (_e *MockWindowStateProvider_Expecter) GetWindowSnapshotState() *MockWindowStateProvider_GetWindowSnapshotState_Call {
10828+ return &MockWindowStateProvider_GetWindowSnapshotState_Call{Call: _e.mock.On("GetWindowSnapshotState")}
10829+}
10830+
10831+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Run(run func()) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10832+ _c.Call.Run(func(args mock.Arguments) {
10833+ run()
10834+ })
10835+ return _c
10836+}
10837+
10838+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Return(windowTabListStates []entity.WindowTabListState, n int) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10839+ _c.Call.Return(windowTabListStates, n)
10840+ return _c
10841+}
10842+
10843+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) RunAndReturn(run func() ([]entity.WindowTabListState, int)) *MockWindowStateProvider_GetWindowSnapshotState_Call {
1074510844 _c.Call.Return(run)
1074610845 return _c
1074710846 }
1074810847
10749-// GetTabList provides a mock function for the type MockTabListProvider
10750-func (_mock *MockTabListProvider) GetTabList() *entity.TabList {
10848+// GetWindowTabLists provides a mock function for the type MockWindowStateProvider
10849+func (_mock *MockWindowStateProvider) GetWindowTabLists() []entity.WindowTabListState {
1075110850 ret := _mock.Called()
1075210851
1075310852 if len(ret) == 0 {
10754- panic("no return value specified for GetTabList")
10853+ panic("no return value specified for GetWindowTabLists")
1075510854 }
1075610855
10757- var r0 *entity.TabList
10758- if returnFunc, ok := ret.Get(0).(func() *entity.TabList); ok {
10856+ var r0 []entity.WindowTabListState
10857+ if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok {
1075910858 r0 = returnFunc()
1076010859 } else {
1076110860 if ret.Get(0) != nil {
10762- r0 = ret.Get(0).(*entity.TabList)
10861+ r0 = ret.Get(0).([]entity.WindowTabListState)
1076310862 }
1076410863 }
1076510864 return r0
1076610865 }
1076710866
10768-// MockTabListProvider_GetTabList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTabList'
10769-type MockTabListProvider_GetTabList_Call struct {
10867+// MockWindowStateProvider_GetWindowTabLists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowTabLists'
10868+type MockWindowStateProvider_GetWindowTabLists_Call struct {
1077010869 *mock.Call
1077110870 }
1077210871
10773-// GetTabList is a helper method to define mock.On call
10774-func (_e *MockTabListProvider_Expecter) GetTabList() *MockTabListProvider_GetTabList_Call {
10775- return &MockTabListProvider_GetTabList_Call{Call: _e.mock.On("GetTabList")}
10872+// GetWindowTabLists is a helper method to define mock.On call
10873+func (_e *MockWindowStateProvider_Expecter) GetWindowTabLists() *MockWindowStateProvider_GetWindowTabLists_Call {
10874+ return &MockWindowStateProvider_GetWindowTabLists_Call{Call: _e.mock.On("GetWindowTabLists")}
1077610875 }
1077710876
10778-func (_c *MockTabListProvider_GetTabList_Call) Run(run func()) *MockTabListProvider_GetTabList_Call {
10877+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Run(run func()) *MockWindowStateProvider_GetWindowTabLists_Call {
1077910878 _c.Call.Run(func(args mock.Arguments) {
1078010879 run()
1078110880 })
1078210881 return _c
1078310882 }
1078410883
10785-func (_c *MockTabListProvider_GetTabList_Call) Return(tabList *entity.TabList) *MockTabListProvider_GetTabList_Call {
10786- _c.Call.Return(tabList)
10884+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Return(windowTabListStates []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call {
10885+ _c.Call.Return(windowTabListStates)
1078710886 return _c
1078810887 }
1078910888
10790-func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.TabList) *MockTabListProvider_GetTabList_Call {
10889+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) RunAndReturn(run func() []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call {
1079110890 _c.Call.Return(run)
1079210891 return _c
1079310892 }
M internal/application/port/session_state.go +11 -5
@@ -6,11 +6,17 @@ import (
66 "github.com/bnema/dumber/internal/domain/entity"
77 )
88
9-// TabListProvider provides access to the current tab list state.
10-// Implemented by the UI layer to allow the snapshot service to read state.
11-type TabListProvider interface {
12- // GetTabList returns the current tab list state.
13- GetTabList() *entity.TabList
9+// WindowStateProvider provides window-scoped browser state for v2 snapshots.
10+// Implemented by the UI layer and consumed by the snapshot service.
11+type WindowStateProvider interface {
12+ // GetWindowSnapshotState returns the ordered window tab lists and active window index atomically.
13+ GetWindowSnapshotState() ([]entity.WindowTabListState, int)
14+ // GetWindowTabLists returns the ordered list of windows with their tab lists.
15+ // For consistent reads alongside GetActiveWindowIndex, prefer GetWindowSnapshotState.
16+ GetWindowTabLists() []entity.WindowTabListState
17+ // GetActiveWindowIndex returns the index of the currently focused window.
18+ // For consistent reads alongside GetWindowTabLists, prefer GetWindowSnapshotState.
19+ GetActiveWindowIndex() int
1420 // GetSessionID returns the current session ID.
1521 GetSessionID() entity.SessionID
1622 }
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 s.Version >= SessionStateVersion {
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 +387 -0
@@ -1,6 +1,7 @@
11 package entity_test
22
33 import (
4+ "encoding/json"
45 "sync/atomic"
56 "testing"
67 "time"
@@ -261,6 +262,59 @@ func TestTabList_ReplaceFrom(t *testing.T) {
261262 assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
262263 }
263264
265+func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) {
266+ parentPaneID := entity.PaneID("parent-pane")
267+ pane := entity.NewPane("pane-1")
268+ pane.URI = "https://original.example"
269+ pane.ParentPaneID = &parentPaneID
270+ tab := entity.NewTab("tab-1", "workspace-1", pane)
271+ tab.Name = "Original"
272+ tabs := entity.NewTabList()
273+ tabs.Add(tab)
274+
275+ snapshot := tabs.Snapshot()
276+
277+ tab.Position = 99
278+ tab.Name = "Mutated"
279+ tab.Workspace.Root.Pane.URI = "https://mutated.example"
280+ *tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent")
281+
282+ require.Len(t, snapshot.Tabs, 1)
283+ clonedTab := snapshot.Tabs[0]
284+ assert.NotSame(t, tab, clonedTab)
285+ assert.Equal(t, 0, clonedTab.Position)
286+ assert.Equal(t, "Original", clonedTab.Name)
287+ require.NotNil(t, clonedTab.Workspace)
288+ assert.NotSame(t, tab.Workspace, clonedTab.Workspace)
289+ require.NotNil(t, clonedTab.Workspace.Root)
290+ assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root)
291+ require.NotNil(t, clonedTab.Workspace.Root.Pane)
292+ assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane)
293+ assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI)
294+ require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID)
295+ assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID)
296+}
297+
298+func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) {
299+ pane := entity.NewPane("pane-1")
300+ pane.URI = "https://original.example"
301+ tab := entity.NewTab("tab-1", "workspace-1", pane)
302+ other := entity.NewTabList()
303+ other.Add(tab)
304+
305+ original := entity.NewTabList()
306+ original.ReplaceFrom(other)
307+
308+ tab.Name = "Mutated"
309+ tab.Workspace.Root.Pane.URI = "https://mutated.example"
310+
311+ restored := original.Find("tab-1")
312+ require.NotNil(t, restored)
313+ assert.NotSame(t, tab, restored)
314+ assert.Empty(t, restored.Name)
315+ assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI)
316+}
317+
264318 func TestTabList_ReplaceFrom_Nil(t *testing.T) {
265319 original := entity.NewTabList()
266320 pane := entity.NewPane("pane1")
@@ -380,6 +434,263 @@ func TestFindPaneAcrossTabs(t *testing.T) {
380434 assert.Nil(t, notFound)
381435 }
382436
437+// --- Window-scoped snapshot tests ---
438+
439+func TestSnapshotFromWindowTabLists_V2State(t *testing.T) {
440+ // Create two windows, each with tabs
441+ pane1_1 := entity.NewPane("p1_w1")
442+ pane1_1.URI = "https://window1.com"
443+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
444+ tab1_1.Name = "Window1 Tab1"
445+
446+ pane1_2 := entity.NewPane("p1_w2")
447+ pane1_2.URI = "https://window2.com"
448+ tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
449+ tab1_2.Name = "Window2 Tab1"
450+
451+ pane1_2b := entity.NewPane("p2_w2")
452+ pane1_2b.URI = "https://window2-tab2.com"
453+ tab1_2b := entity.NewTab("t2_w2", "ws2_w2", pane1_2b)
454+ tab1_2b.Name = "Window2 Tab2"
455+
456+ tabs1 := entity.NewTabList()
457+ tabs1.Add(tab1_1)
458+
459+ tabs2 := entity.NewTabList()
460+ tabs2.Add(tab1_2)
461+ tabs2.Add(tab1_2b)
462+ tabs2.SetActive("t2_w2")
463+
464+ windows := []entity.WindowTabListState{
465+ {WindowID: "w1", Tabs: tabs1},
466+ {WindowID: "w2", Tabs: tabs2},
467+ }
468+
469+ sessionID := entity.SessionID("test_v2")
470+ state := entity.SnapshotFromWindowTabLists(sessionID, windows, 1, time.Unix(123, 0))
471+
472+ require.NotNil(t, state)
473+ assert.Equal(t, 2, state.Version)
474+ assert.Equal(t, sessionID, state.SessionID)
475+
476+ // Windows field populated
477+ require.Len(t, state.Windows, 2)
478+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
479+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
480+
481+ // Active window index set
482+ assert.Equal(t, 1, state.ActiveWindowIndex)
483+
484+ // No legacy Tabs when using windows
485+ assert.Empty(t, state.Tabs)
486+
487+ // CountPanes across windows
488+ assert.Equal(t, 3, state.CountPanes())
489+}
490+
491+func TestWindowTabListsFromSnapshot_V2Restore(t *testing.T) {
492+ // Create a v2 snapshot from windows, then restore
493+ pane := entity.NewPane("p_w1_t1")
494+ pane.URI = "https://example.com"
495+ tab := entity.NewTab("t_w1_t1", "ws_w1", pane)
496+ tab.Name = "MyTab"
497+
498+ tabs := entity.NewTabList()
499+ tabs.Add(tab)
500+
501+ windows := []entity.WindowTabListState{
502+ {WindowID: "window-1", Tabs: tabs},
503+ }
504+
505+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
506+
507+ idGen := mockIDGenerator()
508+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
509+
510+ require.Len(t, restored, 1)
511+ assert.Equal(t, entity.WindowID("window-1"), restored[0].WindowID)
512+ require.NotNil(t, restored[0].Tabs)
513+ require.Len(t, restored[0].Tabs.Tabs, 1)
514+ assert.Equal(t, "MyTab", restored[0].Tabs.Tabs[0].Name)
515+ assert.Equal(t, "https://example.com", restored[0].Tabs.Tabs[0].Workspace.Root.Pane.URI)
516+}
517+
518+func TestWindowTabListsFromSnapshot_V1LegacySingleWindow(t *testing.T) {
519+ // Legacy v1 flat tabs should be restored as a single window with empty WindowID
520+ state := &entity.SessionState{
521+ Version: 1,
522+ SessionID: "old_sess",
523+ ActiveTabIndex: 1,
524+ Tabs: []entity.TabSnapshot{
525+ {
526+ ID: "tab1",
527+ Name: "First",
528+ Workspace: entity.WorkspaceSnapshot{
529+ Root: &entity.PaneNodeSnapshot{
530+ Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.com"},
531+ },
532+ },
533+ },
534+ {
535+ ID: "tab2",
536+ Name: "Second",
537+ Workspace: entity.WorkspaceSnapshot{
538+ Root: &entity.PaneNodeSnapshot{
539+ Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.com"},
540+ },
541+ },
542+ },
543+ },
544+ }
545+
546+ idGen := mockIDGenerator()
547+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
548+
549+ require.Len(t, restored, 1)
550+ // Legacy window gets empty WindowID
551+ assert.Equal(t, entity.WindowID(""), restored[0].WindowID)
552+ require.NotNil(t, restored[0].Tabs)
553+ require.Len(t, restored[0].Tabs.Tabs, 2)
554+ assert.Equal(t, "Second", restored[0].Tabs.Tabs[1].Name)
555+ // Active tab should be second (index 1)
556+ assert.Equal(t, restored[0].Tabs.Tabs[1].ID, restored[0].Tabs.ActiveTabID)
557+}
558+
559+func TestWindowTabListsFromSnapshot_NilState(t *testing.T) {
560+ idGen := mockIDGenerator()
561+ restored := entity.WindowTabListsFromSnapshot(nil, idGen)
562+ assert.Nil(t, restored)
563+}
564+
565+func TestWindowTabListsFromSnapshot_InvalidIndexesFallback(t *testing.T) {
566+ // Tab with active tab index out of range should fall back to first tab
567+ state := &entity.SessionState{
568+ Version: 1,
569+ ActiveTabIndex: 999, // beyond bounds
570+ Tabs: []entity.TabSnapshot{
571+ {
572+ ID: "tab_a",
573+ Name: "TabA",
574+ Workspace: entity.WorkspaceSnapshot{
575+ Root: &entity.PaneNodeSnapshot{
576+ Pane: &entity.PaneSnapshot{ID: "pa", URI: "https://a.com"},
577+ },
578+ },
579+ },
580+ },
581+ }
582+
583+ idGen := mockIDGenerator()
584+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
585+
586+ require.Len(t, restored, 1)
587+ require.NotNil(t, restored[0].Tabs)
588+ // Should have fallen back to first tab
589+ assert.Equal(t, restored[0].Tabs.Tabs[0].ID, restored[0].Tabs.ActiveTabID)
590+}
591+
592+func TestTabListFromSnapshot_ActiveWindowWithZeroTabsFallsBackToFirstOverallTab(t *testing.T) {
593+ // Regression: when the active window has zero tabs, the active tab should
594+ // be the first tab overall (window 0's tab), not the third window's tab.
595+ state := &entity.SessionState{
596+ Version: entity.SessionStateVersion,
597+ ActiveWindowIndex: 1,
598+ Windows: []entity.WindowSnapshot{
599+ {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
600+ {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
601+ }},
602+ {ID: "w2", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{}},
603+ {ID: "w3", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
604+ {Name: "Third", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://third.example"}}}},
605+ }},
606+ },
607+ }
608+
609+ tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
610+
611+ require.Len(t, tabs.Tabs, 2)
612+ assert.Equal(t, "First", tabs.ActiveTab().Name, "active tab should be the first overall tab, not the third window's tab")
613+}
614+
615+func TestTabListFromSnapshot_FlattensV2WindowSnapshots(t *testing.T) {
616+ state := &entity.SessionState{
617+ Version: entity.SessionStateVersion,
618+ ActiveWindowIndex: 1,
619+ Windows: []entity.WindowSnapshot{
620+ {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
621+ {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
622+ }},
623+ {ID: "w2", ActiveTabIndex: 1, Tabs: []entity.TabSnapshot{
624+ {Name: "Second", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.example"}}}},
625+ {Name: "Active", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://active.example"}}}},
626+ }},
627+ },
628+ }
629+
630+ tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
631+
632+ require.Len(t, tabs.Tabs, 3)
633+ assert.Equal(t, "Active", tabs.ActiveTab().Name)
634+}
635+
636+func TestWindowTabListsFromSnapshot_FutureV2VersionWithEmptyWindowsStaysEmpty(t *testing.T) {
637+ state := &entity.SessionState{Version: entity.SessionStateVersion + 1, Windows: []entity.WindowSnapshot{}}
638+
639+ restored := entity.WindowTabListsFromSnapshot(state, mockIDGenerator())
640+
641+ assert.Empty(t, restored)
642+}
643+
644+func TestSnapshotFromWindowTabLists_NilTabs(t *testing.T) {
645+ windows := []entity.WindowTabListState{
646+ {WindowID: "w1", Tabs: nil},
647+ }
648+
649+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
650+
651+ require.Len(t, state.Windows, 1)
652+ assert.Empty(t, state.Windows[0].Tabs)
653+ assert.Equal(t, 0, state.CountPanes())
654+}
655+
656+func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) {
657+ state := entity.SnapshotFromWindowTabLists("sess", nil, 0, time.Unix(123, 0))
658+
659+ require.NotNil(t, state)
660+ assert.Equal(t, 2, state.Version)
661+ assert.Empty(t, state.Windows)
662+ assert.Equal(t, 0, state.ActiveWindowIndex)
663+}
664+
665+func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) {
666+ state := &entity.SessionState{
667+ Version: entity.SessionStateVersion,
668+ Tabs: []entity.TabSnapshot{{
669+ Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}},
670+ }},
671+ }
672+
673+ assert.Equal(t, 0, state.CountPanes())
674+}
675+
676+func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {
677+ pane := entity.NewPane("p1")
678+ pane.URI = "https://x.com"
679+ tab := entity.NewTab("t1", "ws1", pane)
680+ tabs := entity.NewTabList()
681+ tabs.Add(tab)
682+
683+ windows := []entity.WindowTabListState{
684+ {WindowID: "w1", Tabs: tabs},
685+ }
686+
687+ // Active window index out of range
688+ state := entity.SnapshotFromWindowTabLists("sess", windows, 999, time.Unix(123, 0))
689+
690+ // Should clamp to 0
691+ assert.Equal(t, 0, state.ActiveWindowIndex)
692+}
693+
383694 func TestFindPaneInNestedStructure(t *testing.T) {
384695 // Create a tab with split panes
385696 pane1 := entity.NewPane("left_pane")
@@ -428,3 +739,79 @@ func TestFindPaneInNestedStructure(t *testing.T) {
428739 rightNodeAgain := tab.Workspace.FindPane("right_pane")
429740 assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430741 }
742+
743+func TestV2SnapshotJSON_OmitEmptyLegacyFields(t *testing.T) {
744+ // When using SnapshotFromWindowTabLists (v2 path), the JSON output must
745+ // NOT include top-level legacy "tabs" or "active_tab_index" fields.
746+ // They should be omitted by omitempty.
747+ pane := entity.NewPane(entity.PaneID("p1"))
748+ pane.URI = "https://example.com"
749+ tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
750+ tabs := entity.NewTabList()
751+ tabs.Add(tab)
752+
753+ windows := []entity.WindowTabListState{
754+ {WindowID: entity.WindowID("w1"), Tabs: tabs},
755+ }
756+
757+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
758+
759+ data, err := json.Marshal(state)
760+ require.NoError(t, err)
761+
762+ var raw map[string]any
763+ err = json.Unmarshal(data, &raw)
764+ require.NoError(t, err)
765+
766+ // v2 JSON must not contain top-level legacy fields.
767+ _, hasTabs := raw["tabs"]
768+ _, hasActiveTabIdx := raw["active_tab_index"]
769+ assert.False(t, hasTabs, "v2 JSON must not include legacy 'tabs' field")
770+ assert.False(t, hasActiveTabIdx, "v2 JSON must not include legacy 'active_tab_index' field")
771+
772+ // But must still have the v2 windows field.
773+ windowsRaw, ok := raw["windows"]
774+ require.True(t, ok, "v2 JSON must include 'windows' field")
775+ windowsArr, _ := windowsRaw.([]any)
776+ require.Len(t, windowsArr, 1)
777+}
778+
779+func TestV2SnapshotJSON_CanStillReadV1(t *testing.T) {
780+ // When reading a v1 JSON with the legacy fields, they must still be
781+ // populated correctly (omitempty is only for writes).
782+ v1JSON := `{
783+ "version": 1,
784+ "session_id": "old-sess",
785+ "tabs": [{
786+ "id": "tab1",
787+ "name": "MyTab",
788+ "position": 0,
789+ "is_pinned": false,
790+ "workspace": {
791+ "id": "ws1",
792+ "root": {
793+ "id": "node1",
794+ "pane": {"id": "p1", "uri": "https://example.com", "title": "", "zoom_factor": 0},
795+ "split_dir": 0,
796+ "split_ratio": 0,
797+ "is_stacked": false,
798+ "active_stack_index": 0
799+ },
800+ "active_pane_id": ""
801+ }
802+ }],
803+ "active_tab_index": 0,
804+ "saved_at": "2026-01-01T00:00:00Z"
805+ }`
806+
807+ var state entity.SessionState
808+ err := json.Unmarshal([]byte(v1JSON), &state)
809+ require.NoError(t, err)
810+
811+ assert.Equal(t, 1, state.Version)
812+ assert.Equal(t, entity.SessionID("old-sess"), state.SessionID)
813+ require.Len(t, state.Tabs, 1)
814+ assert.Equal(t, entity.TabID("tab1"), state.Tabs[0].ID)
815+ assert.Equal(t, "MyTab", state.Tabs[0].Name)
816+ assert.Equal(t, 0, state.ActiveTabIndex)
817+}
M internal/domain/entity/tab.go +109 -5
@@ -2,6 +2,7 @@ package entity
22
33 import (
44 "fmt"
5+ "sync"
56 "time"
67 )
78
@@ -51,6 +52,8 @@ type TabList struct {
5152 Tabs []*Tab
5253 ActiveTabID TabID
5354 PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching
55+
56+ mu sync.RWMutex
5457 }
5558
5659 // NewTabList creates an empty tab list.
@@ -62,6 +65,9 @@ func NewTabList() *TabList {
6265
6366 // Add appends a tab to the list.
6467 func (tl *TabList) Add(tab *Tab) {
68+ tl.mu.Lock()
69+ defer tl.mu.Unlock()
70+
6571 tab.Position = len(tl.Tabs)
6672 tl.Tabs = append(tl.Tabs, tab)
6773 if tl.ActiveTabID == "" {
@@ -71,6 +77,9 @@ func (tl *TabList) Add(tab *Tab) {
7177
7278 // Remove removes a tab by ID and reindexes positions.
7379 func (tl *TabList) Remove(id TabID) bool {
80+ tl.mu.Lock()
81+ defer tl.mu.Unlock()
82+
7483 for i, tab := range tl.Tabs {
7584 if tab.ID == id {
7685 tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...)
@@ -99,6 +108,13 @@ func (tl *TabList) Remove(id TabID) bool {
99108
100109 // Find returns a tab by ID.
101110 func (tl *TabList) Find(id TabID) *Tab {
111+ tl.mu.RLock()
112+ defer tl.mu.RUnlock()
113+
114+ return tl.findNoLock(id)
115+}
116+
117+func (tl *TabList) findNoLock(id TabID) *Tab {
102118 for _, tab := range tl.Tabs {
103119 if tab.ID == id {
104120 return tab
@@ -109,16 +125,25 @@ func (tl *TabList) Find(id TabID) *Tab {
109125
110126 // ActiveTab returns the currently active tab.
111127 func (tl *TabList) ActiveTab() *Tab {
112- return tl.Find(tl.ActiveTabID)
128+ tl.mu.RLock()
129+ defer tl.mu.RUnlock()
130+
131+ return tl.findNoLock(tl.ActiveTabID)
113132 }
114133
115134 // Count returns the number of tabs.
116135 func (tl *TabList) Count() int {
136+ tl.mu.RLock()
137+ defer tl.mu.RUnlock()
138+
117139 return len(tl.Tabs)
118140 }
119141
120142 // SetActive sets the active tab and updates the previous active tab.
121143 func (tl *TabList) SetActive(id TabID) {
144+ tl.mu.Lock()
145+ defer tl.mu.Unlock()
146+
122147 if id != tl.ActiveTabID && tl.ActiveTabID != "" {
123148 tl.PreviousActiveTabID = tl.ActiveTabID
124149 }
@@ -127,6 +152,9 @@ func (tl *TabList) SetActive(id TabID) {
127152
128153 // TabAt returns the tab at the given 0-based index.
129154 func (tl *TabList) TabAt(index int) *Tab {
155+ tl.mu.RLock()
156+ defer tl.mu.RUnlock()
157+
130158 if index < 0 || index >= len(tl.Tabs) {
131159 return nil
132160 }
@@ -135,6 +163,9 @@ func (tl *TabList) TabAt(index int) *Tab {
135163
136164 // Move moves a tab to a new position.
137165 func (tl *TabList) Move(id TabID, newPos int) bool {
166+ tl.mu.Lock()
167+ defer tl.mu.Unlock()
168+
138169 if newPos < 0 || newPos >= len(tl.Tabs) {
139170 return false
140171 }
@@ -161,16 +192,89 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
161192 return true
162193 }
163194
195+// Snapshot returns an isolated copy of the TabList safe for concurrent read.
196+// The returned TabList has its own slice, Tab values, and workspace tree.
197+func (tl *TabList) Snapshot() *TabList {
198+ if tl == nil {
199+ return nil
200+ }
201+ tl.mu.RLock()
202+ defer tl.mu.RUnlock()
203+
204+ tabs := make([]*Tab, 0, len(tl.Tabs))
205+ for _, tab := range tl.Tabs {
206+ tabs = append(tabs, cloneTab(tab))
207+ }
208+ return &TabList{
209+ Tabs: tabs,
210+ ActiveTabID: tl.ActiveTabID,
211+ PreviousActiveTabID: tl.PreviousActiveTabID,
212+ }
213+}
214+
215+func cloneTab(tab *Tab) *Tab {
216+ if tab == nil {
217+ return nil
218+ }
219+ cloned := *tab
220+ cloned.Workspace = cloneWorkspace(tab.Workspace)
221+ return &cloned
222+}
223+
224+func cloneWorkspace(workspace *Workspace) *Workspace {
225+ if workspace == nil {
226+ return nil
227+ }
228+ cloned := *workspace
229+ cloned.Root = clonePaneNode(workspace.Root, nil)
230+ return &cloned
231+}
232+
233+func clonePaneNode(node, parent *PaneNode) *PaneNode {
234+ if node == nil {
235+ return nil
236+ }
237+ cloned := *node
238+ cloned.Parent = parent
239+ cloned.Pane = clonePane(node.Pane)
240+ if len(node.Children) > 0 {
241+ cloned.Children = make([]*PaneNode, 0, len(node.Children))
242+ for _, child := range node.Children {
243+ cloned.Children = append(cloned.Children, clonePaneNode(child, &cloned))
244+ }
245+ } else {
246+ cloned.Children = nil
247+ }
248+ return &cloned
249+}
250+
251+func clonePane(pane *Pane) *Pane {
252+ if pane == nil {
253+ return nil
254+ }
255+ cloned := *pane
256+ if pane.ParentPaneID != nil {
257+ parentPaneID := *pane.ParentPaneID
258+ cloned.ParentPaneID = &parentPaneID
259+ }
260+ return &cloned
261+}
262+
164263 // ReplaceFrom replaces this TabList's contents with those from another TabList.
165264 // This modifies in-place so existing references to this TabList remain valid.
166265 func (tl *TabList) ReplaceFrom(other *TabList) {
167- if other == nil {
266+ snapshot := other.Snapshot()
267+
268+ tl.mu.Lock()
269+ defer tl.mu.Unlock()
270+
271+ if snapshot == nil {
168272 tl.Tabs = make([]*Tab, 0)
169273 tl.ActiveTabID = ""
170274 tl.PreviousActiveTabID = ""
171275 return
172276 }
173- tl.Tabs = other.Tabs
174- tl.ActiveTabID = other.ActiveTabID
175- tl.PreviousActiveTabID = other.PreviousActiveTabID
277+ tl.Tabs = snapshot.Tabs
278+ tl.ActiveTabID = snapshot.ActiveTabID
279+ tl.PreviousActiveTabID = snapshot.PreviousActiveTabID
176280 }
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 +1174 -285
@@ -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,29 @@ 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))
670+ }
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
641678 }
642- if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil {
643- a.tabs.SetActive(bw.activeTabID)
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+ a.tabs.Add(cloneTabForGlobalList(tab))
684+ }
685+ }
686+ if a.tabs.Find(activeID) != nil {
687+ a.tabs.SetActive(activeID)
688+ }
644689 }
645- a.updateWindowTitleFromActivePane(bw.activeTabID)
690+ a.updateWindowTitleFromActivePane(activeID)
646691 }
647692
648693 func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {
@@ -667,8 +712,9 @@ func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
667712 }
668713 a.ensureWindowForTabMap()
669714 a.windowForTab[tabID] = bw
670- if bw.activeTabID == "" {
671- bw.activeTabID = tabID
715+ tabs := a.ensureTabListForBrowserWindow(bw)
716+ if tabs.ActiveTabID == "" {
717+ tabs.SetActive(tabID)
672718 }
673719 }
674720
@@ -711,7 +757,7 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
711757 }
712758 a.registerBrowserWindow(created)
713759
714- if a.tabs == nil || a.tabs.Count() == 0 {
760+ if len(a.browserWindows) == 1 {
715761 a.activateBrowserWindow(created)
716762 return nil
717763 }
@@ -733,17 +779,13 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
733779 }
734780
735781 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
782+ previousTarget := a.tabCoord.CurrentTarget()
783+ a.tabCoord.SetCurrentTarget(a.ensureTabTargetForBrowserWindow(created))
741784 defer func() {
742- a.tabCreationWindow = nil
743- a.tabCoord.SetMainWindow(previousMainWindow)
785+ a.tabCoord.SetCurrentTarget(previousTarget)
744786 }()
745787
746- createdTab, createErr := a.tabCoord.Create(ctx, url)
788+ createdTab, createErr := a.tabCoord.Create(ctx, a.ensureTabTargetForBrowserWindow(created), url)
747789 if createErr != nil {
748790 a.removeBrowserWindow(created.id)
749791 if created.mainWindow != nil {
@@ -752,19 +794,17 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
752794 return createErr
753795 }
754796 if a.workspaceViews[createdTab.ID] == nil {
755- if a.tabs != nil {
756- a.tabs.Remove(createdTab.ID)
797+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
798+ createdTabs.Remove(createdTab.ID)
757799 }
758- tabBar := a.mainWindow.TabBar()
759- if created.mainWindow != nil && a.tabCoord != nil {
760- if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil {
761- tabBar = targetTabBar
800+ if created.mainWindow != nil {
801+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
802+ tabBar.RemoveTab(createdTab.ID)
803+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
804+ tabBar.SetActive(activeTab.ID)
805+ }
762806 }
763807 }
764- if tabBar != nil {
765- tabBar.RemoveTab(createdTab.ID)
766- tabBar.SetActive(a.tabs.ActiveTabID)
767- }
768808 a.removeBrowserWindow(created.id)
769809 if created.mainWindow != nil {
770810 created.mainWindow.Destroy()
@@ -772,7 +812,8 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
772812 return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
773813 }
774814 a.setBrowserWindowForTab(createdTab.ID, created)
775- created.activeTabID = createdTab.ID
815+ // Tab was already created in the per-window TabList via tabCoord.Create;
816+ // active state is managed by TabList.SetActive.
776817 if created.mainWindow != nil {
777818 created.mainWindow.Show()
778819 }
@@ -792,30 +833,27 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created
792833 return err
793834 }
794835 a.setBrowserWindowForTab(output.Tab.ID, created)
795- created.activeTabID = output.Tab.ID
836+ // Mirror the tabsUC-created tab into the new browser window's tab list.
837+ // This path uses the tabs usecase directly instead of tabCoord.Create,
838+ // so the per-window TabList must be populated explicitly.
839+ a.ensureTabListForBrowserWindow(created).Add(output.Tab)
796840 if a.widgetFactory != nil {
797- a.tabCreationWindow = created
798- defer func() {
799- a.tabCreationWindow = nil
800- }()
801841 a.createWorkspaceView(ctx, output.Tab)
802842 a.switchWorkspaceView(ctx, output.Tab.ID)
803843 }
804844 if a.workspaceViews[output.Tab.ID] == nil {
845+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
846+ createdTabs.Remove(output.Tab.ID)
847+ }
805848 if a.tabs != nil {
806849 a.tabs.Remove(output.Tab.ID)
807850 }
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)
851+ if created.mainWindow != nil {
852+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
853+ tabBar.RemoveTab(output.Tab.ID)
854+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
855+ tabBar.SetActive(activeTab.ID)
856+ }
819857 }
820858 }
821859 a.removeBrowserWindow(created.id)
@@ -1118,7 +1156,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11181156 return nil
11191157 }
11201158 }
1121- return a.kbDispatcher.Dispatch(actionCtx, action)
1159+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
11221160 })
11231161 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
11241162 a.activateBrowserWindow(bw)
@@ -1165,7 +1203,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
11651203 bw.keyboardHandler,
11661204 func(actionCtx context.Context, action input.Action) error {
11671205 a.activateBrowserWindow(bw)
1168- return a.kbDispatcher.Dispatch(actionCtx, action)
1206+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
11691207 },
11701208 )
11711209 if bw.globalShortcutHandler == nil {
@@ -1181,11 +1219,11 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
11811219 }
11821220 bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
11831221 a.activateBrowserWindow(bw)
1184- if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1222+ if err := a.tabCoord.Switch(ctx, a.ensureTabTargetForBrowserWindow(bw), tabID); err != nil {
11851223 logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")
11861224 return
11871225 }
1188- bw.activeTabID = tabID
1226+ // Active tab state is managed by TabCoordinator.Switch → TabList.SetActive.
11891227 })
11901228 }
11911229
@@ -1432,7 +1470,7 @@ func (a *App) attachTabPickerToActivePane() {
14321470 return
14331471 }
14341472
1435- activeTab := a.tabs.ActiveTab()
1473+ activeTab := a.activeTabForBrowserWindow(bw)
14361474 if activeTab == nil || activeTab.Workspace == nil {
14371475 return
14381476 }
@@ -1488,18 +1526,19 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
14881526 return nil
14891527 }
14901528
1491- sourceTab := a.tabs.ActiveTab()
1492- if sourceTab == nil || sourceTab.Workspace == nil {
1529+ sourceTabs := a.tabListForBrowserWindow(bw)
1530+ sourceTab := a.activeTabForBrowserWindow(bw)
1531+ if sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
14931532 return nil
14941533 }
14951534
14961535 // If there is only 1 tab, auto-create a new tab and move there.
1497- if a.tabs.Count() <= 1 {
1536+ if sourceTabs.Count() <= 1 {
14981537 return a.MoveActivePaneToTab(ctx, "")
14991538 }
15001539
1501- items := make([]component.TabPickerItem, 0, a.tabs.Count())
1502- for _, tab := range a.tabs.Tabs {
1540+ items := make([]component.TabPickerItem, 0, sourceTabs.Count())
1541+ for _, tab := range sourceTabs.Tabs {
15031542 if tab == nil {
15041543 continue
15051544 }
@@ -1524,17 +1563,19 @@ func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
15241563 if a.movePaneToTabUC == nil {
15251564 return nil
15261565 }
1527- active := a.tabs.ActiveTab()
1528- if active == nil {
1566+ bw := a.lastFocusedBrowserWindow()
1567+ sourceTabs := a.tabListForBrowserWindow(bw)
1568+ active := a.activeTabForBrowserWindow(bw)
1569+ if sourceTabs == nil || active == nil {
15291570 return nil
15301571 }
15311572
15321573 nextPos := active.Position + 1
1533- if nextPos >= a.tabs.Count() {
1574+ if nextPos >= sourceTabs.Count() {
15341575 // Create a new tab on the right.
15351576 return a.MoveActivePaneToTab(ctx, "")
15361577 }
1537- next := a.tabs.TabAt(nextPos)
1578+ next := sourceTabs.TabAt(nextPos)
15381579 if next == nil {
15391580 return nil
15401581 }
@@ -1568,10 +1609,23 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
15681609 if out.NewTabCreated && targetWindow != nil {
15691610 a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)
15701611 }
1612+ a.syncCrossWindowMovePaneTabLists(out, sourceTab, sourceWindow, targetWindow)
15711613
15721614 a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)
15731615 a.switchToTargetTabIfConfigured(ctx, out, targetWindow)
1574- a.updateTabBarVisibility(ctx)
1616+ if a.tabCoord != nil {
1617+ seen := map[*browserWindow]struct{}{}
1618+ for _, bw := range []*browserWindow{sourceWindow, targetWindow} {
1619+ if bw == nil {
1620+ continue
1621+ }
1622+ if _, ok := seen[bw]; ok {
1623+ continue
1624+ }
1625+ seen[bw] = struct{}{}
1626+ a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(bw))
1627+ }
1628+ }
15751629 a.MarkDirty()
15761630 return nil
15771631 }
@@ -1587,17 +1641,36 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
15871641 if a.movePaneToTabUC == nil {
15881642 return nil, nil
15891643 }
1590- activeTab := a.tabs.ActiveTab()
1591- if activeTab == nil || activeTab.Workspace == nil {
1644+ sourceWindow := a.lastFocusedBrowserWindow()
1645+ tabList := a.tabListForBrowserWindow(sourceWindow)
1646+ activeTab := a.activeTabForBrowserWindow(sourceWindow)
1647+ if tabList == nil || activeTab == nil || activeTab.Workspace == nil {
15921648 return nil, nil
15931649 }
1650+ if targetTabID != "" {
1651+ if targetWindow := a.browserWindowForTab(targetTabID); targetWindow != nil && targetWindow != sourceWindow {
1652+ // Cross-window pane moves still pass a single derived tab list because
1653+ // MovePaneToTabInput accepts one TabList. Ensure the derived global
1654+ // mirror contains source and target tabs before passing a.tabs.
1655+ a.syncDerivedGlobalTabMirror(activeTab)
1656+ if targetWindowTabs := a.tabListForBrowserWindow(targetWindow); targetWindowTabs != nil {
1657+ if targetTab := targetWindowTabs.Find(targetTabID); targetTab != nil {
1658+ a.syncDerivedGlobalTabMirror(targetTab)
1659+ }
1660+ }
1661+ if a.tabs == nil {
1662+ return nil, nil
1663+ }
1664+ tabList = a.tabs
1665+ }
1666+ }
15941667 sourcePaneID := activeTab.Workspace.ActivePaneID
15951668 if sourcePaneID == "" {
15961669 return nil, nil
15971670 }
15981671
15991672 in := &usecase.MovePaneToTabInput{
1600- TabList: a.tabs,
1673+ TabList: tabList,
16011674 SourceTabID: activeTab.ID,
16021675 SourcePaneID: sourcePaneID,
16031676 TargetTabID: targetTabID,
@@ -1605,6 +1678,41 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
16051678 return in, activeTab
16061679 }
16071680
1681+func (a *App) syncCrossWindowMovePaneTabLists(
1682+ out *usecase.MovePaneToTabOutput,
1683+ sourceTab *entity.Tab,
1684+ sourceWindow *browserWindow,
1685+ targetWindow *browserWindow,
1686+) {
1687+ if out == nil || out.TargetTab == nil || sourceWindow == nil || targetWindow == nil {
1688+ return
1689+ }
1690+
1691+ if out.NewTabCreated {
1692+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1693+ if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1694+ targetTabs.Add(out.TargetTab)
1695+ }
1696+ }
1697+
1698+ if sourceWindow == targetWindow {
1699+ return
1700+ }
1701+
1702+ if out.SourceTabClosed && sourceTab != nil {
1703+ if sourceTabs := a.tabListForBrowserWindow(sourceWindow); sourceTabs != nil {
1704+ sourceTabs.Remove(sourceTab.ID)
1705+ }
1706+ }
1707+
1708+ if owner := a.browserWindowForTab(out.TargetTab.ID); owner == targetWindow {
1709+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1710+ if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1711+ targetTabs.Add(out.TargetTab)
1712+ }
1713+ }
1714+}
1715+
16081716 func (a *App) applyMovePaneToTabUI(
16091717 ctx context.Context,
16101718 out *usecase.MovePaneToTabOutput,
@@ -1639,6 +1747,11 @@ func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserW
16391747 if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {
16401748 tabBar.RemoveTab(sourceTabID)
16411749 }
1750+ // Remove the tab from the derived global tab mirror after the per-window
1751+ // TabList is modified.
1752+ if a.tabs != nil {
1753+ a.tabs.Remove(sourceTabID)
1754+ }
16421755 }
16431756
16441757 func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {
@@ -1714,9 +1827,16 @@ func (a *App) switchToTargetTabIfConfigured(
17141827 return
17151828 }
17161829
1717- a.tabs.SetActive(out.TargetTab.ID)
1830+ if targetWindow == nil {
1831+ targetWindow = a.lastFocusedBrowserWindow()
1832+ }
17181833 if targetWindow != nil {
1719- targetWindow.activeTabID = out.TargetTab.ID
1834+ a.ensureTabListForBrowserWindow(targetWindow).SetActive(out.TargetTab.ID)
1835+ }
1836+ // Sync derived global active state after the target window is resolved
1837+ // and the per-window TabList has been updated.
1838+ if a.tabs != nil && a.tabs.Find(out.TargetTab.ID) != nil {
1839+ a.tabs.SetActive(out.TargetTab.ID)
17201840 }
17211841 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
17221842 tabBar.SetActive(out.TargetTab.ID)
@@ -1727,12 +1847,6 @@ func (a *App) switchToTargetTabIfConfigured(
17271847 a.switchWorkspaceView(ctx, out.TargetTab.ID)
17281848 }
17291849
1730-func (a *App) updateTabBarVisibility(ctx context.Context) {
1731- if a.tabCoord != nil {
1732- a.tabCoord.UpdateBarVisibility(ctx)
1733- }
1734-}
1735-
17361850 func (a *App) initSnapshotService(ctx context.Context) {
17371851 log := logging.FromContext(ctx)
17381852
@@ -1785,12 +1899,7 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
17851899 log.Debug().Msg("update coordinator initialized")
17861900 }
17871901
1788-// GetTabList implements port.TabListProvider.
1789-func (a *App) GetTabList() *entity.TabList {
1790- return a.tabs
1791-}
1792-
1793-// GetSessionID implements port.TabListProvider.
1902+// GetSessionID implements port.WindowStateProvider.
17941903 func (a *App) GetSessionID() entity.SessionID {
17951904 if a.deps == nil {
17961905 return ""
@@ -1798,6 +1907,532 @@ func (a *App) GetSessionID() entity.SessionID {
17981907 return a.deps.CurrentSessionID
17991908 }
18001909
1910+// GetWindowSnapshotState implements port.WindowStateProvider.
1911+// Returns per-window tab lists and active window index from one main-thread capture.
1912+func (a *App) GetWindowSnapshotState() ([]entity.WindowTabListState, int) {
1913+ var (
1914+ browserTabs map[string]*entity.TabList
1915+ windowOrder []string
1916+ globalTabs *entity.TabList
1917+ tabOwners map[entity.TabID]string
1918+ lastFocusedWindowID string
1919+ )
1920+
1921+ capture := func() {
1922+ globalTabs = entity.NewTabList()
1923+ if a.tabs != nil {
1924+ globalTabs = a.tabs.Snapshot()
1925+ }
1926+
1927+ browserTabs = make(map[string]*entity.TabList, len(a.browserWindows))
1928+ for id, bw := range a.browserWindows {
1929+ if bw == nil {
1930+ continue
1931+ }
1932+ if bw.tabs != nil {
1933+ browserTabs[id] = bw.tabs.Snapshot()
1934+ } else {
1935+ browserTabs[id] = nil
1936+ }
1937+ }
1938+
1939+ windowOrder = append([]string(nil), a.browserWindowOrder...)
1940+ tabOwners = make(map[entity.TabID]string, len(a.windowForTab))
1941+ for tabID, bw := range a.windowForTab {
1942+ if bw != nil {
1943+ tabOwners[tabID] = bw.id
1944+ }
1945+ }
1946+ lastFocusedWindowID = a.lastFocusedWindowID
1947+ }
1948+ if a.dispatchOnMainThread != nil {
1949+ a.dispatchOnMainThread(capture)
1950+ } else {
1951+ // nil dispatchOnMainThread is for single-threaded tests only;
1952+ // production should marshal capture to GTK main thread.
1953+ capture()
1954+ }
1955+
1956+ if len(browserTabs) == 0 {
1957+ return []entity.WindowTabListState{
1958+ {WindowID: "", Tabs: globalTabs},
1959+ }, 0
1960+ }
1961+
1962+ windowIDs := windowOrderFrom(browserTabs, windowOrder)
1963+ result := make([]entity.WindowTabListState, 0, len(windowIDs))
1964+ activeWindowIndex := 0
1965+ anyOwnedWindow := false
1966+ for i, wid := range windowIDs {
1967+ if wid == lastFocusedWindowID {
1968+ activeWindowIndex = i
1969+ }
1970+
1971+ tabs := browserTabs[wid]
1972+ if tabs == nil {
1973+ tabs = entity.NewTabList()
1974+ for _, tab := range globalTabs.Tabs {
1975+ if tabOwners[tab.ID] == wid {
1976+ tabs.Add(cloneTabForGlobalList(tab))
1977+ }
1978+ }
1979+
1980+ // Derive active tab from the global list when no per-window TabList
1981+ // exists only for snapshot/export compatibility with older flat snapshot state.
1982+ globalActive := globalTabs.ActiveTabID
1983+ if globalActive != "" && tabs.Find(globalActive) != nil {
1984+ tabs.SetActive(globalActive)
1985+ }
1986+ }
1987+ if tabs.Count() > 0 {
1988+ anyOwnedWindow = true
1989+ }
1990+
1991+ result = append(result, entity.WindowTabListState{
1992+ WindowID: entity.WindowID(wid),
1993+ Tabs: tabs,
1994+ })
1995+ }
1996+ if !anyOwnedWindow && globalTabs.Count() > 0 {
1997+ // No window ownership populated; return the flat global list for
1998+ // snapshot/export compatibility rather than snapshotting empty windows.
1999+ return []entity.WindowTabListState{{WindowID: "", Tabs: globalTabs}}, 0
2000+ }
2001+
2002+ return result, activeWindowIndex
2003+}
2004+
2005+// GetWindowTabLists implements port.WindowStateProvider.
2006+// Returns per-window tab lists in browser window registration order.
2007+// Falls back to a single window with the shared tab list when no browser windows exist.
2008+func (a *App) GetWindowTabLists() []entity.WindowTabListState {
2009+ windows, _ := a.GetWindowSnapshotState()
2010+ return windows
2011+}
2012+
2013+// GetActiveWindowIndex implements port.WindowStateProvider.
2014+// Returns the index of the last focused window in registration order.
2015+func (a *App) GetActiveWindowIndex() int {
2016+ _, activeWindowIndex := a.GetWindowSnapshotState()
2017+ return activeWindowIndex
2018+}
2019+
2020+// tabListForBrowserWindow returns the per-window TabList for a browser window.
2021+// Returns nil when bw is nil or bw.tabs has not been initialized.
2022+func (a *App) tabListForBrowserWindow(bw *browserWindow) *entity.TabList {
2023+ if bw == nil {
2024+ return nil
2025+ }
2026+ return bw.tabs
2027+}
2028+
2029+// ensureTabListForBrowserWindow returns the per-window TabList for a browser window,
2030+// lazy-initializing bw.tabs when nil. Returns nil when bw is nil.
2031+func (a *App) ensureTabListForBrowserWindow(bw *browserWindow) *entity.TabList {
2032+ if bw == nil {
2033+ return nil
2034+ }
2035+ if bw.tabs == nil {
2036+ bw.tabs = entity.NewTabList()
2037+ }
2038+ return bw.tabs
2039+}
2040+
2041+// activeTabForBrowserWindow returns the active tab for a browser window.
2042+// Returns nil when bw is nil or the window has no tabs.
2043+func (a *App) activeTabForBrowserWindow(bw *browserWindow) *entity.Tab {
2044+ tabs := a.tabListForBrowserWindow(bw)
2045+ if tabs == nil {
2046+ return nil
2047+ }
2048+ return tabs.ActiveTab()
2049+}
2050+
2051+// activeWorkspaceForBrowserWindow returns the active workspace for a browser window.
2052+// Returns nil when bw is nil or the active tab has no workspace.
2053+func (a *App) activeWorkspaceForBrowserWindow(bw *browserWindow) *entity.Workspace {
2054+ tab := a.activeTabForBrowserWindow(bw)
2055+ if tab == nil {
2056+ return nil
2057+ }
2058+ return tab.Workspace
2059+}
2060+
2061+func (a *App) activeWorkspaceViewForBrowserWindow(bw *browserWindow) *component.WorkspaceView {
2062+ tab := a.activeTabForBrowserWindow(bw)
2063+ if tab == nil {
2064+ return nil
2065+ }
2066+ return a.workspaceViews[tab.ID]
2067+}
2068+
2069+// tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window.
2070+// It does not allocate a TabList; callers that mutate tabs should use
2071+// ensureTabTargetForBrowserWindow instead.
2072+func (a *App) tabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2073+ if bw == nil {
2074+ return coordinator.TabTarget{}
2075+ }
2076+ return coordinator.TabTarget{
2077+ Tabs: a.tabListForBrowserWindow(bw),
2078+ MainWindow: bw.mainWindow,
2079+ }
2080+}
2081+
2082+func (a *App) ensureTabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2083+ if bw == nil {
2084+ return coordinator.TabTarget{}
2085+ }
2086+ return coordinator.TabTarget{
2087+ Tabs: a.ensureTabListForBrowserWindow(bw),
2088+ MainWindow: bw.mainWindow,
2089+ }
2090+}
2091+
2092+// activePaneIDForBrowserWindow resolves the active pane ID for a browser window.
2093+// A content-coordinator override wins so floating panes can receive navigation.
2094+// Returns the empty string when bw is nil or the active tab has no workspace.
2095+func (a *App) activePaneIDForBrowserWindow(bw *browserWindow) entity.PaneID {
2096+ if a.contentCoord != nil && bw != nil {
2097+ if paneID, ok := a.contentCoord.ActivePaneOverrideID(); ok && a.browserWindowForAnyPane(paneID) == bw {
2098+ return paneID
2099+ }
2100+ }
2101+ ws := a.activeWorkspaceForBrowserWindow(bw)
2102+ if ws == nil {
2103+ return ""
2104+ }
2105+ return ws.ActivePaneID
2106+}
2107+
2108+// activeWebViewForBrowserWindow resolves the active WebView for a browser window.
2109+// Uses the active pane ID and contentCoord.GetWebView (public getter) — never
2110+// contentCoord.ActiveWebView — so that stale global focus cannot be picked.
2111+func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, port.WebView) {
2112+ paneID := a.activePaneIDForBrowserWindow(bw)
2113+ if paneID == "" {
2114+ return "", nil
2115+ }
2116+ if a.contentCoord == nil {
2117+ return paneID, nil
2118+ }
2119+ return paneID, a.contentCoord.GetWebView(paneID)
2120+}
2121+
2122+// omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2123+// navigation through navigate for the supplied browser window. The closure captures
2124+// bw at creation time, so navigation targets the owning window even when another
2125+// window is globally focused.
2126+func omniboxNavigateForBrowserWindow(
2127+ ctx context.Context,
2128+ bw *browserWindow,
2129+ navigate func(context.Context, *browserWindow, string) error,
2130+) func(string) {
2131+ return func(url string) {
2132+ if err := navigate(ctx, bw, url); err != nil {
2133+ logging.FromContext(ctx).Error().
2134+ Err(err).
2135+ Str("url", url).
2136+ Str("window_id", bw.id).
2137+ Msg("omnibox navigation failed")
2138+ }
2139+ }
2140+}
2141+
2142+// navigateFromBrowserWindow loads a URL in the active pane of the given browser window.
2143+// Uses NavigationCoordinator.NavigateWebView for explicit target resolution.
2144+func (a *App) navigateFromBrowserWindow(ctx context.Context, bw *browserWindow, rawURL string) error {
2145+ if a.navCoord == nil {
2146+ return fmt.Errorf("navigation coordinator not initialized")
2147+ }
2148+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2149+ return a.navCoord.NavigateWebView(ctx, rawURL, paneID, wv)
2150+}
2151+
2152+// withBrowserWindowWebView resolves the active WebView for bw, checks that navCoord
2153+// is initialized and wv is not nil, then runs fn with the WebView. Avoids nil bw
2154+// panic in error formatting by falling back to empty window id.
2155+func (a *App) withBrowserWindowWebView(_ context.Context, bw *browserWindow, fn func(wv port.WebView) error) error {
2156+ if a.navCoord == nil {
2157+ return fmt.Errorf("navigation coordinator not initialized")
2158+ }
2159+ _, wv := a.activeWebViewForBrowserWindow(bw)
2160+ if wv == nil {
2161+ windowID := ""
2162+ if bw != nil {
2163+ windowID = bw.id
2164+ }
2165+ return fmt.Errorf("no active webview for browser window %s", windowID)
2166+ }
2167+ return fn(wv)
2168+}
2169+
2170+// reloadBrowserWindow reloads the active page in the given browser window.
2171+// When bypassCache is true, bypasses the cache (hard reload).
2172+func (a *App) reloadBrowserWindow(ctx context.Context, bw *browserWindow, bypassCache bool) error {
2173+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2174+ return a.navCoord.ReloadWebView(ctx, wv, bypassCache)
2175+ })
2176+}
2177+
2178+// stopBrowserWindow stops loading in the active pane of the given browser window.
2179+func (a *App) stopBrowserWindow(ctx context.Context, bw *browserWindow) error {
2180+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2181+ return a.navCoord.StopWebView(ctx, wv)
2182+ })
2183+}
2184+
2185+// goBackBrowserWindow navigates back in history for the active pane of the given browser window.
2186+func (a *App) goBackBrowserWindow(ctx context.Context, bw *browserWindow) error {
2187+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2188+ return a.navCoord.GoBackWebView(ctx, wv)
2189+ })
2190+}
2191+
2192+// goForwardBrowserWindow navigates forward in history for the active pane of the given browser window.
2193+func (a *App) goForwardBrowserWindow(ctx context.Context, bw *browserWindow) error {
2194+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2195+ return a.navCoord.GoForwardWebView(ctx, wv)
2196+ })
2197+}
2198+
2199+func (a *App) printBrowserWindow(ctx context.Context, bw *browserWindow) error {
2200+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2201+ return a.navCoord.PrintWebView(ctx, wv)
2202+ })
2203+}
2204+
2205+func (a *App) openDevToolsBrowserWindow(ctx context.Context, bw *browserWindow) error {
2206+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2207+ return a.navCoord.OpenDevToolsWebView(ctx, wv)
2208+ })
2209+}
2210+
2211+func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action string) error {
2212+ if a.deps == nil || a.deps.ZoomUC == nil {
2213+ logging.FromContext(ctx).Warn().Msg("zoom use case not available")
2214+ return nil
2215+ }
2216+
2217+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2218+ if wv == nil {
2219+ logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for zoom")
2220+ return nil
2221+ }
2222+
2223+ domain, err := usecase.ExtractDomain(wv.URI())
2224+ if err != nil {
2225+ logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
2226+ return nil
2227+ }
2228+
2229+ current := wv.GetZoomLevel()
2230+ var newZoom *entity.ZoomLevel
2231+ switch action {
2232+ case "in":
2233+ newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, domain, current)
2234+ case "out":
2235+ newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, domain, current)
2236+ case "reset":
2237+ err = a.deps.ZoomUC.ResetZoom(ctx, domain)
2238+ if err == nil {
2239+ newZoom = entity.NewZoomLevel(domain, a.deps.ZoomUC.DefaultZoom())
2240+ }
2241+ default:
2242+ return fmt.Errorf("unknown zoom action %q", action)
2243+ }
2244+ if err != nil {
2245+ return err
2246+ }
2247+ if newZoom == nil {
2248+ return nil
2249+ }
2250+ if err := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); err != nil {
2251+ return err
2252+ }
2253+ if a.navCoord != nil {
2254+ a.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
2255+ }
2256+ if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2257+ if paneView := wsView.GetPaneView(paneID); paneView != nil {
2258+ paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100))
2259+ }
2260+ }
2261+ return nil
2262+}
2263+
2264+const (
2265+ tabSwitchIndex0 = iota // 0
2266+ tabSwitchIndex1 // 1
2267+ tabSwitchIndex2 // 2
2268+ tabSwitchIndex3 // 3
2269+ tabSwitchIndex4 // 4
2270+ tabSwitchIndex5 // 5
2271+ tabSwitchIndex6 // 6
2272+ tabSwitchIndex7 // 7
2273+ tabSwitchIndex8 // 8
2274+ tabSwitchIndex9 // 9
2275+)
2276+
2277+// switchTabIndexActionIndex maps ActionSwitchTabIndex1..10 to 0..9.
2278+func switchTabIndexActionIndex(action input.Action) (int, bool) {
2279+ switch action {
2280+ case input.ActionSwitchTabIndex1:
2281+ return tabSwitchIndex0, true
2282+ case input.ActionSwitchTabIndex2:
2283+ return tabSwitchIndex1, true
2284+ case input.ActionSwitchTabIndex3:
2285+ return tabSwitchIndex2, true
2286+ case input.ActionSwitchTabIndex4:
2287+ return tabSwitchIndex3, true
2288+ case input.ActionSwitchTabIndex5:
2289+ return tabSwitchIndex4, true
2290+ case input.ActionSwitchTabIndex6:
2291+ return tabSwitchIndex5, true
2292+ case input.ActionSwitchTabIndex7:
2293+ return tabSwitchIndex6, true
2294+ case input.ActionSwitchTabIndex8:
2295+ return tabSwitchIndex7, true
2296+ case input.ActionSwitchTabIndex9:
2297+ return tabSwitchIndex8, true
2298+ case input.ActionSwitchTabIndex10:
2299+ return tabSwitchIndex9, true
2300+ default:
2301+ return 0, false
2302+ }
2303+}
2304+
2305+// dispatchBrowserWindowAction routes keyboard/global shortcut actions through the
2306+// source browser window helpers instead of falling back to the global active
2307+// webview/tab. Browser-window-owning callbacks (keyboardHandler, globalShortcutHandler)
2308+// must call this instead of activating the window then dispatching globally.
2309+func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow, action input.Action) error {
2310+ switch action {
2311+ case input.ActionReload:
2312+ return a.reloadBrowserWindow(ctx, bw, false)
2313+ case input.ActionHardReload:
2314+ return a.reloadBrowserWindow(ctx, bw, true)
2315+ case input.ActionStop:
2316+ return a.stopBrowserWindow(ctx, bw)
2317+ case input.ActionGoBack:
2318+ return a.goBackBrowserWindow(ctx, bw)
2319+ case input.ActionGoForward:
2320+ return a.goForwardBrowserWindow(ctx, bw)
2321+ case input.ActionPrintPage:
2322+ return a.printBrowserWindow(ctx, bw)
2323+ case input.ActionOpenDevTools:
2324+ return a.openDevToolsBrowserWindow(ctx, bw)
2325+ case input.ActionZoomIn:
2326+ return a.zoomBrowserWindow(ctx, bw, "in")
2327+ case input.ActionZoomOut:
2328+ return a.zoomBrowserWindow(ctx, bw, "out")
2329+ case input.ActionZoomReset:
2330+ return a.zoomBrowserWindow(ctx, bw, "reset")
2331+ case input.ActionSwitchTabIndex1, input.ActionSwitchTabIndex2, input.ActionSwitchTabIndex3,
2332+ input.ActionSwitchTabIndex4, input.ActionSwitchTabIndex5, input.ActionSwitchTabIndex6,
2333+ input.ActionSwitchTabIndex7, input.ActionSwitchTabIndex8, input.ActionSwitchTabIndex9,
2334+ input.ActionSwitchTabIndex10:
2335+ index, ok := switchTabIndexActionIndex(action)
2336+ if !ok {
2337+ return nil
2338+ }
2339+ return a.switchBrowserWindowTabIndex(ctx, bw, index)
2340+ default:
2341+ if a.kbDispatcher != nil {
2342+ return a.kbDispatcher.Dispatch(ctx, action)
2343+ }
2344+ return nil
2345+ }
2346+}
2347+
2348+// switchBrowserWindowTabIndex activates the given browser window and switches to
2349+// the tab at the given 0-based index in that window, creating tabs as needed.
2350+func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error {
2351+ if a.tabCoord == nil {
2352+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil")
2353+ return nil
2354+ }
2355+ if bw == nil {
2356+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window")
2357+ return nil
2358+ }
2359+ a.activateBrowserWindow(bw)
2360+ target := a.ensureTabTargetForBrowserWindow(bw)
2361+ initialURL := ""
2362+ if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" {
2363+ initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)
2364+ }
2365+ return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL)
2366+}
2367+
2368+// windowOrder returns window IDs in registration order.
2369+// Falls back to sorted map keys when no registration order is recorded
2370+// (e.g., in old tests or direct map manipulation).
2371+func (a *App) windowOrder() []string {
2372+ if len(a.browserWindowOrder) > 0 {
2373+ // Deduplicate against browserWindows, skipping stale entries.
2374+ seen := make(map[string]bool, len(a.browserWindowOrder))
2375+ result := make([]string, 0, len(a.browserWindowOrder))
2376+ for _, wid := range a.browserWindowOrder {
2377+ if seen[wid] || a.browserWindows[wid] == nil {
2378+ continue
2379+ }
2380+ seen[wid] = true
2381+ result = append(result, wid)
2382+ }
2383+ // Include any live browserWindows not yet tracked in order
2384+ // (inserted via direct map manipulation) deterministically.
2385+ untracked := make([]string, 0)
2386+ for id, bw := range a.browserWindows {
2387+ if bw != nil && !seen[id] {
2388+ untracked = append(untracked, id)
2389+ }
2390+ }
2391+ sort.Strings(untracked)
2392+ return append(result, untracked...)
2393+ }
2394+
2395+ // Fallback: sort map keys for deterministic output.
2396+ windowIDs := make([]string, 0, len(a.browserWindows))
2397+ for id := range a.browserWindows {
2398+ windowIDs = append(windowIDs, id)
2399+ }
2400+ sort.Strings(windowIDs)
2401+ return windowIDs
2402+}
2403+
2404+func windowOrderFrom(browserTabs map[string]*entity.TabList, browserWindowOrder []string) []string {
2405+ if len(browserWindowOrder) > 0 {
2406+ seen := make(map[string]bool, len(browserWindowOrder))
2407+ result := make([]string, 0, len(browserWindowOrder))
2408+ for _, wid := range browserWindowOrder {
2409+ if seen[wid] {
2410+ continue
2411+ }
2412+ if _, ok := browserTabs[wid]; !ok {
2413+ continue
2414+ }
2415+ seen[wid] = true
2416+ result = append(result, wid)
2417+ }
2418+ untracked := make([]string, 0)
2419+ for id := range browserTabs {
2420+ if !seen[id] {
2421+ untracked = append(untracked, id)
2422+ }
2423+ }
2424+ sort.Strings(untracked)
2425+ return append(result, untracked...)
2426+ }
2427+
2428+ windowIDs := make([]string, 0, len(browserTabs))
2429+ for id := range browserTabs {
2430+ windowIDs = append(windowIDs, id)
2431+ }
2432+ sort.Strings(windowIDs)
2433+ return windowIDs
2434+}
2435+
18012436 // MarkDirty signals that session state has changed and should be saved.
18022437 func (a *App) MarkDirty() {
18032438 if a.snapshotService != nil {
@@ -1823,23 +2458,43 @@ func (a *App) createInitialTab(ctx context.Context) {
18232458 }
18242459
18252460 // Create an initial tab using coordinator.
1826- if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil {
2461+ target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow())
2462+ if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
18272463 log.Error().Err(err).Msg("failed to create initial tab")
18282464 }
18292465 }
18302466
2467+func (a *App) clearRuntimeWindowUI(runtimeWindows []*browserWindow) {
2468+ for _, bw := range runtimeWindows {
2469+ if bw == nil || bw.mainWindow == nil {
2470+ continue
2471+ }
2472+ if tabBar := bw.mainWindow.TabBar(); tabBar != nil {
2473+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
2474+ for _, tab := range tabs.Tabs {
2475+ if tab != nil {
2476+ tabBar.RemoveTab(tab.ID)
2477+ }
2478+ }
2479+ }
2480+ }
2481+ bw.mainWindow.SetContent(nil)
2482+ }
2483+}
2484+
18312485 func (a *App) clearSessionRestoreUIState(ctx context.Context) {
18322486 if a == nil {
18332487 return
18342488 }
18352489
18362490 tabIDs := make(map[entity.TabID]struct{})
1837- if a.tabs != nil {
1838- for _, tab := range a.tabs.Tabs {
1839- if tab == nil {
1840- continue
2491+ for _, bw := range a.browserWindows {
2492+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
2493+ for _, tab := range tabs.Tabs {
2494+ if tab != nil {
2495+ tabIDs[tab.ID] = struct{}{}
2496+ }
18412497 }
1842- tabIDs[tab.ID] = struct{}{}
18432498 }
18442499 }
18452500 for tabID := range a.workspaceViews {
@@ -1867,94 +2522,290 @@ func (a *App) clearSessionRestoreUIState(ctx context.Context) {
18672522 }
18682523
18692524 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1870- log := logging.FromContext(ctx)
2525+ state, restoredWindows, err := a.loadRestoredWindowStates(ctx, sessionID)
2526+ if err != nil {
2527+ return err
2528+ }
2529+
2530+ logging.FromContext(ctx).Info().
2531+ Int("windows", len(restoredWindows)).
2532+ Int("panes", state.CountPanes()).
2533+ Msg("restoring session state")
2534+
2535+ if len(restoredWindows) == 0 {
2536+ if state.Version >= entity.SessionStateVersion {
2537+ // v2+ empty-window snapshots are valid: replace with a single empty window.
2538+ restoredWindows = []entity.WindowTabListState{{
2539+ WindowID: "",
2540+ Tabs: entity.NewTabList(),
2541+ }}
2542+ } else {
2543+ return fmt.Errorf("restored session contains no windows")
2544+ }
2545+ }
2546+
2547+ runtimeWindows, firstBWReused, err := a.prepareRuntimeWindows(ctx, len(restoredWindows))
2548+ if err != nil {
2549+ return err
2550+ }
2551+ a.clearSessionRestoreUIState(ctx)
2552+ a.clearRuntimeWindowUI(runtimeWindows)
2553+ a.assignRuntimeWindowTabLists(runtimeWindows, restoredWindows, firstBWReused)
2554+ a.pruneStaleBrowserWindows(runtimeWindows)
18712555
2556+ activeBW := runtimeWindows[safeWindowIndex(state.ActiveWindowIndex, len(runtimeWindows))]
2557+ a.activateBrowserWindow(activeBW)
2558+ a.replaceGlobalTabsFromRuntimeWindows(runtimeWindows, activeBW)
2559+ a.buildRestoredWindowUI(ctx, runtimeWindows)
2560+ a.showRestoredRuntimeWindows(runtimeWindows)
2561+
2562+ return nil
2563+}
2564+
2565+func (a *App) loadRestoredWindowStates(
2566+ ctx context.Context,
2567+ sessionID entity.SessionID,
2568+) (*entity.SessionState, []entity.WindowTabListState, error) {
18722569 if a.deps == nil || a.deps.SessionStateRepo == nil {
1873- return fmt.Errorf("session state repo not available")
2570+ return nil, nil, fmt.Errorf("session state repo not available")
18742571 }
18752572
1876- // Load session state
18772573 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
18782574 if err != nil {
1879- return fmt.Errorf("load session state: %w", err)
2575+ return nil, nil, fmt.Errorf("load session state: %w", err)
18802576 }
18812577 if state == nil {
1882- return fmt.Errorf("session state not found")
2578+ return nil, nil, fmt.Errorf("session state not found")
18832579 }
18842580
1885- log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
2581+ restoredWindows := entity.WindowTabListsFromSnapshot(state, a.generateID)
2582+ return state, restoredWindows, nil
2583+}
18862584
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")
2585+func (a *App) prepareRuntimeWindows(ctx context.Context, windowCount int) ([]*browserWindow, bool, error) {
2586+ firstBW := a.browserWindowForMainWindow(a.mainWindow)
2587+ if firstBW == nil {
2588+ firstBW = a.lastFocusedBrowserWindow()
18922589 }
2590+ firstBWReused := firstBW != nil
2591+ createdWindows := make([]*browserWindow, 0)
18932592
1894- a.clearSessionRestoreUIState(ctx)
2593+ if firstBW == nil {
2594+ if a.mainWindow != nil {
2595+ firstBW = a.wrapExistingMainWindowForRestore(ctx, a.mainWindow)
2596+ } else {
2597+ created, err := a.createBrowserWindow(ctx, "")
2598+ if err != nil {
2599+ return nil, false, fmt.Errorf("create browser window for restore: %w", err)
2600+ }
2601+ firstBW = created
2602+ createdWindows = append(createdWindows, created)
2603+ }
2604+ }
18952605
1896- // Replace tabs in-place to preserve references held by TabCoordinator
1897- a.tabs.ReplaceFrom(restoredTabs)
2606+ runtimeWindows := make([]*browserWindow, windowCount)
2607+ runtimeWindows[0] = firstBW
2608+ for i := 1; i < windowCount; i++ {
2609+ extraBW, err := a.createBrowserWindow(ctx, "")
2610+ if err != nil {
2611+ a.cleanupCreatedBrowserWindows(createdWindows)
2612+ return nil, false, fmt.Errorf("create browser window %d for restore: %w", i, err)
2613+ }
2614+ createdWindows = append(createdWindows, extraBW)
2615+ runtimeWindows[i] = extraBW
2616+ }
2617+
2618+ return runtimeWindows, firstBWReused, nil
2619+}
18982620
1899- a.assignRestoredTabsToCurrentWindow()
2621+func (a *App) wrapExistingMainWindowForRestore(ctx context.Context, mainWindow *window.MainWindow) *browserWindow {
2622+ bw := &browserWindow{id: a.generateWindowID(), tabs: entity.NewTabList(), mainWindow: mainWindow}
2623+ a.wireBrowserWindowActivationTracking(bw)
2624+ if a.kbDispatcher != nil {
2625+ a.initBrowserWindowInput(ctx, bw)
2626+ }
2627+ if a.tabCoord != nil {
2628+ a.wireBrowserWindowTabBar(ctx, bw)
2629+ }
2630+ bw.initChrome(ctx, a)
2631+ return bw
2632+}
19002633
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)
2634+func (a *App) cleanupCreatedBrowserWindows(createdWindows []*browserWindow) {
2635+ for _, bw := range createdWindows {
2636+ if bw == nil {
2637+ continue
19092638 }
1910- if tabBar != nil {
1911- tabBar.AddTab(tab)
2639+ mainWindow := bw.mainWindow
2640+ a.removeBrowserWindow(bw.id)
2641+ if mainWindow != nil {
2642+ if a.mainWindow == mainWindow {
2643+ a.mainWindow = nil
2644+ }
2645+ mainWindow.Destroy()
19122646 }
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")
19182647 }
2648+}
19192649
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)
2650+func (a *App) assignRuntimeWindowTabLists(
2651+ runtimeWindows []*browserWindow,
2652+ restoredWindows []entity.WindowTabListState,
2653+ firstBWReused bool,
2654+) {
2655+ for i, bw := range runtimeWindows {
2656+ if i > 0 || !firstBWReused {
2657+ a.registerBrowserWindow(bw)
19262658 }
2659+ a.assignRestoredWindowTabList(bw, restoredWindows[i])
19272660 }
2661+}
19282662
1929- return nil
2663+func (a *App) pruneStaleBrowserWindows(runtimeWindows []*browserWindow) {
2664+ runtimeSet := make(map[string]bool, len(runtimeWindows))
2665+ for _, bw := range runtimeWindows {
2666+ runtimeSet[bw.id] = true
2667+ }
2668+ for id, bw := range a.browserWindows {
2669+ if !runtimeSet[id] {
2670+ if bw != nil && bw.mainWindow != nil {
2671+ bw.mainWindow.Destroy()
2672+ }
2673+ a.removeBrowserWindow(id)
2674+ }
2675+ }
2676+
2677+ a.browserWindowOrder = make([]string, len(runtimeWindows))
2678+ for i, bw := range runtimeWindows {
2679+ a.browserWindowOrder[i] = bw.id
2680+ }
19302681 }
19312682
1932-func (a *App) assignRestoredTabsToCurrentWindow() {
1933- if a == nil || a.tabs == nil {
1934- return
2683+func safeWindowIndex(index, count int) int {
2684+ if index < 0 || index >= count {
2685+ return 0
19352686 }
2687+ return index
2688+}
19362689
1937- restoreWindow := a.browserWindowForMainWindow(a.mainWindow)
1938- if restoreWindow == nil {
1939- restoreWindow = a.lastFocusedBrowserWindow()
2690+func (a *App) syncDerivedGlobalTabMirror(tab *entity.Tab) {
2691+ if a.tabs != nil && tab != nil && a.tabs.Find(tab.ID) == nil {
2692+ a.tabs.Add(cloneTabForGlobalList(tab))
19402693 }
1941- if restoreWindow == nil {
1942- return
2694+}
2695+
2696+// workspace pointer is intentionally shared because App.tabs is a derived
2697+// snapshot/export mirror, not runtime command target; runtime mutations must
2698+// target browserWindow tabs.
2699+func cloneTabForGlobalList(tab *entity.Tab) *entity.Tab {
2700+ if tab == nil {
2701+ return nil
2702+ }
2703+ return &entity.Tab{
2704+ ID: tab.ID,
2705+ Name: tab.Name,
2706+ Workspace: tab.Workspace,
2707+ IsPinned: tab.IsPinned,
2708+ CreatedAt: tab.CreatedAt,
19432709 }
2710+}
19442711
1945- for _, tab := range a.tabs.Tabs {
1946- if tab != nil {
1947- a.setBrowserWindowForTab(tab.ID, restoreWindow)
2712+func (a *App) replaceGlobalTabsFromRuntimeWindows(runtimeWindows []*browserWindow, activeBW *browserWindow) {
2713+ globalTabs := entity.NewTabList()
2714+ for _, bw := range runtimeWindows {
2715+ perWinTabs := a.tabListForBrowserWindow(bw)
2716+ if perWinTabs == nil {
2717+ continue
2718+ }
2719+ for _, tab := range perWinTabs.Tabs {
2720+ if cloned := cloneTabForGlobalList(tab); cloned != nil {
2721+ globalTabs.Add(cloned)
2722+ }
2723+ }
2724+ }
2725+
2726+ if activeTabs := a.tabListForBrowserWindow(activeBW); activeTabs != nil {
2727+ activeID := activeTabs.ActiveTabID
2728+ if activeID != "" && globalTabs.Find(activeID) != nil {
2729+ globalTabs.SetActive(activeID)
2730+ }
2731+ }
2732+ a.tabs.ReplaceFrom(globalTabs)
2733+}
2734+
2735+func (a *App) showRestoredRuntimeWindows(runtimeWindows []*browserWindow) {
2736+ for _, bw := range runtimeWindows {
2737+ if bw != nil && bw.mainWindow != nil {
2738+ bw.mainWindow.Show()
19482739 }
19492740 }
2741+}
2742+
2743+func (a *App) buildRestoredWindowUI(ctx context.Context, runtimeWindows []*browserWindow) {
2744+ for _, bw := range runtimeWindows {
2745+ perWinTabs := a.tabListForBrowserWindow(bw)
2746+ if perWinTabs == nil || perWinTabs.Count() == 0 {
2747+ a.updateBrowserWindowTabBarVisibility(bw)
2748+ continue
2749+ }
2750+
2751+ tabBar := bw.mainWindow.TabBar()
2752+ activeTab := perWinTabs.ActiveTab()
2753+ for _, tab := range perWinTabs.Tabs {
2754+ a.buildRestoredTabUI(ctx, bw, tabBar, tab)
2755+ }
2756+
2757+ if activeTab != nil {
2758+ a.switchWorkspaceView(ctx, activeTab.ID)
2759+ if tabBar != nil {
2760+ tabBar.SetActive(activeTab.ID)
2761+ }
2762+ }
2763+ a.updateBrowserWindowTabBarVisibility(bw)
2764+ }
2765+}
2766+
2767+func (a *App) buildRestoredTabUI(ctx context.Context, bw *browserWindow, tabBar *component.TabBar, tab *entity.Tab) {
2768+ if tab == nil {
2769+ return
2770+ }
2771+ a.createWorkspaceViewWithoutAttach(ctx, tab)
2772+ wsView := a.workspaceViews[tab.ID]
2773+ if a.wsCoord != nil && wsView != nil {
2774+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
2775+ }
2776+ if tabBar != nil {
2777+ tabBar.AddTab(tab)
2778+ }
2779+ logging.FromContext(ctx).Debug().
2780+ Str("tab_id", string(tab.ID)).
2781+ Str("name", tab.Name).
2782+ Str("window_id", bw.id).
2783+ Int("panes", tab.Workspace.PaneCount()).
2784+ Msg("restored tab with workspace")
2785+}
2786+
2787+// assignRestoredWindowTabList assigns a restored WindowTabListState to a runtime browser window.
2788+// It sets bw.tabs to the restored TabList (or a new empty one if nil) and registers each
2789+// tab's ownership via windowForTab. It never inserts saved window IDs into browserWindows;
2790+// the runtime window ID is owned by the caller.
2791+func (a *App) assignRestoredWindowTabList(bw *browserWindow, restored entity.WindowTabListState) {
2792+ if bw == nil {
2793+ return
2794+ }
2795+
2796+ a.ensureWindowForTabMap()
19502797
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
2798+ if restored.Tabs == nil {
2799+ bw.tabs = entity.NewTabList()
19552800 return
19562801 }
1957- restoreWindow.prevActiveTabID = ""
2802+
2803+ bw.tabs = restored.Tabs
2804+ for _, tab := range restored.Tabs.Tabs {
2805+ if tab != nil {
2806+ a.windowForTab[tab.ID] = bw
2807+ }
2808+ }
19582809 }
19592810
19602811 func (a *App) finalizeActivation(ctx context.Context) {
@@ -2126,14 +2977,12 @@ func (a *App) initContentCoordinator(
21262977 func (a *App) initTabCoordinator(ctx context.Context) {
21272978 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
21282979 TabsUC: a.tabsUC,
2129- Tabs: a.tabs,
21302980 MainWindow: a.mainWindow,
21312981 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
21322982 })
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)
2983+ a.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
2984+ // Assign ownership from the callback target, not focus/global state.
2985+ bw := a.browserWindowForTabTarget(target)
21372986 if bw != nil {
21382987 a.setBrowserWindowForTab(tab.ID, bw)
21392988 }
@@ -2147,59 +2996,38 @@ func (a *App) initTabCoordinator(ctx context.Context) {
21472996 }
21482997 tab.Name = defaultTabName(count)
21492998 }
2999+ // Keep the derived global App.tabs mirror populated from per-window
3000+ // tab creation for usecase and snapshot/export boundaries.
3001+ // This must happen AFTER default tab name assignment so the clone
3002+ // has the final window-scoped default name.
3003+ if a.tabs != nil && a.tabs.Find(tab.ID) == nil {
3004+ a.tabs.Add(cloneTabForGlobalList(tab))
3005+ }
21503006 a.createWorkspaceView(ctx, tab)
21513007 })
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
3008+ a.tabCoord.SetOnTabSwitched(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
3009+ // TabList.SetActive (called by TabCoordinator.Switch → tabsUC.Switch) already
3010+ // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
3011+ if bw := a.browserWindowForTabTarget(target); bw != nil {
21613012 a.activateBrowserWindow(bw)
21623013 }
21633014 a.switchWorkspaceView(ctx, tab.ID)
21643015 })
2165- a.tabCoord.SetOnQuit(a.Quit)
21663016 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)
3017+ // Per-window tab ownership is now enforced through explicit TabTarget.
3018+ // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.
3019+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, target coordinator.TabTarget) {
3020+ bw := a.browserWindowForTabTarget(target)
21973021 if bw != nil {
21983022 a.removeBrowserWindow(bw.id)
21993023 if bw.mainWindow != nil {
22003024 bw.mainWindow.Destroy()
22013025 }
22023026 }
3027+ // Quit the app only when all browser windows are gone.
3028+ if len(a.browserWindows) == 0 {
3029+ a.Quit()
3030+ }
22033031 })
22043032 // Wire popup tab WebView attachment
22053033 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
@@ -2216,7 +3044,11 @@ func (a *App) initCoordinators(ctx context.Context) {
22163044 log := logging.FromContext(ctx)
22173045 log.Debug().Msg("initializing coordinators")
22183046
2219- // Helper to get active workspace and view
3047+ // Coordinators resolve the workspace and view through
3048+ // lastFocusedBrowserWindow via activeWorkspace helpers.
3049+ // Shared coordinator instances defer to window-scoped resolution;
3050+ // the startup edge case (no focused window yet) is handled inside
3051+ // activeWorkspace/activeWorkspaceView.
22203052 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
22213053 return a.activeWorkspace(), a.activeWorkspaceView()
22223054 }
@@ -2252,7 +3084,8 @@ func (a *App) initCoordinators(ctx context.Context) {
22523084 ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
22533085 })
22543086 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
2255- return a.tabCoord.Close(ctx)
3087+ bw := a.lastFocusedBrowserWindow()
3088+ return a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw))
22563089 })
22573090 a.wsCoord.SetOnStateChanged(a.MarkDirty)
22583091
@@ -2273,16 +3106,8 @@ func (a *App) initCoordinators(ctx context.Context) {
22733106 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
22743107 return a.wsCoord.ClosePaneByID(ctx, paneID)
22753108 })
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- })
3109+ // Wire tabbed popup behavior to create new tabs in the originating window.
3110+ a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
22863111
22873112 // Move pane use case (cross-tab)
22883113 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
@@ -2338,7 +3163,10 @@ func (a *App) initCoordinators(ctx context.Context) {
23383163 })
23393164 }
23403165
2341- // Set the WebView as the focused input for accent picker (if this is the active pane)
3166+ // This only updates focus when the shown pane belongs to the last-focused
3167+ // window's active workspace. activeWorkspace() delegates to
3168+ // lastFocusedBrowserWindow, so the accent-picker does not activate for
3169+ // background windows.
23423170 ws := a.activeWorkspace()
23433171 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
23443172 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
@@ -2346,14 +3174,16 @@ func (a *App) initCoordinators(ctx context.Context) {
23463174 })
23473175
23483176 // 5. Keyboard Dispatcher
3177+ // Dispatcher receives an app-level active pane provider (contentCoord.ActivePaneID)
3178+ // and per-window handlers route source-window actions through
3179+ // dispatchBrowserWindowAction for proper multi-window isolation.
23493180 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
23503181 ctx,
2351- a.tabCoord,
23523182 a.wsCoord,
23533183 a.navCoord,
23543184 a.deps.ZoomUC,
23553185 a.deps.CopyURLUC,
2356- a.deps.Config.Workspace.NewPaneURL,
3186+ a.keyboardActions(),
23573187 a.contentCoord.ActivePaneID,
23583188 )
23593189 a.wireKeyboardActions()
@@ -2364,6 +3194,65 @@ func (a *App) initCoordinators(ctx context.Context) {
23643194 log.Debug().Msg("coordinators initialized")
23653195 }
23663196
3197+func (a *App) keyboardActions() dispatcher.KeyboardActions {
3198+ return dispatcher.KeyboardActions{
3199+ NewTab: func(ctx context.Context) error {
3200+ if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" {
3201+ logging.FromContext(ctx).Warn().Msg("new tab ignored: new pane URL is not configured")
3202+ return fmt.Errorf("newPaneURL is not configured")
3203+ }
3204+ return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3205+ _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL))
3206+ return err
3207+ })
3208+ },
3209+ CloseTab: func(ctx context.Context) error {
3210+ return a.withFocusedTabTarget(ctx, "close tab", false, func(target coordinator.TabTarget) error {
3211+ return a.tabCoord.Close(ctx, target)
3212+ })
3213+ },
3214+ NextTab: func(ctx context.Context) error {
3215+ return a.withFocusedTabTarget(ctx, "next tab", false, func(target coordinator.TabTarget) error {
3216+ return a.tabCoord.SwitchNext(ctx, target)
3217+ })
3218+ },
3219+ PreviousTab: func(ctx context.Context) error {
3220+ return a.withFocusedTabTarget(ctx, "previous tab", false, func(target coordinator.TabTarget) error {
3221+ return a.tabCoord.SwitchPrev(ctx, target)
3222+ })
3223+ },
3224+ SwitchLastTab: func(ctx context.Context) error {
3225+ return a.withFocusedTabTarget(ctx, "switch last tab", false, func(target coordinator.TabTarget) error {
3226+ return a.tabCoord.SwitchToLastActive(ctx, target)
3227+ })
3228+ },
3229+ SwitchTabIndex: func(ctx context.Context, index int) error {
3230+ return a.switchBrowserWindowTabIndex(ctx, a.lastFocusedBrowserWindow(), index)
3231+ },
3232+ ActiveWebView: func(context.Context) port.WebView {
3233+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
3234+ return wv
3235+ },
3236+ }
3237+}
3238+
3239+func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bool, fn func(coordinator.TabTarget) error) error {
3240+ if a.tabCoord == nil {
3241+ logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: tab coordinator is nil")
3242+ return nil
3243+ }
3244+ bw := a.lastFocusedBrowserWindow()
3245+ if bw == nil {
3246+ logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: no focused browser window")
3247+ return nil
3248+ }
3249+ target := a.tabTargetForBrowserWindow(bw)
3250+ if ensure {
3251+ target = a.ensureTabTargetForBrowserWindow(bw)
3252+ }
3253+ return fn(target)
3254+}
3255+
23673256 func (a *App) wireKeyboardActions() {
23683257 a.kbDispatcher.SetOnQuit(a.Quit)
23693258 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
@@ -2631,13 +3520,13 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
26313520 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
26323521 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
26333522 var ws *entity.Workspace
2634- if a.tabs != nil {
2635- if tab := a.tabs.Find(tabID); tab != nil {
3523+ bw := a.browserWindowForTab(tabID)
3524+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
3525+ if tab := tabs.Find(tabID); tab != nil {
26363526 ws = tab.Workspace
2637- } else {
2638- ws = a.activeWorkspace()
26393527 }
2640- } else {
3528+ }
3529+ if ws == nil {
26413530 ws = a.activeWorkspace()
26423531 }
26433532 if ws == nil || a.contentCoord == nil {
@@ -2657,6 +3546,7 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
26573546 a.clearResizeModeBorder()
26583547 }
26593548 if to == input.ModeResize {
3549+ // Resize mode targets the last-focused browser window's active workspace.
26603550 a.applyResizeModeBorder(ctx, a.activeWorkspace())
26613551 }
26623552
@@ -2722,6 +3612,8 @@ func (a *App) clearResizeModeBorder() {
27223612 }
27233613
27243614 func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
3615+ // activeWorkspaceView resolves via lastFocusedBrowserWindow; resize mode
3616+ // always operates on the last-focused window.
27253617 wsView := a.activeWorkspaceView()
27263618 if wsView == nil || ws == nil {
27273619 a.clearResizeModeBorder()
@@ -2809,8 +3701,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
28093701 // Note: Mode borders for tab/pane/session are attached to MainWindow.
28103702 // Resize mode border is attached to the active pane's stack container.
28113703
2812- // Set omnibox config for this workspace view
2813- wsView.SetOmniboxConfig(a.omniboxCfg)
3704+ // Set omnibox config for this workspace view.
3705+ // When the owning browser window is known, bind navigation to the
3706+ // owning window so that the omnibox always navigates in the correct
3707+ // window, regardless of global focus.
3708+ if owner := a.browserWindowForTab(tab.ID); owner != nil {
3709+ cfg := a.omniboxCfg
3710+ cfg.OnNavigate = omniboxNavigateForBrowserWindow(ctx, owner, a.navigateFromBrowserWindow)
3711+ wsView.SetOmniboxConfig(cfg)
3712+ } else {
3713+ wsView.SetOmniboxConfig(a.omniboxCfg)
3714+ }
28143715 // Set find bar config for this workspace view
28153716 wsView.SetFindBarConfig(a.findBarCfg)
28163717 // Set auto-open omnibox on new pane
@@ -2820,6 +3721,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
28203721
28213722 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
28223723 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
3724+ // Resize-mode pane focus tracks the last-focused window's workspace.
28233725 ws := a.activeWorkspace()
28243726 if ws != nil {
28253727 ws.ActivePaneID = paneID
@@ -2842,50 +3744,40 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
28423744 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
28433745 }
28443746
2845-// activeWorkspace returns the workspace of the active tab.
3747+// activeWorkspace returns the workspace of the focused browser window's active tab.
28463748 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
3749+ return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
28553750 }
28563751
28573752 // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
28583753 // This is necessary because panes in inactive tabs also need URI updates for session snapshots.
28593754 func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
2860- for _, tab := range a.tabs.Tabs {
2861- if tab.Workspace == nil {
3755+ for _, bw := range a.browserWindows {
3756+ if bw == nil || bw.tabs == nil {
28623757 continue
28633758 }
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
3759+ for _, tab := range bw.tabs.Tabs {
3760+ if tab == nil || tab.Workspace == nil {
3761+ continue
3762+ }
3763+ paneNode := tab.Workspace.FindPane(paneID)
3764+ if paneNode != nil && paneNode.Pane != nil {
3765+ paneNode.Pane.URI = url
3766+ return // Pane IDs are unique, no need to continue
3767+ }
28683768 }
28693769 }
28703770 }
28713771
2872-// activeWorkspaceView returns the workspace view for the active tab.
3772+// activeWorkspaceView returns the workspace view for the focused browser window's active tab.
28733773 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]
3774+ return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow())
28793775 }
28803776
28813777 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
28823778 // Used by the accent picker to insert accented characters into web content.
28833779 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2884- if a.contentCoord == nil {
2885- return nil
2886- }
2887-
2888- wv := a.contentCoord.ActiveWebView(context.Background())
3780+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
28893781 if wv == nil {
28903782 return nil
28913783 }
@@ -2952,9 +3844,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
29523844 return
29533845 }
29543846
2955- // Swap content (MainWindow.SetContent now properly removes old content)
3847+ // Swap content (MainWindow.SetContent now properly removes old content).
3848+ // Active tab state is managed by TabList.SetActive; no per-window field needed.
29563849 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
2957- target.activeTabID = tabID
29583850 target.mainWindow.SetContent(gtkWidget)
29593851 } else if a.mainWindow != nil {
29603852 a.mainWindow.SetContent(gtkWidget)
@@ -3292,7 +4184,7 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta
32924184 }
32934185
32944186 func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
3295- activeTab := a.tabs.ActiveTab()
4187+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
32964188 if activeTab == nil {
32974189 return floatingSessionKey{}, nil, false
32984190 }
@@ -3377,7 +4269,7 @@ func (a *App) syncFloatingFocus() {
33774269 return
33784270 }
33794271
3380- activeTab := a.tabs.ActiveTab()
4272+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
33814273 if activeTab == nil {
33824274 a.contentCoord.ClearActivePaneOverride()
33834275 return
@@ -3402,6 +4294,14 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
34024294
34034295 if session.omnibox == nil {
34044296 cfg := a.omniboxCfg
4297+ cfg.OnNavigate = func(url string) {
4298+ if session.pane == nil {
4299+ return
4300+ }
4301+ if err := session.pane.Navigate(ctx, url); err != nil {
4302+ logging.FromContext(ctx).Error().Err(err).Str("url", url).Msg("floating omnibox navigation failed")
4303+ }
4304+ }
34054305 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
34064306 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
34074307 }
@@ -3619,7 +4519,7 @@ func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
36194519
36204520 // ToggleFloatingPane toggles the active workspace floating pane visibility.
36214521 func (a *App) ToggleFloatingPane(ctx context.Context) error {
3622- activeTab := a.tabs.ActiveTab()
4522+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
36234523 if activeTab == nil {
36244524 return nil
36254525 }
@@ -3640,7 +4540,7 @@ func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url str
36404540 }
36414541
36424542 func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
3643- activeTab := a.tabs.ActiveTab()
4543+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
36444544 if activeTab == nil {
36454545 return nil
36464546 }
@@ -3694,14 +4594,10 @@ func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
36944594 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
36954595 return session.pane.Navigate(ctx, url)
36964596 }
3697- if a.omniboxNavigateFn != nil {
3698- a.omniboxNavigateFn(url)
3699- return nil
4597+ if bw := a.lastFocusedBrowserWindow(); bw != nil {
4598+ return a.navigateFromBrowserWindow(ctx, bw, url)
37004599 }
3701- if a.navCoord == nil {
3702- return fmt.Errorf("navigation coordinator not initialized")
3703- }
3704- return a.navCoord.Navigate(ctx, url)
4600+ return fmt.Errorf("no focused browser window for omnibox navigation")
37054601 }
37064602
37074603 // ToggleOmnibox implements OmniboxProvider.
@@ -3802,13 +4698,6 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
38024698 }
38034699 }
38044700
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-
38124701 // initFilteringAsync starts background filter loading with toast feedback.
38134702 func (a *App) initConfigWatcher(ctx context.Context) {
38144703 log := logging.FromContext(ctx)
M internal/ui/app_browser_launch_test.go +1504 -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,76 @@ 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+
1285+ 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 TestTabBarSetVisibleDoesNotClearAutoHiddenInteractionState(t *testing.T) {
1301+ gtkApp := requireGTKDisplayApp(t)
1302+ defer gtkApp.Unref()
1303+
1304+ tabBar := component.NewTabBar()
1305+ if tabBar == nil {
1306+ t.Fatal("tab bar creation failed")
1307+ }
1308+
1309+ tabBar.SetAutoHidden(true)
9861310 tabBar.SetVisible(false)
1311+ tabBar.SetVisible(true)
1312+
1313+ box := tabBar.Box()
1314+ if !box.GetVisible() {
1315+ t.Fatal("tab bar should stay mounted when explicitly visible")
1316+ }
1317+ if got := box.GetOpacity(); got != 0.0 {
1318+ t.Fatalf("tab bar opacity = %v, want 0 while auto-hidden", got)
1319+ }
1320+ if got := box.GetCanTarget(); got {
1321+ t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
1322+ }
1323+ if got := box.GetFocusable(); got {
1324+ t.Fatalf("tab bar focusable = %v, want false while auto-hidden", got)
1325+ }
1326+}
1327+
1328+func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
1329+ gtkApp := requireGTKDisplayApp(t)
1330+ defer gtkApp.Unref()
1331+
1332+ mainWindow := &window.MainWindow{}
1333+ tabBar := component.NewTabBar()
1334+ if tabBar == nil {
1335+ t.Fatal("tab bar creation failed")
1336+ }
9871337 setWindowTabBar(t, mainWindow, tabBar)
9881338 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
9891339 app := &App{
@@ -993,8 +1343,141 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(
9931343
9941344 app.updateBrowserWindowTabBarVisibility(bw)
9951345
996- if got := windowTabBarVisible(t, mainWindow); !got {
997- t.Fatalf("tab bar visible = %v, want true", got)
1346+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1347+}
1348+
1349+func TestApp_ActivePaneIDForNilBrowserWindowIgnoresStaleOverride(t *testing.T) {
1350+ ctx := context.Background()
1351+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1352+ contentCoord.SetActivePaneOverride(entity.PaneID("stale-pane"))
1353+ app := &App{contentCoord: contentCoord}
1354+
1355+ if got := app.activePaneIDForBrowserWindow(nil); got != "" {
1356+ t.Fatalf("active pane for nil browser window = %q, want empty", got)
1357+ }
1358+}
1359+
1360+func TestApp_BuildRestoredWindowUIUpdatesEmptyWindowTabBarVisibility(t *testing.T) {
1361+ gtkApp := requireGTKDisplayApp(t)
1362+ defer gtkApp.Unref()
1363+
1364+ mw, err := window.New(context.Background(), gtkApp, "bottom")
1365+ if err != nil {
1366+ t.Fatalf("window creation failed: %v", err)
1367+ }
1368+ defer mw.Destroy()
1369+
1370+ mw.SetTabBarContentInsetVisible(true)
1371+ bw := &browserWindow{id: "window-1", mainWindow: mw, tabs: entity.NewTabList()}
1372+ app := &App{deps: &Dependencies{Config: &config.Config{}}}
1373+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1374+
1375+ app.buildRestoredWindowUI(context.Background(), []*browserWindow{bw})
1376+
1377+ assertWindowTabBarAutoHidden(t, mw, true)
1378+ if mw.HasTabBarContentInset() {
1379+ t.Fatal("empty restored bottom window should not keep tab bar content inset")
1380+ }
1381+}
1382+
1383+func TestApp_UpdateBrowserWindowTabBarVisibility_BottomInset(t *testing.T) {
1384+ gtkApp := requireGTKDisplayApp(t)
1385+ defer gtkApp.Unref()
1386+
1387+ // Use a real bottom MainWindow so SetTabBarContentInsetVisible is effective.
1388+ mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1389+ if err != nil {
1390+ t.Fatalf("window creation failed: %v", err)
1391+ }
1392+ defer mainWindow.Destroy()
1393+
1394+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1395+ app := &App{
1396+ deps: &Dependencies{Config: &config.Config{}},
1397+ }
1398+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1399+
1400+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1401+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1402+
1403+ // One tab + hide enabled: auto-hidden and no inset
1404+ mainWindow.TabBar().AddTab(tab1)
1405+ app.updateBrowserWindowTabBarVisibility(bw)
1406+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1407+ if mainWindow.HasTabBarContentInset() {
1408+ t.Fatal("one tab: expected no content area inset")
1409+ }
1410+
1411+ // Two tabs: visible and inset present
1412+ mainWindow.TabBar().AddTab(tab2)
1413+ app.updateBrowserWindowTabBarVisibility(bw)
1414+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1415+ if !mainWindow.HasTabBarContentInset() {
1416+ t.Fatal("two tabs: expected content area inset")
1417+ }
1418+
1419+ // Remove back to one tab: auto-hidden and no inset
1420+ mainWindow.TabBar().RemoveTab(tab2.ID)
1421+ app.updateBrowserWindowTabBarVisibility(bw)
1422+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1423+ if mainWindow.HasTabBarContentInset() {
1424+ t.Fatal("back to one tab: expected no content area inset")
1425+ }
1426+}
1427+
1428+func TestApp_HandlePaneFullscreenChanged_ClearsBottomInset(t *testing.T) {
1429+ gtkApp := requireGTKDisplayApp(t)
1430+ defer gtkApp.Unref()
1431+
1432+ // Use a bottom MainWindow so SetTabBarContentInsetVisible is effective.
1433+ mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1434+ if err != nil {
1435+ t.Fatalf("window creation failed: %v", err)
1436+ }
1437+ defer mainWindow.Destroy()
1438+
1439+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1440+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1441+ mainWindow.TabBar().AddTab(tab1)
1442+ mainWindow.TabBar().AddTab(tab2)
1443+
1444+ tabs := entity.NewTabList()
1445+ tabs.Add(tab1)
1446+ tabs.Add(tab2)
1447+ tabs.SetActive(tab2.ID)
1448+
1449+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow, tabs: tabs}
1450+ app := &App{
1451+ tabs: tabs,
1452+ deps: &Dependencies{Config: &config.Config{}},
1453+ browserWindows: map[string]*browserWindow{bw.id: bw},
1454+ lastFocusedWindowID: bw.id,
1455+ }
1456+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1457+
1458+ // Start with two tabs: inset should be present
1459+ app.updateBrowserWindowTabBarVisibility(bw)
1460+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1461+ if !mainWindow.HasTabBarContentInset() {
1462+ t.Fatal("two tabs: expected content area inset before fullscreen")
1463+ }
1464+
1465+ // Enter fullscreen: tab bar hidden, inset cleared
1466+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
1467+ if windowTabBarVisible(t, mainWindow) {
1468+ t.Fatal("fullscreen: tab bar should be not visible")
1469+ }
1470+ if mainWindow.HasTabBarContentInset() {
1471+ t.Fatal("fullscreen: expected no content area inset")
1472+ }
1473+
1474+ // Exit fullscreen: tab bar restored, inset restored
1475+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
1476+ if !windowTabBarVisible(t, mainWindow) {
1477+ t.Fatal("exited fullscreen: tab bar should be visible")
1478+ }
1479+ if !mainWindow.HasTabBarContentInset() {
1480+ t.Fatal("exited fullscreen: expected content area inset to be restored")
9981481 }
9991482 }
10001483
@@ -1012,16 +1495,17 @@ func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *t
10121495 firstValid.id: firstValid,
10131496 secondValid.id: secondValid,
10141497 },
1498+ browserWindowOrder: []string{removed.id, nilWindow.id, secondValid.id, firstValid.id},
10151499 lastFocusedWindowID: removed.id,
10161500 }
10171501
10181502 app.removeBrowserWindow(removed.id)
10191503
1020- if app.mainWindow != firstValid.mainWindow {
1021- t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow)
1504+ if app.mainWindow != secondValid.mainWindow {
1505+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow)
10221506 }
1023- if app.lastFocusedWindowID != firstValid.id {
1024- t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id)
1507+ if app.lastFocusedWindowID != secondValid.id {
1508+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, secondValid.id)
10251509 }
10261510 }
10271511 }
@@ -1070,6 +1554,52 @@ func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
10701554 }
10711555 }
10721556
1557+func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
1558+ ctx := context.Background()
1559+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1560+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1561+ globalTabs := entity.NewTabList()
1562+ globalTabs.Add(firstTab)
1563+ globalTabs.Add(secondTab)
1564+ firstTabs := entity.NewTabList()
1565+ firstTabs.Add(firstTab)
1566+ secondTabs := entity.NewTabList()
1567+ secondTabs.Add(secondTab)
1568+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1569+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1570+ app := &App{
1571+ deps: &Dependencies{Config: &config.Config{}},
1572+ tabs: globalTabs,
1573+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }),
1574+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1575+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1576+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1577+ lastFocusedWindowID: first.id,
1578+ mainWindow: first.mainWindow,
1579+ }
1580+ app.initTabCoordinator(ctx)
1581+
1582+ err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
1583+ ParentPaneID: secondTab.Workspace.ActivePaneID,
1584+ PopupPane: entity.NewPane(entity.PaneID("popup-pane")),
1585+ TargetURI: "https://example.com/popup",
1586+ })
1587+ if err != nil {
1588+ t.Fatalf("createPopupTab returned error: %v", err)
1589+ }
1590+
1591+ if first.tabs.Find(entity.TabID("popup-tab")) != nil {
1592+ t.Fatalf("popup tab was added to stale focused window")
1593+ }
1594+ created := second.tabs.Find(entity.TabID("popup-tab"))
1595+ if created == nil {
1596+ t.Fatalf("popup tab was not added to parent pane owner window")
1597+ }
1598+ if got := app.windowForTab[created.ID]; got != second {
1599+ t.Fatalf("popup tab owner = %p, want %p", got, second)
1600+ }
1601+}
1602+
10731603 func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
10741604 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
10751605 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
@@ -1085,3 +1615,930 @@ func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
10851615 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
10861616 }
10871617 }
1618+
1619+func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T) {
1620+ ctx := context.Background()
1621+ firstTabs := entity.NewTabList()
1622+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1623+ firstTabs.Add(firstTab)
1624+ secondTabs := entity.NewTabList()
1625+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1626+ secondTabs.Add(secondTab)
1627+
1628+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1629+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1630+ app := &App{
1631+ deps: &Dependencies{Config: &config.Config{}},
1632+ tabs: entity.NewTabList(),
1633+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }),
1634+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1635+ lastFocusedWindowID: first.id,
1636+ mainWindow: first.mainWindow,
1637+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1638+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1639+ }
1640+ app.tabs.Add(firstTab)
1641+ app.tabs.Add(secondTab)
1642+ app.initTabCoordinator(ctx)
1643+
1644+ created, err := app.tabCoord.Create(ctx, app.tabTargetForBrowserWindow(second), "https://example.com")
1645+ if err != nil {
1646+ t.Fatalf("Create returned error: %v", err)
1647+ }
1648+ if got := app.windowForTab[created.ID]; got != second {
1649+ t.Fatalf("created tab owner = %p, want second window %p", got, second)
1650+ }
1651+ if app.mainWindow != second.mainWindow {
1652+ t.Fatalf("mainWindow after create switch = %p, want %p", app.mainWindow, second.mainWindow)
1653+ }
1654+
1655+ app.mainWindow = first.mainWindow
1656+ app.lastFocusedWindowID = first.id
1657+ if err := app.tabCoord.Switch(ctx, app.tabTargetForBrowserWindow(second), secondTab.ID); err != nil {
1658+ t.Fatalf("Switch returned error: %v", err)
1659+ }
1660+ if app.mainWindow != second.mainWindow {
1661+ t.Fatalf("mainWindow after target switch = %p, want %p", app.mainWindow, second.mainWindow)
1662+ }
1663+}
1664+
1665+func TestApp_ActivateBrowserWindowAddsGlobalSnapshotWithoutChangingWindowTabPosition(t *testing.T) {
1666+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1667+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1668+ firstTabs := entity.NewTabList()
1669+ firstTabs.Add(firstTab)
1670+ secondTabs := entity.NewTabList()
1671+ secondTabs.Add(secondTab)
1672+ secondTabs.SetActive(secondTab.ID)
1673+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1674+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1675+ globalTabs := entity.NewTabList()
1676+ globalTabs.Add(firstTab)
1677+ app := &App{
1678+ tabs: globalTabs,
1679+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1680+ }
1681+
1682+ app.activateBrowserWindow(second)
1683+
1684+ if secondTab.Position != 0 {
1685+ t.Fatalf("window tab position = %d, want 0", secondTab.Position)
1686+ }
1687+ globalSecond := app.tabs.Find(secondTab.ID)
1688+ if globalSecond == nil {
1689+ t.Fatalf("global tab snapshot was not added")
1690+ }
1691+ if globalSecond == secondTab {
1692+ t.Fatalf("global tab should be a snapshot, not the live per-window tab")
1693+ }
1694+ if globalSecond.Position != 1 {
1695+ t.Fatalf("global tab position = %d, want 1", globalSecond.Position)
1696+ }
1697+}
1698+
1699+func TestApp_ActiveWorkspaceRequiresFocusedBrowserWindow(t *testing.T) {
1700+ globalTab := entity.NewTab(entity.TabID("global-tab"), entity.WorkspaceID("ws-global"), entity.NewPane(entity.PaneID("pane-global")))
1701+ globalTabs := entity.NewTabList()
1702+ globalTabs.Add(globalTab)
1703+ globalTabs.SetActive(globalTab.ID)
1704+ app := &App{
1705+ tabs: globalTabs,
1706+ browserWindows: map[string]*browserWindow{},
1707+ workspaceViews: map[entity.TabID]*component.WorkspaceView{globalTab.ID: {}},
1708+ }
1709+
1710+ if got := app.activeWorkspace(); got != nil {
1711+ t.Fatalf("activeWorkspace() = %p, want nil without focused browser window", got)
1712+ }
1713+ if got := app.activeWorkspaceView(); got != nil {
1714+ t.Fatalf("activeWorkspaceView() = %p, want nil without focused browser window", got)
1715+ }
1716+}
1717+
1718+func TestApp_ActiveWorkspaceUsesLastFocusedWindowTabsNotGlobalActiveTab(t *testing.T) {
1719+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1720+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1721+ firstTabs := entity.NewTabList()
1722+ firstTabs.Add(firstTab)
1723+ firstTabs.SetActive(firstTab.ID)
1724+ secondTabs := entity.NewTabList()
1725+ secondTabs.Add(secondTab)
1726+ secondTabs.SetActive(secondTab.ID)
1727+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1728+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1729+ globalTabs := entity.NewTabList()
1730+ globalTabs.Add(firstTab)
1731+ globalTabs.Add(secondTab)
1732+ globalTabs.SetActive(firstTab.ID)
1733+ secondView := &component.WorkspaceView{}
1734+ app := &App{
1735+ tabs: globalTabs,
1736+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1737+ lastFocusedWindowID: second.id,
1738+ workspaceViews: map[entity.TabID]*component.WorkspaceView{secondTab.ID: secondView},
1739+ }
1740+
1741+ if got := app.activeWorkspace(); got != secondTab.Workspace {
1742+ t.Fatalf("activeWorkspace() = %p, want second workspace %p", got, secondTab.Workspace)
1743+ }
1744+ if got := app.activeWorkspaceView(); got != secondView {
1745+ t.Fatalf("activeWorkspaceView() = %p, want second view %p", got, secondView)
1746+ }
1747+}
1748+
1749+// fakeRecordingWebView is a complete port.WebView that records which navigation
1750+// methods were called and with what arguments. It does not use reflect or unsafe.
1751+type fakeRecordingWebView struct {
1752+ id port.WebViewID
1753+
1754+ loadURICalled bool
1755+ loadURILastURI string
1756+ reloadCalls int
1757+ reloadBypassCacheCalls int
1758+ stopCalls int
1759+ goBackCalls int
1760+ goForwardCalls int
1761+ loadHTMLCalls int
1762+ setZoomLevelCalls int
1763+ setZoomLevelLastLevel float64
1764+ openDevToolsCalls int
1765+ printPageCalls int
1766+}
1767+
1768+func (f *fakeRecordingWebView) ID() port.WebViewID { return f.id }
1769+
1770+func (f *fakeRecordingWebView) LoadURI(_ context.Context, uri string) error {
1771+ f.loadURICalled = true
1772+ f.loadURILastURI = uri
1773+ return nil
1774+}
1775+
1776+func (f *fakeRecordingWebView) LoadHTML(_ context.Context, _, _ string) error {
1777+ f.loadHTMLCalls++
1778+ return nil
1779+}
1780+
1781+func (f *fakeRecordingWebView) Reload(_ context.Context) error {
1782+ f.reloadCalls++
1783+ return nil
1784+}
1785+
1786+func (f *fakeRecordingWebView) ReloadBypassCache(_ context.Context) error {
1787+ f.reloadBypassCacheCalls++
1788+ return nil
1789+}
1790+
1791+func (f *fakeRecordingWebView) Stop(_ context.Context) error {
1792+ f.stopCalls++
1793+ return nil
1794+}
1795+
1796+func (f *fakeRecordingWebView) GoBack(_ context.Context) error {
1797+ f.goBackCalls++
1798+ return nil
1799+}
1800+
1801+func (f *fakeRecordingWebView) GoForward(_ context.Context) error {
1802+ f.goForwardCalls++
1803+ return nil
1804+}
1805+
1806+func (f *fakeRecordingWebView) State() port.WebViewState { return port.WebViewState{} }
1807+
1808+func (f *fakeRecordingWebView) URI() string { return f.loadURILastURI }
1809+func (f *fakeRecordingWebView) Title() string { return "" }
1810+
1811+func (f *fakeRecordingWebView) IsLoading() bool { return false }
1812+func (f *fakeRecordingWebView) EstimatedProgress() float64 { return 0 }
1813+func (f *fakeRecordingWebView) CanGoBack() bool { return false }
1814+func (f *fakeRecordingWebView) CanGoForward() bool { return false }
1815+
1816+func (f *fakeRecordingWebView) SetZoomLevel(_ context.Context, level float64) error {
1817+ f.setZoomLevelCalls++
1818+ f.setZoomLevelLastLevel = level
1819+ return nil
1820+}
1821+
1822+func (f *fakeRecordingWebView) OpenDevTools() { f.openDevToolsCalls++ }
1823+func (f *fakeRecordingWebView) PrintPage() { f.printPageCalls++ }
1824+
1825+func (f *fakeRecordingWebView) GetZoomLevel() float64 { return 1.0 }
1826+func (f *fakeRecordingWebView) GetFindController() port.FindController { return nil }
1827+func (f *fakeRecordingWebView) SetCallbacks(_ *port.WebViewCallbacks) {}
1828+func (f *fakeRecordingWebView) RunJavaScript(_ context.Context, _ string) {}
1829+func (f *fakeRecordingWebView) SetBackgroundColor(_, _, _, _ float64) {}
1830+func (f *fakeRecordingWebView) ResetBackgroundToDefault() {}
1831+func (f *fakeRecordingWebView) Favicon() port.Texture { return nil }
1832+func (f *fakeRecordingWebView) Generation() uint64 { return 0 }
1833+func (f *fakeRecordingWebView) IsFullscreen() bool { return false }
1834+func (f *fakeRecordingWebView) IsPlayingAudio() bool { return false }
1835+func (f *fakeRecordingWebView) IsDestroyed() bool { return false }
1836+func (f *fakeRecordingWebView) Destroy() {}
1837+
1838+type fakeZoomRepo struct{}
1839+
1840+func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil }
1841+func (f *fakeZoomRepo) Set(context.Context, *entity.ZoomLevel) error { return nil }
1842+func (f *fakeZoomRepo) Delete(context.Context, string) error { return nil }
1843+func (f *fakeZoomRepo) GetAll(context.Context) ([]*entity.ZoomLevel, error) { return nil, nil }
1844+
1845+func TestApp_BrowserWindowWebViewActionsIgnoreStaleFocusedWindow(t *testing.T) {
1846+ ctx := context.Background()
1847+
1848+ // Build two browser windows with independent bw.tabs and active tabs/panes.
1849+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1850+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
1851+
1852+ firstTabs := entity.NewTabList()
1853+ firstTabs.Add(tab1)
1854+ firstTabs.SetActive(tab1.ID)
1855+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1856+
1857+ secondTabs := entity.NewTabList()
1858+ secondTabs.Add(tab2)
1859+ secondTabs.SetActive(tab2.ID)
1860+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1861+
1862+ // Create fake recording webviews.
1863+ fakeWv1 := &fakeRecordingWebView{id: 1}
1864+ fakeWv2 := &fakeRecordingWebView{id: 2}
1865+
1866+ // Create content coordinator with initialized internal maps to avoid
1867+ // nil-map panics in SetNavigationOrigin during NavigateWebView calls.
1868+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1869+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
1870+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
1871+
1872+ // Create NavigationCoordinator (no navigateUC needed for direct webview calls).
1873+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1874+
1875+ app := &App{
1876+ tabs: entity.NewTabList(),
1877+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1878+ lastFocusedWindowID: first.id, // stale! should NOT be used
1879+ contentCoord: contentCoord,
1880+ navCoord: navCoord,
1881+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
1882+ tab1.ID: &component.WorkspaceView{},
1883+ tab2.ID: &component.WorkspaceView{},
1884+ },
1885+ }
1886+ app.tabs.Add(tab1)
1887+ app.tabs.Add(tab2)
1888+
1889+ // Call window-scoped app helpers for the second window.
1890+ if err := app.navigateFromBrowserWindow(ctx, second, "https://google.com"); err != nil {
1891+ t.Fatalf("navigateFromBrowserWindow returned error: %v", err)
1892+ }
1893+ if err := app.reloadBrowserWindow(ctx, second, false); err != nil {
1894+ t.Fatalf("reloadBrowserWindow (soft) returned error: %v", err)
1895+ }
1896+ if err := app.reloadBrowserWindow(ctx, second, true); err != nil {
1897+ t.Fatalf("reloadBrowserWindow (hard) returned error: %v", err)
1898+ }
1899+ if err := app.stopBrowserWindow(ctx, second); err != nil {
1900+ t.Fatalf("stopBrowserWindow returned error: %v", err)
1901+ }
1902+ if err := app.goBackBrowserWindow(ctx, second); err != nil {
1903+ t.Fatalf("goBackBrowserWindow returned error: %v", err)
1904+ }
1905+ if err := app.goForwardBrowserWindow(ctx, second); err != nil {
1906+ t.Fatalf("goForwardBrowserWindow returned error: %v", err)
1907+ }
1908+
1909+ assertRecordingWebViewIdle(t, "first", fakeWv1)
1910+ assertRecordingWebViewActions(t, "second", fakeWv2, "https://google.com")
1911+}
1912+
1913+func assertRecordingWebViewIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
1914+ t.Helper()
1915+ if wv.loadURICalled || wv.loadURILastURI != "" {
1916+ t.Errorf("%s webview LoadURI was called, expected zero calls", name)
1917+ }
1918+ if wv.reloadCalls > 0 {
1919+ t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
1920+ }
1921+ if wv.reloadBypassCacheCalls > 0 {
1922+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
1923+ }
1924+ if wv.stopCalls > 0 {
1925+ t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
1926+ }
1927+ if wv.goBackCalls > 0 {
1928+ t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
1929+ }
1930+ if wv.goForwardCalls > 0 {
1931+ t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
1932+ }
1933+}
1934+
1935+func assertRecordingWebViewActions(t *testing.T, name string, wv *fakeRecordingWebView, wantURI string) {
1936+ t.Helper()
1937+ if !wv.loadURICalled {
1938+ t.Errorf("%s webview LoadURI was not called", name)
1939+ }
1940+ if wv.loadURILastURI != wantURI {
1941+ t.Errorf("%s webview LoadURI URI = %q, want %q", name, wv.loadURILastURI, wantURI)
1942+ }
1943+ if wv.reloadCalls != 1 {
1944+ t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
1945+ }
1946+ if wv.reloadBypassCacheCalls != 1 {
1947+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
1948+ }
1949+ if wv.stopCalls != 1 {
1950+ t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
1951+ }
1952+ if wv.goBackCalls != 1 {
1953+ t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
1954+ }
1955+ if wv.goForwardCalls != 1 {
1956+ t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
1957+ }
1958+}
1959+
1960+func assertRecordingWebViewBrowserActionsIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
1961+ t.Helper()
1962+ if wv.reloadCalls > 0 {
1963+ t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
1964+ }
1965+ if wv.reloadBypassCacheCalls > 0 {
1966+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
1967+ }
1968+ if wv.stopCalls > 0 {
1969+ t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
1970+ }
1971+ if wv.goBackCalls > 0 {
1972+ t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
1973+ }
1974+ if wv.goForwardCalls > 0 {
1975+ t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
1976+ }
1977+ if wv.printPageCalls > 0 {
1978+ t.Errorf("%s webview PrintPage calls = %d, want 0", name, wv.printPageCalls)
1979+ }
1980+ if wv.openDevToolsCalls > 0 {
1981+ t.Errorf("%s webview OpenDevTools calls = %d, want 0", name, wv.openDevToolsCalls)
1982+ }
1983+ if wv.setZoomLevelCalls > 0 {
1984+ t.Errorf("%s webview SetZoomLevel calls = %d, want 0", name, wv.setZoomLevelCalls)
1985+ }
1986+}
1987+
1988+func assertRecordingWebViewBrowserActionsCalledOnce(t *testing.T, name string, wv *fakeRecordingWebView) {
1989+ t.Helper()
1990+ if wv.reloadCalls != 1 {
1991+ t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
1992+ }
1993+ if wv.reloadBypassCacheCalls != 1 {
1994+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
1995+ }
1996+ if wv.stopCalls != 1 {
1997+ t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
1998+ }
1999+ if wv.goBackCalls != 1 {
2000+ t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
2001+ }
2002+ if wv.goForwardCalls != 1 {
2003+ t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
2004+ }
2005+ if wv.printPageCalls != 1 {
2006+ t.Errorf("%s webview PrintPage calls = %d, want 1", name, wv.printPageCalls)
2007+ }
2008+ if wv.openDevToolsCalls != 1 {
2009+ t.Errorf("%s webview OpenDevTools calls = %d, want 1", name, wv.openDevToolsCalls)
2010+ }
2011+ if wv.setZoomLevelCalls != 1 {
2012+ t.Errorf("%s webview SetZoomLevel calls = %d, want 1", name, wv.setZoomLevelCalls)
2013+ }
2014+}
2015+
2016+func TestApp_OmniboxNavigateCallbackCapturesBrowserWindow(t *testing.T) {
2017+ second := &browserWindow{id: "window-2"}
2018+
2019+ var capturedBW *browserWindow
2020+ var capturedURL string
2021+ testNavigate := func(_ context.Context, bw *browserWindow, url string) error {
2022+ capturedBW = bw
2023+ capturedURL = url
2024+ return nil
2025+ }
2026+
2027+ ctx := context.Background()
2028+ cb := omniboxNavigateForBrowserWindow(ctx, second, testNavigate)
2029+ cb("https://google.com")
2030+
2031+ if capturedBW != second {
2032+ capturedID := "<nil>"
2033+ if capturedBW != nil {
2034+ capturedID = capturedBW.id
2035+ }
2036+ t.Errorf("captured browser window = %p (id=%s), want %p (id=%s)", capturedBW, capturedID, second, second.id)
2037+ }
2038+ if capturedURL != "https://google.com" {
2039+ t.Errorf("captured URL = %q, want %q", capturedURL, "https://google.com")
2040+ }
2041+}
2042+
2043+func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
2044+ ctx := context.Background()
2045+
2046+ // Build two browser windows with independent bw.tabs and active panes.
2047+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2048+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2049+
2050+ firstTabs := entity.NewTabList()
2051+ firstTabs.Add(tab1)
2052+ firstTabs.SetActive(tab1.ID)
2053+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2054+
2055+ secondTabs := entity.NewTabList()
2056+ secondTabs.Add(tab2)
2057+ secondTabs.SetActive(tab2.ID)
2058+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2059+
2060+ // Create fake recording webviews.
2061+ fakeWv1 := &fakeRecordingWebView{id: 1, loadURILastURI: "https://first.example"}
2062+ fakeWv2 := &fakeRecordingWebView{id: 2, loadURILastURI: "https://second.example"}
2063+
2064+ // Register fake webviews in content coordinator.
2065+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2066+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2067+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2068+
2069+ // Create navCoord (no navigateUC needed for direct webview calls).
2070+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2071+
2072+ app := &App{
2073+ tabs: entity.NewTabList(),
2074+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2075+ lastFocusedWindowID: first.id, // stale! should NOT be used
2076+ contentCoord: contentCoord,
2077+ navCoord: navCoord,
2078+ deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)},
2079+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
2080+ tab1.ID: &component.WorkspaceView{},
2081+ tab2.ID: &component.WorkspaceView{},
2082+ },
2083+ }
2084+ app.tabs.Add(tab1)
2085+ app.tabs.Add(tab2)
2086+
2087+ // Dispatch browser window actions targeting the second window.
2088+ for _, a := range []input.Action{
2089+ input.ActionReload,
2090+ input.ActionHardReload,
2091+ input.ActionStop,
2092+ input.ActionGoBack,
2093+ input.ActionGoForward,
2094+ input.ActionPrintPage,
2095+ input.ActionOpenDevTools,
2096+ input.ActionZoomIn,
2097+ } {
2098+ if err := app.dispatchBrowserWindowAction(ctx, second, a); err != nil {
2099+ t.Fatalf("dispatchBrowserWindowAction (%s) returned error: %v", a, err)
2100+ }
2101+ }
2102+
2103+ // Assert first fake webview receives zero calls (stale focus is ignored).
2104+ assertRecordingWebViewBrowserActionsIdle(t, "first", fakeWv1)
2105+
2106+ // Assert second fake webview receives exactly one call per action.
2107+ assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2)
2108+}
2109+
2110+func counterIDGen() func() string {
2111+ var counter int
2112+ return func() string {
2113+ counter++
2114+ return strconv.Itoa(counter)
2115+ }
2116+}
2117+
2118+func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *testing.T) {
2119+ ctx := context.Background()
2120+
2121+ // Two windows, each with one tab.
2122+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2123+ firstTabs := entity.NewTabList()
2124+ firstTabs.Add(firstTab)
2125+ firstTabs.SetActive(firstTab.ID)
2126+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2127+
2128+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2129+ secondTabs := entity.NewTabList()
2130+ secondTabs.Add(secondTab)
2131+ secondTabs.SetActive(secondTab.ID)
2132+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2133+
2134+ app := &App{
2135+ deps: &Dependencies{
2136+ Config: &config.Config{
2137+ Workspace: config.WorkspaceConfig{
2138+ NewPaneURL: "about:blank",
2139+ },
2140+ },
2141+ },
2142+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2143+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2144+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2145+ lastFocusedWindowID: first.id, // stale — should NOT be used
2146+ }
2147+ app.initTabCoordinator(ctx)
2148+
2149+ // Dispatch ActionSwitchTabIndex4 targeting the second window.
2150+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2151+ if err != nil {
2152+ t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2153+ }
2154+
2155+ // secondTabs should have 4 tabs (1 existing + 3 new).
2156+ if got := secondTabs.Count(); got != 4 {
2157+ t.Fatalf("second target tab count = %d, want 4", got)
2158+ }
2159+ if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[3].ID {
2160+ t.Fatalf("active tab = %q, want %q (index 3)", got, secondTabs.Tabs[3].ID)
2161+ }
2162+
2163+ // firstTabs must be completely unchanged.
2164+ if got := firstTabs.Count(); got != 1 {
2165+ t.Fatalf("first target tab count = %d, want 1", got)
2166+ }
2167+
2168+ // windowForTab should map all second window tabs to second.
2169+ for _, tab := range secondTabs.Tabs {
2170+ if got := app.windowForTab[tab.ID]; got != second {
2171+ t.Fatalf("windowForTab[%s] = %p, want second window %p", tab.ID, got, second)
2172+ }
2173+ }
2174+}
2175+
2176+func TestApp_WorkspaceOmniboxNavigateUsesOwnerWindow(t *testing.T) {
2177+ gtkApp := requireGTKDisplayApp(t)
2178+ defer gtkApp.Unref()
2179+
2180+ ctx := context.Background()
2181+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2182+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2183+ firstTabs := entity.NewTabList()
2184+ firstTabs.Add(tab1)
2185+ firstTabs.SetActive(tab1.ID)
2186+ secondTabs := entity.NewTabList()
2187+ secondTabs.Add(tab2)
2188+ secondTabs.SetActive(tab2.ID)
2189+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2190+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2191+
2192+ fakeWv1 := &fakeRecordingWebView{id: 1}
2193+ fakeWv2 := &fakeRecordingWebView{id: 2}
2194+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2195+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2196+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2197+
2198+ app := &App{
2199+ deps: &Dependencies{Config: &config.Config{}},
2200+ widgetFactory: layout.NewGtkWidgetFactory(),
2201+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2202+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
2203+ lastFocusedWindowID: first.id,
2204+ contentCoord: contentCoord,
2205+ navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
2206+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
2207+ }
2208+
2209+ app.createWorkspaceViewWithoutAttach(ctx, tab2)
2210+ wsView := app.workspaceViews[tab2.ID]
2211+ if wsView == nil {
2212+ t.Fatal("workspace view was not created for second tab")
2213+ }
2214+ wsView.ShowOmnibox(ctx, "")
2215+ cb := omniboxNavigateCallbackForTest(t, wsView.GetOmnibox())
2216+ cb("https://example.com")
2217+
2218+ if fakeWv1.loadURICalled {
2219+ t.Errorf("first window webview navigated to %q, want no navigation", fakeWv1.loadURILastURI)
2220+ }
2221+ if !fakeWv2.loadURICalled || fakeWv2.loadURILastURI != "https://example.com" {
2222+ t.Errorf("second window webview navigation = called %v uri %q, want https://example.com", fakeWv2.loadURICalled, fakeWv2.loadURILastURI)
2223+ }
2224+}
2225+
2226+func TestApp_FloatingOmniboxNavigateUsesSessionPane(t *testing.T) {
2227+ gtkApp := requireGTKDisplayApp(t)
2228+ defer gtkApp.Unref()
2229+
2230+ ctx := context.Background()
2231+ factory := layout.NewGtkWidgetFactory()
2232+ overlay := factory.NewOverlay()
2233+
2234+ loadedURL := ""
2235+ pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
2236+ OnNavigate: func(_ context.Context, url string) error {
2237+ loadedURL = url
2238+ return nil
2239+ },
2240+ })
2241+ session := &floatingWorkspaceSession{
2242+ paneID: entity.PaneID("floating-pane"),
2243+ pane: pane,
2244+ overlay: overlay,
2245+ }
2246+ app := &App{widgetFactory: factory}
2247+
2248+ app.showFloatingOmnibox(ctx, session)
2249+ cb := omniboxNavigateCallbackForTest(t, session.omnibox)
2250+ cb("https://example.com")
2251+
2252+ if loadedURL != "https://example.com" {
2253+ t.Fatalf("floating pane loaded URL = %q, want https://example.com", loadedURL)
2254+ }
2255+}
2256+
2257+// TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge verifies that existing
2258+// browserWindows not participating in the restore do not leak their tabs into the
2259+// restored global tab list or UI aggregation. Only runtimeWindows (the windows
2260+// actually restored) contribute.
2261+func TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge(t *testing.T) {
2262+ // Two stale browserWindows exist in the map; only one window is restored.
2263+ staleBW1 := &browserWindow{id: "stale-w1"}
2264+ staleBW2 := &browserWindow{id: "stale-w2"}
2265+
2266+ staleTab1 := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-ws"), entity.NewPane(entity.PaneID("stale-pane")))
2267+ staleTab1.Name = "StaleTab"
2268+ staleBW1.tabs = entity.NewTabList()
2269+ staleBW1.tabs.Add(staleTab1)
2270+
2271+ staleBW2.tabs = entity.NewTabList()
2272+
2273+ // One restored window with its own tab. Tab names survive the
2274+ // snapshot/restore cycle even though IDs are regenerated.
2275+ restoredTabs := entity.NewTabList()
2276+ restoredTab := entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-ws"), entity.NewPane(entity.PaneID("restored-pane")))
2277+ restoredTab.Name = "RestoredTab"
2278+ restoredTabs.Add(restoredTab)
2279+
2280+ sessionID := entity.SessionID("test-session")
2281+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2282+ {WindowID: "saved-w1", Tabs: restoredTabs},
2283+ }, 0, time.Unix(123, 0))
2284+
2285+ mainWindow := &window.MainWindow{}
2286+ runtimeBW := &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2287+
2288+ app := &App{
2289+ deps: &Dependencies{
2290+ Config: &config.Config{},
2291+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2292+ },
2293+ mainWindow: mainWindow,
2294+ browserWindows: map[string]*browserWindow{
2295+ staleBW1.id: staleBW1,
2296+ staleBW2.id: staleBW2,
2297+ runtimeBW.id: runtimeBW,
2298+ },
2299+ tabs: entity.NewTabList(),
2300+ }
2301+ // Seed global tabs with a stale tab to verify it is replaced.
2302+ app.tabs.Add(staleTab1)
2303+
2304+ if err := app.restoreSession(context.Background(), sessionID); err != nil {
2305+ t.Fatalf("restoreSession returned error: %v", err)
2306+ }
2307+
2308+ // Global tabs must only contain the restored tab, not stale tabs.
2309+ if got := app.tabs.Count(); got != 1 {
2310+ t.Fatalf("tabs.Count() = %d, want 1 (only restored tab)", got)
2311+ }
2312+ // Verify by name since IDs are regenerated during restore.
2313+ restored := app.tabs.Tabs[0]
2314+ if restored == nil || restored.Name != "RestoredTab" {
2315+ t.Fatalf("restored tab name = %q, want RestoredTab", firstTabName(app.tabs))
2316+ }
2317+ if app.tabs.Find(staleTab1.ID) != nil {
2318+ t.Fatal("stale tab leaked into global tabs")
2319+ }
2320+}
2321+
2322+func firstTabName(tl *entity.TabList) string {
2323+ if len(tl.Tabs) == 0 || tl.Tabs[0] == nil {
2324+ return ""
2325+ }
2326+ return tl.Tabs[0].Name
2327+}
2328+
2329+// TestApp_RestoreSessionHonorsActiveWindowIndex verifies that when restoring a
2330+// v2 multi-window session, the ActiveWindowIndex from the snapshot is used to
2331+// determine the focused window and global active tab.
2332+func TestApp_RestoreSessionHonorsActiveWindowIndex(t *testing.T) {
2333+ gtkApp := requireGTKDisplayApp(t)
2334+ defer gtkApp.Unref()
2335+
2336+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
2337+ if err != nil {
2338+ t.Fatalf("window creation failed: %v", err)
2339+ }
2340+ defer mainWindow.Destroy()
2341+
2342+ // Window 0 has a tab named "Tab-W0", window 1 has "Tab-W1" (active).
2343+ // Tab names survive snapshot/restore; IDs are regenerated.
2344+ pane0 := entity.NewPane("pane-w0")
2345+ tabW0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2346+ tabW0.Name = "Tab-W0"
2347+ tabs0 := entity.NewTabList()
2348+ tabs0.Add(tabW0)
2349+
2350+ pane1 := entity.NewPane("pane-w1")
2351+ tabW1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2352+ tabW1.Name = "Tab-W1"
2353+ tabs1 := entity.NewTabList()
2354+ tabs1.Add(tabW1)
2355+ tabs1.SetActive("tab-w1")
2356+
2357+ sessionID := entity.SessionID("test-sess")
2358+ // ActiveWindowIndex = 1: second window should be focused.
2359+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2360+ {WindowID: "saved-w0", Tabs: tabs0},
2361+ {WindowID: "saved-w1", Tabs: tabs1},
2362+ }, 1, time.Unix(123, 0))
2363+
2364+ firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2365+ // Factory creates the second runtime window; share the mainWindow so the UI
2366+ // restore loop can walk its tab bar without needing a second GTK window.
2367+ var runtimeW1 *browserWindow
2368+ app := &App{
2369+ deps: &Dependencies{
2370+ Config: &config.Config{},
2371+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2372+ },
2373+ mainWindow: mainWindow,
2374+ widgetFactory: layout.NewGtkWidgetFactory(),
2375+ browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2376+ tabs: entity.NewTabList(),
2377+ windowForTab: map[entity.TabID]*browserWindow{},
2378+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
2379+ browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2380+ runtimeW1 = &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2381+ return runtimeW1, nil
2382+ },
2383+ }
2384+
2385+ if err := app.restoreSession(context.Background(), sessionID); err != nil {
2386+ t.Fatalf("restoreSession returned error: %v", err)
2387+ }
2388+
2389+ // lastFocusedWindowID must point to the active window (index 1).
2390+ if app.lastFocusedWindowID != "runtime-w1" {
2391+ t.Errorf("lastFocusedWindowID = %s, want runtime-w1 (active window index 1)", app.lastFocusedWindowID)
2392+ }
2393+
2394+ // Global active tab must come from the active window (Tab-W1), not the first (Tab-W0).
2395+ activeTab := app.tabs.ActiveTab()
2396+ if activeTab == nil {
2397+ t.Fatal("active tab is nil")
2398+ }
2399+ if activeTab.Name != "Tab-W1" {
2400+ t.Errorf("active tab name = %q, want Tab-W1 (from active window)", activeTab.Name)
2401+ }
2402+}
2403+
2404+// TestApp_RestoreSessionFailsOnAdditionalWindowCreationError verifies that
2405+// restoreSession returns an error (not silent partial restore) when creating
2406+// a browser window for an additional restored window fails.
2407+func TestApp_RestoreSessionFailsOnAdditionalWindowCreationError(t *testing.T) {
2408+ gtkApp := requireGTKDisplayApp(t)
2409+ defer gtkApp.Unref()
2410+
2411+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
2412+ if err != nil {
2413+ t.Fatalf("window creation failed: %v", err)
2414+ }
2415+ defer mainWindow.Destroy()
2416+
2417+ pane0 := entity.NewPane("pane-w0")
2418+ tab0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2419+ tabs0 := entity.NewTabList()
2420+ tabs0.Add(tab0)
2421+
2422+ pane1 := entity.NewPane("pane-w1")
2423+ tab1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2424+ tabs1 := entity.NewTabList()
2425+ tabs1.Add(tab1)
2426+
2427+ sessionID := entity.SessionID("test-sess")
2428+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2429+ {WindowID: "saved-w0", Tabs: tabs0},
2430+ {WindowID: "saved-w1", Tabs: tabs1},
2431+ }, 0, time.Unix(123, 0))
2432+
2433+ firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2434+ factoryErr := errors.New("window creation failed")
2435+ app := &App{
2436+ deps: &Dependencies{
2437+ Config: &config.Config{},
2438+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2439+ },
2440+ mainWindow: mainWindow,
2441+ browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2442+ tabs: entity.NewTabList(),
2443+ windowForTab: map[entity.TabID]*browserWindow{},
2444+ browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2445+ return nil, factoryErr
2446+ },
2447+ }
2448+
2449+ gotErr := app.restoreSession(context.Background(), sessionID)
2450+ if gotErr == nil {
2451+ t.Fatal("restoreSession returned nil error, want failure when additional window creation fails")
2452+ }
2453+ if !strings.Contains(gotErr.Error(), "create browser window 1 for restore") {
2454+ t.Errorf("error message should mention window creation for restore, got: %v", gotErr)
2455+ }
2456+ if !errors.Is(gotErr, factoryErr) {
2457+ t.Errorf("error should wrap factoryErr, got: %v", gotErr)
2458+ }
2459+}
2460+
2461+func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
2462+ gtkApp := requireGTKDisplayApp(t)
2463+ defer gtkApp.Unref()
2464+
2465+ mw, err := window.New(context.Background(), gtkApp, "bottom")
2466+ if err != nil {
2467+ t.Fatalf("window creation failed: %v", err)
2468+ }
2469+ defer mw.Destroy()
2470+
2471+ // Bottom window should start WITHOUT the inset
2472+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2473+ t.Fatal("bottom-tab-bar content area: should NOT have inset at window creation")
2474+ }
2475+
2476+ // HasTabBarContentInset must return false initially
2477+ if mw.HasTabBarContentInset() {
2478+ t.Fatal("HasTabBarContentInset should return false initially")
2479+ }
2480+
2481+ // SetTabBarContentInsetVisible(true) must add the class
2482+ mw.SetTabBarContentInsetVisible(true)
2483+ if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2484+ t.Fatal("bottom-tab-bar content area: expected content-area-tabbar-inset-bottom CSS class after SetTabBarContentInsetVisible(true)")
2485+ }
2486+ if !mw.HasTabBarContentInset() {
2487+ t.Fatal("HasTabBarContentInset should return true after SetTabBarContentInsetVisible(true)")
2488+ }
2489+
2490+ // SetTabBarContentInsetVisible(false) must remove the class
2491+ mw.SetTabBarContentInsetVisible(false)
2492+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2493+ t.Fatal("bottom-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
2494+ }
2495+ if mw.HasTabBarContentInset() {
2496+ t.Fatal("HasTabBarContentInset should return false after SetTabBarContentInsetVisible(false)")
2497+ }
2498+
2499+ // Verify tab bar remains a non-measured overlay regardless of inset state
2500+ if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
2501+ t.Fatal("bottom-tab-bar: expected tab bar to remain a non-measured overlay")
2502+ }
2503+}
2504+
2505+func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) {
2506+ gtkApp := requireGTKDisplayApp(t)
2507+ defer gtkApp.Unref()
2508+
2509+ mw, err := window.New(context.Background(), gtkApp, "top")
2510+ if err != nil {
2511+ t.Fatalf("window creation failed: %v", err)
2512+ }
2513+ defer mw.Destroy()
2514+
2515+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
2516+ t.Fatal("top-tab-bar content area: should NOT have inset at window creation")
2517+ }
2518+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2519+ t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
2520+ }
2521+
2522+ mw.SetTabBarContentInsetVisible(true)
2523+ if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
2524+ t.Fatal("top-tab-bar content area: expected content-area-tabbar-inset-top CSS class after SetTabBarContentInsetVisible(true)")
2525+ }
2526+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2527+ t.Fatal("top-tab-bar content area: should NOT have bottom inset after SetTabBarContentInsetVisible(true)")
2528+ }
2529+ if !mw.HasTabBarContentInset() {
2530+ t.Fatal("HasTabBarContentInset should return true for top inset")
2531+ }
2532+
2533+ mw.SetTabBarContentInsetVisible(false)
2534+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
2535+ t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
2536+ }
2537+ if mw.HasTabBarContentInset() {
2538+ t.Fatal("HasTabBarContentInset should return false after removing top inset")
2539+ }
2540+
2541+ if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
2542+ t.Fatal("top-tab-bar: expected tab bar to remain a non-measured overlay")
2543+ }
2544+}
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 -4
@@ -12,6 +12,9 @@ type TabBar struct {
1212 box *gtk.Box
1313 buttons map[entity.TabID]*TabButton
1414
15+ explicitlyVisible bool
16+ autoHidden bool
17+
1518 // Active tab tracking
1619 activeTabID entity.TabID
1720
@@ -26,7 +29,8 @@ type TabBar struct {
2629 // NewTabBar creates a new tab bar widget.
2730 func NewTabBar() *TabBar {
2831 tb := &TabBar{
29- buttons: make(map[entity.TabID]*TabButton),
32+ buttons: make(map[entity.TabID]*TabButton),
33+ explicitlyVisible: true,
3034 }
3135
3236 // Create horizontal box container
@@ -157,12 +161,42 @@ func (tb *TabBar) ActiveTabID() entity.TabID {
157161 return tb.activeTabID
158162 }
159163
160-// SetVisible shows or hides the tab bar.
164+// SetVisible explicitly shows or hides the tab bar.
161165 func (tb *TabBar) SetVisible(visible bool) {
162- if tb.box == nil {
166+ if tb == nil || tb.box == nil {
163167 return
164168 }
165- tb.box.SetVisible(visible)
169+ tb.explicitlyVisible = visible
170+ tb.applyVisibilityState()
171+}
172+
173+// SetAutoHidden hides the tab bar visually while keeping the overlay widget
174+// visible to GTK when the bar has not been explicitly hidden. It is used for
175+// hide_tab_bar_when_single_tab; WebView viewport height stays stable because the
176+// tab bar is mounted as a non-measured overlay above the content.
177+func (tb *TabBar) SetAutoHidden(hidden bool) {
178+ if tb == nil || tb.box == nil {
179+ return
180+ }
181+
182+ tb.autoHidden = hidden
183+ tb.applyVisibilityState()
184+}
185+
186+func (tb *TabBar) applyVisibilityState() {
187+ if tb == nil || tb.box == nil {
188+ return
189+ }
190+
191+ interactive := tb.explicitlyVisible && !tb.autoHidden
192+ tb.box.SetVisible(tb.explicitlyVisible)
193+ if interactive {
194+ tb.box.SetOpacity(1.0)
195+ } else {
196+ tb.box.SetOpacity(0.0)
197+ }
198+ tb.box.SetCanTarget(interactive)
199+ tb.box.SetFocusable(interactive)
166200 }
167201
168202 // 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+ paneIDStr := string(paneID)
88+
9789 if c.navigateUC != nil {
9890 input := usecase.NavigateInput{
9991 URL: url,
100- PaneID: paneID,
101- WebView: wv, // webkit.WebView implements port.WebView
92+ PaneID: paneIDStr,
93+ WebView: wv,
10294 }
10395 output, err := c.navigateUC.Execute(ctx, input)
10496 if err != nil {
105- log.Error().Err(err).Str("url", url).Msg("navigation failed")
97+ log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed")
10698 return err
10799 }
100+ c.trackNavigationOrigin(ctx, paneID, url)
108101 log.Debug().
109102 Str("url", url).
103+ Str("pane_id", paneIDStr).
110104 Float64("zoom", output.AppliedZoom).
105+ Uint64("webview_id", uint64(wv.ID())).
111106 Msg("navigation initiated via usecase")
112107 return nil
113108 }
114109
115- // Fallback: direct navigation without usecase
116110 log.Warn().Msg("navigateUC not available, using direct LoadURI")
117111 if err := wv.LoadURI(ctx, url); err != nil {
118112 log.Error().Err(err).Str("url", url).Msg("failed to navigate")
119113 return err
120114 }
121115
122- log.Debug().Str("url", url).Msg("navigated active pane (direct)")
116+ c.trackNavigationOrigin(ctx, paneID, url)
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 +270 -0
@@ -0,0 +1,270 @@
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+type mockDevToolsWebView struct {
13+ *mocks.MockWebView
14+ *mocks.MockDevToolsOpener
15+}
16+
17+type mockPrinterWebView struct {
18+ *mocks.MockWebView
19+ *mocks.MockPrinter
20+}
21+
22+// TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that
23+// the explicit WebView-targeted navigation methods call the correct methods on the
24+// provided WebView rather than resolving through the content coordinator.
25+func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testing.T) {
26+ ctx := context.Background()
27+
28+ t.Run("NavigateWebView with nil webview returns error", func(t *testing.T) {
29+ testNavigateWebViewNilWebView(t, ctx)
30+ })
31+ t.Run("NavigateWebView targets provided webview", func(t *testing.T) {
32+ testNavigateWebViewTargetsProvidedWebView(t, ctx)
33+ })
34+ t.Run("ReloadWebView with nil webview returns error", func(t *testing.T) {
35+ testReloadWebViewNilWebView(t, ctx)
36+ })
37+ t.Run("ReloadWebView bypassCache=false calls Reload once", func(t *testing.T) {
38+ testReloadWebViewCallsReload(t, ctx)
39+ })
40+ t.Run("ReloadWebView bypassCache=true calls ReloadBypassCache once", func(t *testing.T) {
41+ testReloadWebViewCallsReloadBypassCache(t, ctx)
42+ })
43+ t.Run("StopWebView with nil webview returns error", func(t *testing.T) {
44+ testStopWebViewNilWebView(t, ctx)
45+ })
46+ t.Run("StopWebView calls Stop once", func(t *testing.T) {
47+ testStopWebViewCallsStop(t, ctx)
48+ })
49+ t.Run("GoBackWebView with nil webview returns error", func(t *testing.T) {
50+ testGoBackWebViewNilWebView(t, ctx)
51+ })
52+ t.Run("GoBackWebView calls GoBack once", func(t *testing.T) {
53+ testGoBackWebViewCallsGoBack(t, ctx)
54+ })
55+ t.Run("GoForwardWebView with nil webview returns error", func(t *testing.T) {
56+ testGoForwardWebViewNilWebView(t, ctx)
57+ })
58+ t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) {
59+ testGoForwardWebViewCallsGoForward(t, ctx)
60+ })
61+ t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) {
62+ testOpenDevToolsWebViewNilWebView(t, ctx)
63+ })
64+ t.Run("OpenDevToolsWebView unsupported capability returns error", func(t *testing.T) {
65+ testOpenDevToolsWebViewUnsupportedCapability(t, ctx)
66+ })
67+ t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) {
68+ testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx)
69+ })
70+ t.Run("PrintWebView with nil webview returns error", func(t *testing.T) {
71+ testPrintWebViewNilWebView(t, ctx)
72+ })
73+ t.Run("PrintWebView unsupported capability returns error", func(t *testing.T) {
74+ testPrintWebViewUnsupportedCapability(t, ctx)
75+ })
76+ t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
77+ testPrintWebViewTargetsProvidedWebView(t, ctx)
78+ })
79+}
80+
81+func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
82+ t.Helper()
83+ c := &NavigationCoordinator{}
84+ err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), nil)
85+ if err == nil {
86+ t.Fatal("expected error for nil webview, got nil")
87+ }
88+}
89+
90+func testNavigateWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
91+ t.Helper()
92+ wv := mocks.NewMockWebView(t)
93+ wv.EXPECT().LoadURI(ctx, "https://example.com").Return(nil).Once()
94+ wv.EXPECT().ID().Return(port.WebViewID(42)).Once()
95+ c := &NavigationCoordinator{}
96+ err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), wv)
97+ if err != nil {
98+ t.Fatalf("unexpected error: %v", err)
99+ }
100+}
101+
102+func testReloadWebViewNilWebView(t *testing.T, ctx context.Context) {
103+ t.Helper()
104+ c := &NavigationCoordinator{}
105+ err := c.ReloadWebView(ctx, nil, false)
106+ if err == nil {
107+ t.Fatal("expected error for nil webview, got nil")
108+ }
109+}
110+
111+func testReloadWebViewCallsReload(t *testing.T, ctx context.Context) {
112+ t.Helper()
113+ wv := mocks.NewMockWebView(t)
114+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
115+ wv.EXPECT().Reload(ctx).Return(nil).Once()
116+ c := &NavigationCoordinator{}
117+ err := c.ReloadWebView(ctx, wv, false)
118+ if err != nil {
119+ t.Fatalf("unexpected error: %v", err)
120+ }
121+}
122+
123+func testReloadWebViewCallsReloadBypassCache(t *testing.T, ctx context.Context) {
124+ t.Helper()
125+ wv := mocks.NewMockWebView(t)
126+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
127+ wv.EXPECT().ReloadBypassCache(ctx).Return(nil).Once()
128+ c := &NavigationCoordinator{}
129+ err := c.ReloadWebView(ctx, wv, true)
130+ if err != nil {
131+ t.Fatalf("unexpected error: %v", err)
132+ }
133+}
134+
135+func testStopWebViewNilWebView(t *testing.T, ctx context.Context) {
136+ t.Helper()
137+ c := &NavigationCoordinator{}
138+ err := c.StopWebView(ctx, nil)
139+ if err == nil {
140+ t.Fatal("expected error for nil webview, got nil")
141+ }
142+}
143+
144+func testStopWebViewCallsStop(t *testing.T, ctx context.Context) {
145+ t.Helper()
146+ wv := mocks.NewMockWebView(t)
147+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
148+ wv.EXPECT().Stop(ctx).Return(nil).Once()
149+ c := &NavigationCoordinator{}
150+ err := c.StopWebView(ctx, wv)
151+ if err != nil {
152+ t.Fatalf("unexpected error: %v", err)
153+ }
154+}
155+
156+func testGoBackWebViewNilWebView(t *testing.T, ctx context.Context) {
157+ t.Helper()
158+ c := &NavigationCoordinator{}
159+ err := c.GoBackWebView(ctx, nil)
160+ if err == nil {
161+ t.Fatal("expected error for nil webview, got nil")
162+ }
163+}
164+
165+func testGoBackWebViewCallsGoBack(t *testing.T, ctx context.Context) {
166+ t.Helper()
167+ wv := mocks.NewMockWebView(t)
168+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
169+ wv.EXPECT().GoBack(ctx).Return(nil).Once()
170+ c := &NavigationCoordinator{}
171+ err := c.GoBackWebView(ctx, wv)
172+ if err != nil {
173+ t.Fatalf("unexpected error: %v", err)
174+ }
175+}
176+
177+func testGoForwardWebViewNilWebView(t *testing.T, ctx context.Context) {
178+ t.Helper()
179+ c := &NavigationCoordinator{}
180+ err := c.GoForwardWebView(ctx, nil)
181+ if err == nil {
182+ t.Fatal("expected error for nil webview, got nil")
183+ }
184+}
185+
186+func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) {
187+ t.Helper()
188+ wv := mocks.NewMockWebView(t)
189+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
190+ wv.EXPECT().GoForward(ctx).Return(nil).Once()
191+ c := &NavigationCoordinator{}
192+ err := c.GoForwardWebView(ctx, wv)
193+ if err != nil {
194+ t.Fatalf("unexpected error: %v", err)
195+ }
196+}
197+
198+func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) {
199+ t.Helper()
200+ c := &NavigationCoordinator{}
201+ err := c.OpenDevToolsWebView(ctx, nil)
202+ if err == nil {
203+ t.Fatal("expected error for nil webview, got nil")
204+ }
205+}
206+
207+func testOpenDevToolsWebViewUnsupportedCapability(t *testing.T, ctx context.Context) {
208+ t.Helper()
209+ wv := mocks.NewMockWebView(t)
210+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
211+ c := &NavigationCoordinator{}
212+ err := c.OpenDevToolsWebView(ctx, wv)
213+ if err == nil {
214+ t.Fatal("expected unsupported devtools error, got nil")
215+ }
216+ if got, want := err.Error(), "webview does not support devtools"; got != want {
217+ t.Fatalf("error = %q, want %q", got, want)
218+ }
219+}
220+
221+func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
222+ t.Helper()
223+ base := mocks.NewMockWebView(t)
224+ opener := mocks.NewMockDevToolsOpener(t)
225+ wv := &mockDevToolsWebView{MockWebView: base, MockDevToolsOpener: opener}
226+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
227+ opener.EXPECT().OpenDevTools().Return().Once()
228+ c := &NavigationCoordinator{}
229+ err := c.OpenDevToolsWebView(ctx, wv)
230+ if err != nil {
231+ t.Fatalf("unexpected error: %v", err)
232+ }
233+}
234+
235+func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) {
236+ t.Helper()
237+ c := &NavigationCoordinator{}
238+ err := c.PrintWebView(ctx, nil)
239+ if err == nil {
240+ t.Fatal("expected error for nil webview, got nil")
241+ }
242+}
243+
244+func testPrintWebViewUnsupportedCapability(t *testing.T, ctx context.Context) {
245+ t.Helper()
246+ wv := mocks.NewMockWebView(t)
247+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
248+ c := &NavigationCoordinator{}
249+ err := c.PrintWebView(ctx, wv)
250+ if err == nil {
251+ t.Fatal("expected unsupported printing error, got nil")
252+ }
253+ if got, want := err.Error(), "webview does not support printing"; got != want {
254+ t.Fatalf("error = %q, want %q", got, want)
255+ }
256+}
257+
258+func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
259+ t.Helper()
260+ base := mocks.NewMockWebView(t)
261+ printer := mocks.NewMockPrinter(t)
262+ wv := &mockPrinterWebView{MockWebView: base, MockPrinter: printer}
263+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
264+ printer.EXPECT().PrintPage().Return().Once()
265+ c := &NavigationCoordinator{}
266+ err := c.PrintWebView(ctx, wv)
267+ if err != nil {
268+ t.Fatalf("unexpected error: %v", err)
269+ }
270+}
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 +10 -0
@@ -144,6 +144,16 @@ 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 overlaid tab bars — uses the same 2em token as
148+ .tab-bar min-height and remains static to avoid OSR vertical reallocations. */
149+.content-area-tabbar-inset-top {
150+ padding-top: 2em;
151+}
152+
153+.content-area-tabbar-inset-bottom {
154+ padding-bottom: 2em;
155+}
156+
147157 .tab-bar {
148158 background-color: var(--surface);
149159 border-top: 0.125em solid var(--border);
M internal/ui/theme/css_test.go +29 -0
@@ -185,6 +185,35 @@ 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_ContentAreaTabBarInset(t *testing.T) {
189+ css := GenerateCSS(DefaultDarkPalette())
190+
191+ topBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-top")
192+ if !strings.Contains(topBlock, "padding-top: 2em;") {
193+ t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block")
194+ }
195+ bottomBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-bottom")
196+ if !strings.Contains(bottomBlock, "padding-bottom: 2em;") {
197+ t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block")
198+ }
199+ if !strings.Contains(css, ".tab-bar") {
200+ t.Fatal("expected .tab-bar selector in generated CSS")
201+ }
202+ if !strings.Contains(css, "min-height: 2em;") {
203+ t.Fatal("expected min-height: 2em in .tab-bar block")
204+ }
205+}
206+
207+func cssRuleBlock(t *testing.T, css, selector string) string {
208+ t.Helper()
209+ re := regexp.MustCompile(regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`)
210+ matches := re.FindStringSubmatch(css)
211+ if len(matches) != 2 {
212+ t.Fatalf("expected %s selector in generated CSS", selector)
213+ }
214+ return matches[1]
215+}
216+
188217 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
189218 css := GenerateCSS(DefaultDarkPalette())
190219
M internal/ui/window/main_window.go +48 -5
@@ -104,13 +104,22 @@ 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 while
121+ // the overlay remains non-measured to avoid WebView allocation changes.
122+ mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
114123
115124 mw.logger.Debug().
116125 Str("tab_bar_position", tabBarPos).
@@ -191,6 +200,40 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191200 return mw.contentOverlay
192201 }
193202
203+// SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class
204+// on the content area. Avoids duplicate add/remove if already in the desired state.
205+func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
206+ if mw.contentArea == nil {
207+ return
208+ }
209+ class := mw.tabBarContentInsetClass()
210+ if visible {
211+ if !mw.contentArea.HasCssClass(class) {
212+ mw.contentArea.AddCssClass(class)
213+ }
214+ } else {
215+ if mw.contentArea.HasCssClass(class) {
216+ mw.contentArea.RemoveCssClass(class)
217+ }
218+ }
219+}
220+
221+// HasTabBarContentInset returns whether the tab bar inset CSS class is currently
222+// applied to the content area.
223+func (mw *MainWindow) HasTabBarContentInset() bool {
224+ if mw.contentArea == nil {
225+ return false
226+ }
227+ return mw.contentArea.HasCssClass(mw.tabBarContentInsetClass())
228+}
229+
230+func (mw *MainWindow) tabBarContentInsetClass() string {
231+ if mw.tabBarPosition == "bottom" {
232+ return "content-area-tabbar-inset-bottom"
233+ }
234+ return "content-area-tabbar-inset-top"
235+}
236+
194237 // AddOverlay adds a widget as an overlay above the content area.
195238 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
196239 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