detached eac59c2

fix(routes): preserve navigation across session switches

commit
eac59c20e1fdee9e83ff80f5cad770e8ca700e49
author
brice · · 2026-08-09 18:25 UTC
parent
129ef59
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +164 -2
M internal/usecase/client/client.go +6 -0
@@ -546,8 +546,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
546546 } else {
547547 attemptRequest.Intent = ports.IntentAttach
548548 }
549+ // Back is transient to the picker overlay. Re-derive the home-picker
550+ // capability from the selected route instead of trusting request
551+ // metadata, which may have been cleared after a daemon-side switch.
549552 attemptRequest.StartupOverlay = ports.StartupOverlayNone
550553 attemptRequest.NavigationCapabilities = 0
554+ if homeRoute != nil && selection.selected.presentation.kind == ports.RouteKindRemote {
555+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityHomePicker
556+ }
551557 resumeToken = selection.selected.resumeToken
552558 routeNavigationPending = true
553559 routeNavigationResumeFallback = selection.selected.resumeToken != 0
@@ -546,8 +546,14 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
546 } else {546 } else {
547 attemptRequest.Intent = ports.IntentAttach547 attemptRequest.Intent = ports.IntentAttach
548 }548 }
549+ // Back is transient to the picker overlay. Re-derive the home-picker
550+ // capability from the selected route instead of trusting request
551+ // metadata, which may have been cleared after a daemon-side switch.
549 attemptRequest.StartupOverlay = ports.StartupOverlayNone552 attemptRequest.StartupOverlay = ports.StartupOverlayNone
550 attemptRequest.NavigationCapabilities = 0553 attemptRequest.NavigationCapabilities = 0
554+ if homeRoute != nil && selection.selected.presentation.kind == ports.RouteKindRemote {
555+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityHomePicker
556+ }
551 resumeToken = selection.selected.resumeToken557 resumeToken = selection.selected.resumeToken
552 routeNavigationPending = true558 routeNavigationPending = true
553 routeNavigationResumeFallback = selection.selected.resumeToken != 0559 routeNavigationResumeFallback = selection.selected.resumeToken != 0
M internal/usecase/client/client_test.go +91 -0
@@ -483,6 +483,97 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
483483 require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
484484 }
485485
486+func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
487+ term := newRunTerminal()
488+ defer term.in.unblock()
489+
490+ localLifecycle := domain.SessionLifecycleID{1, 1, 1}
491+ remoteLifecycle := domain.SessionLifecycleID{2, 2, 2}
492+ newLifecycle := domain.SessionLifecycleID{3, 3, 3}
493+ welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame {
494+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
495+ SessionID: name + "-id",
496+ SessionName: name,
497+ ResumeToken: token,
498+ Capabilities: ports.CapabilityResume,
499+ CommittedIdentity: &ports.CommittedRouteIdentity{
500+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
501+ },
502+ }))
503+ }
504+ remoteTarget := domain.RemoteSessionTarget{
505+ Endpoint: "remote",
506+ DisplayOrigin: "remote",
507+ LifecycleID: remoteLifecycle,
508+ SessionName: "remote-manual",
509+ LiveTabID: "tab-1",
510+ }
511+ remoteHandoff := ports.AttachTarget{
512+ Endpoint: "remote", Session: "remote-manual", Intent: ports.IntentAttach,
513+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
514+ }
515+
516+ local1 := &recordingTransport{recvs: []recvItem{
517+ {f: welcome("test", localLifecycle, 11)},
518+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(remoteHandoff))},
519+ }}
520+ local2 := &recordingTransport{recvs: []recvItem{
521+ {f: welcome("test", localLifecycle, 11)},
522+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
523+ Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"},
524+ }))},
525+ {f: frameOf(ports.MsgNavigateRecentRoute, mustMarshalRouteAction(ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 2}))},
526+ }}
527+ local3 := &recordingTransport{recvs: []recvItem{
528+ {f: welcome("test", localLifecycle, 11)},
529+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
530+ }}
531+ remote1 := &recordingTransport{recvs: []recvItem{
532+ {f: welcome("remote-manual", remoteLifecycle, 22)},
533+ {f: frameOf(ports.MsgNavigateRecentRoute, mustMarshalRouteAction(ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))},
534+ }}
535+ remote2 := &recordingTransport{recvs: []recvItem{
536+ {f: welcome("remote-manual", remoteLifecycle, 22)},
537+ {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
538+ }}
539+ localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2, local3}}
540+ remoteDialer := &sequenceDialer{trs: []ports.Transport{remote1, remote2}}
541+
542+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
543+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
544+ require.Equal(t, remoteHandoff, target)
545+ return remoteDialer, client.AttachRequest{
546+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
547+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
548+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
549+ }, nil
550+ }
551+
552+ err := runTestClient(context.Background(), deps, client.AttachRequest{
553+ Intent: ports.IntentAttach, SessionName: "test", Origin: ports.RouteOriginLocal, OriginKey: "local",
554+ })
555+ remoteHello := helloFromSend(t, remote2)
556+ require.NoError(t, err)
557+
558+ require.Equal(t, ports.NavigationCapabilityHomePicker, remoteHello.NavigationCapabilities)
559+}
560+
561+func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {
562+ payload, err := ports.MarshalCommittedRouteIdentity(identity)
563+ if err != nil {
564+ panic(err)
565+ }
566+ return payload
567+}
568+
569+func mustMarshalRouteAction(action ports.RouteNavigationAction) []byte {
570+ payload, err := ports.MarshalRouteNavigationAction(action)
571+ if err != nil {
572+ panic(err)
573+ }
574+ return payload
575+}
576+
486577 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
487578 var out bytes.Buffer
488579 var restoreCount atomic.Int32
@@ -483,6 +483,97 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
483 require.Equal(t, request.StartupOverlay, hello.StartupOverlay)483 require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
484 }484 }
485 485
486+func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
487+ term := newRunTerminal()
488+ defer term.in.unblock()
489+
490+ localLifecycle := domain.SessionLifecycleID{1, 1, 1}
491+ remoteLifecycle := domain.SessionLifecycleID{2, 2, 2}
492+ newLifecycle := domain.SessionLifecycleID{3, 3, 3}
493+ welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame {
494+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
495+ SessionID: name + "-id",
496+ SessionName: name,
497+ ResumeToken: token,
498+ Capabilities: ports.CapabilityResume,
499+ CommittedIdentity: &ports.CommittedRouteIdentity{
500+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
501+ },
502+ }))
503+ }
504+ remoteTarget := domain.RemoteSessionTarget{
505+ Endpoint: "remote",
506+ DisplayOrigin: "remote",
507+ LifecycleID: remoteLifecycle,
508+ SessionName: "remote-manual",
509+ LiveTabID: "tab-1",
510+ }
511+ remoteHandoff := ports.AttachTarget{
512+ Endpoint: "remote", Session: "remote-manual", Intent: ports.IntentAttach,
513+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
514+ }
515+
516+ local1 := &recordingTransport{recvs: []recvItem{
517+ {f: welcome("test", localLifecycle, 11)},
518+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(remoteHandoff))},
519+ }}
520+ local2 := &recordingTransport{recvs: []recvItem{
521+ {f: welcome("test", localLifecycle, 11)},
522+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
523+ Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"},
524+ }))},
525+ {f: frameOf(ports.MsgNavigateRecentRoute, mustMarshalRouteAction(ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 2}))},
526+ }}
527+ local3 := &recordingTransport{recvs: []recvItem{
528+ {f: welcome("test", localLifecycle, 11)},
529+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
530+ }}
531+ remote1 := &recordingTransport{recvs: []recvItem{
532+ {f: welcome("remote-manual", remoteLifecycle, 22)},
533+ {f: frameOf(ports.MsgNavigateRecentRoute, mustMarshalRouteAction(ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}))},
534+ }}
535+ remote2 := &recordingTransport{recvs: []recvItem{
536+ {f: welcome("remote-manual", remoteLifecycle, 22)},
537+ {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
538+ }}
539+ localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2, local3}}
540+ remoteDialer := &sequenceDialer{trs: []ports.Transport{remote1, remote2}}
541+
542+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
543+ deps.AttachHandoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
544+ require.Equal(t, remoteHandoff, target)
545+ return remoteDialer, client.AttachRequest{
546+ Intent: ports.IntentAttach, SessionName: target.Session, Remote: true,
547+ Origin: ports.RouteOriginDiscovery, OriginKey: target.Endpoint,
548+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
549+ }, nil
550+ }
551+
552+ err := runTestClient(context.Background(), deps, client.AttachRequest{
553+ Intent: ports.IntentAttach, SessionName: "test", Origin: ports.RouteOriginLocal, OriginKey: "local",
554+ })
555+ remoteHello := helloFromSend(t, remote2)
556+ require.NoError(t, err)
557+
558+ require.Equal(t, ports.NavigationCapabilityHomePicker, remoteHello.NavigationCapabilities)
559+}
560+
561+func mustMarshalCommittedIdentity(identity ports.CommittedRouteIdentity) []byte {
562+ payload, err := ports.MarshalCommittedRouteIdentity(identity)
563+ if err != nil {
564+ panic(err)
565+ }
566+ return payload
567+}
568+
569+func mustMarshalRouteAction(action ports.RouteNavigationAction) []byte {
570+ payload, err := ports.MarshalRouteNavigationAction(action)
571+ if err != nil {
572+ panic(err)
573+ }
574+ return payload
575+}
576+
486 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {577 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
487 var out bytes.Buffer578 var out bytes.Buffer
488 var restoreCount atomic.Int32579 var restoreCount atomic.Int32
A internal/usecase/daemon/attachment_route_identity_test.go +59 -0
@@ -0,0 +1,59 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestAttachmentTransitionPublishesCommittedRouteIdentity(t *testing.T) {
12+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
13+ transport := &closeTrackingTransport{}
14+ ac.replaceTransport(transport)
15+ rc := d.attachCoordinator(source, nil, ac, true)
16+ token := source.attachmentToken(ac, transport)
17+ token.lease = rc.attachmentLease(ac)
18+ ac.publishAttachmentCapability(token)
19+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
20+
21+ target := &session{
22+ sessionCore: sessionCore{
23+ id: "target",
24+ name: "target",
25+ incarnation: domain.IncarnationID{2},
26+ },
27+ ctx: source.ctx,
28+ cancel: func() {},
29+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
30+ }
31+ d.mu.Lock()
32+ d.sessions[target.id] = target
33+ d.mu.Unlock()
34+
35+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
36+ source: source,
37+ target: target,
38+ next: ac,
39+ expectedTransport: transportSnapshot{transport: transport, incarnation: ac.transportSnapshot().incarnation},
40+ ready: true,
41+ })
42+ require.NoError(t, err)
43+ d.deferAttachmentTransitionCleanups(transition)
44+
45+ var identity ports.CommittedRouteIdentity
46+ found := false
47+ for _, frame := range transport.Sends() {
48+ if frame.Type != ports.MsgCommittedRouteIdentity {
49+ continue
50+ }
51+ var decodeErr error
52+ identity, decodeErr = ports.UnmarshalCommittedRouteIdentity(frame.Payload)
53+ require.NoError(t, decodeErr)
54+ found = true
55+ }
56+ require.True(t, found, "attachment transitions must publish their committed route identity")
57+ require.Equal(t, "target", identity.Target.SessionName)
58+ require.Equal(t, domain.IncarnationID{2}, identity.Target.LifecycleID)
59+}
@@ -0,0 +1,59 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestAttachmentTransitionPublishesCommittedRouteIdentity(t *testing.T) {
12+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
13+ transport := &closeTrackingTransport{}
14+ ac.replaceTransport(transport)
15+ rc := d.attachCoordinator(source, nil, ac, true)
16+ token := source.attachmentToken(ac, transport)
17+ token.lease = rc.attachmentLease(ac)
18+ ac.publishAttachmentCapability(token)
19+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
20+
21+ target := &session{
22+ sessionCore: sessionCore{
23+ id: "target",
24+ name: "target",
25+ incarnation: domain.IncarnationID{2},
26+ },
27+ ctx: source.ctx,
28+ cancel: func() {},
29+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
30+ }
31+ d.mu.Lock()
32+ d.sessions[target.id] = target
33+ d.mu.Unlock()
34+
35+ transition, err := d.transitionAttachment(attachmentTransitionRequest{
36+ source: source,
37+ target: target,
38+ next: ac,
39+ expectedTransport: transportSnapshot{transport: transport, incarnation: ac.transportSnapshot().incarnation},
40+ ready: true,
41+ })
42+ require.NoError(t, err)
43+ d.deferAttachmentTransitionCleanups(transition)
44+
45+ var identity ports.CommittedRouteIdentity
46+ found := false
47+ for _, frame := range transport.Sends() {
48+ if frame.Type != ports.MsgCommittedRouteIdentity {
49+ continue
50+ }
51+ var decodeErr error
52+ identity, decodeErr = ports.UnmarshalCommittedRouteIdentity(frame.Payload)
53+ require.NoError(t, decodeErr)
54+ found = true
55+ }
56+ require.True(t, found, "attachment transitions must publish their committed route identity")
57+ require.Equal(t, "target", identity.Target.SessionName)
58+ require.Equal(t, domain.IncarnationID{2}, identity.Target.LifecycleID)
59+}
M internal/usecase/daemon/attachment_transition.go +8 -2
@@ -167,17 +167,23 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
167167 }
168168
169169 frozen, err := d.freezeAttachmentTransition(req, participants)
170- defer frozen.unfreeze()
171170 if err != nil {
171+ frozen.unfreeze()
172172 return attachmentTransitionResult{}, err
173173 }
174174
175175 result, err := d.publishAttachmentTransition(req)
176+ frozen.unfreeze()
176177 if err != nil {
177178 return result, err
178179 }
179180 if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
180- if identityErr := d.sendCommittedRouteIdentityForAttachment(result.published); identityErr != nil {
181+ identityErr := errAttachmentTransition
182+ if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
183+ identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())
184+ effect.End()
185+ }
186+ if identityErr != nil {
181187 d.log.Warn("publishing committed route identity failed", "err", identityErr, "action", req.action)
182188 }
183189 }
@@ -167,17 +167,23 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
167 }167 }
168 168
169 frozen, err := d.freezeAttachmentTransition(req, participants)169 frozen, err := d.freezeAttachmentTransition(req, participants)
170- defer frozen.unfreeze()
171 if err != nil {170 if err != nil {
171+ frozen.unfreeze()
172 return attachmentTransitionResult{}, err172 return attachmentTransitionResult{}, err
173 }173 }
174 174
175 result, err := d.publishAttachmentTransition(req)175 result, err := d.publishAttachmentTransition(req)
176+ frozen.unfreeze()
176 if err != nil {177 if err != nil {
177 return result, err178 return result, err
178 }179 }
179 if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {180 if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
180- if identityErr := d.sendCommittedRouteIdentityForAttachment(result.published); identityErr != nil {181+ identityErr := errAttachmentTransition
182+ if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
183+ identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())
184+ effect.End()
185+ }
186+ if identityErr != nil {
181 d.log.Warn("publishing committed route identity failed", "err", identityErr, "action", req.action)187 d.log.Warn("publishing committed route identity failed", "err", identityErr, "action", req.action)
182 }188 }
183 }189 }

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