feat(client): remember selected tabs per session (#304)
* feat(client): remember selected tabs across session switches * perf(client): avoid rescanning tab memory on output frames
- commit
- 74436e95aaff02678b26eb0445b3ae1737532f7f
- author
- bnema · · 2026-09-30 06:23 UTC
- committer
- GitHub · · 2026-09-30 06:23 UTC
- parent
- 80d04fe
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
6 files · +249 -8
M docs/remote-resilience.md +1 -1
| @@ -52,7 +52,7 @@ Then the session picker (`SSP`) shows local and remote sessions in one list. Rem | |||
| 52 | 52 | - Selecting a session switches to it, local or remote. `BCK` goes back to the previous one; `JRS` jumps to a recent one. | |
| 53 | 53 | - A remote session opened from the picker uses the remote host's environment and saved working directory. | |
| 54 | 54 | - `CNS` asks where to create the new session when several daemons are available. | |
| 55 | - | - Session history is per client and is not saved. | |
| 55 | + | - Session history and the last selected tab in each session are per client and kept only while that client runs. Switching back restores that tab; choosing a specific tab overrides it. If the remembered tab was closed, vev falls back to the first tab. | |
| 56 | 56 | - Previews of remote sessions are cached in memory only, never written to disk. | |
| 57 | 57 | ||
| 58 | 58 | After you leave a remote, vev keeps its connection warm so going back is instant. See [warm remote transports](configuration.md#warm-remote-transports). | |
| @@ -52,7 +52,7 @@ Then the session picker (`SSP`) shows local and remote sessions in one list. Rem | |||||
| 52 | - Selecting a session switches to it, local or remote. `BCK` goes back to the previous one; `JRS` jumps to a recent one. | 52 | - Selecting a session switches to it, local or remote. `BCK` goes back to the previous one; `JRS` jumps to a recent one. | ||
| 53 | - A remote session opened from the picker uses the remote host's environment and saved working directory. | 53 | - A remote session opened from the picker uses the remote host's environment and saved working directory. | ||
| 54 | - `CNS` asks where to create the new session when several daemons are available. | 54 | - `CNS` asks where to create the new session when several daemons are available. | ||
| 55 | - | - Session history is per client and is not saved. | 55 | + | - Session history and the last selected tab in each session are per client and kept only while that client runs. Switching back restores that tab; choosing a specific tab overrides it. If the remembered tab was closed, vev falls back to the first tab. |
| 56 | - Previews of remote sessions are cached in memory only, never written to disk. | 56 | - Previews of remote sessions are cached in memory only, never written to disk. | ||
| 57 | 57 | ||||
| 58 | After you leave a remote, vev keeps its connection warm so going back is instant. See [warm remote transports](configuration.md#warm-remote-transports). | 58 | After you leave a remote, vev keeps its connection warm so going back is instant. See [warm remote transports](configuration.md#warm-remote-transports). | ||
M internal/usecase/client/attachment_session.go +13 -7
| @@ -96,6 +96,8 @@ type sessionAttachmentConfig struct { | |||
| 96 | 96 | // Tab is the exact tab a picker tab row committed; its zero value | |
| 97 | 97 | // attaches at session level. | |
| 98 | 98 | Tab attachmentTab | |
| 99 | + | // Tabs retains this client's committed tab per authority and session. | |
| 100 | + | Tabs *sessionTabMemory | |
| 99 | 101 | } | |
| 100 | 102 | ||
| 101 | 103 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | |
| @@ -219,7 +221,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||
| 219 | 221 | return state, &event | |
| 220 | 222 | } | |
| 221 | 223 | state = next | |
| 222 | - | attachmentNoteCommitted(fg, state.context) | |
| 224 | + | w.noteCommitted(fg, state.context) | |
| 223 | 225 | // The frame transaction is the pre-attach publication: it commits the first | |
| 224 | 226 | // session bytes with the Connecting presentation and, through the foreground | |
| 225 | 227 | // shape rule, a public generation of zero. Attached is published only after | |
| @@ -284,7 +286,8 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground { | |||
| 284 | 286 | return overlay | |
| 285 | 287 | } | |
| 286 | 288 | ||
| 287 | - | func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) { | |
| 289 | + | func (w *sessionAttachmentWorker) noteCommitted(fg AttachmentForeground, view protocol.ViewContext) { | |
| 290 | + | w.cfg.Tabs.remember(requestAuthority(w.cfg.Request), view.Route.Target, view.TabID) | |
| 288 | 291 | if overlay := attachmentOverlay(fg); overlay != nil { | |
| 289 | 292 | overlay.noteCommitted(view.Route.Target, view.TabID) | |
| 290 | 293 | } | |
| @@ -413,7 +416,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 413 | 416 | samePeerUI.uiSamePeerArrived() | |
| 414 | 417 | } | |
| 415 | 418 | } | |
| 416 | - | attachmentNoteCommitted(fg, state.context) | |
| 419 | + | w.noteCommitted(fg, state.context) | |
| 417 | 420 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | |
| 418 | 421 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | |
| 419 | 422 | } | |
| @@ -432,7 +435,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 432 | 435 | continue | |
| 433 | 436 | } | |
| 434 | 437 | state = next | |
| 435 | - | attachmentNoteCommitted(fg, state.context) | |
| 438 | + | w.noteCommitted(fg, state.context) | |
| 436 | 439 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | |
| 437 | 440 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)} | |
| 438 | 441 | } | |
| @@ -453,7 +456,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 453 | 456 | // An endpoint-empty offer on this very connection: confirm | |
| 454 | 457 | // it and the daemon moves this attachment in place. | |
| 455 | 458 | samePeerRequests++ | |
| 456 | - | request := protocol.SamePeerSwitchRequest{RequestID: samePeerRequests, Target: *typed.ExactTarget, PreferredTabID: typed.PreferredTabID} | |
| 459 | + | request := protocol.SamePeerSwitchRequest{ | |
| 460 | + | RequestID: samePeerRequests, Target: *typed.ExactTarget, | |
| 461 | + | PreferredTabID: w.cfg.Tabs.preferred(requestAuthority(w.cfg.Request), *typed.ExactTarget, typed.PreferredTabID), | |
| 462 | + | } | |
| 457 | 463 | if request.Validate() != nil { | |
| 458 | 464 | continue | |
| 459 | 465 | } | |
| @@ -773,8 +779,8 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||
| 773 | 779 | // A stopped remote tab is restored through its exact selector. | |
| 774 | 780 | selector := *w.cfg.Tab.stopped | |
| 775 | 781 | hello.SessionTarget = &selector | |
| 776 | - | case w.cfg.Tab.preferred != "": | |
| 777 | - | hello.PreferredTabID = w.cfg.Tab.preferred | |
| 782 | + | default: | |
| 783 | + | hello.PreferredTabID = w.cfg.Tabs.preferred(requestAuthority(request), target, w.cfg.Tab.preferred) | |
| 778 | 784 | } | |
| 779 | 785 | case ports.BrokerAdmissionCreateNamed: | |
| 780 | 786 | hello.Intent = protocol.IntentNew | |
| @@ -96,6 +96,8 @@ type sessionAttachmentConfig struct { | |||||
| 96 | // Tab is the exact tab a picker tab row committed; its zero value | 96 | // Tab is the exact tab a picker tab row committed; its zero value | ||
| 97 | // attaches at session level. | 97 | // attaches at session level. | ||
| 98 | Tab attachmentTab | 98 | Tab attachmentTab | ||
| 99 | + | // Tabs retains this client's committed tab per authority and session. | |||
| 100 | + | Tabs *sessionTabMemory | |||
| 99 | } | 101 | } | ||
| 100 | 102 | ||||
| 101 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | 103 | // sessionAttachmentWorker implements AttachmentWorker for one broker logical | ||
| @@ -219,7 +221,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f | |||||
| 219 | return state, &event | 221 | return state, &event | ||
| 220 | } | 222 | } | ||
| 221 | state = next | 223 | state = next | ||
| 222 | - | attachmentNoteCommitted(fg, state.context) | 224 | + | w.noteCommitted(fg, state.context) |
| 223 | // The frame transaction is the pre-attach publication: it commits the first | 225 | // The frame transaction is the pre-attach publication: it commits the first | ||
| 224 | // session bytes with the Connecting presentation and, through the foreground | 226 | // session bytes with the Connecting presentation and, through the foreground | ||
| 225 | // shape rule, a public generation of zero. Attached is published only after | 227 | // shape rule, a public generation of zero. Attached is published only after | ||
| @@ -284,7 +286,8 @@ func attachmentOverlay(fg AttachmentForeground) attachmentOverlayForeground { | |||||
| 284 | return overlay | 286 | return overlay | ||
| 285 | } | 287 | } | ||
| 286 | 288 | ||||
| 287 | - | func attachmentNoteCommitted(fg AttachmentForeground, view protocol.ViewContext) { | 289 | + | func (w *sessionAttachmentWorker) noteCommitted(fg AttachmentForeground, view protocol.ViewContext) { |
| 290 | + | w.cfg.Tabs.remember(requestAuthority(w.cfg.Request), view.Route.Target, view.TabID) | |||
| 288 | if overlay := attachmentOverlay(fg); overlay != nil { | 291 | if overlay := attachmentOverlay(fg); overlay != nil { | ||
| 289 | overlay.noteCommitted(view.Route.Target, view.TabID) | 292 | overlay.noteCommitted(view.Route.Target, view.TabID) | ||
| 290 | } | 293 | } | ||
| @@ -413,7 +416,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 413 | samePeerUI.uiSamePeerArrived() | 416 | samePeerUI.uiSamePeerArrived() | ||
| 414 | } | 417 | } | ||
| 415 | } | 418 | } | ||
| 416 | - | attachmentNoteCommitted(fg, state.context) | 419 | + | w.noteCommitted(fg, state.context) |
| 417 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | 420 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil { | ||
| 418 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | 421 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)} | ||
| 419 | } | 422 | } | ||
| @@ -432,7 +435,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 432 | continue | 435 | continue | ||
| 433 | } | 436 | } | ||
| 434 | state = next | 437 | state = next | ||
| 435 | - | attachmentNoteCommitted(fg, state.context) | 438 | + | w.noteCommitted(fg, state.context) |
| 436 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | 439 | if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil { | ||
| 437 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)} | 440 | return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)} | ||
| 438 | } | 441 | } | ||
| @@ -453,7 +456,10 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 453 | // An endpoint-empty offer on this very connection: confirm | 456 | // An endpoint-empty offer on this very connection: confirm | ||
| 454 | // it and the daemon moves this attachment in place. | 457 | // it and the daemon moves this attachment in place. | ||
| 455 | samePeerRequests++ | 458 | samePeerRequests++ | ||
| 456 | - | request := protocol.SamePeerSwitchRequest{RequestID: samePeerRequests, Target: *typed.ExactTarget, PreferredTabID: typed.PreferredTabID} | 459 | + | request := protocol.SamePeerSwitchRequest{ |
| 460 | + | RequestID: samePeerRequests, Target: *typed.ExactTarget, | |||
| 461 | + | PreferredTabID: w.cfg.Tabs.preferred(requestAuthority(w.cfg.Request), *typed.ExactTarget, typed.PreferredTabID), | |||
| 462 | + | } | |||
| 457 | if request.Validate() != nil { | 463 | if request.Validate() != nil { | ||
| 458 | continue | 464 | continue | ||
| 459 | } | 465 | } | ||
| @@ -773,8 +779,8 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||||
| 773 | // A stopped remote tab is restored through its exact selector. | 779 | // A stopped remote tab is restored through its exact selector. | ||
| 774 | selector := *w.cfg.Tab.stopped | 780 | selector := *w.cfg.Tab.stopped | ||
| 775 | hello.SessionTarget = &selector | 781 | hello.SessionTarget = &selector | ||
| 776 | - | case w.cfg.Tab.preferred != "": | 782 | + | default: |
| 777 | - | hello.PreferredTabID = w.cfg.Tab.preferred | 783 | + | hello.PreferredTabID = w.cfg.Tabs.preferred(requestAuthority(request), target, w.cfg.Tab.preferred) |
| 778 | } | 784 | } | ||
| 779 | case ports.BrokerAdmissionCreateNamed: | 785 | case ports.BrokerAdmissionCreateNamed: | ||
| 780 | hello.Intent = protocol.IntentNew | 786 | hello.Intent = protocol.IntentNew | ||
A internal/usecase/client/session_tab_memory.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // sessionTabMemory belongs to one client process and survives attachment swaps. | |
| 11 | + | // Authority and lifecycle identity prevent unrelated hosts or recreated sessions | |
| 12 | + | // from inheriting a selection. Only committed views update the memory. | |
| 13 | + | type sessionTabMemory struct { | |
| 14 | + | mu sync.Mutex | |
| 15 | + | tabs map[sessionTabKey]domain.TabStableID | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | type sessionTabKey struct { | |
| 19 | + | authority routeAuthority | |
| 20 | + | target protocol.ExactSessionTarget | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (m *sessionTabMemory) remember(authority routeAuthority, target protocol.ExactSessionTarget, tab domain.TabStableID) { | |
| 24 | + | if m == nil || target.Validate() != nil || domain.ValidateTabStableID(tab) != nil { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | m.mu.Lock() | |
| 28 | + | defer m.mu.Unlock() | |
| 29 | + | if m.tabs == nil { | |
| 30 | + | m.tabs = make(map[sessionTabKey]domain.TabStableID) | |
| 31 | + | } | |
| 32 | + | key := sessionTabKey{authority: authority, target: target} | |
| 33 | + | if previous, known := m.tabs[key]; known { | |
| 34 | + | if previous != tab { | |
| 35 | + | m.tabs[key] = tab | |
| 36 | + | } | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | // Reclaim obsolete lifecycles only on the first view of a session. | |
| 40 | + | // The exact key, not this cleanup, isolates recreated sessions. | |
| 41 | + | for old := range m.tabs { | |
| 42 | + | if old.authority == authority && old.target.SessionName == target.SessionName { | |
| 43 | + | delete(m.tabs, old) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | m.tabs[key] = tab | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (m *sessionTabMemory) preferred(authority routeAuthority, target protocol.ExactSessionTarget, explicit domain.TabStableID) domain.TabStableID { | |
| 50 | + | if explicit != "" || m == nil { | |
| 51 | + | return explicit | |
| 52 | + | } | |
| 53 | + | m.mu.Lock() | |
| 54 | + | defer m.mu.Unlock() | |
| 55 | + | return m.tabs[sessionTabKey{authority: authority, target: target}] | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/protocol" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // sessionTabMemory belongs to one client process and survives attachment swaps. | |||
| 11 | + | // Authority and lifecycle identity prevent unrelated hosts or recreated sessions | |||
| 12 | + | // from inheriting a selection. Only committed views update the memory. | |||
| 13 | + | type sessionTabMemory struct { | |||
| 14 | + | mu sync.Mutex | |||
| 15 | + | tabs map[sessionTabKey]domain.TabStableID | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | type sessionTabKey struct { | |||
| 19 | + | authority routeAuthority | |||
| 20 | + | target protocol.ExactSessionTarget | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (m *sessionTabMemory) remember(authority routeAuthority, target protocol.ExactSessionTarget, tab domain.TabStableID) { | |||
| 24 | + | if m == nil || target.Validate() != nil || domain.ValidateTabStableID(tab) != nil { | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | m.mu.Lock() | |||
| 28 | + | defer m.mu.Unlock() | |||
| 29 | + | if m.tabs == nil { | |||
| 30 | + | m.tabs = make(map[sessionTabKey]domain.TabStableID) | |||
| 31 | + | } | |||
| 32 | + | key := sessionTabKey{authority: authority, target: target} | |||
| 33 | + | if previous, known := m.tabs[key]; known { | |||
| 34 | + | if previous != tab { | |||
| 35 | + | m.tabs[key] = tab | |||
| 36 | + | } | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | // Reclaim obsolete lifecycles only on the first view of a session. | |||
| 40 | + | // The exact key, not this cleanup, isolates recreated sessions. | |||
| 41 | + | for old := range m.tabs { | |||
| 42 | + | if old.authority == authority && old.target.SessionName == target.SessionName { | |||
| 43 | + | delete(m.tabs, old) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | m.tabs[key] = tab | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (m *sessionTabMemory) preferred(authority routeAuthority, target protocol.ExactSessionTarget, explicit domain.TabStableID) domain.TabStableID { | |||
| 50 | + | if explicit != "" || m == nil { | |||
| 51 | + | return explicit | |||
| 52 | + | } | |||
| 53 | + | m.mu.Lock() | |||
| 54 | + | defer m.mu.Unlock() | |||
| 55 | + | return m.tabs[sessionTabKey{authority: authority, target: target}] | |||
| 56 | + | } | |||
A internal/usecase/client/session_tab_memory_test.go +176 -0
| @@ -0,0 +1,176 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestSessionTabMemoryIsolation(t *testing.T) { | |
| 13 | + | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} | |
| 14 | + | local := routeAuthority{local: true} | |
| 15 | + | var memory sessionTabMemory | |
| 16 | + | memory.remember(local, target, "t_second") | |
| 17 | + | memory.remember(local, target, "t_latest") | |
| 18 | + | var otherClient sessionTabMemory | |
| 19 | + | for _, tt := range []struct { | |
| 20 | + | name string | |
| 21 | + | memory *sessionTabMemory | |
| 22 | + | authority routeAuthority | |
| 23 | + | target protocol.ExactSessionTarget | |
| 24 | + | explicit domain.TabStableID | |
| 25 | + | want domain.TabStableID | |
| 26 | + | }{ | |
| 27 | + | {name: "latest committed tab", memory: &memory, authority: local, target: target, want: "t_latest"}, | |
| 28 | + | {name: "explicit selection wins", memory: &memory, authority: local, target: target, explicit: "t_explicit", want: "t_explicit"}, | |
| 29 | + | {name: "other host", memory: &memory, authority: routeAuthority{endpoint: "remote"}, target: target}, | |
| 30 | + | {name: "other client", memory: &otherClient, authority: local, target: target}, | |
| 31 | + | {name: "recreated session", memory: &memory, authority: local, target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}}, | |
| 32 | + | {name: "no memory", authority: local, target: target}, | |
| 33 | + | } { | |
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |
| 35 | + | require.Equal(t, tt.want, tt.memory.preferred(tt.authority, tt.target, tt.explicit)) | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestSessionTabMemorySurvivesAttachmentSwap(t *testing.T) { | |
| 41 | + | for _, local := range []bool{true, false} { | |
| 42 | + | name := "remote" | |
| 43 | + | if local { | |
| 44 | + | name = "local" | |
| 45 | + | } | |
| 46 | + | t.Run(name, func(t *testing.T) { | |
| 47 | + | terminal := newWorkerTestTerminal() | |
| 48 | + | supervisor := &Supervisor{ | |
| 49 | + | cfg: SupervisorConfig{Terminal: terminal}, clientID: [16]byte{1}, | |
| 50 | + | attachments: &attachmentHost{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, geometryValid: true}, | |
| 51 | + | } | |
| 52 | + | request := sessionTestRequest(local) | |
| 53 | + | environment := sessionTestWorkerConfig(request).SessionEnvironment | |
| 54 | + | worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, environment) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | view := *sessionTestOutput(1, "").Context | |
| 57 | + | view.TabID = "t_second" | |
| 58 | + | worker.(*sessionAttachmentWorker).noteCommitted(nil, view) | |
| 59 | + | other := view | |
| 60 | + | other.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |
| 61 | + | other.TabID = "t_beta" | |
| 62 | + | worker.(*sessionAttachmentWorker).noteCommitted(nil, other) | |
| 63 | + | ||
| 64 | + | for _, explicit := range []domain.TabStableID{"", "t_explicit"} { | |
| 65 | + | stream := newSessionTestStream() | |
| 66 | + | attachments := newSessionTestAttachments() | |
| 67 | + | next, err := supervisor.newAttachmentWorker(request, attachmentTab{preferred: explicit}, nil, environment) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | host := newWorkerTestHost(terminal, nil, attachments.record) | |
| 70 | + | events := make(chan AttachmentEvent, 1) | |
| 71 | + | go func() { | |
| 72 | + | event, _ := host.Run(t.Context(), AttachmentToken{Generation: 1, Attempt: 1}, next, stream) | |
| 73 | + | events <- event | |
| 74 | + | }() | |
| 75 | + | hello := awaitHello(t, stream) | |
| 76 | + | want := explicit | |
| 77 | + | if want == "" { | |
| 78 | + | want = "t_second" | |
| 79 | + | } | |
| 80 | + | require.Equal(t, want, hello.PreferredTabID) | |
| 81 | + | require.NoError(t, protocol.ValidateHello(hello)) | |
| 82 | + | require.NoError(t, stream.Close()) | |
| 83 | + | <-events | |
| 84 | + | } | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestSessionTabMemorySamePeerRoundTrip(t *testing.T) { | |
| 90 | + | stream := newSessionTestStream() | |
| 91 | + | terminal := newWorkerTestTerminal() | |
| 92 | + | attachments := newSessionTestAttachments() | |
| 93 | + | var memory sessionTabMemory | |
| 94 | + | cfg := sessionTestWorkerConfig(sessionTestRequest(true)) | |
| 95 | + | cfg.Tabs = &memory | |
| 96 | + | events := sessionTestRun(t, stream, terminal, cfg, attachments) | |
| 97 | + | t.Cleanup(func() { _ = stream.Close(); <-events }) | |
| 98 | + | awaitHello(t, stream) | |
| 99 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |
| 100 | + | initial := sessionTestOutput(1, "initial") | |
| 101 | + | initial.Context.TabID = "t_second" | |
| 102 | + | stream.deliver(initial) | |
| 103 | + | attachments.token(t) | |
| 104 | + | alpha := initial.Context.Route.Target | |
| 105 | + | beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |
| 106 | + | stream.deliver(protocol.AttachTarget{Session: "beta", Intent: protocol.IntentAttach, ExactTarget: &beta, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}) | |
| 107 | + | first := awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest) | |
| 108 | + | require.Empty(t, first.PreferredTabID) | |
| 109 | + | output := sessionTestOutput(2, "beta") | |
| 110 | + | output.Epoch = 2 | |
| 111 | + | output.Context.Route.Target = beta | |
| 112 | + | output.Context.TabID = "t_beta" | |
| 113 | + | stream.deliver(output) | |
| 114 | + | awaitSent(t, stream, "Ack", func(message protocol.ClientMessage) bool { | |
| 115 | + | ack, ok := message.(protocol.Ack) | |
| 116 | + | return ok && ack.Epoch == output.Epoch | |
| 117 | + | }) | |
| 118 | + | stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}) | |
| 119 | + | second := awaitSent(t, stream, "return switch", func(message protocol.ClientMessage) bool { | |
| 120 | + | request, ok := message.(protocol.SamePeerSwitchRequest) | |
| 121 | + | return ok && request.RequestID == 2 | |
| 122 | + | }).(protocol.SamePeerSwitchRequest) | |
| 123 | + | require.Equal(t, domain.TabStableID("t_second"), second.PreferredTabID) | |
| 124 | + | stream.deliver(protocol.SamePeerSwitchFailure{RequestID: 2, Code: protocol.SamePeerSwitchStaleTarget}) | |
| 125 | + | stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "t_explicit"}) | |
| 126 | + | third := awaitSent(t, stream, "explicit switch", func(message protocol.ClientMessage) bool { | |
| 127 | + | request, ok := message.(protocol.SamePeerSwitchRequest) | |
| 128 | + | return ok && request.RequestID == 3 | |
| 129 | + | }).(protocol.SamePeerSwitchRequest) | |
| 130 | + | require.Equal(t, domain.TabStableID("t_explicit"), third.PreferredTabID) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestSessionTabMemoryLifecycleCleanup(t *testing.T) { | |
| 134 | + | local := routeAuthority{local: true} | |
| 135 | + | remote := routeAuthority{endpoint: "remote"} | |
| 136 | + | alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} | |
| 137 | + | beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "beta"} | |
| 138 | + | replacement := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"} | |
| 139 | + | var memory sessionTabMemory | |
| 140 | + | memory.remember(local, alpha, "t_old") | |
| 141 | + | memory.remember(remote, alpha, "t_remote") | |
| 142 | + | memory.remember(local, beta, "t_beta") | |
| 143 | + | memory.remember(local, replacement, "t_new") | |
| 144 | + | memory.remember(local, replacement, "t_latest") | |
| 145 | + | for _, tt := range []struct { | |
| 146 | + | name string | |
| 147 | + | authority routeAuthority | |
| 148 | + | target protocol.ExactSessionTarget | |
| 149 | + | want domain.TabStableID | |
| 150 | + | }{ | |
| 151 | + | {name: "old lifecycle reclaimed", authority: local, target: alpha}, | |
| 152 | + | {name: "new lifecycle retained", authority: local, target: replacement, want: "t_latest"}, | |
| 153 | + | {name: "other host retained", authority: remote, target: alpha, want: "t_remote"}, | |
| 154 | + | {name: "other session retained", authority: local, target: beta, want: "t_beta"}, | |
| 155 | + | } { | |
| 156 | + | t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, memory.preferred(tt.authority, tt.target, "")) }) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestSessionTabMemoryPreservesStoppedRemoteSelector(t *testing.T) { | |
| 161 | + | request := sessionTestRequest(false) | |
| 162 | + | var memory sessionTabMemory | |
| 163 | + | memory.remember(requestAuthority(request), request.Target, "t_remembered") | |
| 164 | + | selector := protocol.SessionAttachTarget{LifecycleID: request.Target.LifecycleID, SessionName: request.Target.SessionName, Stopped: true, TabID: "t_explicit", TabIndex: protocol.NoTabIndex} | |
| 165 | + | cfg := sessionTestWorkerConfig(request) | |
| 166 | + | cfg.Tabs = &memory | |
| 167 | + | cfg.Tab = attachmentTab{stopped: &selector} | |
| 168 | + | cfg.SessionEnvironment = SessionEnvironment{Provenance: SessionEnvironmentRemote} | |
| 169 | + | stream := newSessionTestStream() | |
| 170 | + | events := sessionTestRun(t, stream, newWorkerTestTerminal(), cfg, newSessionTestAttachments()) | |
| 171 | + | t.Cleanup(func() { _ = stream.Close(); <-events }) | |
| 172 | + | hello := awaitHello(t, stream) | |
| 173 | + | require.Equal(t, &selector, hello.SessionTarget) | |
| 174 | + | require.Empty(t, hello.PreferredTabID) | |
| 175 | + | require.NoError(t, protocol.ValidateHello(hello)) | |
| 176 | + | } | |
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestSessionTabMemoryIsolation(t *testing.T) { | |||
| 13 | + | target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} | |||
| 14 | + | local := routeAuthority{local: true} | |||
| 15 | + | var memory sessionTabMemory | |||
| 16 | + | memory.remember(local, target, "t_second") | |||
| 17 | + | memory.remember(local, target, "t_latest") | |||
| 18 | + | var otherClient sessionTabMemory | |||
| 19 | + | for _, tt := range []struct { | |||
| 20 | + | name string | |||
| 21 | + | memory *sessionTabMemory | |||
| 22 | + | authority routeAuthority | |||
| 23 | + | target protocol.ExactSessionTarget | |||
| 24 | + | explicit domain.TabStableID | |||
| 25 | + | want domain.TabStableID | |||
| 26 | + | }{ | |||
| 27 | + | {name: "latest committed tab", memory: &memory, authority: local, target: target, want: "t_latest"}, | |||
| 28 | + | {name: "explicit selection wins", memory: &memory, authority: local, target: target, explicit: "t_explicit", want: "t_explicit"}, | |||
| 29 | + | {name: "other host", memory: &memory, authority: routeAuthority{endpoint: "remote"}, target: target}, | |||
| 30 | + | {name: "other client", memory: &otherClient, authority: local, target: target}, | |||
| 31 | + | {name: "recreated session", memory: &memory, authority: local, target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}}, | |||
| 32 | + | {name: "no memory", authority: local, target: target}, | |||
| 33 | + | } { | |||
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | + | require.Equal(t, tt.want, tt.memory.preferred(tt.authority, tt.target, tt.explicit)) | |||
| 36 | + | }) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestSessionTabMemorySurvivesAttachmentSwap(t *testing.T) { | |||
| 41 | + | for _, local := range []bool{true, false} { | |||
| 42 | + | name := "remote" | |||
| 43 | + | if local { | |||
| 44 | + | name = "local" | |||
| 45 | + | } | |||
| 46 | + | t.Run(name, func(t *testing.T) { | |||
| 47 | + | terminal := newWorkerTestTerminal() | |||
| 48 | + | supervisor := &Supervisor{ | |||
| 49 | + | cfg: SupervisorConfig{Terminal: terminal}, clientID: [16]byte{1}, | |||
| 50 | + | attachments: &attachmentHost{geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, geometryValid: true}, | |||
| 51 | + | } | |||
| 52 | + | request := sessionTestRequest(local) | |||
| 53 | + | environment := sessionTestWorkerConfig(request).SessionEnvironment | |||
| 54 | + | worker, err := supervisor.newAttachmentWorker(request, attachmentTab{}, nil, environment) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | view := *sessionTestOutput(1, "").Context | |||
| 57 | + | view.TabID = "t_second" | |||
| 58 | + | worker.(*sessionAttachmentWorker).noteCommitted(nil, view) | |||
| 59 | + | other := view | |||
| 60 | + | other.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |||
| 61 | + | other.TabID = "t_beta" | |||
| 62 | + | worker.(*sessionAttachmentWorker).noteCommitted(nil, other) | |||
| 63 | + | ||||
| 64 | + | for _, explicit := range []domain.TabStableID{"", "t_explicit"} { | |||
| 65 | + | stream := newSessionTestStream() | |||
| 66 | + | attachments := newSessionTestAttachments() | |||
| 67 | + | next, err := supervisor.newAttachmentWorker(request, attachmentTab{preferred: explicit}, nil, environment) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | host := newWorkerTestHost(terminal, nil, attachments.record) | |||
| 70 | + | events := make(chan AttachmentEvent, 1) | |||
| 71 | + | go func() { | |||
| 72 | + | event, _ := host.Run(t.Context(), AttachmentToken{Generation: 1, Attempt: 1}, next, stream) | |||
| 73 | + | events <- event | |||
| 74 | + | }() | |||
| 75 | + | hello := awaitHello(t, stream) | |||
| 76 | + | want := explicit | |||
| 77 | + | if want == "" { | |||
| 78 | + | want = "t_second" | |||
| 79 | + | } | |||
| 80 | + | require.Equal(t, want, hello.PreferredTabID) | |||
| 81 | + | require.NoError(t, protocol.ValidateHello(hello)) | |||
| 82 | + | require.NoError(t, stream.Close()) | |||
| 83 | + | <-events | |||
| 84 | + | } | |||
| 85 | + | }) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestSessionTabMemorySamePeerRoundTrip(t *testing.T) { | |||
| 90 | + | stream := newSessionTestStream() | |||
| 91 | + | terminal := newWorkerTestTerminal() | |||
| 92 | + | attachments := newSessionTestAttachments() | |||
| 93 | + | var memory sessionTabMemory | |||
| 94 | + | cfg := sessionTestWorkerConfig(sessionTestRequest(true)) | |||
| 95 | + | cfg.Tabs = &memory | |||
| 96 | + | events := sessionTestRun(t, stream, terminal, cfg, attachments) | |||
| 97 | + | t.Cleanup(func() { _ = stream.Close(); <-events }) | |||
| 98 | + | awaitHello(t, stream) | |||
| 99 | + | stream.deliver(protocol.Welcome{SessionName: "alpha"}) | |||
| 100 | + | initial := sessionTestOutput(1, "initial") | |||
| 101 | + | initial.Context.TabID = "t_second" | |||
| 102 | + | stream.deliver(initial) | |||
| 103 | + | attachments.token(t) | |||
| 104 | + | alpha := initial.Context.Route.Target | |||
| 105 | + | beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"} | |||
| 106 | + | stream.deliver(protocol.AttachTarget{Session: "beta", Intent: protocol.IntentAttach, ExactTarget: &beta, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}) | |||
| 107 | + | first := awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest) | |||
| 108 | + | require.Empty(t, first.PreferredTabID) | |||
| 109 | + | output := sessionTestOutput(2, "beta") | |||
| 110 | + | output.Epoch = 2 | |||
| 111 | + | output.Context.Route.Target = beta | |||
| 112 | + | output.Context.TabID = "t_beta" | |||
| 113 | + | stream.deliver(output) | |||
| 114 | + | awaitSent(t, stream, "Ack", func(message protocol.ClientMessage) bool { | |||
| 115 | + | ack, ok := message.(protocol.Ack) | |||
| 116 | + | return ok && ack.Epoch == output.Epoch | |||
| 117 | + | }) | |||
| 118 | + | stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}) | |||
| 119 | + | second := awaitSent(t, stream, "return switch", func(message protocol.ClientMessage) bool { | |||
| 120 | + | request, ok := message.(protocol.SamePeerSwitchRequest) | |||
| 121 | + | return ok && request.RequestID == 2 | |||
| 122 | + | }).(protocol.SamePeerSwitchRequest) | |||
| 123 | + | require.Equal(t, domain.TabStableID("t_second"), second.PreferredTabID) | |||
| 124 | + | stream.deliver(protocol.SamePeerSwitchFailure{RequestID: 2, Code: protocol.SamePeerSwitchStaleTarget}) | |||
| 125 | + | stream.deliver(protocol.AttachTarget{Session: "alpha", Intent: protocol.IntentAttach, ExactTarget: &alpha, SamePeer: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "t_explicit"}) | |||
| 126 | + | third := awaitSent(t, stream, "explicit switch", func(message protocol.ClientMessage) bool { | |||
| 127 | + | request, ok := message.(protocol.SamePeerSwitchRequest) | |||
| 128 | + | return ok && request.RequestID == 3 | |||
| 129 | + | }).(protocol.SamePeerSwitchRequest) | |||
| 130 | + | require.Equal(t, domain.TabStableID("t_explicit"), third.PreferredTabID) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestSessionTabMemoryLifecycleCleanup(t *testing.T) { | |||
| 134 | + | local := routeAuthority{local: true} | |||
| 135 | + | remote := routeAuthority{endpoint: "remote"} | |||
| 136 | + | alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"} | |||
| 137 | + | beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "beta"} | |||
| 138 | + | replacement := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"} | |||
| 139 | + | var memory sessionTabMemory | |||
| 140 | + | memory.remember(local, alpha, "t_old") | |||
| 141 | + | memory.remember(remote, alpha, "t_remote") | |||
| 142 | + | memory.remember(local, beta, "t_beta") | |||
| 143 | + | memory.remember(local, replacement, "t_new") | |||
| 144 | + | memory.remember(local, replacement, "t_latest") | |||
| 145 | + | for _, tt := range []struct { | |||
| 146 | + | name string | |||
| 147 | + | authority routeAuthority | |||
| 148 | + | target protocol.ExactSessionTarget | |||
| 149 | + | want domain.TabStableID | |||
| 150 | + | }{ | |||
| 151 | + | {name: "old lifecycle reclaimed", authority: local, target: alpha}, | |||
| 152 | + | {name: "new lifecycle retained", authority: local, target: replacement, want: "t_latest"}, | |||
| 153 | + | {name: "other host retained", authority: remote, target: alpha, want: "t_remote"}, | |||
| 154 | + | {name: "other session retained", authority: local, target: beta, want: "t_beta"}, | |||
| 155 | + | } { | |||
| 156 | + | t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, memory.preferred(tt.authority, tt.target, "")) }) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestSessionTabMemoryPreservesStoppedRemoteSelector(t *testing.T) { | |||
| 161 | + | request := sessionTestRequest(false) | |||
| 162 | + | var memory sessionTabMemory | |||
| 163 | + | memory.remember(requestAuthority(request), request.Target, "t_remembered") | |||
| 164 | + | selector := protocol.SessionAttachTarget{LifecycleID: request.Target.LifecycleID, SessionName: request.Target.SessionName, Stopped: true, TabID: "t_explicit", TabIndex: protocol.NoTabIndex} | |||
| 165 | + | cfg := sessionTestWorkerConfig(request) | |||
| 166 | + | cfg.Tabs = &memory | |||
| 167 | + | cfg.Tab = attachmentTab{stopped: &selector} | |||
| 168 | + | cfg.SessionEnvironment = SessionEnvironment{Provenance: SessionEnvironmentRemote} | |||
| 169 | + | stream := newSessionTestStream() | |||
| 170 | + | events := sessionTestRun(t, stream, newWorkerTestTerminal(), cfg, newSessionTestAttachments()) | |||
| 171 | + | t.Cleanup(func() { _ = stream.Close(); <-events }) | |||
| 172 | + | hello := awaitHello(t, stream) | |||
| 173 | + | require.Equal(t, &selector, hello.SessionTarget) | |||
| 174 | + | require.Empty(t, hello.PreferredTabID) | |||
| 175 | + | require.NoError(t, protocol.ValidateHello(hello)) | |||
| 176 | + | } | |||
M internal/usecase/client/supervisor.go +2 -0
| @@ -444,6 +444,8 @@ type Supervisor struct { | |||
| 444 | 444 | // theme retains the terminal-reported colors across attachments, so a | |
| 445 | 445 | // replacement attachment restores them before its own palette query ends. | |
| 446 | 446 | theme terminalThemeState | |
| 447 | + | // tabs remembers navigation only for this client process. | |
| 448 | + | tabs sessionTabMemory | |
| 447 | 449 | // capabilities is the outer terminal's probed and enabled capabilities, | |
| 448 | 450 | // written once in Run before any attachment starts. | |
| 449 | 451 | capabilities terminalCapabilities | |
| @@ -444,6 +444,8 @@ type Supervisor struct { | |||||
| 444 | // theme retains the terminal-reported colors across attachments, so a | 444 | // theme retains the terminal-reported colors across attachments, so a | ||
| 445 | // replacement attachment restores them before its own palette query ends. | 445 | // replacement attachment restores them before its own palette query ends. | ||
| 446 | theme terminalThemeState | 446 | theme terminalThemeState | ||
| 447 | + | // tabs remembers navigation only for this client process. | |||
| 448 | + | tabs sessionTabMemory | |||
| 447 | // capabilities is the outer terminal's probed and enabled capabilities, | 449 | // capabilities is the outer terminal's probed and enabled capabilities, | ||
| 448 | // written once in Run before any attachment starts. | 450 | // written once in Run before any attachment starts. | ||
| 449 | capabilities terminalCapabilities | 451 | capabilities terminalCapabilities | ||
M internal/usecase/client/supervisor_attach.go +1 -0
| @@ -356,6 +356,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||
| 356 | 356 | Theme: &s.theme, | |
| 357 | 357 | Clipboard: s.cfg.Clipboard, | |
| 358 | 358 | Tab: tab, | |
| 359 | + | Tabs: &s.tabs, | |
| 359 | 360 | }) | |
| 360 | 361 | if err != nil { | |
| 361 | 362 | return nil, err | |
| @@ -356,6 +356,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||||
| 356 | Theme: &s.theme, | 356 | Theme: &s.theme, | ||
| 357 | Clipboard: s.cfg.Clipboard, | 357 | Clipboard: s.cfg.Clipboard, | ||
| 358 | Tab: tab, | 358 | Tab: tab, | ||
| 359 | + | Tabs: &s.tabs, | |||
| 359 | }) | 360 | }) | ||
| 360 | if err != nil { | 361 | if err != nil { | ||
| 361 | return nil, err | 362 | return nil, err | ||