detached 8230561

fix(navigation): restore hybrid local selection and route history (#257)

* fix(navigation): hand off local tabs from transient home pickers

* test(navigation): harden sandbox prerequisite diagnostics

* test(navigation): keep visual sandbox tooling out of the fix

* style(wire): consolidate tab handoff test imports
commit
823056118810cbfedca99840e0b2b88f37d60ec6
author
bnema · · 2026-09-05 23:06 UTC
committer
GitHub · · 2026-09-05 23:06 UTC
parent
bc06929
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
17 files · +205 -36
M docs/architecture.md +1 -1
@@ -48,4 +48,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
4848 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
4949 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
5050 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
51-- Keep protocol version `37` unless a negotiated wire layout changes.
51+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `40`).
@@ -48,4 +48,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
48 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.48 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
49 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.49 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
50 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.50 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
51-- Keep protocol version `37` unless a negotiated wire layout changes.51+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `40`).
M docs/remote-resilience.md +6 -1
@@ -82,7 +82,12 @@ session order, on either UDP or SSH stdio. Its status bar keeps the source
8282 session label (for example, `misc@igor`), not the local session hosting the
8383 picker. Local backing-session tabs and bar-script output are hidden while
8484 this temporary attachment renders the picker. Selecting a destination commits
85-that route; Back returns to the source.
85+that route; Back returns to the source. Local tab selections also hand off to
86+the client, including selections in the backing session itself: the temporary
87+picker connection never becomes the destination attachment. Protocol version
88+40 carries the explicit local tab in `AttachTarget.PreferredTabID`; it overrides
89+remembered tab state. If that tab disappears before attach, the existing Hello
90+default-tab fallback applies. Upgrade the client and both daemons together.
8691
8792 ## Durable record compatibility
8893
@@ -82,7 +82,12 @@ session order, on either UDP or SSH stdio. Its status bar keeps the source
82 session label (for example, `misc@igor`), not the local session hosting the82 session label (for example, `misc@igor`), not the local session hosting the
83 picker. Local backing-session tabs and bar-script output are hidden while83 picker. Local backing-session tabs and bar-script output are hidden while
84 this temporary attachment renders the picker. Selecting a destination commits84 this temporary attachment renders the picker. Selecting a destination commits
85-that route; Back returns to the source.85+that route; Back returns to the source. Local tab selections also hand off to
86+the client, including selections in the backing session itself: the temporary
87+picker connection never becomes the destination attachment. Protocol version
88+40 carries the explicit local tab in `AttachTarget.PreferredTabID`; it overrides
89+remembered tab state. If that tab disappears before attach, the existing Hello
90+default-tab fallback applies. Upgrade the client and both daemons together.
86 91
87 ## Durable record compatibility92 ## Durable record compatibility
88 93
M internal/protocol/session.go +8 -1
@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 39
16+const Version uint16 = 40
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
1919 // first committed publication.
@@ -205,6 +205,10 @@ type AttachTarget struct {
205205 ExactTarget *ExactSessionTarget
206206 EnvironmentPolicy EnvironmentPolicy
207207 SamePeer bool
208+ // PreferredTabID carries an explicit local tab selection across a handoff.
209+ // Empty retains the client's route memory; a disappeared tab uses Hello's
210+ // existing default-tab fallback.
211+ PreferredTabID domain.TabStableID
208212 }
209213
210214 type Detached struct{ Reason uint8 }
@@ -395,6 +399,9 @@ func ValidateAttachTarget(m AttachTarget) error {
395399 if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) {
396400 return ErrInvalidAttachTarget
397401 }
402+ if m.PreferredTabID != "" && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
403+ return ErrInvalidAttachTarget
404+ }
398405 if m.RemoteTarget == nil {
399406 return nil
400407 }
@@ -13,7 +13,7 @@ import (
13 )13 )
14 14
15 // Version is the negotiated vev session protocol version.15 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 3916+const Version uint16 = 40
17 17
18 // HandshakeTimeout bounds every transport handshake from connect through the18 // HandshakeTimeout bounds every transport handshake from connect through the
19 // first committed publication.19 // first committed publication.
@@ -205,6 +205,10 @@ type AttachTarget struct {
205 ExactTarget *ExactSessionTarget205 ExactTarget *ExactSessionTarget
206 EnvironmentPolicy EnvironmentPolicy206 EnvironmentPolicy EnvironmentPolicy
207 SamePeer bool207 SamePeer bool
208+ // PreferredTabID carries an explicit local tab selection across a handoff.
209+ // Empty retains the client's route memory; a disappeared tab uses Hello's
210+ // existing default-tab fallback.
211+ PreferredTabID domain.TabStableID
208 }212 }
209 213
210 type Detached struct{ Reason uint8 }214 type Detached struct{ Reason uint8 }
@@ -395,6 +399,9 @@ func ValidateAttachTarget(m AttachTarget) error {
395 if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) {399 if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) {
396 return ErrInvalidAttachTarget400 return ErrInvalidAttachTarget
397 }401 }
402+ if m.PreferredTabID != "" && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
403+ return ErrInvalidAttachTarget
404+ }
398 if m.RemoteTarget == nil {405 if m.RemoteTarget == nil {
399 return nil406 return nil
400 }407 }
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(39), Version)
13+ require.Equal(t, uint16(40), Version)
1414 require.Equal(t, (16<<20)-55, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(39), Version)13+ require.Equal(t, uint16(40), Version)
14 require.Equal(t, (16<<20)-55, MaxOutputDataLen)14 require.Equal(t, (16<<20)-55, MaxOutputDataLen)
15 }15 }
16 16
A internal/protocol/wire/attach_target_tab_test.go +54 -0
@@ -0,0 +1,54 @@
1+package wire
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAttachTargetPreferredTabStrict(t *testing.T) {
13+ valid := protocol.AttachTarget{
14+ Session: "work", Intent: protocol.IntentAttach,
15+ ExactTarget: &protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"},
16+ PreferredTabID: "tab",
17+ }
18+ payload := MarshalAttachTarget(valid)
19+ want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1)
20+ want = append(want, make([]byte, 15)...)
21+ want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b')
22+ require.Equal(t, want, payload)
23+ decoded, err := UnmarshalAttachTarget(payload)
24+ require.NoError(t, err)
25+ require.Equal(t, valid, decoded)
26+ assertAllPrefixesFail(t, payload, UnmarshalAttachTarget)
27+ assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget)
28+ malformed := append([]byte(nil), payload...)
29+ malformed[len(malformed)-1] = '\n'
30+ _, err = UnmarshalAttachTarget(malformed)
31+ require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget)
32+
33+ for _, tc := range []struct {
34+ name string
35+ change func(*protocol.AttachTarget)
36+ }{
37+ {name: "no exact lifecycle", change: func(m *protocol.AttachTarget) { m.ExactTarget = nil }},
38+ {name: "remote endpoint", change: func(m *protocol.AttachTarget) { m.Endpoint = "host" }},
39+ {name: "remote target", change: func(m *protocol.AttachTarget) { m.RemoteTarget = &domain.RemoteSessionTarget{} }},
40+ {name: "creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentNew }},
41+ {name: "ephemeral creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentEphemeral }},
42+ {name: "control byte", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab\n" }},
43+ {name: "whitespace", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab name" }},
44+ {name: "invalid UTF8", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "\xff" }},
45+ {name: "oversize", change: func(m *protocol.AttachTarget) { m.PreferredTabID = domain.TabStableID(strings.Repeat("x", 129)) }},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ message := valid
49+ tc.change(&message)
50+ require.ErrorIs(t, protocol.ValidateAttachTarget(message), protocol.ErrInvalidAttachTarget)
51+ require.Nil(t, MarshalAttachTarget(message))
52+ })
53+ }
54+}
@@ -0,0 +1,54 @@
1+package wire
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAttachTargetPreferredTabStrict(t *testing.T) {
13+ valid := protocol.AttachTarget{
14+ Session: "work", Intent: protocol.IntentAttach,
15+ ExactTarget: &protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"},
16+ PreferredTabID: "tab",
17+ }
18+ payload := MarshalAttachTarget(valid)
19+ want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1)
20+ want = append(want, make([]byte, 15)...)
21+ want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b')
22+ require.Equal(t, want, payload)
23+ decoded, err := UnmarshalAttachTarget(payload)
24+ require.NoError(t, err)
25+ require.Equal(t, valid, decoded)
26+ assertAllPrefixesFail(t, payload, UnmarshalAttachTarget)
27+ assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget)
28+ malformed := append([]byte(nil), payload...)
29+ malformed[len(malformed)-1] = '\n'
30+ _, err = UnmarshalAttachTarget(malformed)
31+ require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget)
32+
33+ for _, tc := range []struct {
34+ name string
35+ change func(*protocol.AttachTarget)
36+ }{
37+ {name: "no exact lifecycle", change: func(m *protocol.AttachTarget) { m.ExactTarget = nil }},
38+ {name: "remote endpoint", change: func(m *protocol.AttachTarget) { m.Endpoint = "host" }},
39+ {name: "remote target", change: func(m *protocol.AttachTarget) { m.RemoteTarget = &domain.RemoteSessionTarget{} }},
40+ {name: "creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentNew }},
41+ {name: "ephemeral creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentEphemeral }},
42+ {name: "control byte", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab\n" }},
43+ {name: "whitespace", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab name" }},
44+ {name: "invalid UTF8", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "\xff" }},
45+ {name: "oversize", change: func(m *protocol.AttachTarget) { m.PreferredTabID = domain.TabStableID(strings.Repeat("x", 129)) }},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ message := valid
49+ tc.change(&message)
50+ require.ErrorIs(t, protocol.ValidateAttachTarget(message), protocol.ErrInvalidAttachTarget)
51+ require.Nil(t, MarshalAttachTarget(message))
52+ })
53+ }
54+}
M internal/protocol/wire/frame_test.go +2 -2
@@ -7,8 +7,8 @@ import (
77 )
88
99 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 39 {
11- t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)
10+ if protocol.Version != 40 {
11+ t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)
1212 }
1313 }
1414
@@ -7,8 +7,8 @@ import (
7 )7 )
8 8
9 func TestProtocolVersion(t *testing.T) {9 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 39 {10+ if protocol.Version != 40 {
11- t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)11+ t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)
12 }12 }
13 }13 }
14 14
M internal/protocol/wire/navigation_test.go +1 -1
@@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
156- {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002702" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002802" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157157 {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},
158158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
159159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
@@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
156- {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002702" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002802" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},
157 {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},157 {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker},
158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
M internal/protocol/wire/remote_preview_hardening_test.go +1 -1
@@ -203,7 +203,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
203203 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
204204 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
205205 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
206- 0x00, 0x00,
206+ 0x00, 0x00, 0x00, 0x00,
207207 }
208208 if !bytes.Equal(got, want) {
209209 t.Fatalf("rich target bytes = %x, want %x", got, want)
@@ -203,7 +203,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
203 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,203 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
204 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,204 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
205 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,205 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
206- 0x00, 0x00,206+ 0x00, 0x00, 0x00, 0x00,
207 }207 }
208 if !bytes.Equal(got, want) {208 if !bytes.Equal(got, want) {
209 t.Fatalf("rich target bytes = %x, want %x", got, want)209 t.Fatalf("rich target bytes = %x, want %x", got, want)
M internal/protocol/wire/remote_target_wire_test.go +2 -2
@@ -140,7 +140,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
140140 })
141141 },
142142 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
143- policyOffset: 3,
143+ policyOffset: 5,
144144 wantErr: protocol.ErrInvalidAttachTarget,
145145 },
146146 }
@@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
196196 }
197197
198198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
199- want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0}
200200 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
201201 if !bytes.Equal(got, want) {
202202 t.Fatalf("target bytes = %x, want %x", got, want)
@@ -140,7 +140,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
140 })140 })
141 },141 },
142 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },142 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
143- policyOffset: 3,143+ policyOffset: 5,
144 wantErr: protocol.ErrInvalidAttachTarget,144 wantErr: protocol.ErrInvalidAttachTarget,
145 },145 },
146 }146 }
@@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
196 }196 }
197 197
198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
199- want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0}
200 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})200 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
201 if !bytes.Equal(got, want) {201 if !bytes.Equal(got, want) {
202 t.Fatalf("target bytes = %x, want %x", got, want)202 t.Fatalf("target bytes = %x, want %x", got, want)
M internal/protocol/wire/wire.go +9 -0
@@ -1207,6 +1207,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
12071207 marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
12081208 marshalExactTargetSection(&w, m.ExactTarget)
12091209 w.putBool(m.SamePeer)
1210+ w.putString(string(m.PreferredTabID))
12101211 return w.b
12111212 }
12121213
@@ -1240,6 +1241,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
12401241 if _, err := probe.getBool(); err != nil {
12411242 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
12421243 }
1244+ if err := probe.skipString(); err != nil {
1245+ return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1246+ }
12431247 if err := probe.done(); err != nil {
12441248 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
12451249 }
@@ -1267,6 +1271,11 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
12671271 if m.SamePeer, err = r.getBool(); err != nil {
12681272 return protocol.AttachTarget{}, err
12691273 }
1274+ preferredTabID, err := r.getString()
1275+ if err != nil {
1276+ return protocol.AttachTarget{}, err
1277+ }
1278+ m.PreferredTabID = domain.TabStableID(preferredTabID)
12701279 if err := r.done(); err != nil {
12711280 return protocol.AttachTarget{}, err
12721281 }
@@ -1207,6 +1207,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
1207 marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)1207 marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
1208 marshalExactTargetSection(&w, m.ExactTarget)1208 marshalExactTargetSection(&w, m.ExactTarget)
1209 w.putBool(m.SamePeer)1209 w.putBool(m.SamePeer)
1210+ w.putString(string(m.PreferredTabID))
1210 return w.b1211 return w.b
1211 }1212 }
1212 1213
@@ -1240,6 +1241,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
1240 if _, err := probe.getBool(); err != nil {1241 if _, err := probe.getBool(); err != nil {
1241 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget1242 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1242 }1243 }
1244+ if err := probe.skipString(); err != nil {
1245+ return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1246+ }
1243 if err := probe.done(); err != nil {1247 if err := probe.done(); err != nil {
1244 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget1248 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1245 }1249 }
@@ -1267,6 +1271,11 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
1267 if m.SamePeer, err = r.getBool(); err != nil {1271 if m.SamePeer, err = r.getBool(); err != nil {
1268 return protocol.AttachTarget{}, err1272 return protocol.AttachTarget{}, err
1269 }1273 }
1274+ preferredTabID, err := r.getString()
1275+ if err != nil {
1276+ return protocol.AttachTarget{}, err
1277+ }
1278+ m.PreferredTabID = domain.TabStableID(preferredTabID)
1270 if err := r.done(); err != nil {1279 if err := r.done(); err != nil {
1271 return protocol.AttachTarget{}, err1280 return protocol.AttachTarget{}, err
1272 }1281 }
M internal/protocol/wire/wire_final_test.go +6 -6
@@ -12,8 +12,8 @@ import (
1212 )
1313
1414 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 39 {
16- t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)
15+ if protocol.Version != 40 {
16+ t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)
1717 }
1818 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1919 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2727
2828 func TestFinalHelloGoldenStrict(t *testing.T) {
2929 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 39, 2}, make([]byte, 16+8)...)
30+ want := append([]byte{0, 40, 2}, make([]byte, 16+8)...)
3131 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3232 want = append(want, 0, 0)
3333 got := MarshalHello(msg)
@@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
196196
197197 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
198198 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}
199- want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0}
199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0, 0, 0}
200200 got := MarshalAttachTarget(msg)
201201 if !bytes.Equal(got, want) {
202202 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
212212 if localPayload == nil {
213213 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
214214 }
215- wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}
215+ wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0}
216216 if !bytes.Equal(localPayload, wantLocal) {
217217 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
218218 }
@@ -248,7 +248,7 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) {
248248 payload := MarshalAttachTarget(target)
249249 want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)
250250 want = append(want, make([]byte, 15)...)
251- want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1)
251+ want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0)
252252 if !bytes.Equal(payload, want) {
253253 t.Fatalf("exact target bytes = %x, want %x", payload, want)
254254 }
@@ -12,8 +12,8 @@ import (
12 )12 )
13 13
14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 39 {15+ if protocol.Version != 40 {
16- t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version)16+ t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)
17 }17 }
18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
19 if len(payload) < 2 {19 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
27 27
28 func TestFinalHelloGoldenStrict(t *testing.T) {28 func TestFinalHelloGoldenStrict(t *testing.T) {
29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 39, 2}, make([]byte, 16+8)...)30+ want := append([]byte{0, 40, 2}, make([]byte, 16+8)...)
31 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)31 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
32 want = append(want, 0, 0)32 want = append(want, 0, 0)
33 got := MarshalHello(msg)33 got := MarshalHello(msg)
@@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
196 196
197 func TestFinalAttachTargetGoldenStrict(t *testing.T) {197 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
198 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}198 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}
199- want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0}199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0, 0, 0}
200 got := MarshalAttachTarget(msg)200 got := MarshalAttachTarget(msg)
201 if !bytes.Equal(got, want) {201 if !bytes.Equal(got, want) {
202 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)202 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
212 if localPayload == nil {212 if localPayload == nil {
213 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")213 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
214 }214 }
215- wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0}215+ wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0}
216 if !bytes.Equal(localPayload, wantLocal) {216 if !bytes.Equal(localPayload, wantLocal) {
217 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)217 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
218 }218 }
@@ -248,7 +248,7 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) {
248 payload := MarshalAttachTarget(target)248 payload := MarshalAttachTarget(target)
249 want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)249 want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)
250 want = append(want, make([]byte, 15)...)250 want = append(want, make([]byte, 15)...)
251- want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1)251+ want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0)
252 if !bytes.Equal(payload, want) {252 if !bytes.Equal(payload, want) {
253 t.Fatalf("exact target bytes = %x, want %x", payload, want)253 t.Fatalf("exact target bytes = %x, want %x", payload, want)
254 }254 }
M internal/protocol/wire/wire_test.go +6 -6
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136136 }
137137 got := MarshalHello(msg)
138138 want := []byte{
139- 0x00, 0x27, 0x00, // version, intent, first client ID byte
139+ 0x00, 0x28, 0x00, // version, intent, first client ID byte
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367367 }
368368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(39), protocol.Version)
370+ require.Equal(t, uint16(40), protocol.Version)
371371 }
372372
373373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528528 name: "minimal",
529529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530530 want: []byte{
531- 0x00, 0x27,
531+ 0x00, 0x28,
532532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533533 0x00, // attached
534534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551551 JSON: true,
552552 },
553553 want: []byte{
554- 0x00, 0x27,
554+ 0x00, 0x28,
555555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556556 0x00, // attached
557557 0x01, // self
@@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
989989 name: "attached",
990990 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
991991 want: []byte{
992- 0x00, 0x27, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
992+ 0x00, 0x28, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
993993 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
994994 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
995995 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
999999 name: "control",
10001000 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
10011001 want: []byte{
1002- 0x00, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1002+ 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10031003 0x00, 0x02, 'l', 's', 0x00, 0x00,
10041004 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10051005 },
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 }136 }
137 got := MarshalHello(msg)137 got := MarshalHello(msg)
138 want := []byte{138 want := []byte{
139- 0x00, 0x27, 0x00, // version, intent, first client ID byte139+ 0x00, 0x28, 0x00, // version, intent, first client ID byte
140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367 }367 }
368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369 require.Equal(t, want, MarshalTheme(cleared))369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(39), protocol.Version)370+ require.Equal(t, uint16(40), protocol.Version)
371 }371 }
372 372
373 func TestResizeGoldenAndRoundTrip(t *testing.T) {373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528 name: "minimal",528 name: "minimal",
529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530 want: []byte{530 want: []byte{
531- 0x00, 0x27,531+ 0x00, 0x28,
532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533 0x00, // attached533 0x00, // attached
534 0x00, // self534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551 JSON: true,551 JSON: true,
552 },552 },
553 want: []byte{553 want: []byte{
554- 0x00, 0x27,554+ 0x00, 0x28,
555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556 0x00, // attached556 0x00, // attached
557 0x01, // self557 0x01, // self
@@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
989 name: "attached",989 name: "attached",
990 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},990 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
991 want: []byte{991 want: []byte{
992- 0x00, 0x27, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,992+ 0x00, 0x28, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
993 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',993 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
994 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',994 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
995 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,995 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
999 name: "control",999 name: "control",
1000 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},1000 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
1001 want: []byte{1001 want: []byte{
1002- 0x00, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1002+ 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1003 0x00, 0x02, 'l', 's', 0x00, 0x00,1003 0x00, 0x02, 'l', 's', 0x00, 0x00,
1004 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1004 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1005 },1005 },
M internal/usecase/client/home_picker_context_test.go +3 -1
@@ -24,6 +24,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) {
2424 {name: "UDP backing session renamed", udp: true, rename: true},
2525 {name: "stdio backing session renamed", rename: true},
2626 {name: "stdio select new local destination", selectLocal: true},
27+ {name: "UDP select new local destination", udp: true, selectLocal: true},
2728 } {
2829 t.Run(tc.name, func(t *testing.T) {
2930 term := newRunTerminal()
@@ -69,7 +70,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) {
6970 selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"}
7071 localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
7172 Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected,
72- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true,
73+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "chosen-tab",
7374 }))})
7475 localDestination = &recordingTransport{recvs: []recvItem{
7576 {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)},
@@ -114,6 +115,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) {
114115 if localDestination != nil {
115116 hello := helloFromSend(t, localDestination)
116117 require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay)
118+ require.Equal(t, domain.TabStableID("chosen-tab"), hello.PreferredTabID)
117119 require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack)
118120 var selected protocol.RecentRouteSnapshot
119121 for _, sent := range localDestination.Sends() {
@@ -24,6 +24,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) {
24 {name: "UDP backing session renamed", udp: true, rename: true},24 {name: "UDP backing session renamed", udp: true, rename: true},
25 {name: "stdio backing session renamed", rename: true},25 {name: "stdio backing session renamed", rename: true},
26 {name: "stdio select new local destination", selectLocal: true},26 {name: "stdio select new local destination", selectLocal: true},
27+ {name: "UDP select new local destination", udp: true, selectLocal: true},
27 } {28 } {
28 t.Run(tc.name, func(t *testing.T) {29 t.Run(tc.name, func(t *testing.T) {
29 term := newRunTerminal()30 term := newRunTerminal()
@@ -69,7 +70,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) {
69 selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"}70 selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"}
70 localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{71 localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
71 Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected,72 Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected,
72- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true,73+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "chosen-tab",
73 }))})74 }))})
74 localDestination = &recordingTransport{recvs: []recvItem{75 localDestination = &recordingTransport{recvs: []recvItem{
75 {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)},76 {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)},
@@ -114,6 +115,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) {
114 if localDestination != nil {115 if localDestination != nil {
115 hello := helloFromSend(t, localDestination)116 hello := helloFromSend(t, localDestination)
116 require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay)117 require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay)
118+ require.Equal(t, domain.TabStableID("chosen-tab"), hello.PreferredTabID)
117 require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack)119 require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack)
118 var selected protocol.RecentRouteSnapshot120 var selected protocol.RecentRouteSnapshot
119 for _, sent := range localDestination.Sends() {121 for _, sent := range localDestination.Sends() {
M internal/usecase/client/routes.go +6 -5
@@ -551,8 +551,9 @@ func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot {
551551 }
552552
553553 // samePeerHandoff restores a previously committed route on the daemon already
554-// connected to by the client. The daemon deliberately sends no tab state: the
555-// ledger is the sole owner of per-route view memory.
554+// connected to by the client. An explicit tab selection overrides route memory;
555+// a session-only selection restores the client's remembered view only when
556+// the exact lifecycle matches. Without an exact target, tab memory is cleared.
556557 func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.AttachTarget) AttachRequest {
557558 origin := normalizeRouteOrigin(active.Origin, active.Remote)
558559 originKey := normalizeRouteOriginKey(active.OriginKey, origin)
@@ -582,8 +583,8 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
582583 }
583584 request.EnvironmentPolicy = environmentPolicy
584585 request.ExactTarget = target.ExactTarget
585- if target.ExactTarget == nil {
586- request.PreferredTabID = ""
586+ if target.ExactTarget == nil || target.PreferredTabID != "" {
587+ request.PreferredTabID = target.PreferredTabID
587588 }
588589 return request
589590 }
@@ -592,7 +593,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
592593 request.Intent = target.Intent
593594 request.SessionName = target.Session
594595 request.ExactTarget = target.ExactTarget
595- request.PreferredTabID = ""
596+ request.PreferredTabID = target.PreferredTabID
596597 request.RemoteTarget = nil
597598 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {
598599 request.NavigationCapabilities = 0
@@ -551,8 +551,9 @@ func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot {
551 }551 }
552 552
553 // samePeerHandoff restores a previously committed route on the daemon already553 // samePeerHandoff restores a previously committed route on the daemon already
554-// connected to by the client. The daemon deliberately sends no tab state: the554+// connected to by the client. An explicit tab selection overrides route memory;
555-// ledger is the sole owner of per-route view memory.555+// a session-only selection restores the client's remembered view only when
556+// the exact lifecycle matches. Without an exact target, tab memory is cleared.
556 func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.AttachTarget) AttachRequest {557 func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.AttachTarget) AttachRequest {
557 origin := normalizeRouteOrigin(active.Origin, active.Remote)558 origin := normalizeRouteOrigin(active.Origin, active.Remote)
558 originKey := normalizeRouteOriginKey(active.OriginKey, origin)559 originKey := normalizeRouteOriginKey(active.OriginKey, origin)
@@ -582,8 +583,8 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
582 }583 }
583 request.EnvironmentPolicy = environmentPolicy584 request.EnvironmentPolicy = environmentPolicy
584 request.ExactTarget = target.ExactTarget585 request.ExactTarget = target.ExactTarget
585- if target.ExactTarget == nil {586+ if target.ExactTarget == nil || target.PreferredTabID != "" {
586- request.PreferredTabID = ""587+ request.PreferredTabID = target.PreferredTabID
587 }588 }
588 return request589 return request
589 }590 }
@@ -592,7 +593,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
592 request.Intent = target.Intent593 request.Intent = target.Intent
593 request.SessionName = target.Session594 request.SessionName = target.Session
594 request.ExactTarget = target.ExactTarget595 request.ExactTarget = target.ExactTarget
595- request.PreferredTabID = ""596+ request.PreferredTabID = target.PreferredTabID
596 request.RemoteTarget = nil597 request.RemoteTarget = nil
597 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {598 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {
598 request.NavigationCapabilities = 0599 request.NavigationCapabilities = 0
M internal/usecase/client/routes_test.go +4 -0
@@ -190,16 +190,20 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
190190 name string
191191 target protocol.ExactSessionTarget
192192 wantExactTarget *protocol.ExactSessionTarget
193+ explicitTab domain.TabStableID
193194 wantTab domain.TabStableID
194195 }{
195196 {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"},
196197 {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated},
198+ {name: "explicit tab overrides memory", target: first.target, wantExactTarget: &first.target, explicitTab: "chosen-tab", wantTab: "chosen-tab"},
199+ {name: "new lifecycle retains explicit tab", target: recreated, wantExactTarget: &recreated, explicitTab: "chosen-tab", wantTab: "chosen-tab"},
197200 } {
198201 t.Run(tt.name, func(t *testing.T) {
199202 request := ledger.samePeerHandoff(second.request, protocol.AttachTarget{
200203 Session: first.target.SessionName,
201204 Intent: protocol.IntentAttach,
202205 ExactTarget: &tt.target,
206+ PreferredTabID: tt.explicitTab,
203207 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
204208 })
205209 require.Equal(t, first.target.SessionName, request.SessionName)
@@ -190,16 +190,20 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
190 name string190 name string
191 target protocol.ExactSessionTarget191 target protocol.ExactSessionTarget
192 wantExactTarget *protocol.ExactSessionTarget192 wantExactTarget *protocol.ExactSessionTarget
193+ explicitTab domain.TabStableID
193 wantTab domain.TabStableID194 wantTab domain.TabStableID
194 }{195 }{
195 {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"},196 {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"},
196 {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated},197 {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated},
198+ {name: "explicit tab overrides memory", target: first.target, wantExactTarget: &first.target, explicitTab: "chosen-tab", wantTab: "chosen-tab"},
199+ {name: "new lifecycle retains explicit tab", target: recreated, wantExactTarget: &recreated, explicitTab: "chosen-tab", wantTab: "chosen-tab"},
197 } {200 } {
198 t.Run(tt.name, func(t *testing.T) {201 t.Run(tt.name, func(t *testing.T) {
199 request := ledger.samePeerHandoff(second.request, protocol.AttachTarget{202 request := ledger.samePeerHandoff(second.request, protocol.AttachTarget{
200 Session: first.target.SessionName,203 Session: first.target.SessionName,
201 Intent: protocol.IntentAttach,204 Intent: protocol.IntentAttach,
202 ExactTarget: &tt.target,205 ExactTarget: &tt.target,
206+ PreferredTabID: tt.explicitTab,
203 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,207 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
204 })208 })
205 require.Equal(t, first.target.SessionName, request.SessionName)209 require.Equal(t, first.target.SessionName, request.SessionName)
A internal/usecase/daemon/home_picker_navigation_test.go +86 -0
@@ -0,0 +1,86 @@
1+package daemon
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+ "github.com/bnema/vev/internal/protocol/wire"
11+)
12+
13+func TestHomePickerTabSelectionHandsOffToClient(t *testing.T) {
14+ for _, tc := range []struct {
15+ name string
16+ sameSession bool
17+ homePicker bool
18+ }{
19+ {name: "different local session", homePicker: true},
20+ {name: "backing local session", homePicker: true, sameSession: true},
21+ {name: "ordinary local session switch"},
22+ {name: "ordinary local tab switch", sameSession: true},
23+ } {
24+ t.Run(tc.name, func(t *testing.T) {
25+ d, source, ac, sends, releases := newManualTabSession(t, 2)
26+ defer releaseAll(releases)
27+ source.name = "home"
28+ source.incarnation = domain.SessionLifecycleID{1}
29+ target := source
30+ if !tc.sameSession {
31+ target = &session{
32+ sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.SessionLifecycleID{2}, attachments: make(map[*attachedClient]struct{})},
33+ ctx: source.ctx, cancel: func() {},
34+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23}), newTab(nil, domain.Size{Cols: 80, Rows: 23})},
35+ }
36+ for _, tab := range target.tabs {
37+ publishTiledPaneOwners(target, tab)
38+ }
39+ d.sessions[target.id] = target
40+ }
41+ target.tabs[1].name = "chosen-tab"
42+ if tc.homePicker {
43+ ac.startupOverlay = protocol.StartupOverlaySessionPicker
44+ ac.navigationCapabilities = protocol.NavigationCapabilityBack
45+ }
46+ ref := protocol.RouteRef{Key: 7, Generation: 1}
47+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
48+ Generation: 1, Active: ref,
49+ ActiveEntry: protocol.RecentRouteEntry{Key: ref.Key, Generation: ref.Generation,
50+ Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote"},
51+ Name: "remote", HostLabel: "remote-host", Kind: protocol.RouteKindRemote},
52+ })
53+ effect := beginRecentRoutePaletteEffect(t, d, source, ac)
54+ d.enterPicker(source, ac)
55+ // Exercise the real picker model and input dispatch, not a fabricated
56+ // AttachTarget: navigation selects tab rows, including search hits.
57+ d.handleInputForAttachment(effect, []byte("/chosen-tab"))
58+ ac.overlays.pickerMu.Lock()
59+ selected, ok := ac.overlays.picker.Selected()
60+ ac.overlays.pickerMu.Unlock()
61+ require.True(t, ok)
62+ require.Equal(t, domain.TabStableID(target.tabs[1].stableID), selected.TabID)
63+ d.handleInputForAttachment(effect, []byte("\r"))
64+
65+ if !tc.homePicker {
66+ require.Same(t, target, ac.currentAttachmentSession())
67+ require.Equal(t, selected.TabID, ac.viewSnapshot().tabID)
68+ return
69+ }
70+ require.True(t, source == ac.currentAttachmentSession(), "a temporary picker must not become the destination attachment")
71+ var handoff *protocol.AttachTarget
72+ for len(sends) != 0 {
73+ frame := <-sends
74+ if frame.Type == wire.MsgAttachTarget {
75+ decoded, err := wire.UnmarshalAttachTarget(frame.Payload)
76+ require.NoError(t, err)
77+ handoff = &decoded
78+ }
79+ }
80+ require.NotNil(t, handoff, "selection must return to the client that owns the parked remote route")
81+ require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, handoff.ExactTarget)
82+ require.Equal(t, selected.TabID, handoff.PreferredTabID)
83+ require.False(t, handoff.SamePeer, "the temporary connection must end, not be promoted in place")
84+ })
85+ }
86+}
@@ -0,0 +1,86 @@
1+package daemon
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+ "github.com/bnema/vev/internal/protocol/wire"
11+)
12+
13+func TestHomePickerTabSelectionHandsOffToClient(t *testing.T) {
14+ for _, tc := range []struct {
15+ name string
16+ sameSession bool
17+ homePicker bool
18+ }{
19+ {name: "different local session", homePicker: true},
20+ {name: "backing local session", homePicker: true, sameSession: true},
21+ {name: "ordinary local session switch"},
22+ {name: "ordinary local tab switch", sameSession: true},
23+ } {
24+ t.Run(tc.name, func(t *testing.T) {
25+ d, source, ac, sends, releases := newManualTabSession(t, 2)
26+ defer releaseAll(releases)
27+ source.name = "home"
28+ source.incarnation = domain.SessionLifecycleID{1}
29+ target := source
30+ if !tc.sameSession {
31+ target = &session{
32+ sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.SessionLifecycleID{2}, attachments: make(map[*attachedClient]struct{})},
33+ ctx: source.ctx, cancel: func() {},
34+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23}), newTab(nil, domain.Size{Cols: 80, Rows: 23})},
35+ }
36+ for _, tab := range target.tabs {
37+ publishTiledPaneOwners(target, tab)
38+ }
39+ d.sessions[target.id] = target
40+ }
41+ target.tabs[1].name = "chosen-tab"
42+ if tc.homePicker {
43+ ac.startupOverlay = protocol.StartupOverlaySessionPicker
44+ ac.navigationCapabilities = protocol.NavigationCapabilityBack
45+ }
46+ ref := protocol.RouteRef{Key: 7, Generation: 1}
47+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
48+ Generation: 1, Active: ref,
49+ ActiveEntry: protocol.RecentRouteEntry{Key: ref.Key, Generation: ref.Generation,
50+ Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote"},
51+ Name: "remote", HostLabel: "remote-host", Kind: protocol.RouteKindRemote},
52+ })
53+ effect := beginRecentRoutePaletteEffect(t, d, source, ac)
54+ d.enterPicker(source, ac)
55+ // Exercise the real picker model and input dispatch, not a fabricated
56+ // AttachTarget: navigation selects tab rows, including search hits.
57+ d.handleInputForAttachment(effect, []byte("/chosen-tab"))
58+ ac.overlays.pickerMu.Lock()
59+ selected, ok := ac.overlays.picker.Selected()
60+ ac.overlays.pickerMu.Unlock()
61+ require.True(t, ok)
62+ require.Equal(t, domain.TabStableID(target.tabs[1].stableID), selected.TabID)
63+ d.handleInputForAttachment(effect, []byte("\r"))
64+
65+ if !tc.homePicker {
66+ require.Same(t, target, ac.currentAttachmentSession())
67+ require.Equal(t, selected.TabID, ac.viewSnapshot().tabID)
68+ return
69+ }
70+ require.True(t, source == ac.currentAttachmentSession(), "a temporary picker must not become the destination attachment")
71+ var handoff *protocol.AttachTarget
72+ for len(sends) != 0 {
73+ frame := <-sends
74+ if frame.Type == wire.MsgAttachTarget {
75+ decoded, err := wire.UnmarshalAttachTarget(frame.Payload)
76+ require.NoError(t, err)
77+ handoff = &decoded
78+ }
79+ }
80+ require.NotNil(t, handoff, "selection must return to the client that owns the parked remote route")
81+ require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, handoff.ExactTarget)
82+ require.Equal(t, selected.TabID, handoff.PreferredTabID)
83+ require.False(t, handoff.SamePeer, "the temporary connection must end, not be promoted in place")
84+ })
85+ }
86+}
M internal/usecase/daemon/picker.go +9 -8
@@ -1017,17 +1017,17 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
10171017 if !matches || !effect.current() {
10181018 return errAttachmentTransition
10191019 }
1020- if targetSess != nil && target.TabID != "" {
1021- // An explicit tab row is a direct user choice, not route memory; retain
1022- // the established daemon-side transition so it opens exactly that tab.
1020+ homePicker := effect.ac.startupOverlay == protocol.StartupOverlaySessionPicker
1021+ if !homePicker && targetSess != nil && target.TabID != "" {
1022+ // Ordinary local tab navigation keeps the attachment. A home picker
1023+ // only borrows it: selecting even its backing session must hand off
1024+ // to the client so the remote route and temporary attachment end.
10231025 return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)
10241026 }
10251027
1026- // An active session row with no explicit non-default tab is on the
1027- // authenticated daemon and can use the client-confirmed same-peer path.
1028- // Explicit tab rows already take the direct transition path above, while
1029- // stopped sessions have no active target session and retain the fallback.
1030- samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0
1028+ // Ordinary live session rows can use the client-confirmed same-peer
1029+ // path. Temporary home pickers and stopped targets require a handoff.
1030+ samePeerEligible := !homePicker && guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0
10311031 if exactTarget == nil {
10321032 return errAttachmentTransition
10331033 }
@@ -1044,6 +1044,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
10441044 Session: sessionName,
10451045 Intent: protocol.IntentAttach,
10461046 ExactTarget: exactTarget,
1047+ PreferredTabID: target.TabID,
10471048 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
10481049 }
10491050 if protocol.ValidateAttachTarget(handoff) != nil {
@@ -1017,17 +1017,17 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
1017 if !matches || !effect.current() {1017 if !matches || !effect.current() {
1018 return errAttachmentTransition1018 return errAttachmentTransition
1019 }1019 }
1020- if targetSess != nil && target.TabID != "" {1020+ homePicker := effect.ac.startupOverlay == protocol.StartupOverlaySessionPicker
1021- // An explicit tab row is a direct user choice, not route memory; retain1021+ if !homePicker && targetSess != nil && target.TabID != "" {
1022- // the established daemon-side transition so it opens exactly that tab.1022+ // Ordinary local tab navigation keeps the attachment. A home picker
1023+ // only borrows it: selecting even its backing session must hand off
1024+ // to the client so the remote route and temporary attachment end.
1023 return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)1025 return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)
1024 }1026 }
1025 1027
1026- // An active session row with no explicit non-default tab is on the1028+ // Ordinary live session rows can use the client-confirmed same-peer
1027- // authenticated daemon and can use the client-confirmed same-peer path.1029+ // path. Temporary home pickers and stopped targets require a handoff.
1028- // Explicit tab rows already take the direct transition path above, while1030+ samePeerEligible := !homePicker && guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0
1029- // stopped sessions have no active target session and retain the fallback.
1030- samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0
1031 if exactTarget == nil {1031 if exactTarget == nil {
1032 return errAttachmentTransition1032 return errAttachmentTransition
1033 }1033 }
@@ -1044,6 +1044,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
1044 Session: sessionName,1044 Session: sessionName,
1045 Intent: protocol.IntentAttach,1045 Intent: protocol.IntentAttach,
1046 ExactTarget: exactTarget,1046 ExactTarget: exactTarget,
1047+ PreferredTabID: target.TabID,
1047 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,1048 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
1048 }1049 }
1049 if protocol.ValidateAttachTarget(handoff) != nil {1050 if protocol.ValidateAttachTarget(handoff) != 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 12

Tags 11

↑↓ moveenter openesc close