detached 2f86aba

feat(client): cut over global recent navigation

commit
2f86aba232a9ea7b05e8e36df148a8fbe95c021c
author
brice · · 2026-08-09 13:58 UTC
parent
121c2e1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
35 files · +713 -764
M docs/remote-resilience.md +13 -9
@@ -27,15 +27,19 @@ directory; direct CLI remote attaches retain their client-request semantics.
2727
2828 Remote attachments remain direct: the selected remote daemon owns the session,
2929 PTYs, rendering, input, resize, effects, and teardown. The client does not
30-proxy remote content through the local daemon. A picker-selected remote target
31-is a temporary direct handoff; the client keeps one input pump and remembers a
32-bounded home route. A remote daemon may request the home picker, and cancelling
33-that temporary local picker returns to the parked remote route, first attempting
34-resume and then falling back to a fresh attach when the resume token is stale.
35-These navigation actions and the strict handshake layout use protocol v27.
36-Attach handshakes may carry an exact lifecycle/name target, and successful
37-Welcomes can return the daemon's committed identity; the client keeps bounded
38-route identity and display snapshots separate from transport capabilities.
30+proxy remote content through the local daemon. A single client process keeps a
31+bounded, in-memory route history across local, direct remote, and picker-
32+discovered attachments. The daemon renders the latest attachment snapshot;
33+active routes are metadata-only, while `JRS` and `BSK` send typed key/generation
34+actions back to the client. History is process-local and is not persisted or
35+shared between clients.
36+
37+A picker-selected remote target is a direct handoff with one input pump. Exact
38+lifecycle/name identities are carried separately from display labels, and a
39+successful daemon-local switch publishes a new committed identity before the
40+client republishes its snapshot. These navigation actions and the strict
41+handshake layout use protocol v27. Attach handshakes may carry an exact target,
42+and successful Welcomes can return the daemon's committed identity.
3943
4044 ## Durable record compatibility
4145
@@ -27,15 +27,19 @@ directory; direct CLI remote attaches retain their client-request semantics.
27 27
28 Remote attachments remain direct: the selected remote daemon owns the session,28 Remote attachments remain direct: the selected remote daemon owns the session,
29 PTYs, rendering, input, resize, effects, and teardown. The client does not29 PTYs, rendering, input, resize, effects, and teardown. The client does not
30-proxy remote content through the local daemon. A picker-selected remote target30+proxy remote content through the local daemon. A single client process keeps a
31-is a temporary direct handoff; the client keeps one input pump and remembers a31+bounded, in-memory route history across local, direct remote, and picker-
32-bounded home route. A remote daemon may request the home picker, and cancelling32+discovered attachments. The daemon renders the latest attachment snapshot;
33-that temporary local picker returns to the parked remote route, first attempting33+active routes are metadata-only, while `JRS` and `BSK` send typed key/generation
34-resume and then falling back to a fresh attach when the resume token is stale.34+actions back to the client. History is process-local and is not persisted or
35-These navigation actions and the strict handshake layout use protocol v27.35+shared between clients.
36-Attach handshakes may carry an exact lifecycle/name target, and successful36+
37-Welcomes can return the daemon's committed identity; the client keeps bounded37+A picker-selected remote target is a direct handoff with one input pump. Exact
38-route identity and display snapshots separate from transport capabilities.38+lifecycle/name identities are carried separately from display labels, and a
39+successful daemon-local switch publishes a new committed identity before the
40+client republishes its snapshot. These navigation actions and the strict
41+handshake layout use protocol v27. Attach handshakes may carry an exact target,
42+and successful Welcomes can return the daemon's committed identity.
39 43
40 ## Durable record compatibility44 ## Durable record compatibility
41 45
M internal/usecase/client/client.go +119 -7
@@ -205,6 +205,7 @@ type Runner struct {
205205 remote bool
206206 origin ports.RouteOrigin
207207 ledger *routeLedger
208+ routeFailure *ports.RouteNavigationFailure
208209
209210 inputMu sync.Mutex
210211 input *terminalInputPump
@@ -341,6 +342,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
341342 returnResumeFallback := false
342343 homeNavigationPending := false
343344 returnNavigationPending := false
345+ routeNavigationPending := false
346+ routeNavigationResumeFallback := false
347+ var routeNavigationFallback *attachRoute
348+ var routeNavigationAction *ports.RouteNavigationAction
344349 backoff := defaultReconnectBackoff.initial
345350 themeState := &terminalThemeState{}
346351 var rememberOnce sync.Once
@@ -519,6 +524,36 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
519524 if !result.transportClosed {
520525 _ = connection.Close()
521526 }
527+ if result.routeAction != nil {
528+ if r.ledger == nil {
529+ return errors.New("vev: route ledger unavailable")
530+ }
531+ record, prior, valid, noOp := r.ledger.navigationRecords(*result.routeAction)
532+ if !valid {
533+ return errors.New("vev: stale route navigation action")
534+ }
535+ if noOp {
536+ continue
537+ }
538+ action := *result.routeAction
539+ routeNavigationAction = &action
540+ routeNavigationFallback = &attachRoute{dialer: prior.dialer, request: prior.request, resumeToken: prior.resumeToken}
541+ dialer = record.dialer
542+ attemptRequest = cloneAttachRequest(record.request)
543+ if record.resumeToken != 0 {
544+ attemptRequest.Intent = ports.IntentResume
545+ } else {
546+ attemptRequest.Intent = ports.IntentAttach
547+ }
548+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
549+ attemptRequest.NavigationCapabilities = 0
550+ resumeToken = record.resumeToken
551+ routeNavigationPending = true
552+ routeNavigationResumeFallback = record.resumeToken != 0
553+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
554+ backoff = defaultReconnectBackoff.initial
555+ continue
556+ }
522557 if result.action != 0 {
523558 switch result.action {
524559 case ports.NavigationOpenHomePicker:
@@ -594,10 +629,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
594629 if result.sessionName != "" {
595630 attemptRequest.SessionName = result.sessionName
596631 }
632+ if result.committedIdentity != nil {
633+ identity := *result.committedIdentity
634+ if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target {
635+ attemptRequest.RemoteTarget = nil
636+ }
637+ attemptRequest.ExactTarget = &identity.Target
638+ }
597639 if result.welcomed {
598640 homeNavigationPending = false
599641 }
600642 if result.err == nil {
643+ if result.welcomed && routeNavigationPending {
644+ routeNavigationPending = false
645+ routeNavigationResumeFallback = false
646+ routeNavigationFallback = nil
647+ routeNavigationAction = nil
648+ }
601649 if result.welcomed && returnNavigationPending {
602650 returnNavigationPending = false
603651 returnResumeFallback = false
@@ -615,6 +663,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
615663 restoreReturnRoute(route)
616664 continue
617665 }
666+ if routeNavigationPending && routeNavigationResumeFallback {
667+ var protocolErr *ProtocolError
668+ if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {
669+ attemptRequest.Intent = ports.IntentAttach
670+ resumeToken = 0
671+ routeNavigationResumeFallback = false
672+ continue
673+ }
674+ }
675+ if routeNavigationPending && routeNavigationFallback != nil {
676+ if routeNavigationAction != nil {
677+ action := *routeNavigationAction
678+ r.routeFailure = &ports.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)}
679+ routeNavigationAction = nil
680+ }
681+ route := *routeNavigationFallback
682+ routeNavigationFallback = nil
683+ routeNavigationPending = false
684+ routeNavigationResumeFallback = false
685+ returnNavigationPending = true
686+ restoreReturnRoute(route)
687+ continue
688+ }
618689 if returnResumeFallback {
619690 var protocolErr *ProtocolError
620691 if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {
@@ -808,6 +879,7 @@ type attachResult struct {
808879 transportClosed bool
809880 target *ports.AttachTarget
810881 action ports.NavigationAction
882+ routeAction *ports.RouteNavigationAction
811883 err error
812884 }
813885
@@ -1016,6 +1088,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10161088 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
10171089 }
10181090 }
1091+ if a.runner.routeFailure != nil {
1092+ payload, err := ports.MarshalRouteNavigationFailure(*a.runner.routeFailure)
1093+ if err != nil {
1094+ return welcomedResult(fmt.Errorf("vev: encoding route navigation failure: %w", err))
1095+ }
1096+ if err := sendHandshake(func() error {
1097+ return transport.Send(ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload})
1098+ }); err != nil {
1099+ return welcomedResult(fmt.Errorf("vev: publishing route navigation failure: %w", err))
1100+ }
1101+ a.runner.routeFailure = nil
1102+ }
10191103
10201104 // 3. Enter raw mode after Welcome; Run owns restoration.
10211105 if err := enterRaw(); err != nil {
@@ -1227,6 +1311,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
12271311 return context.Canceled
12281312 }
12291313 }
1314+ publishRouteSnapshot := func() error {
1315+ if a.runner.ledger == nil {
1316+ return errors.New("vev: route ledger unavailable")
1317+ }
1318+ payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1319+ if err != nil {
1320+ return err
1321+ }
1322+ select {
1323+ case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}:
1324+ return nil
1325+ case <-loopCtx.Done():
1326+ return context.Canceled
1327+ }
1328+ }
12301329 sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {
12311330 payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{
12321331 Key: action.Key,
@@ -1427,16 +1526,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14271526 if noOp {
14281527 continue
14291528 }
1430- // Stack 2 validates and rejects stale actions without changing
1431- // command authority. The live handoff connector is installed by the
1432- // atomic Stack 3 cutover.
1433- if ferr := sendRouteFailure(action, ports.RouteFailureUnavailable); ferr != nil {
1434- return welcomedResult(ferr)
1435- }
1529+ navigation := welcomedResult(nil)
1530+ navigation.routeAction = &action
1531+ return navigation
14361532 case ports.MsgCommittedRouteIdentity:
1437- if _, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload); derr != nil {
1533+ identity, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload)
1534+ if derr != nil {
14381535 return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr))
14391536 }
1537+ name = identity.Target.SessionName
1538+ committedIdentity = cloneCommittedIdentity(&identity)
1539+ if a.runner.ledger == nil {
1540+ return welcomedResult(errors.New("vev: route ledger unavailable"))
1541+ }
1542+ active, activeOK := a.runner.ledger.lookup(a.runner.ledger.activeRef())
1543+ if !activeOK {
1544+ return welcomedResult(errors.New("vev: committed route identity has no active route"))
1545+ }
1546+ if _, derr := a.runner.ledger.commit(routeCandidateForCommittedIdentity(active, identity)); derr != nil {
1547+ return welcomedResult(fmt.Errorf("vev: committing daemon-local route identity: %w", derr))
1548+ }
1549+ if derr := publishRouteSnapshot(); derr != nil {
1550+ return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr))
1551+ }
14401552 case ports.MsgRouteNavigationFailure:
14411553 if _, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload); derr != nil {
14421554 return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr))
@@ -205,6 +205,7 @@ type Runner struct {
205 remote bool205 remote bool
206 origin ports.RouteOrigin206 origin ports.RouteOrigin
207 ledger *routeLedger207 ledger *routeLedger
208+ routeFailure *ports.RouteNavigationFailure
208 209
209 inputMu sync.Mutex210 inputMu sync.Mutex
210 input *terminalInputPump211 input *terminalInputPump
@@ -341,6 +342,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
341 returnResumeFallback := false342 returnResumeFallback := false
342 homeNavigationPending := false343 homeNavigationPending := false
343 returnNavigationPending := false344 returnNavigationPending := false
345+ routeNavigationPending := false
346+ routeNavigationResumeFallback := false
347+ var routeNavigationFallback *attachRoute
348+ var routeNavigationAction *ports.RouteNavigationAction
344 backoff := defaultReconnectBackoff.initial349 backoff := defaultReconnectBackoff.initial
345 themeState := &terminalThemeState{}350 themeState := &terminalThemeState{}
346 var rememberOnce sync.Once351 var rememberOnce sync.Once
@@ -519,6 +524,36 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
519 if !result.transportClosed {524 if !result.transportClosed {
520 _ = connection.Close()525 _ = connection.Close()
521 }526 }
527+ if result.routeAction != nil {
528+ if r.ledger == nil {
529+ return errors.New("vev: route ledger unavailable")
530+ }
531+ record, prior, valid, noOp := r.ledger.navigationRecords(*result.routeAction)
532+ if !valid {
533+ return errors.New("vev: stale route navigation action")
534+ }
535+ if noOp {
536+ continue
537+ }
538+ action := *result.routeAction
539+ routeNavigationAction = &action
540+ routeNavigationFallback = &attachRoute{dialer: prior.dialer, request: prior.request, resumeToken: prior.resumeToken}
541+ dialer = record.dialer
542+ attemptRequest = cloneAttachRequest(record.request)
543+ if record.resumeToken != 0 {
544+ attemptRequest.Intent = ports.IntentResume
545+ } else {
546+ attemptRequest.Intent = ports.IntentAttach
547+ }
548+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
549+ attemptRequest.NavigationCapabilities = 0
550+ resumeToken = record.resumeToken
551+ routeNavigationPending = true
552+ routeNavigationResumeFallback = record.resumeToken != 0
553+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
554+ backoff = defaultReconnectBackoff.initial
555+ continue
556+ }
522 if result.action != 0 {557 if result.action != 0 {
523 switch result.action {558 switch result.action {
524 case ports.NavigationOpenHomePicker:559 case ports.NavigationOpenHomePicker:
@@ -594,10 +629,23 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
594 if result.sessionName != "" {629 if result.sessionName != "" {
595 attemptRequest.SessionName = result.sessionName630 attemptRequest.SessionName = result.sessionName
596 }631 }
632+ if result.committedIdentity != nil {
633+ identity := *result.committedIdentity
634+ if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target {
635+ attemptRequest.RemoteTarget = nil
636+ }
637+ attemptRequest.ExactTarget = &identity.Target
638+ }
597 if result.welcomed {639 if result.welcomed {
598 homeNavigationPending = false640 homeNavigationPending = false
599 }641 }
600 if result.err == nil {642 if result.err == nil {
643+ if result.welcomed && routeNavigationPending {
644+ routeNavigationPending = false
645+ routeNavigationResumeFallback = false
646+ routeNavigationFallback = nil
647+ routeNavigationAction = nil
648+ }
601 if result.welcomed && returnNavigationPending {649 if result.welcomed && returnNavigationPending {
602 returnNavigationPending = false650 returnNavigationPending = false
603 returnResumeFallback = false651 returnResumeFallback = false
@@ -615,6 +663,29 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
615 restoreReturnRoute(route)663 restoreReturnRoute(route)
616 continue664 continue
617 }665 }
666+ if routeNavigationPending && routeNavigationResumeFallback {
667+ var protocolErr *ProtocolError
668+ if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {
669+ attemptRequest.Intent = ports.IntentAttach
670+ resumeToken = 0
671+ routeNavigationResumeFallback = false
672+ continue
673+ }
674+ }
675+ if routeNavigationPending && routeNavigationFallback != nil {
676+ if routeNavigationAction != nil {
677+ action := *routeNavigationAction
678+ r.routeFailure = &ports.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)}
679+ routeNavigationAction = nil
680+ }
681+ route := *routeNavigationFallback
682+ routeNavigationFallback = nil
683+ routeNavigationPending = false
684+ routeNavigationResumeFallback = false
685+ returnNavigationPending = true
686+ restoreReturnRoute(route)
687+ continue
688+ }
618 if returnResumeFallback {689 if returnResumeFallback {
619 var protocolErr *ProtocolError690 var protocolErr *ProtocolError
620 if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {691 if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {
@@ -808,6 +879,7 @@ type attachResult struct {
808 transportClosed bool879 transportClosed bool
809 target *ports.AttachTarget880 target *ports.AttachTarget
810 action ports.NavigationAction881 action ports.NavigationAction
882+ routeAction *ports.RouteNavigationAction
811 err error883 err error
812 }884 }
813 885
@@ -1016,6 +1088,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1016 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))1088 return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
1017 }1089 }
1018 }1090 }
1091+ if a.runner.routeFailure != nil {
1092+ payload, err := ports.MarshalRouteNavigationFailure(*a.runner.routeFailure)
1093+ if err != nil {
1094+ return welcomedResult(fmt.Errorf("vev: encoding route navigation failure: %w", err))
1095+ }
1096+ if err := sendHandshake(func() error {
1097+ return transport.Send(ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload})
1098+ }); err != nil {
1099+ return welcomedResult(fmt.Errorf("vev: publishing route navigation failure: %w", err))
1100+ }
1101+ a.runner.routeFailure = nil
1102+ }
1019 1103
1020 // 3. Enter raw mode after Welcome; Run owns restoration.1104 // 3. Enter raw mode after Welcome; Run owns restoration.
1021 if err := enterRaw(); err != nil {1105 if err := enterRaw(); err != nil {
@@ -1227,6 +1311,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1227 return context.Canceled1311 return context.Canceled
1228 }1312 }
1229 }1313 }
1314+ publishRouteSnapshot := func() error {
1315+ if a.runner.ledger == nil {
1316+ return errors.New("vev: route ledger unavailable")
1317+ }
1318+ payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1319+ if err != nil {
1320+ return err
1321+ }
1322+ select {
1323+ case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}:
1324+ return nil
1325+ case <-loopCtx.Done():
1326+ return context.Canceled
1327+ }
1328+ }
1230 sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {1329 sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {
1231 payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{1330 payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{
1232 Key: action.Key,1331 Key: action.Key,
@@ -1427,16 +1526,29 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1427 if noOp {1526 if noOp {
1428 continue1527 continue
1429 }1528 }
1430- // Stack 2 validates and rejects stale actions without changing1529+ navigation := welcomedResult(nil)
1431- // command authority. The live handoff connector is installed by the1530+ navigation.routeAction = &action
1432- // atomic Stack 3 cutover.1531+ return navigation
1433- if ferr := sendRouteFailure(action, ports.RouteFailureUnavailable); ferr != nil {
1434- return welcomedResult(ferr)
1435- }
1436 case ports.MsgCommittedRouteIdentity:1532 case ports.MsgCommittedRouteIdentity:
1437- if _, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload); derr != nil {1533+ identity, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload)
1534+ if derr != nil {
1438 return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr))1535 return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr))
1439 }1536 }
1537+ name = identity.Target.SessionName
1538+ committedIdentity = cloneCommittedIdentity(&identity)
1539+ if a.runner.ledger == nil {
1540+ return welcomedResult(errors.New("vev: route ledger unavailable"))
1541+ }
1542+ active, activeOK := a.runner.ledger.lookup(a.runner.ledger.activeRef())
1543+ if !activeOK {
1544+ return welcomedResult(errors.New("vev: committed route identity has no active route"))
1545+ }
1546+ if _, derr := a.runner.ledger.commit(routeCandidateForCommittedIdentity(active, identity)); derr != nil {
1547+ return welcomedResult(fmt.Errorf("vev: committing daemon-local route identity: %w", derr))
1548+ }
1549+ if derr := publishRouteSnapshot(); derr != nil {
1550+ return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr))
1551+ }
1440 case ports.MsgRouteNavigationFailure:1552 case ports.MsgRouteNavigationFailure:
1441 if _, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload); derr != nil {1553 if _, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload); derr != nil {
1442 return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr))1554 return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr))
M internal/usecase/client/routes.go +63 -12
@@ -115,6 +115,9 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
115115 if request.RemoteTarget != nil {
116116 hostLabel = request.RemoteTarget.DisplayOrigin
117117 }
118+ if hostLabel == "" {
119+ hostLabel = originKey
120+ }
118121 }
119122 request = cloneAttachRequest(request)
120123 request.Origin = origin
@@ -139,6 +142,29 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
139142 }
140143 }
141144
145+func routeCandidateForCommittedIdentity(active routeRecord, identity ports.CommittedRouteIdentity) routeCandidate {
146+ request := cloneAttachRequest(active.request)
147+ request.SessionName = identity.Target.SessionName
148+ request.ExactTarget = &identity.Target
149+ request.RemoteTarget = nil
150+ return routeCandidate{
151+ origin: active.origin,
152+ originKey: active.originKey,
153+ target: identity.Target,
154+ presentation: routePresentation{
155+ name: identity.Target.SessionName,
156+ hostLabel: active.presentation.hostLabel,
157+ kind: active.presentation.kind,
158+ ephemeral: identity.Ephemeral,
159+ reachability: ports.RouteReachabilityReachable,
160+ },
161+ dialer: active.dialer,
162+ request: request,
163+ resumeToken: active.resumeToken,
164+ home: false,
165+ }
166+}
167+
142168 func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin {
143169 if origin != 0 {
144170 return origin
@@ -149,6 +175,20 @@ func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrig
149175 return ports.RouteOriginLocal
150176 }
151177
178+func routeFailureCode(err error) ports.RouteFailureCode {
179+ var protocolErr *ProtocolError
180+ if errors.As(err, &protocolErr) {
181+ switch protocolErr.Code {
182+ case ports.ErrNoSuchSession, ports.ErrNoSuchTarget:
183+ return ports.RouteFailureNoSuchRoute
184+ }
185+ }
186+ if errors.Is(err, errRouteTargetChanged) {
187+ return ports.RouteFailureTargetChanged
188+ }
189+ return ports.RouteFailureUnavailable
190+}
191+
152192 func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string {
153193 if key != "" {
154194 return key
@@ -402,33 +442,44 @@ type routeTransitionConnector interface {
402442 Restore(context.Context, routeRecord) error
403443 }
404444
405-// navigationRecord validates that an action belongs to the latest complete
406-// snapshot. The active route is metadata-only and selecting it is a no-op.
407-func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) {
445+// navigationRecords validates an action against the latest complete snapshot
446+// and captures both the selected route and the live route that must be
447+// restored if the transition fails. Capturing them under one read lock keeps a
448+// concurrent committed-identity update from changing the restoration target
449+// between validation and dialing.
450+func (l *routeLedger) navigationRecords(action ports.RouteNavigationAction) (routeRecord, routeRecord, bool, bool) {
408451 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
409- return routeRecord{}, false, false
452+ return routeRecord{}, routeRecord{}, false, false
410453 }
411454 identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation})
412455 l.mu.RLock()
413456 defer l.mu.RUnlock()
414457 if uint64(l.generation) != action.SnapshotGeneration {
415- return routeRecord{}, false, false
458+ return routeRecord{}, routeRecord{}, false, false
416459 }
417460 if identity == l.active {
418- return routeRecord{}, true, true
461+ return routeRecord{}, routeRecord{}, true, true
419462 }
420- index := l.indexByIdentityLocked(identity)
421- if index < 0 {
422- return routeRecord{}, false, false
463+ selectedIndex := l.indexByIdentityLocked(identity)
464+ activeIndex := l.indexByIdentityLocked(l.active)
465+ if selectedIndex < 0 || activeIndex < 0 {
466+ return routeRecord{}, routeRecord{}, false, false
423467 }
424- return cloneRouteRecord(l.entries[index]), true, false
468+ return cloneRouteRecord(l.entries[selectedIndex]), cloneRouteRecord(l.entries[activeIndex]), true, false
469+}
470+
471+// navigationRecord validates that an action belongs to the latest complete
472+// snapshot. The active route is metadata-only and selecting it is a no-op.
473+func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) {
474+ selected, _, ok, noOp := l.navigationRecords(action)
475+ return selected, ok, noOp
425476 }
426477
427478 // navigate resolves a captured key/generation, attempts resume before exact
428479 // attach, and commits only after the connector returns a matching identity.
429480 // A failed transition restores the prior live route and leaves history alone.
430481 func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error {
431- record, ok, noOp := l.navigationRecord(action)
482+ record, prior, ok, noOp := l.navigationRecords(action)
432483 if !ok {
433484 return errRouteStaleSelection
434485 }
@@ -450,7 +501,7 @@ func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigation
450501 target: record.target,
451502 }
452503 restoreOnFailure := func(err error) error {
453- if restoreErr := connector.Restore(ctx, record); restoreErr != nil {
504+ if restoreErr := connector.Restore(ctx, prior); restoreErr != nil {
454505 return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr))
455506 }
456507 return err
@@ -115,6 +115,9 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
115 if request.RemoteTarget != nil {115 if request.RemoteTarget != nil {
116 hostLabel = request.RemoteTarget.DisplayOrigin116 hostLabel = request.RemoteTarget.DisplayOrigin
117 }117 }
118+ if hostLabel == "" {
119+ hostLabel = originKey
120+ }
118 }121 }
119 request = cloneAttachRequest(request)122 request = cloneAttachRequest(request)
120 request.Origin = origin123 request.Origin = origin
@@ -139,6 +142,29 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
139 }142 }
140 }143 }
141 144
145+func routeCandidateForCommittedIdentity(active routeRecord, identity ports.CommittedRouteIdentity) routeCandidate {
146+ request := cloneAttachRequest(active.request)
147+ request.SessionName = identity.Target.SessionName
148+ request.ExactTarget = &identity.Target
149+ request.RemoteTarget = nil
150+ return routeCandidate{
151+ origin: active.origin,
152+ originKey: active.originKey,
153+ target: identity.Target,
154+ presentation: routePresentation{
155+ name: identity.Target.SessionName,
156+ hostLabel: active.presentation.hostLabel,
157+ kind: active.presentation.kind,
158+ ephemeral: identity.Ephemeral,
159+ reachability: ports.RouteReachabilityReachable,
160+ },
161+ dialer: active.dialer,
162+ request: request,
163+ resumeToken: active.resumeToken,
164+ home: false,
165+ }
166+}
167+
142 func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin {168 func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin {
143 if origin != 0 {169 if origin != 0 {
144 return origin170 return origin
@@ -149,6 +175,20 @@ func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrig
149 return ports.RouteOriginLocal175 return ports.RouteOriginLocal
150 }176 }
151 177
178+func routeFailureCode(err error) ports.RouteFailureCode {
179+ var protocolErr *ProtocolError
180+ if errors.As(err, &protocolErr) {
181+ switch protocolErr.Code {
182+ case ports.ErrNoSuchSession, ports.ErrNoSuchTarget:
183+ return ports.RouteFailureNoSuchRoute
184+ }
185+ }
186+ if errors.Is(err, errRouteTargetChanged) {
187+ return ports.RouteFailureTargetChanged
188+ }
189+ return ports.RouteFailureUnavailable
190+}
191+
152 func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string {192 func normalizeRouteOriginKey(key string, origin ports.RouteOrigin) string {
153 if key != "" {193 if key != "" {
154 return key194 return key
@@ -402,33 +442,44 @@ type routeTransitionConnector interface {
402 Restore(context.Context, routeRecord) error442 Restore(context.Context, routeRecord) error
403 }443 }
404 444
405-// navigationRecord validates that an action belongs to the latest complete445+// navigationRecords validates an action against the latest complete snapshot
406-// snapshot. The active route is metadata-only and selecting it is a no-op.446+// and captures both the selected route and the live route that must be
407-func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) {447+// restored if the transition fails. Capturing them under one read lock keeps a
448+// concurrent committed-identity update from changing the restoration target
449+// between validation and dialing.
450+func (l *routeLedger) navigationRecords(action ports.RouteNavigationAction) (routeRecord, routeRecord, bool, bool) {
408 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {451 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
409- return routeRecord{}, false, false452+ return routeRecord{}, routeRecord{}, false, false
410 }453 }
411 identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation})454 identity := identityFromWire(ports.RouteRef{Key: action.Key, Generation: action.Generation})
412 l.mu.RLock()455 l.mu.RLock()
413 defer l.mu.RUnlock()456 defer l.mu.RUnlock()
414 if uint64(l.generation) != action.SnapshotGeneration {457 if uint64(l.generation) != action.SnapshotGeneration {
415- return routeRecord{}, false, false458+ return routeRecord{}, routeRecord{}, false, false
416 }459 }
417 if identity == l.active {460 if identity == l.active {
418- return routeRecord{}, true, true461+ return routeRecord{}, routeRecord{}, true, true
419 }462 }
420- index := l.indexByIdentityLocked(identity)463+ selectedIndex := l.indexByIdentityLocked(identity)
421- if index < 0 {464+ activeIndex := l.indexByIdentityLocked(l.active)
422- return routeRecord{}, false, false465+ if selectedIndex < 0 || activeIndex < 0 {
466+ return routeRecord{}, routeRecord{}, false, false
423 }467 }
424- return cloneRouteRecord(l.entries[index]), true, false468+ return cloneRouteRecord(l.entries[selectedIndex]), cloneRouteRecord(l.entries[activeIndex]), true, false
469+}
470+
471+// navigationRecord validates that an action belongs to the latest complete
472+// snapshot. The active route is metadata-only and selecting it is a no-op.
473+func (l *routeLedger) navigationRecord(action ports.RouteNavigationAction) (routeRecord, bool, bool) {
474+ selected, _, ok, noOp := l.navigationRecords(action)
475+ return selected, ok, noOp
425 }476 }
426 477
427 // navigate resolves a captured key/generation, attempts resume before exact478 // navigate resolves a captured key/generation, attempts resume before exact
428 // attach, and commits only after the connector returns a matching identity.479 // attach, and commits only after the connector returns a matching identity.
429 // A failed transition restores the prior live route and leaves history alone.480 // A failed transition restores the prior live route and leaves history alone.
430 func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error {481 func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error {
431- record, ok, noOp := l.navigationRecord(action)482+ record, prior, ok, noOp := l.navigationRecords(action)
432 if !ok {483 if !ok {
433 return errRouteStaleSelection484 return errRouteStaleSelection
434 }485 }
@@ -450,7 +501,7 @@ func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigation
450 target: record.target,501 target: record.target,
451 }502 }
452 restoreOnFailure := func(err error) error {503 restoreOnFailure := func(err error) error {
453- if restoreErr := connector.Restore(ctx, record); restoreErr != nil {504+ if restoreErr := connector.Restore(ctx, prior); restoreErr != nil {
454 return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr))505 return errors.Join(err, fmt.Errorf("restoring prior route: %w", restoreErr))
455 }506 }
456 return err507 return err
M internal/usecase/client/routes_test.go +31 -7
@@ -69,6 +69,26 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
6969 require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
7070 }
7171
72+func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
73+ ledger := newRouteLedger()
74+ home := routeTestCandidate(1, ports.RouteOriginLocal)
75+ home.home = true
76+ homeIdentity, err := ledger.commit(home)
77+ require.NoError(t, err)
78+ active, ok := ledger.lookup(ledger.activeRef())
79+ require.True(t, ok)
80+
81+ candidate := routeCandidateForCommittedIdentity(active, ports.CommittedRouteIdentity{
82+ Target: routeTestTarget(2),
83+ Ephemeral: true,
84+ })
85+ committed, err := ledger.commit(candidate)
86+ require.NoError(t, err)
87+ require.Equal(t, uint64(homeIdentity.key), uint64(ledger.homeRef().Key))
88+ require.NotEqual(t, ledger.homeRef(), committed.wire())
89+ require.False(t, candidate.home)
90+}
91+
7292 func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {
7393 ledger := newRouteLedger()
7494 candidate := routeTestCandidate(1, ports.RouteOriginRemote)
@@ -95,12 +115,13 @@ func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) {
95115 }
96116
97117 type routeTestConnector struct {
98- resumeErr error
99- attachErr error
100- resumeCalls int
101- attachCalls int
102- restoreCalls int
103- restoreErr error
118+ resumeErr error
119+ attachErr error
120+ resumeCalls int
121+ attachCalls int
122+ restoreCalls int
123+ restoreErr error
124+ restoreTarget ports.ExactSessionTarget
104125 }
105126
106127 func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {
@@ -111,8 +132,9 @@ func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransiti
111132 return routeTransitionCommit{}, errors.New("unexpected resume success")
112133 }
113134
114-func (c *routeTestConnector) Restore(_ context.Context, _ routeRecord) error {
135+func (c *routeTestConnector) Restore(_ context.Context, record routeRecord) error {
115136 c.restoreCalls++
137+ c.restoreTarget = record.target
116138 return c.restoreErr
117139 }
118140
@@ -172,6 +194,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T)
172194 err = ledger.navigate(context.Background(), action, connector)
173195 require.Error(t, err)
174196 require.Equal(t, before, ledger.snapshot())
197+ require.Equal(t, routeTestTarget(5), connector.restoreTarget)
175198 require.Equal(t, 1, connector.restoreCalls)
176199
177200 connector = &routeTestConnector{}
@@ -180,6 +203,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T)
180203 err = ledger.navigate(context.Background(), action, mismatch)
181204 require.ErrorIs(t, err, errRouteTargetChanged)
182205 require.Equal(t, before, ledger.snapshot())
206+ require.Equal(t, routeTestTarget(5), connector.restoreTarget)
183207 require.Equal(t, 1, connector.restoreCalls)
184208 }
185209
@@ -69,6 +69,26 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
69 require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")69 require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
70 }70 }
71 71
72+func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
73+ ledger := newRouteLedger()
74+ home := routeTestCandidate(1, ports.RouteOriginLocal)
75+ home.home = true
76+ homeIdentity, err := ledger.commit(home)
77+ require.NoError(t, err)
78+ active, ok := ledger.lookup(ledger.activeRef())
79+ require.True(t, ok)
80+
81+ candidate := routeCandidateForCommittedIdentity(active, ports.CommittedRouteIdentity{
82+ Target: routeTestTarget(2),
83+ Ephemeral: true,
84+ })
85+ committed, err := ledger.commit(candidate)
86+ require.NoError(t, err)
87+ require.Equal(t, uint64(homeIdentity.key), uint64(ledger.homeRef().Key))
88+ require.NotEqual(t, ledger.homeRef(), committed.wire())
89+ require.False(t, candidate.home)
90+}
91+
72 func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {92 func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {
73 ledger := newRouteLedger()93 ledger := newRouteLedger()
74 candidate := routeTestCandidate(1, ports.RouteOriginRemote)94 candidate := routeTestCandidate(1, ports.RouteOriginRemote)
@@ -95,12 +115,13 @@ func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) {
95 }115 }
96 116
97 type routeTestConnector struct {117 type routeTestConnector struct {
98- resumeErr error118+ resumeErr error
99- attachErr error119+ attachErr error
100- resumeCalls int120+ resumeCalls int
101- attachCalls int121+ attachCalls int
102- restoreCalls int122+ restoreCalls int
103- restoreErr error123+ restoreErr error
124+ restoreTarget ports.ExactSessionTarget
104 }125 }
105 126
106 func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {127 func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {
@@ -111,8 +132,9 @@ func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransiti
111 return routeTransitionCommit{}, errors.New("unexpected resume success")132 return routeTransitionCommit{}, errors.New("unexpected resume success")
112 }133 }
113 134
114-func (c *routeTestConnector) Restore(_ context.Context, _ routeRecord) error {135+func (c *routeTestConnector) Restore(_ context.Context, record routeRecord) error {
115 c.restoreCalls++136 c.restoreCalls++
137+ c.restoreTarget = record.target
116 return c.restoreErr138 return c.restoreErr
117 }139 }
118 140
@@ -172,6 +194,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T)
172 err = ledger.navigate(context.Background(), action, connector)194 err = ledger.navigate(context.Background(), action, connector)
173 require.Error(t, err)195 require.Error(t, err)
174 require.Equal(t, before, ledger.snapshot())196 require.Equal(t, before, ledger.snapshot())
197+ require.Equal(t, routeTestTarget(5), connector.restoreTarget)
175 require.Equal(t, 1, connector.restoreCalls)198 require.Equal(t, 1, connector.restoreCalls)
176 199
177 connector = &routeTestConnector{}200 connector = &routeTestConnector{}
@@ -180,6 +203,7 @@ func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T)
180 err = ledger.navigate(context.Background(), action, mismatch)203 err = ledger.navigate(context.Background(), action, mismatch)
181 require.ErrorIs(t, err, errRouteTargetChanged)204 require.ErrorIs(t, err, errRouteTargetChanged)
182 require.Equal(t, before, ledger.snapshot())205 require.Equal(t, before, ledger.snapshot())
206+ require.Equal(t, routeTestTarget(5), connector.restoreTarget)
183 require.Equal(t, 1, connector.restoreCalls)207 require.Equal(t, 1, connector.restoreCalls)
184 }208 }
185 209
M internal/usecase/daemon/attachment_effect_gate_test.go +17 -3
@@ -699,7 +699,12 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
699699 d.mu.Lock()
700700 d.sessions[target.id] = target
701701 d.mu.Unlock()
702- ac.previousSession.Set(target.id)
702+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
703+ Generation: 2,
704+ Active: ports.RouteRef{Key: 2, Generation: 2},
705+ Previous: ports.RouteRef{Key: 1, Generation: 1},
706+ Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}},
707+ })
703708
704709 transport := &closeTrackingTransport{}
705710 ac.replaceTransport(transport)
@@ -713,7 +718,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
713718 require.NoError(t, err)
714719
715720 require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
716- require.Same(t, target, ac.currentAttachmentSession())
721+ require.Same(t, source, ac.currentAttachmentSession())
717722 frames := transport.Sends()
718723 require.NotEmpty(t, frames)
719724 var result ports.CommandResult
@@ -726,7 +731,16 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
726731 break
727732 }
728733 require.True(t, result.OK, result.Text)
729- require.False(t, transport.Closed(), "local navigation must not tear down the transitioned attachment")
734+ var action ports.RouteNavigationAction
735+ for _, frame := range frames {
736+ if frame.Type != ports.MsgNavigateRecentRoute {
737+ continue
738+ }
739+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
740+ require.NoError(t, err)
741+ }
742+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}, action)
743+ require.False(t, transport.Closed(), "client navigation must not tear down the attachment")
730744 }
731745
732746 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
@@ -699,7 +699,12 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
699 d.mu.Lock()699 d.mu.Lock()
700 d.sessions[target.id] = target700 d.sessions[target.id] = target
701 d.mu.Unlock()701 d.mu.Unlock()
702- ac.previousSession.Set(target.id)702+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
703+ Generation: 2,
704+ Active: ports.RouteRef{Key: 2, Generation: 2},
705+ Previous: ports.RouteRef{Key: 1, Generation: 1},
706+ Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}},
707+ })
703 708
704 transport := &closeTrackingTransport{}709 transport := &closeTrackingTransport{}
705 ac.replaceTransport(transport)710 ac.replaceTransport(transport)
@@ -713,7 +718,7 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
713 require.NoError(t, err)718 require.NoError(t, err)
714 719
715 require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))720 require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
716- require.Same(t, target, ac.currentAttachmentSession())721+ require.Same(t, source, ac.currentAttachmentSession())
717 frames := transport.Sends()722 frames := transport.Sends()
718 require.NotEmpty(t, frames)723 require.NotEmpty(t, frames)
719 var result ports.CommandResult724 var result ports.CommandResult
@@ -726,7 +731,16 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
726 break731 break
727 }732 }
728 require.True(t, result.OK, result.Text)733 require.True(t, result.OK, result.Text)
729- require.False(t, transport.Closed(), "local navigation must not tear down the transitioned attachment")734+ var action ports.RouteNavigationAction
735+ for _, frame := range frames {
736+ if frame.Type != ports.MsgNavigateRecentRoute {
737+ continue
738+ }
739+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
740+ require.NoError(t, err)
741+ }
742+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 2, Key: 1, Generation: 1}, action)
743+ require.False(t, transport.Closed(), "client navigation must not tear down the attachment")
730 }744 }
731 745
732 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {746 func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
M internal/usecase/daemon/attachment_transition.go +5 -0
@@ -176,6 +176,11 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
176176 if err != nil {
177177 return result, err
178178 }
179+ if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
180+ if identityErr := d.sendCommittedRouteIdentityForAttachment(result.published); identityErr != nil {
181+ d.log.Warn("publishing committed route identity failed", "err", identityErr, "action", req.action)
182+ }
183+ }
179184 if result.sourceGeometrySession != nil {
180185 d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go")
181186 }
@@ -176,6 +176,11 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
176 if err != nil {176 if err != nil {
177 return result, err177 return result, err
178 }178 }
179+ if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
180+ if identityErr := d.sendCommittedRouteIdentityForAttachment(result.published); identityErr != nil {
181+ d.log.Warn("publishing committed route identity failed", "err", identityErr, "action", req.action)
182+ }
183+ }
179 if result.sourceGeometrySession != nil {184 if result.sourceGeometrySession != nil {
180 d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go")185 d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go")
181 }186 }
M internal/usecase/daemon/attention_test.go +0 -43
@@ -419,49 +419,6 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
419419 }
420420 }
421421
422-// TestCloseRingingTabRefreshesOtherSessionBottomBar covers Fix 2c: closing a
423-// ringing tab in one session must refresh the bottom bar of a client attached
424-// to a DIFFERENT session, since that client's bar shows the ringing session's
425-// bell until it stops.
426-func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
427- pA1, releaseA1 := newBlockingPTY(t)
428- pA2, releaseA2 := newBlockingPTY(t)
429- pB, releaseB := newBlockingPTY(t)
430- defer releaseA1()
431- defer releaseA2()
432- defer releaseB()
433-
434- d, sessA, _, sendsA := newManualSessionWithPTYs(t, pA1, pA2)
435- sessA.name = "ringer"
436- ringing := sessA.tabs[1]
437- sessA.mu.Lock()
438- ringing.attention = true
439- ringing.attentionAt = time.Unix(1, 0)
440- sessA.mu.Unlock()
441- d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank
442-
443- trB, sendsB := newCapturingTransport(t)
444- acB := &attachedClient{tr: trB, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
445- acB.initOverlays()
446- sctxB, cancelB := context.WithCancel(d.serveCtx)
447- t.Cleanup(cancelB)
448- tbB := newTestTabWithContext(pB, sctxB, cancelB)
449- sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}}
450- acB.setSession(sessB)
451- acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452- d.sessions[sessB.id] = sessB
453-
454- d.paint(sessB, acB, true, nil)
455- before := mustOutputData(t, sendsB)
456- require.Contains(t, string(before), string(ui.AttentionGlyph))
457-
458- require.NoError(t, d.closeTab(sessA, ringing, true))
459- _ = mustOutputData(t, sendsA)
460-
461- after := mustOutputData(t, sendsB)
462- require.NotContains(t, string(after), string(ui.AttentionGlyph))
463-}
464-
465422 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
466423 d, sess, ac, sends, releases := newManualTabSession(t, 3)
467424 sess.mu.Lock()
@@ -419,49 +419,6 @@ func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
419 }419 }
420 }420 }
421 421
422-// TestCloseRingingTabRefreshesOtherSessionBottomBar covers Fix 2c: closing a
423-// ringing tab in one session must refresh the bottom bar of a client attached
424-// to a DIFFERENT session, since that client's bar shows the ringing session's
425-// bell until it stops.
426-func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
427- pA1, releaseA1 := newBlockingPTY(t)
428- pA2, releaseA2 := newBlockingPTY(t)
429- pB, releaseB := newBlockingPTY(t)
430- defer releaseA1()
431- defer releaseA2()
432- defer releaseB()
433-
434- d, sessA, _, sendsA := newManualSessionWithPTYs(t, pA1, pA2)
435- sessA.name = "ringer"
436- ringing := sessA.tabs[1]
437- sessA.mu.Lock()
438- ringing.attention = true
439- ringing.attentionAt = time.Unix(1, 0)
440- sessA.mu.Unlock()
441- d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank
442-
443- trB, sendsB := newCapturingTransport(t)
444- acB := &attachedClient{tr: trB, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
445- acB.initOverlays()
446- sctxB, cancelB := context.WithCancel(d.serveCtx)
447- t.Cleanup(cancelB)
448- tbB := newTestTabWithContext(pB, sctxB, cancelB)
449- sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", attachments: map[*attachedClient]struct{}{acB: {}}}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}}
450- acB.setSession(sessB)
451- acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452- d.sessions[sessB.id] = sessB
453-
454- d.paint(sessB, acB, true, nil)
455- before := mustOutputData(t, sendsB)
456- require.Contains(t, string(before), string(ui.AttentionGlyph))
457-
458- require.NoError(t, d.closeTab(sessA, ringing, true))
459- _ = mustOutputData(t, sendsA)
460-
461- after := mustOutputData(t, sendsB)
462- require.NotContains(t, string(after), string(ui.AttentionGlyph))
463-}
464-
465 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {422 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
466 d, sess, ac, sends, releases := newManualTabSession(t, 3)423 d, sess, ac, sends, releases := newManualTabSession(t, 3)
467 sess.mu.Lock()424 sess.mu.Lock()
M internal/usecase/daemon/client.go +14 -22
@@ -85,22 +85,19 @@ type attachedClient struct {
8585 // view is attachment-local navigation state. It is never inferred from a
8686 // session-wide active tab, so multiple attachments can observe different
8787 // tabs and panes without changing shared session ownership.
88- viewMu sync.Mutex
89- view attachmentView
90- sess Guarded[*session]
91- mouseScan mouse.Scanner
92- themeMu sync.Mutex
93- clientTheme themeui.Theme
94- appliedTheme appliedTheme
95- lastCursor cursorOut
96- renderStages renderStageHooks // optional render and handoff observability hooks
97- // previousSession is guarded independently. It is retained through temporary
98- // setSession(nil) hand-offs and cleared only on terminal teardown.
99- previousSession Guarded[domain.SessionID]
100- linkMu sync.Mutex
101- sendMu sync.Mutex
102- routeMu sync.RWMutex
103- routeSnapshot ports.RecentRouteSnapshot
88+ viewMu sync.Mutex
89+ view attachmentView
90+ sess Guarded[*session]
91+ mouseScan mouse.Scanner
92+ themeMu sync.Mutex
93+ clientTheme themeui.Theme
94+ appliedTheme appliedTheme
95+ lastCursor cursorOut
96+ renderStages renderStageHooks // optional render and handoff observability hooks
97+ linkMu sync.Mutex
98+ sendMu sync.Mutex
99+ routeMu sync.RWMutex
100+ routeSnapshot ports.RecentRouteSnapshot
104101 // routeCreatedSession marks a session created by this attachment's route.
105102 // A handshake that never commits Welcome must tear down that exact empty
106103 // session, while an attachment routed to an existing session must not.
@@ -227,12 +224,6 @@ func (ac *attachedClient) currentSession() *session {
227224
228225 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
229226
230-func (ac *attachedClient) clearPreviousSession() {
231- if ac != nil {
232- ac.previousSession.Set("")
233- }
234-}
235-
236227 // pruneCaptureFrames releases snapshots for panes that have left their owner.
237228 // Callers must not hold daemon, session, tab, or pane locks.
238229 func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
@@ -358,6 +349,7 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
358349 }
359350
360351 func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) {
352+ snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...)
361353 ac.routeMu.Lock()
362354 ac.routeSnapshot = snapshot
363355 ac.routeMu.Unlock()
@@ -85,22 +85,19 @@ type attachedClient struct {
85 // view is attachment-local navigation state. It is never inferred from a85 // view is attachment-local navigation state. It is never inferred from a
86 // session-wide active tab, so multiple attachments can observe different86 // session-wide active tab, so multiple attachments can observe different
87 // tabs and panes without changing shared session ownership.87 // tabs and panes without changing shared session ownership.
88- viewMu sync.Mutex88+ viewMu sync.Mutex
89- view attachmentView89+ view attachmentView
90- sess Guarded[*session]90+ sess Guarded[*session]
91- mouseScan mouse.Scanner91+ mouseScan mouse.Scanner
92- themeMu sync.Mutex92+ themeMu sync.Mutex
93- clientTheme themeui.Theme93+ clientTheme themeui.Theme
94- appliedTheme appliedTheme94+ appliedTheme appliedTheme
95- lastCursor cursorOut95+ lastCursor cursorOut
96- renderStages renderStageHooks // optional render and handoff observability hooks96+ renderStages renderStageHooks // optional render and handoff observability hooks
97- // previousSession is guarded independently. It is retained through temporary97+ linkMu sync.Mutex
98- // setSession(nil) hand-offs and cleared only on terminal teardown.98+ sendMu sync.Mutex
99- previousSession Guarded[domain.SessionID]99+ routeMu sync.RWMutex
100- linkMu sync.Mutex100+ routeSnapshot ports.RecentRouteSnapshot
101- sendMu sync.Mutex
102- routeMu sync.RWMutex
103- routeSnapshot ports.RecentRouteSnapshot
104 // routeCreatedSession marks a session created by this attachment's route.101 // routeCreatedSession marks a session created by this attachment's route.
105 // A handshake that never commits Welcome must tear down that exact empty102 // A handshake that never commits Welcome must tear down that exact empty
106 // session, while an attachment routed to an existing session must not.103 // session, while an attachment routed to an existing session must not.
@@ -227,12 +224,6 @@ func (ac *attachedClient) currentSession() *session {
227 224
228 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }225 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
229 226
230-func (ac *attachedClient) clearPreviousSession() {
231- if ac != nil {
232- ac.previousSession.Set("")
233- }
234-}
235-
236 // pruneCaptureFrames releases snapshots for panes that have left their owner.227 // pruneCaptureFrames releases snapshots for panes that have left their owner.
237 // Callers must not hold daemon, session, tab, or pane locks.228 // Callers must not hold daemon, session, tab, or pane locks.
238 func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {229 func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
@@ -358,6 +349,7 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
358 }349 }
359 350
360 func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) {351 func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) {
352+ snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...)
361 ac.routeMu.Lock()353 ac.routeMu.Lock()
362 ac.routeSnapshot = snapshot354 ac.routeSnapshot = snapshot
363 ac.routeMu.Unlock()355 ac.routeMu.Unlock()
M internal/usecase/daemon/client_frame_routing.go +18 -1
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "runtime"
66
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/ports"
89 "github.com/bnema/vev/internal/usecase/command"
910 )
@@ -133,9 +134,25 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
133134 d.log.Warn("malformed recent route snapshot", "err", derr)
134135 }
135136 case ports.MsgRouteNavigationFailure:
136- if _, derr := ports.UnmarshalRouteNavigationFailure(f.Payload); derr != nil {
137+ failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)
138+ if derr != nil {
137139 d.log.Warn("malformed route navigation failure", "err", derr)
140+ break
141+ }
142+ message := "route navigation failed"
143+ switch failure.Code {
144+ case ports.RouteFailureStaleSelection:
145+ message = "that recent route is no longer available"
146+ case ports.RouteFailureTargetChanged:
147+ message = "that recent route changed before attach"
148+ case ports.RouteFailureOriginUnavailable:
149+ message = "the original route is no longer available"
150+ case ports.RouteFailureUnavailable:
151+ message = "the selected route is unavailable"
152+ case ports.RouteFailureNoSuchRoute:
153+ message = "that recent route no longer exists"
138154 }
155+ d.notify(token.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
139156 case ports.MsgCommand:
140157 request, derr := ports.UnmarshalCommandRequest(f.Payload)
141158 if derr != nil {
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "runtime"5 "runtime"
6 6
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
9 )10 )
@@ -133,9 +134,25 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
133 d.log.Warn("malformed recent route snapshot", "err", derr)134 d.log.Warn("malformed recent route snapshot", "err", derr)
134 }135 }
135 case ports.MsgRouteNavigationFailure:136 case ports.MsgRouteNavigationFailure:
136- if _, derr := ports.UnmarshalRouteNavigationFailure(f.Payload); derr != nil {137+ failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)
138+ if derr != nil {
137 d.log.Warn("malformed route navigation failure", "err", derr)139 d.log.Warn("malformed route navigation failure", "err", derr)
140+ break
141+ }
142+ message := "route navigation failed"
143+ switch failure.Code {
144+ case ports.RouteFailureStaleSelection:
145+ message = "that recent route is no longer available"
146+ case ports.RouteFailureTargetChanged:
147+ message = "that recent route changed before attach"
148+ case ports.RouteFailureOriginUnavailable:
149+ message = "the original route is no longer available"
150+ case ports.RouteFailureUnavailable:
151+ message = "the selected route is unavailable"
152+ case ports.RouteFailureNoSuchRoute:
153+ message = "that recent route no longer exists"
138 }154 }
155+ d.notify(token.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
139 case ports.MsgCommand:156 case ports.MsgCommand:
140 request, derr := ports.UnmarshalCommandRequest(f.Payload)157 request, derr := ports.UnmarshalCommandRequest(f.Payload)
141 if derr != nil {158 if derr != nil {
M internal/usecase/daemon/client_transport_errors.go +0 -2
@@ -96,7 +96,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
9696 d.closePicker(ac)
9797
9898 d.resetScreenDefaultColors(sess)
99- ac.clearPreviousSession()
10099 if explicit && notice {
101100 // Synchronous so the ack is delivered before the transport closes
102101 // (the client is actively awaiting it), but deadline-bounded so a
@@ -183,7 +182,6 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
183182 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
184183 d.closePicker(ac)
185184 d.resetScreenDefaultColors(sess)
186- ac.clearPreviousSession()
187185 _ = ac.closeCapturedTransport(failed)
188186 d.log.Warn("detached client after send error", "session", name)
189187 }
@@ -96,7 +96,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
96 d.closePicker(ac)96 d.closePicker(ac)
97 97
98 d.resetScreenDefaultColors(sess)98 d.resetScreenDefaultColors(sess)
99- ac.clearPreviousSession()
100 if explicit && notice {99 if explicit && notice {
101 // Synchronous so the ack is delivered before the transport closes100 // Synchronous so the ack is delivered before the transport closes
102 // (the client is actively awaiting it), but deadline-bounded so a101 // (the client is actively awaiting it), but deadline-bounded so a
@@ -183,7 +182,6 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
183 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)182 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
184 d.closePicker(ac)183 d.closePicker(ac)
185 d.resetScreenDefaultColors(sess)184 d.resetScreenDefaultColors(sess)
186- ac.clearPreviousSession()
187 _ = ac.closeCapturedTransport(failed)185 _ = ac.closeCapturedTransport(failed)
188 d.log.Warn("detached client after send error", "session", name)186 d.log.Warn("detached client after send error", "session", name)
189 }187 }
M internal/usecase/daemon/input_test.go +44 -178
@@ -778,197 +778,69 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
778778 }
779779 }
780780
781-func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
782- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
783- defer releaseAll(releases)
784- recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
785-
786- // Picker-style successful transition records its origin.
787- require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
788- for _, sess := range []*session{current, recent, current} {
789- runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
790- require.Same(t, sess, ac.currentSession())
791- }
792-}
793-
794-func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
795- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
781+func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
782+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
796783 defer releaseAll(releases)
797- for _, tc := range []struct {
798- name string
799- prepare func()
800- }{
801- {name: "no target", prepare: func() {}},
802- {name: "stale target", prepare: func() { ac.previousSession.Set("gone") }},
803- {name: "same session", prepare: func() { ac.previousSession.Set(current.id) }},
804- } {
805- t.Run(tc.name, func(t *testing.T) {
806- ac.clearPreviousSession()
807- tc.prepare()
808- runPaletteCommand(t, d, current, ac, "BSK")
809- require.Same(t, current, ac.currentSession())
810- if tc.name != "no target" {
811- require.Empty(t, ac.previousSession.Get(), "invalid target must not remain toggleable")
812- }
813- })
814- }
784+ transport := ac.transport()
785+ rc := d.attachCoordinator(current, nil, ac, true)
786+ token := current.attachmentToken(ac, transport)
787+ token.lease = rc.attachmentLease(ac)
788+ ac.publishAttachmentCapability(token)
789+ effect, admitted := ac.beginAttachmentEffect(token)
790+ require.True(t, admitted)
791+ defer effect.End()
792+ token.effect = effect
793+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
794+ Generation: 3,
795+ Active: ports.RouteRef{Key: 3, Generation: 3},
796+ Previous: ports.RouteRef{Key: 2, Generation: 2},
797+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}},
798+ })
799+
800+ require.NoError(t, d.backSessionForAttachment(token))
801+ frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
802+ action, err := ports.UnmarshalRouteNavigationAction(frame.Payload)
803+ require.NoError(t, err)
804+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}, action)
815805 }
816806
817-func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
807+func TestPaletteBackSessionWithoutClientHistoryIsNoop(t *testing.T) {
818808 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819809 defer releaseAll(releases)
820- target := d.sessions[domain.SessionID("recent")]
821- ac.previousSession.Set(target.id)
822-
823- // A displaced attachment makes the previously valid handoff stale by the
824- // time switchToTarget commits it.
825- current.mu.Lock()
826- clearAttachmentsForTestLocked(current)
827- current.mu.Unlock()
828810
829811 d.backSession(current, ac)
830812
831- require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")
832- require.Equal(t, target.id, ac.previousSession.Get(), "a failed handoff remains retryable")
833- history := d.notices.history()
834- require.Len(t, history, 1, "the switch failure must be reported exactly once")
835- require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
813+ require.Same(t, current, ac.currentSession())
836814 }
837815
838-func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839- ac := &attachedClient{}
840- stale := &session{sessionCore: sessionCore{id: "stale"}}
841- updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousSession.Set(stale.id)
816+func TestBackSessionWithoutSnapshotDoesNotReportDaemonHandoffFailure(t *testing.T) {
817+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
818+ defer releaseAll(releases)
843819
844- // Model a completed hand-off between observing a stale target and clearing
845- // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousSession.Set(updated.id)
847- ac.clearPreviousSessionIf(stale.id)
820+ d.backSession(current, ac)
848821
849- require.Equal(t, updated.id, ac.previousSession.Get())
822+ require.Same(t, current, ac.currentSession())
823+ require.Empty(t, d.notices.history())
850824 }
851825
852-// backSession's invalid-target fallback is a render producer: it must publish
853-// exactly one coordinator invalidation instead of composing directly, while
854-// the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and
855-// the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget)
856-// keep their PR #73 behavior unchanged.
857-func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
858- for _, tc := range []struct {
859- name string
860- prepare func(current *session, ac *attachedClient)
861- }{
862- {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863- {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
864- ac.previousSession.Set("gone")
865- }},
866- {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousSession.Set(current.id)
868- }},
869- } {
870- t.Run(tc.name, func(t *testing.T) {
871- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
872- defer releaseAll(releases)
873- invs := make(chan renderInvalidation, 4)
874- current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
875- clock: d.clock,
876- wake: func(renderWake) {},
877- onInvalidate: func(inv renderInvalidation) { invs <- inv },
878- }))
879- tc.prepare(current, ac)
880-
881- d.backSession(current, ac)
882-
883- require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment")
884- awaitInvalidation(t, invs)
885- requireNoInvalidation(t, invs)
886- requireNoOutputFrame(t, sends)
887- })
888- }
889-}
826+func TestBackSessionWithoutSnapshotDoesNotPublishFrames(t *testing.T) {
827+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
828+ defer releaseAll(releases)
890829
891-func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
892- for _, tc := range []struct {
893- name string
894- target func(*session, *session) picker.Target
895- wantCurrent string
896- wantPrev string
897- }{
898- {
899- name: "picker cross-session switch records origin",
900- target: func(_ *session, recent *session) picker.Target {
901- return picker.Target{Session: recent.id, TabIndex: -1}
902- },
903- wantCurrent: "recent",
904- wantPrev: "current",
905- },
906- {
907- name: "attention cross-session switch records origin",
908- target: func(_ *session, recent *session) picker.Target {
909- return picker.Target{Session: recent.id, TabIndex: 0}
910- },
911- wantCurrent: "recent",
912- wantPrev: "current",
913- },
914- {
915- name: "same-session tab switch does not record",
916- target: func(current *session, _ *session) picker.Target {
917- return picker.Target{Session: current.id, TabIndex: 0}
918- },
919- wantCurrent: "current",
920- wantPrev: "",
921- },
922- {
923- name: "missing target does not record",
924- target: func(_ *session, _ *session) picker.Target {
925- return picker.Target{Session: "missing", TabIndex: -1}
926- },
927- wantCurrent: "current",
928- wantPrev: "",
929- },
930- } {
931- t.Run(tc.name, func(t *testing.T) {
932- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
933- defer releaseAll(releases)
934- recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
830+ d.backSession(current, ac)
935831
936- if tc.name == "missing target does not record" {
937- require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent)))
938- } else {
939- require.NoError(t, d.switchToTarget(current, ac, tc.target(current, recent)))
940- }
941- require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942- if tc.wantPrev == "" {
943- require.Empty(t, ac.previousSession.Get())
944- return
945- }
946- require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get())
947- })
948- }
832+ require.Same(t, current, ac.currentSession())
833+ requireNoOutputFrame(t, sends)
949834 }
950835
951-func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
952- for _, tc := range []struct {
953- name string
954- teardown func(*Daemon, *session, *attachedClient)
955- }{
956- {name: "explicit detach", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), true) }},
957- {name: "non-park send error fallback", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
958- {name: "killed attached session", teardown: func(d *Daemon, sess *session, _ *attachedClient) {
959- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
960- }},
961- } {
962- t.Run(tc.name, func(t *testing.T) {
963- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964- defer releaseAll(releases)
965-
966- ac.previousSession.Set(domain.SessionID("recent"))
967- tc.teardown(d, current, ac)
836+func TestSwitchSourceDoesNotOwnPreviousRoute(t *testing.T) {
837+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
838+ defer releaseAll(releases)
839+ recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
968840
969- require.Empty(t, ac.previousSession.Get())
970- })
971- }
841+ require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
842+ require.Same(t, recent, ac.currentSession())
843+ require.Equal(t, uint64(0), ac.routeSnapshotCopy().Generation, "route ownership starts in the client")
972844 }
973845
974846 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
@@ -990,12 +862,6 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached
990862 return d, current, ac, sends, []func(){release1, release2, release3}
991863 }
992864
993-func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
994- t.Helper()
995- d.handleInput(sess, ac, []byte("\x1b "))
996- d.handleInput(sess, ac, []byte(code+"\r"))
997-}
998-
999865 func releaseAll(releases []func()) {
1000866 for _, release := range releases {
1001867 release()
@@ -778,197 +778,69 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
778 }778 }
779 }779 }
780 780
781-func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {781+func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
782- d, current, ac, _, releases := newRecentNavigationTestSessions(t)782+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
783- defer releaseAll(releases)
784- recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
785-
786- // Picker-style successful transition records its origin.
787- require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
788- for _, sess := range []*session{current, recent, current} {
789- runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
790- require.Same(t, sess, ac.currentSession())
791- }
792-}
793-
794-func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
795- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
796 defer releaseAll(releases)783 defer releaseAll(releases)
797- for _, tc := range []struct {784+ transport := ac.transport()
798- name string785+ rc := d.attachCoordinator(current, nil, ac, true)
799- prepare func()786+ token := current.attachmentToken(ac, transport)
800- }{787+ token.lease = rc.attachmentLease(ac)
801- {name: "no target", prepare: func() {}},788+ ac.publishAttachmentCapability(token)
802- {name: "stale target", prepare: func() { ac.previousSession.Set("gone") }},789+ effect, admitted := ac.beginAttachmentEffect(token)
803- {name: "same session", prepare: func() { ac.previousSession.Set(current.id) }},790+ require.True(t, admitted)
804- } {791+ defer effect.End()
805- t.Run(tc.name, func(t *testing.T) {792+ token.effect = effect
806- ac.clearPreviousSession()793+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
807- tc.prepare()794+ Generation: 3,
808- runPaletteCommand(t, d, current, ac, "BSK")795+ Active: ports.RouteRef{Key: 3, Generation: 3},
809- require.Same(t, current, ac.currentSession())796+ Previous: ports.RouteRef{Key: 2, Generation: 2},
810- if tc.name != "no target" {797+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}},
811- require.Empty(t, ac.previousSession.Get(), "invalid target must not remain toggleable")798+ })
812- }799+
813- })800+ require.NoError(t, d.backSessionForAttachment(token))
814- }801+ frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
802+ action, err := ports.UnmarshalRouteNavigationAction(frame.Payload)
803+ require.NoError(t, err)
804+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 3, Key: 2, Generation: 2}, action)
815 }805 }
816 806
817-func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {807+func TestPaletteBackSessionWithoutClientHistoryIsNoop(t *testing.T) {
818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)808 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819 defer releaseAll(releases)809 defer releaseAll(releases)
820- target := d.sessions[domain.SessionID("recent")]
821- ac.previousSession.Set(target.id)
822-
823- // A displaced attachment makes the previously valid handoff stale by the
824- // time switchToTarget commits it.
825- current.mu.Lock()
826- clearAttachmentsForTestLocked(current)
827- current.mu.Unlock()
828 810
829 d.backSession(current, ac)811 d.backSession(current, ac)
830 812
831- require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")813+ require.Same(t, current, ac.currentSession())
832- require.Equal(t, target.id, ac.previousSession.Get(), "a failed handoff remains retryable")
833- history := d.notices.history()
834- require.Len(t, history, 1, "the switch failure must be reported exactly once")
835- require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
836 }814 }
837 815
838-func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {816+func TestBackSessionWithoutSnapshotDoesNotReportDaemonHandoffFailure(t *testing.T) {
839- ac := &attachedClient{}817+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
840- stale := &session{sessionCore: sessionCore{id: "stale"}}818+ defer releaseAll(releases)
841- updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousSession.Set(stale.id)
843 819
844- // Model a completed hand-off between observing a stale target and clearing820+ d.backSession(current, ac)
845- // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousSession.Set(updated.id)
847- ac.clearPreviousSessionIf(stale.id)
848 821
849- require.Equal(t, updated.id, ac.previousSession.Get())822+ require.Same(t, current, ac.currentSession())
823+ require.Empty(t, d.notices.history())
850 }824 }
851 825
852-// backSession's invalid-target fallback is a render producer: it must publish826+func TestBackSessionWithoutSnapshotDoesNotPublishFrames(t *testing.T) {
853-// exactly one coordinator invalidation instead of composing directly, while827+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
854-// the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and828+ defer releaseAll(releases)
855-// the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget)
856-// keep their PR #73 behavior unchanged.
857-func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
858- for _, tc := range []struct {
859- name string
860- prepare func(current *session, ac *attachedClient)
861- }{
862- {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863- {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
864- ac.previousSession.Set("gone")
865- }},
866- {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousSession.Set(current.id)
868- }},
869- } {
870- t.Run(tc.name, func(t *testing.T) {
871- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
872- defer releaseAll(releases)
873- invs := make(chan renderInvalidation, 4)
874- current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
875- clock: d.clock,
876- wake: func(renderWake) {},
877- onInvalidate: func(inv renderInvalidation) { invs <- inv },
878- }))
879- tc.prepare(current, ac)
880-
881- d.backSession(current, ac)
882-
883- require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment")
884- awaitInvalidation(t, invs)
885- requireNoInvalidation(t, invs)
886- requireNoOutputFrame(t, sends)
887- })
888- }
889-}
890 829
891-func TestSwitchSourcePreviousSessionContracts(t *testing.T) {830+ d.backSession(current, ac)
892- for _, tc := range []struct {
893- name string
894- target func(*session, *session) picker.Target
895- wantCurrent string
896- wantPrev string
897- }{
898- {
899- name: "picker cross-session switch records origin",
900- target: func(_ *session, recent *session) picker.Target {
901- return picker.Target{Session: recent.id, TabIndex: -1}
902- },
903- wantCurrent: "recent",
904- wantPrev: "current",
905- },
906- {
907- name: "attention cross-session switch records origin",
908- target: func(_ *session, recent *session) picker.Target {
909- return picker.Target{Session: recent.id, TabIndex: 0}
910- },
911- wantCurrent: "recent",
912- wantPrev: "current",
913- },
914- {
915- name: "same-session tab switch does not record",
916- target: func(current *session, _ *session) picker.Target {
917- return picker.Target{Session: current.id, TabIndex: 0}
918- },
919- wantCurrent: "current",
920- wantPrev: "",
921- },
922- {
923- name: "missing target does not record",
924- target: func(_ *session, _ *session) picker.Target {
925- return picker.Target{Session: "missing", TabIndex: -1}
926- },
927- wantCurrent: "current",
928- wantPrev: "",
929- },
930- } {
931- t.Run(tc.name, func(t *testing.T) {
932- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
933- defer releaseAll(releases)
934- recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
935 831
936- if tc.name == "missing target does not record" {832+ require.Same(t, current, ac.currentSession())
937- require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent)))833+ requireNoOutputFrame(t, sends)
938- } else {
939- require.NoError(t, d.switchToTarget(current, ac, tc.target(current, recent)))
940- }
941- require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942- if tc.wantPrev == "" {
943- require.Empty(t, ac.previousSession.Get())
944- return
945- }
946- require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get())
947- })
948- }
949 }834 }
950 835
951-func TestTerminalTeardownClearsPreviousSession(t *testing.T) {836+func TestSwitchSourceDoesNotOwnPreviousRoute(t *testing.T) {
952- for _, tc := range []struct {837+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
953- name string838+ defer releaseAll(releases)
954- teardown func(*Daemon, *session, *attachedClient)839+ recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
955- }{
956- {name: "explicit detach", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.clientGone(sess, ac, ac.transport(), true) }},
957- {name: "non-park send error fallback", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
958- {name: "killed attached session", teardown: func(d *Daemon, sess *session, _ *attachedClient) {
959- require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
960- }},
961- } {
962- t.Run(tc.name, func(t *testing.T) {
963- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964- defer releaseAll(releases)
965-
966- ac.previousSession.Set(domain.SessionID("recent"))
967- tc.teardown(d, current, ac)
968 840
969- require.Empty(t, ac.previousSession.Get())841+ require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
970- })842+ require.Same(t, recent, ac.currentSession())
971- }843+ require.Equal(t, uint64(0), ac.routeSnapshotCopy().Generation, "route ownership starts in the client")
972 }844 }
973 845
974 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {846 func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
@@ -990,12 +862,6 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached
990 return d, current, ac, sends, []func(){release1, release2, release3}862 return d, current, ac, sends, []func(){release1, release2, release3}
991 }863 }
992 864
993-func runPaletteCommand(t *testing.T, d *Daemon, sess *session, ac *attachedClient, code string) {
994- t.Helper()
995- d.handleInput(sess, ac, []byte("\x1b "))
996- d.handleInput(sess, ac, []byte(code+"\r"))
997-}
998-
999 func releaseAll(releases []func()) {865 func releaseAll(releases []func()) {
1000 for _, release := range releases {866 for _, release := range releases {
1001 release()867 release()
M internal/usecase/daemon/move_pane_postcommit.go +0 -1
@@ -116,7 +116,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
116116 }
117117 for _, attachment := range p.retiredAttachments {
118118 d.unregisterPreview(attachment.ac)
119- attachment.ac.clearPreviousSession()
120119 attachment.ac.clearCaptureFrames()
121120 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
122121 }
@@ -116,7 +116,6 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
116 }116 }
117 for _, attachment := range p.retiredAttachments {117 for _, attachment := range p.retiredAttachments {
118 d.unregisterPreview(attachment.ac)118 d.unregisterPreview(attachment.ac)
119- attachment.ac.clearPreviousSession()
120 attachment.ac.clearCaptureFrames()119 attachment.ac.clearCaptureFrames()
121 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)120 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
122 }121 }
M internal/usecase/daemon/navigation_attachment_handoff_test.go +0 -6
@@ -32,12 +32,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
3232 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
3333 },
3434 },
35- {
36- name: "palette recent session", action: "palette-recent-session",
37- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
38- return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
39- },
40- },
4135 {
4236 name: "session overflow", action: "overflow-session",
4337 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
@@ -32,12 +32,6 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
32 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")32 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
33 },33 },
34 },34 },
35- {
36- name: "palette recent session", action: "palette-recent-session",
37- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
38- return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
39- },
40- },
41 {35 {
42 name: "session overflow", action: "overflow-session",36 name: "session overflow", action: "overflow-session",
43 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {37 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
M internal/usecase/daemon/overlay_runtime.go +14 -12
@@ -6,6 +6,7 @@ import (
66 "time"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
910 scopy "github.com/bnema/vev/internal/usecase/copy"
1011 "github.com/bnema/vev/internal/usecase/notices"
1112 "github.com/bnema/vev/internal/usecase/palette"
@@ -40,13 +41,13 @@ type overlayRuntime struct {
4041 beforeRemotePickerRegistration func()
4142 afterPickerRefreshBuild func(*picker.Model)
4243
43- paletteMu sync.Mutex
44- palette *palette.Model
45- paletteRecent []recentSession // immutable for this palette interaction
46- paletteGeneration uint64
47- paletteHints palette.ContextualHints
48- paletteFeedback string
49- palettePending []byte
44+ paletteMu sync.Mutex
45+ palette *palette.Model
46+ paletteRouteSnapshot ports.RecentRouteSnapshot
47+ paletteGeneration uint64
48+ paletteHints palette.ContextualHints
49+ paletteFeedback string
50+ palettePending []byte
5051
5152 promptMu sync.Mutex
5253 prompt *promptui.Model
@@ -304,10 +305,10 @@ type overlayRenderSnapshot struct {
304305 paletteModel *palette.Model
305306 // paletteHints is a copy captured under paletteMu. Rendering must use this
306307 // immutable interaction snapshot rather than consult live session state.
307- paletteHints *palette.ContextualHints
308- paletteFeedback string
309- paletteRecent []recentSession
310- paletteLocked bool
308+ paletteHints *palette.ContextualHints
309+ paletteFeedback string
310+ paletteRouteSnapshot ports.RecentRouteSnapshot
311+ paletteLocked bool
311312
312313 promptActive bool
313314 promptModel *promptui.Model
@@ -378,7 +379,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
378379 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
379380 snap.paletteHints = &hints
380381 snap.paletteFeedback = rt.paletteFeedback
381- snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
382+ snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
383+ snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
382384 snap.paletteLocked = true
383385 } else {
384386 rt.paletteMu.Unlock()
@@ -6,6 +6,7 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
9 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/notices"11 "github.com/bnema/vev/internal/usecase/notices"
11 "github.com/bnema/vev/internal/usecase/palette"12 "github.com/bnema/vev/internal/usecase/palette"
@@ -40,13 +41,13 @@ type overlayRuntime struct {
40 beforeRemotePickerRegistration func()41 beforeRemotePickerRegistration func()
41 afterPickerRefreshBuild func(*picker.Model)42 afterPickerRefreshBuild func(*picker.Model)
42 43
43- paletteMu sync.Mutex44+ paletteMu sync.Mutex
44- palette *palette.Model45+ palette *palette.Model
45- paletteRecent []recentSession // immutable for this palette interaction46+ paletteRouteSnapshot ports.RecentRouteSnapshot
46- paletteGeneration uint6447+ paletteGeneration uint64
47- paletteHints palette.ContextualHints48+ paletteHints palette.ContextualHints
48- paletteFeedback string49+ paletteFeedback string
49- palettePending []byte50+ palettePending []byte
50 51
51 promptMu sync.Mutex52 promptMu sync.Mutex
52 prompt *promptui.Model53 prompt *promptui.Model
@@ -304,10 +305,10 @@ type overlayRenderSnapshot struct {
304 paletteModel *palette.Model305 paletteModel *palette.Model
305 // paletteHints is a copy captured under paletteMu. Rendering must use this306 // paletteHints is a copy captured under paletteMu. Rendering must use this
306 // immutable interaction snapshot rather than consult live session state.307 // immutable interaction snapshot rather than consult live session state.
307- paletteHints *palette.ContextualHints308+ paletteHints *palette.ContextualHints
308- paletteFeedback string309+ paletteFeedback string
309- paletteRecent []recentSession310+ paletteRouteSnapshot ports.RecentRouteSnapshot
310- paletteLocked bool311+ paletteLocked bool
311 312
312 promptActive bool313 promptActive bool
313 promptModel *promptui.Model314 promptModel *promptui.Model
@@ -378,7 +379,8 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
378 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)379 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
379 snap.paletteHints = &hints380 snap.paletteHints = &hints
380 snap.paletteFeedback = rt.paletteFeedback381 snap.paletteFeedback = rt.paletteFeedback
381- snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)382+ snap.paletteRouteSnapshot = rt.paletteRouteSnapshot
383+ snap.paletteRouteSnapshot.Entries = append([]ports.RecentRouteEntry(nil), rt.paletteRouteSnapshot.Entries...)
382 snap.paletteLocked = true384 snap.paletteLocked = true
383 } else {385 } else {
384 rt.paletteMu.Unlock()386 rt.paletteMu.Unlock()
M internal/usecase/daemon/palette.go +39 -34
@@ -37,15 +37,16 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
3737 }
3838
3939 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
40- // Capture daemon/session state before taking paletteMu: lock ordering forbids
41- // holding an overlay lock while inspecting live sessions.
42- recent := d.recentSessions(sess)
40+ // Capture the client-owned route snapshot before taking paletteMu. The
41+ // snapshot is immutable for this palette interaction and the daemon never
42+ // consults its own session history for recent-route commands.
43+ routeSnapshot := ac.routeSnapshotCopy()
4344 commands := d.paletteCommands()
4445 results := d.paletteResults(sess, commands)
4546 ac.overlays.paletteMu.Lock()
4647 ac.overlays.paletteGeneration++
4748 ac.overlays.palette = palette.New(results)
48- ac.overlays.paletteRecent = recent
49+ ac.overlays.paletteRouteSnapshot = routeSnapshot
4950 ac.overlays.paletteHints = palette.ContextualHints{}
5051 ac.overlays.paletteFeedback = ""
5152 ac.overlays.palettePending = nil
@@ -93,13 +94,22 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
9394 return results
9495 }
9596
96-func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
97- formatted := formatRecentRoutePresentations(recentRoutePresentations(recent))
97+func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {
98+ formatted := formatRecentRouteSnapshot(snapshot)
9899 names := make([]string, len(formatted))
99100 for i, entry := range formatted {
100101 names[i] = entry.name
101102 }
102- return palette.BuildRecentSessionHints(names, args)
103+ hints := palette.BuildRecentSessionHints(names, args)
104+ for i, entry := range snapshot.Entries {
105+ if i >= len(hints.Recent) {
106+ break
107+ }
108+ hints.Recent[i].SnapshotGeneration = snapshot.Generation
109+ hints.Recent[i].Key = entry.Key
110+ hints.Recent[i].Generation = entry.Generation
111+ }
112+ return hints
103113 }
104114
105115 func (d *Daemon) paletteCommands() []command.Command {
@@ -157,7 +167,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
157167 var sessionTarget palette.Result
158168 var hasSessionTarget bool
159169 var args []string
160- var recent []recentSession
170+ var routeSnapshot ports.RecentRouteSnapshot
161171 var generation uint64
162172 var rawQuery string
163173 changed, cancel, execute := false, false, false
@@ -228,7 +238,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
228238 return
229239 }
230240 args = action.Args
231- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
241+ routeSnapshot = ac.overlays.paletteRouteSnapshot
242+ routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
232243 }
233244 } else if _, ok := selected.SessionName(); ok {
234245 sessionTarget = selected
@@ -245,7 +256,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
245256 ac.overlays.paletteFeedback = ""
246257 active, ok := ac.overlays.palette.ArgumentCommand()
247258 if ok && active.Slug == "jump-recent-session" {
248- ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
259+ args := paletteArgs(ac.overlays.palette.Query(), active)
260+ if ac.overlays.paletteRouteSnapshot.Generation != 0 {
261+ ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
262+ } else {
263+ ac.overlays.paletteHints = palette.ContextualHints{}
264+ }
249265 } else {
250266 ac.overlays.paletteHints = palette.ContextualHints{}
251267 }
@@ -304,7 +320,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
304320 d.invalidateRender(entry, ac, true, "palette.go")
305321 return
306322 }
307- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}
323+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
308324 if err := exec.JumpRecentSession(rank); err != nil {
309325 if errors.Is(err, errAttachmentTransition) {
310326 return
@@ -371,7 +387,7 @@ func paletteArgs(query string, cmd command.Command) []string {
371387 func (ac *attachedClient) clearPaletteLocked() {
372388 ac.overlays.paletteGeneration++
373389 ac.overlays.palette = nil
374- ac.overlays.paletteRecent = nil
390+ ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
375391 ac.overlays.paletteHints = palette.ContextualHints{}
376392 ac.overlays.paletteFeedback = ""
377393 ac.overlays.palettePending = nil
@@ -401,7 +417,7 @@ type paletteExec struct {
401417 sess *session
402418 attachment *session
403419 ac *attachedClient
404- recent []recentSession
420+ routeSnapshot ports.RecentRouteSnapshot
405421 actions daemonActionRunner
406422 effect *attachmentEffectTicket
407423 redrawClosedPalette bool
@@ -634,36 +650,25 @@ func (e paletteExec) YankLastNotification() error {
634650 }
635651
636652 func (e paletteExec) JumpRecentSession(rank int) error {
637- if rank < 1 || rank > len(e.recent) {
653+ if e.routeSnapshot.Generation == 0 || rank < 1 || rank > len(e.routeSnapshot.Entries) {
638654 return command.ErrInvalidArguments
639655 }
640- target := e.recent[rank-1]
641- if e.d.sessionByID(target.id) == nil {
642- return command.ErrInvalidArguments
656+ entry := e.routeSnapshot.Entries[rank-1]
657+ action := ports.RouteNavigationAction{
658+ SnapshotGeneration: e.routeSnapshot.Generation,
659+ Key: entry.Key,
660+ Generation: entry.Generation,
643661 }
644662 if e.d.beforeRecentSessionHandoff != nil {
645663 e.d.beforeRecentSessionHandoff()
646664 }
647- var err error
648665 if e.effect != nil {
649- err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
650- } else {
651- sess := e.attachment
652- if sess == nil {
653- sess = e.sess
654- }
655- if sess == nil {
656- return errAttachmentTransition
657- }
658- err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
666+ return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action)
659667 }
660- if e.effect != nil && errors.Is(err, errAttachmentTransition) {
661- return err
668+ if e.ac == nil || e.attachment == nil {
669+ return errAttachmentTransition
662670 }
663- if err != nil {
664- return command.ErrInvalidArguments
665- }
666- return nil
671+ return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action)
667672 }
668673
669674 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
@@ -37,15 +37,16 @@ func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
37 }37 }
38 38
39 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {39 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
40- // Capture daemon/session state before taking paletteMu: lock ordering forbids40+ // Capture the client-owned route snapshot before taking paletteMu. The
41- // holding an overlay lock while inspecting live sessions.41+ // snapshot is immutable for this palette interaction and the daemon never
42- recent := d.recentSessions(sess)42+ // consults its own session history for recent-route commands.
43+ routeSnapshot := ac.routeSnapshotCopy()
43 commands := d.paletteCommands()44 commands := d.paletteCommands()
44 results := d.paletteResults(sess, commands)45 results := d.paletteResults(sess, commands)
45 ac.overlays.paletteMu.Lock()46 ac.overlays.paletteMu.Lock()
46 ac.overlays.paletteGeneration++47 ac.overlays.paletteGeneration++
47 ac.overlays.palette = palette.New(results)48 ac.overlays.palette = palette.New(results)
48- ac.overlays.paletteRecent = recent49+ ac.overlays.paletteRouteSnapshot = routeSnapshot
49 ac.overlays.paletteHints = palette.ContextualHints{}50 ac.overlays.paletteHints = palette.ContextualHints{}
50 ac.overlays.paletteFeedback = ""51 ac.overlays.paletteFeedback = ""
51 ac.overlays.palettePending = nil52 ac.overlays.palettePending = nil
@@ -93,13 +94,22 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
93 return results94 return results
94 }95 }
95 96
96-func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {97+func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {
97- formatted := formatRecentRoutePresentations(recentRoutePresentations(recent))98+ formatted := formatRecentRouteSnapshot(snapshot)
98 names := make([]string, len(formatted))99 names := make([]string, len(formatted))
99 for i, entry := range formatted {100 for i, entry := range formatted {
100 names[i] = entry.name101 names[i] = entry.name
101 }102 }
102- return palette.BuildRecentSessionHints(names, args)103+ hints := palette.BuildRecentSessionHints(names, args)
104+ for i, entry := range snapshot.Entries {
105+ if i >= len(hints.Recent) {
106+ break
107+ }
108+ hints.Recent[i].SnapshotGeneration = snapshot.Generation
109+ hints.Recent[i].Key = entry.Key
110+ hints.Recent[i].Generation = entry.Generation
111+ }
112+ return hints
103 }113 }
104 114
105 func (d *Daemon) paletteCommands() []command.Command {115 func (d *Daemon) paletteCommands() []command.Command {
@@ -157,7 +167,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
157 var sessionTarget palette.Result167 var sessionTarget palette.Result
158 var hasSessionTarget bool168 var hasSessionTarget bool
159 var args []string169 var args []string
160- var recent []recentSession170+ var routeSnapshot ports.RecentRouteSnapshot
161 var generation uint64171 var generation uint64
162 var rawQuery string172 var rawQuery string
163 changed, cancel, execute := false, false, false173 changed, cancel, execute := false, false, false
@@ -228,7 +238,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
228 return238 return
229 }239 }
230 args = action.Args240 args = action.Args
231- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)241+ routeSnapshot = ac.overlays.paletteRouteSnapshot
242+ routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
232 }243 }
233 } else if _, ok := selected.SessionName(); ok {244 } else if _, ok := selected.SessionName(); ok {
234 sessionTarget = selected245 sessionTarget = selected
@@ -245,7 +256,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
245 ac.overlays.paletteFeedback = ""256 ac.overlays.paletteFeedback = ""
246 active, ok := ac.overlays.palette.ArgumentCommand()257 active, ok := ac.overlays.palette.ArgumentCommand()
247 if ok && active.Slug == "jump-recent-session" {258 if ok && active.Slug == "jump-recent-session" {
248- ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))259+ args := paletteArgs(ac.overlays.palette.Query(), active)
260+ if ac.overlays.paletteRouteSnapshot.Generation != 0 {
261+ ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
262+ } else {
263+ ac.overlays.paletteHints = palette.ContextualHints{}
264+ }
249 } else {265 } else {
250 ac.overlays.paletteHints = palette.ContextualHints{}266 ac.overlays.paletteHints = palette.ContextualHints{}
251 }267 }
@@ -304,7 +320,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
304 d.invalidateRender(entry, ac, true, "palette.go")320 d.invalidateRender(entry, ac, true, "palette.go")
305 return321 return
306 }322 }
307- exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, recent: recent, effect: effect}323+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
308 if err := exec.JumpRecentSession(rank); err != nil {324 if err := exec.JumpRecentSession(rank); err != nil {
309 if errors.Is(err, errAttachmentTransition) {325 if errors.Is(err, errAttachmentTransition) {
310 return326 return
@@ -371,7 +387,7 @@ func paletteArgs(query string, cmd command.Command) []string {
371 func (ac *attachedClient) clearPaletteLocked() {387 func (ac *attachedClient) clearPaletteLocked() {
372 ac.overlays.paletteGeneration++388 ac.overlays.paletteGeneration++
373 ac.overlays.palette = nil389 ac.overlays.palette = nil
374- ac.overlays.paletteRecent = nil390+ ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
375 ac.overlays.paletteHints = palette.ContextualHints{}391 ac.overlays.paletteHints = palette.ContextualHints{}
376 ac.overlays.paletteFeedback = ""392 ac.overlays.paletteFeedback = ""
377 ac.overlays.palettePending = nil393 ac.overlays.palettePending = nil
@@ -401,7 +417,7 @@ type paletteExec struct {
401 sess *session417 sess *session
402 attachment *session418 attachment *session
403 ac *attachedClient419 ac *attachedClient
404- recent []recentSession420+ routeSnapshot ports.RecentRouteSnapshot
405 actions daemonActionRunner421 actions daemonActionRunner
406 effect *attachmentEffectTicket422 effect *attachmentEffectTicket
407 redrawClosedPalette bool423 redrawClosedPalette bool
@@ -634,36 +650,25 @@ func (e paletteExec) YankLastNotification() error {
634 }650 }
635 651
636 func (e paletteExec) JumpRecentSession(rank int) error {652 func (e paletteExec) JumpRecentSession(rank int) error {
637- if rank < 1 || rank > len(e.recent) {653+ if e.routeSnapshot.Generation == 0 || rank < 1 || rank > len(e.routeSnapshot.Entries) {
638 return command.ErrInvalidArguments654 return command.ErrInvalidArguments
639 }655 }
640- target := e.recent[rank-1]656+ entry := e.routeSnapshot.Entries[rank-1]
641- if e.d.sessionByID(target.id) == nil {657+ action := ports.RouteNavigationAction{
642- return command.ErrInvalidArguments658+ SnapshotGeneration: e.routeSnapshot.Generation,
659+ Key: entry.Key,
660+ Generation: entry.Generation,
643 }661 }
644 if e.d.beforeRecentSessionHandoff != nil {662 if e.d.beforeRecentSessionHandoff != nil {
645 e.d.beforeRecentSessionHandoff()663 e.d.beforeRecentSessionHandoff()
646 }664 }
647- var err error
648 if e.effect != nil {665 if e.effect != nil {
649- err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")666+ return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action)
650- } else {
651- sess := e.attachment
652- if sess == nil {
653- sess = e.sess
654- }
655- if sess == nil {
656- return errAttachmentTransition
657- }
658- err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})
659 }667 }
660- if e.effect != nil && errors.Is(err, errAttachmentTransition) {668+ if e.ac == nil || e.attachment == nil {
661- return err669+ return errAttachmentTransition
662 }670 }
663- if err != nil {671+ return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action)
664- return command.ErrInvalidArguments
665- }
666- return nil
667 }672 }
668 673
669 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {674 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
M internal/usecase/daemon/palette_test.go +71 -101
@@ -18,6 +18,31 @@ import (
1818 "github.com/bnema/vev/pkg/renderer"
1919 )
2020
21+func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
22+ return ports.RecentRouteSnapshot{
23+ Generation: 1,
24+ Active: ports.RouteRef{Key: 1, Generation: 1},
25+ Entries: []ports.RecentRouteEntry{
26+ {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal},
27+ {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal},
28+ },
29+ }
30+}
31+
32+func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) attachmentConnectionToken {
33+ t.Helper()
34+ transport := ac.transport()
35+ rc := d.attachCoordinator(sess, nil, ac, true)
36+ token := sess.attachmentToken(ac, transport)
37+ token.lease = rc.attachmentLease(ac)
38+ ac.publishAttachmentCapability(token)
39+ effect, admitted := ac.beginAttachmentEffect(token)
40+ require.True(t, admitted)
41+ t.Cleanup(effect.End)
42+ token.effect = effect
43+ return token
44+}
45+
2146 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
2247 paletteColors := [16]renderer.RGB{}
2348 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
@@ -220,25 +245,6 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
220245 }, got)
221246 }
222247
223-func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) {
224- p, release := newBlockingPTY(t)
225- defer release()
226- d, current, ac, _ := newManualSessionWithPTYs(t, p)
227- named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
228- named.mruAt.Store(1)
229- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
230- ephemeral.mruAt.Store(2)
231- d.sessions[named.id] = named
232- d.sessions[ephemeral.id] = ephemeral
233-
234- d.enterPalette(current, ac)
235- ac.overlays.paletteMu.Lock()
236- defer ac.overlays.paletteMu.Unlock()
237-
238- require.Len(t, ac.overlays.paletteRecent, 1)
239- require.Equal(t, named.id, ac.overlays.paletteRecent[0].id)
240-}
241-
242248 func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
243249 p, release := newBlockingPTY(t)
244250 defer release()
@@ -359,6 +365,7 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
359365 p, release := newBlockingPTY(t)
360366 defer release()
361367 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
368+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1, Active: ports.RouteRef{Key: 1, Generation: 1}})
362369
363370 d.handleInput(sess, ac, []byte("\x1b "))
364371 awaitFrame(t, sends, ports.MsgOutput)
@@ -380,31 +387,35 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
380387 func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
381388 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
382389 defer releaseAll(releases)
390+ ac.setRouteSnapshot(testRecentRouteSnapshot())
391+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
383392 d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})
384393
385- d.handleInput(current, ac, []byte("\x1b "))
394+ d.handleInputForAttachment(token, []byte("\x1b "))
386395 awaitFrame(t, sends, ports.MsgOutput)
387- d.handleInput(current, ac, []byte("RJS"))
396+ d.handleInputForAttachment(token, []byte("RJS"))
388397 awaitFrame(t, sends, ports.MsgOutput)
389398 ac.overlays.paletteMu.Lock()
390399 require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
391400 ac.overlays.paletteMu.Unlock()
392- d.handleInput(current, ac, []byte(" 1\r"))
401+ d.handleInputForAttachment(token, []byte(" 1\r"))
393402
394- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
403+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
404+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
405+ require.NoError(t, err)
406+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
407+ require.Same(t, current, ac.currentSession())
395408 require.False(t, ac.overlays.paletteActive())
396- awaitFrame(t, sends, ports.MsgOutput)
397- awaitFrame(t, sends, ports.MsgOutput)
398409
399- d.handleInput(ac.currentSession(), ac, []byte("\x1b "))
410+ d.handleInputForAttachment(token, []byte("\x1b "))
400411 awaitFrame(t, sends, ports.MsgOutput)
401- d.handleInput(ac.currentSession(), ac, []byte("JRS"))
412+ d.handleInputForAttachment(token, []byte("JRS"))
402413 awaitFrame(t, sends, ports.MsgOutput)
403414 ac.overlays.paletteMu.Lock()
404415 require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
405416 ac.overlays.paletteMu.Unlock()
406- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
407- d.handleInput(ac.currentSession(), ac, []byte("\r"))
417+ require.Same(t, current, ac.currentSession())
418+ d.handleInputForAttachment(token, []byte("\r"))
408419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
409420 }
410421
@@ -425,62 +436,20 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
425436 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
426437 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
427438 defer releaseAll(releases)
428- captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
429- d.handleInput(current, ac, []byte("\x1b "))
439+ ac.setRouteSnapshot(testRecentRouteSnapshot())
440+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
441+ d.handleInputForAttachment(token, []byte("\x1b "))
430442 awaitFrame(t, sends, ports.MsgOutput)
431443 // Reordering live MRU after opening must not shift rank 1 from its capture.
432444 d.sessions[domain.SessionID("older")].core().mruAt.Store(100)
433- d.handleInput(current, ac, []byte("JRS 1\r"))
434-
435- require.Same(t, captured, ac.currentSession())
436- require.False(t, ac.overlays.paletteActive())
437- awaitFrame(t, sends, ports.MsgOutput)
438- awaitFrame(t, sends, ports.MsgOutput)
439-}
440-
441-func TestPaletteJRSThenBSKReversesJump(t *testing.T) {
442- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
443- defer releaseAll(releases)
445+ d.handleInputForAttachment(token, []byte("JRS 1\r"))
444446
445- d.handleInput(current, ac, []byte("\x1b "))
446- awaitFrame(t, sends, ports.MsgOutput)
447- d.handleInput(current, ac, []byte("JRS 1\r"))
448- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())
449- awaitFrame(t, sends, ports.MsgOutput)
450- awaitFrame(t, sends, ports.MsgOutput)
451-
452- runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
447+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
448+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
449+ require.NoError(t, err)
450+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
453451 require.Same(t, current, ac.currentSession())
454-}
455-
456-func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
457- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
458- defer releaseAll(releases)
459-
460- // Establish a valid back-session target, then remove that target immediately
461- // after the command releases its role admission.
462- target := d.sessions[domain.SessionID("recent")]
463- ac.previousSession.Set(target.id)
464- invalidations := installPaletteInvalidationObserver(current)
465- d.enterPalette(current, ac)
466- d.handlePaletteInput(ac, []byte("BSK"))
467- d.afterActionAttachmentEffectEnded = func(action string) {
468- if action == "back-session" {
469- d.mu.Lock()
470- delete(d.sessions, target.core().id)
471- d.mu.Unlock()
472- }
473- }
474-
475- token := current.attachmentToken(ac, ac.transport())
476- effect, admitted := ac.beginAttachmentEffect(token)
477- require.True(t, admitted)
478- d.handlePaletteInput(ac, []byte("\r"), effect)
479-
480- require.False(t, ac.overlays.paletteActive(), "a failed executed handoff must not leave a stale palette")
481- require.Same(t, current, ac.currentSession(), "failed handoff must preserve the source attachment")
482- invalidation := awaitTestValue(t, invalidations, "failed palette handoff did not invalidate rendering")
483- require.True(t, invalidation.reset)
452+ require.False(t, ac.overlays.paletteActive())
484453 }
485454
486455 func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
@@ -505,12 +474,15 @@ func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing
505474 require.True(t, invalidation.reset)
506475 }
507476
508-func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
477+func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
509478 p, release := newBlockingPTY(t)
510479 defer release()
511480 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
512- target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}}
513- d.sessions[target.id] = target
481+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
482+ Generation: 7,
483+ Active: ports.RouteRef{Key: 1, Generation: 1},
484+ Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},
485+ })
514486
515487 validated := make(chan struct{})
516488 releaseHandoff := make(chan struct{})
@@ -518,38 +490,36 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
518490 close(validated)
519491 <-releaseHandoff
520492 }
521- d.handleInput(sess, ac, []byte("\x1b "))
493+ token := beginRecentRoutePaletteEffect(t, d, sess, ac)
494+ d.handleInputForAttachment(token, []byte("\x1b "))
522495 awaitFrame(t, sends, ports.MsgOutput)
523496 inputHandled := make(chan struct{})
524497 go func() {
525- d.handleInput(sess, ac, []byte("JRS 1\r"))
498+ d.handleInputForAttachment(token, []byte("JRS 1\r"))
526499 close(inputHandled)
527500 }()
528- awaitTestCompletion(t, validated, "JRS did not validate its captured target")
529- d.mu.Lock()
530- delete(d.sessions, target.core().id)
531- d.mu.Unlock()
501+ awaitTestCompletion(t, validated, "JRS did not publish the captured route action")
502+ // The daemon must not consult or revalidate the target lifecycle. The
503+ // client owns exact-target validation after it receives this action.
532504 close(releaseHandoff)
505+ awaitTestCompletion(t, inputHandled, "JRS route-action input did not complete")
533506
534- // switchToTarget repaints its failed hand-off before handlePaletteInput
535- // records the generation-safe feedback and schedules the feedback repaint.
536- // Wait for the input transaction, rather than mistaking that intermediate
537- // repaint for publication of the final palette state.
538- awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete")
539- require.True(t, ac.overlays.paletteActive())
540- ac.overlays.paletteMu.Lock()
541- require.Equal(t, "JRS 1", ac.overlays.palette.Query())
542- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
543- ac.overlays.paletteMu.Unlock()
507+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
508+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
509+ require.NoError(t, err)
510+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 7, Key: 9, Generation: 4}, action)
511+ require.False(t, ac.overlays.paletteActive())
544512 }
545513
546514 func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
547515 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
548516 defer releaseAll(releases)
517+ ac.setRouteSnapshot(testRecentRouteSnapshot())
518+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
549519
550- d.handleInput(current, ac, []byte("\x1b "))
520+ d.handleInputForAttachment(token, []byte("\x1b "))
551521 awaitFrame(t, sends, ports.MsgOutput)
552- d.handleInput(current, ac, []byte("JRS 3\r"))
522+ d.handleInputForAttachment(token, []byte("JRS 3\r"))
553523
554524 require.Same(t, current, ac.currentSession())
555525 require.True(t, ac.overlays.paletteActive())
@@ -18,6 +18,31 @@ import (
18 "github.com/bnema/vev/pkg/renderer"18 "github.com/bnema/vev/pkg/renderer"
19 )19 )
20 20
21+func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
22+ return ports.RecentRouteSnapshot{
23+ Generation: 1,
24+ Active: ports.RouteRef{Key: 1, Generation: 1},
25+ Entries: []ports.RecentRouteEntry{
26+ {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal},
27+ {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal},
28+ },
29+ }
30+}
31+
32+func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) attachmentConnectionToken {
33+ t.Helper()
34+ transport := ac.transport()
35+ rc := d.attachCoordinator(sess, nil, ac, true)
36+ token := sess.attachmentToken(ac, transport)
37+ token.lease = rc.attachmentLease(ac)
38+ ac.publishAttachmentCapability(token)
39+ effect, admitted := ac.beginAttachmentEffect(token)
40+ require.True(t, admitted)
41+ t.Cleanup(effect.End)
42+ token.effect = effect
43+ return token
44+}
45+
21 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {46 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
22 paletteColors := [16]renderer.RGB{}47 paletteColors := [16]renderer.RGB{}
23 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}48 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
@@ -220,25 +245,6 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
220 }, got)245 }, got)
221 }246 }
222 247
223-func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) {
224- p, release := newBlockingPTY(t)
225- defer release()
226- d, current, ac, _ := newManualSessionWithPTYs(t, p)
227- named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
228- named.mruAt.Store(1)
229- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
230- ephemeral.mruAt.Store(2)
231- d.sessions[named.id] = named
232- d.sessions[ephemeral.id] = ephemeral
233-
234- d.enterPalette(current, ac)
235- ac.overlays.paletteMu.Lock()
236- defer ac.overlays.paletteMu.Unlock()
237-
238- require.Len(t, ac.overlays.paletteRecent, 1)
239- require.Equal(t, named.id, ac.overlays.paletteRecent[0].id)
240-}
241-
242 func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {248 func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
243 p, release := newBlockingPTY(t)249 p, release := newBlockingPTY(t)
244 defer release()250 defer release()
@@ -359,6 +365,7 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
359 p, release := newBlockingPTY(t)365 p, release := newBlockingPTY(t)
360 defer release()366 defer release()
361 d, sess, ac, sends := newManualSessionWithPTYs(t, p)367 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
368+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1, Active: ports.RouteRef{Key: 1, Generation: 1}})
362 369
363 d.handleInput(sess, ac, []byte("\x1b "))370 d.handleInput(sess, ac, []byte("\x1b "))
364 awaitFrame(t, sends, ports.MsgOutput)371 awaitFrame(t, sends, ports.MsgOutput)
@@ -380,31 +387,35 @@ func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
380 func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {387 func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
381 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)388 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
382 defer releaseAll(releases)389 defer releaseAll(releases)
390+ ac.setRouteSnapshot(testRecentRouteSnapshot())
391+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
383 d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})392 d.ApplyConfig(domain.Config{Codes: map[string]string{"jump-recent-session": "RJS"}})
384 393
385- d.handleInput(current, ac, []byte("\x1b "))394+ d.handleInputForAttachment(token, []byte("\x1b "))
386 awaitFrame(t, sends, ports.MsgOutput)395 awaitFrame(t, sends, ports.MsgOutput)
387- d.handleInput(current, ac, []byte("RJS"))396+ d.handleInputForAttachment(token, []byte("RJS"))
388 awaitFrame(t, sends, ports.MsgOutput)397 awaitFrame(t, sends, ports.MsgOutput)
389 ac.overlays.paletteMu.Lock()398 ac.overlays.paletteMu.Lock()
390 require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)399 require.Equal(t, command.ContextHintRecentSessions, ac.overlays.paletteHints.Kind)
391 ac.overlays.paletteMu.Unlock()400 ac.overlays.paletteMu.Unlock()
392- d.handleInput(current, ac, []byte(" 1\r"))401+ d.handleInputForAttachment(token, []byte(" 1\r"))
393 402
394- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())403+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
404+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
405+ require.NoError(t, err)
406+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
407+ require.Same(t, current, ac.currentSession())
395 require.False(t, ac.overlays.paletteActive())408 require.False(t, ac.overlays.paletteActive())
396- awaitFrame(t, sends, ports.MsgOutput)
397- awaitFrame(t, sends, ports.MsgOutput)
398 409
399- d.handleInput(ac.currentSession(), ac, []byte("\x1b "))410+ d.handleInputForAttachment(token, []byte("\x1b "))
400 awaitFrame(t, sends, ports.MsgOutput)411 awaitFrame(t, sends, ports.MsgOutput)
401- d.handleInput(ac.currentSession(), ac, []byte("JRS"))412+ d.handleInputForAttachment(token, []byte("JRS"))
402 awaitFrame(t, sends, ports.MsgOutput)413 awaitFrame(t, sends, ports.MsgOutput)
403 ac.overlays.paletteMu.Lock()414 ac.overlays.paletteMu.Lock()
404 require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)415 require.Equal(t, command.ContextHintNone, ac.overlays.paletteHints.Kind)
405 ac.overlays.paletteMu.Unlock()416 ac.overlays.paletteMu.Unlock()
406- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())417+ require.Same(t, current, ac.currentSession())
407- d.handleInput(ac.currentSession(), ac, []byte("\r"))418+ d.handleInputForAttachment(token, []byte("\r"))
408 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
409 }420 }
410 421
@@ -425,62 +436,20 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
425 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {436 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
426 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)437 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
427 defer releaseAll(releases)438 defer releaseAll(releases)
428- captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")])439+ ac.setRouteSnapshot(testRecentRouteSnapshot())
429- d.handleInput(current, ac, []byte("\x1b "))440+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
441+ d.handleInputForAttachment(token, []byte("\x1b "))
430 awaitFrame(t, sends, ports.MsgOutput)442 awaitFrame(t, sends, ports.MsgOutput)
431 // Reordering live MRU after opening must not shift rank 1 from its capture.443 // Reordering live MRU after opening must not shift rank 1 from its capture.
432 d.sessions[domain.SessionID("older")].core().mruAt.Store(100)444 d.sessions[domain.SessionID("older")].core().mruAt.Store(100)
433- d.handleInput(current, ac, []byte("JRS 1\r"))445+ d.handleInputForAttachment(token, []byte("JRS 1\r"))
434-
435- require.Same(t, captured, ac.currentSession())
436- require.False(t, ac.overlays.paletteActive())
437- awaitFrame(t, sends, ports.MsgOutput)
438- awaitFrame(t, sends, ports.MsgOutput)
439-}
440-
441-func TestPaletteJRSThenBSKReversesJump(t *testing.T) {
442- d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
443- defer releaseAll(releases)
444 446
445- d.handleInput(current, ac, []byte("\x1b "))447+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
446- awaitFrame(t, sends, ports.MsgOutput)448+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
447- d.handleInput(current, ac, []byte("JRS 1\r"))449+ require.NoError(t, err)
448- require.Same(t, d.sessions[domain.SessionID("recent")], ac.currentSession())450+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 1, Key: 2, Generation: 1}, action)
449- awaitFrame(t, sends, ports.MsgOutput)
450- awaitFrame(t, sends, ports.MsgOutput)
451-
452- runPaletteCommand(t, d, ac.currentSession(), ac, "BSK")
453 require.Same(t, current, ac.currentSession())451 require.Same(t, current, ac.currentSession())
454-}452+ require.False(t, ac.overlays.paletteActive())
455-
456-func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
457- d, current, ac, _, releases := newRecentNavigationTestSessions(t)
458- defer releaseAll(releases)
459-
460- // Establish a valid back-session target, then remove that target immediately
461- // after the command releases its role admission.
462- target := d.sessions[domain.SessionID("recent")]
463- ac.previousSession.Set(target.id)
464- invalidations := installPaletteInvalidationObserver(current)
465- d.enterPalette(current, ac)
466- d.handlePaletteInput(ac, []byte("BSK"))
467- d.afterActionAttachmentEffectEnded = func(action string) {
468- if action == "back-session" {
469- d.mu.Lock()
470- delete(d.sessions, target.core().id)
471- d.mu.Unlock()
472- }
473- }
474-
475- token := current.attachmentToken(ac, ac.transport())
476- effect, admitted := ac.beginAttachmentEffect(token)
477- require.True(t, admitted)
478- d.handlePaletteInput(ac, []byte("\r"), effect)
479-
480- require.False(t, ac.overlays.paletteActive(), "a failed executed handoff must not leave a stale palette")
481- require.Same(t, current, ac.currentSession(), "failed handoff must preserve the source attachment")
482- invalidation := awaitTestValue(t, invalidations, "failed palette handoff did not invalidate rendering")
483- require.True(t, invalidation.reset)
484 }453 }
485 454
486 func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {455 func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
@@ -505,12 +474,15 @@ func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing
505 require.True(t, invalidation.reset)474 require.True(t, invalidation.reset)
506 }475 }
507 476
508-func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {477+func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) {
509 p, release := newBlockingPTY(t)478 p, release := newBlockingPTY(t)
510 defer release()479 defer release()
511 d, sess, ac, sends := newManualSessionWithPTYs(t, p)480 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
512- target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}}481+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
513- d.sessions[target.id] = target482+ Generation: 7,
483+ Active: ports.RouteRef{Key: 1, Generation: 1},
484+ Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}},
485+ })
514 486
515 validated := make(chan struct{})487 validated := make(chan struct{})
516 releaseHandoff := make(chan struct{})488 releaseHandoff := make(chan struct{})
@@ -518,38 +490,36 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
518 close(validated)490 close(validated)
519 <-releaseHandoff491 <-releaseHandoff
520 }492 }
521- d.handleInput(sess, ac, []byte("\x1b "))493+ token := beginRecentRoutePaletteEffect(t, d, sess, ac)
494+ d.handleInputForAttachment(token, []byte("\x1b "))
522 awaitFrame(t, sends, ports.MsgOutput)495 awaitFrame(t, sends, ports.MsgOutput)
523 inputHandled := make(chan struct{})496 inputHandled := make(chan struct{})
524 go func() {497 go func() {
525- d.handleInput(sess, ac, []byte("JRS 1\r"))498+ d.handleInputForAttachment(token, []byte("JRS 1\r"))
526 close(inputHandled)499 close(inputHandled)
527 }()500 }()
528- awaitTestCompletion(t, validated, "JRS did not validate its captured target")501+ awaitTestCompletion(t, validated, "JRS did not publish the captured route action")
529- d.mu.Lock()502+ // The daemon must not consult or revalidate the target lifecycle. The
530- delete(d.sessions, target.core().id)503+ // client owns exact-target validation after it receives this action.
531- d.mu.Unlock()
532 close(releaseHandoff)504 close(releaseHandoff)
505+ awaitTestCompletion(t, inputHandled, "JRS route-action input did not complete")
533 506
534- // switchToTarget repaints its failed hand-off before handlePaletteInput507+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
535- // records the generation-safe feedback and schedules the feedback repaint.508+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
536- // Wait for the input transaction, rather than mistaking that intermediate509+ require.NoError(t, err)
537- // repaint for publication of the final palette state.510+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 7, Key: 9, Generation: 4}, action)
538- awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete")511+ require.False(t, ac.overlays.paletteActive())
539- require.True(t, ac.overlays.paletteActive())
540- ac.overlays.paletteMu.Lock()
541- require.Equal(t, "JRS 1", ac.overlays.palette.Query())
542- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
543- ac.overlays.paletteMu.Unlock()
544 }512 }
545 513
546 func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {514 func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
547 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)515 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
548 defer releaseAll(releases)516 defer releaseAll(releases)
517+ ac.setRouteSnapshot(testRecentRouteSnapshot())
518+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
549 519
550- d.handleInput(current, ac, []byte("\x1b "))520+ d.handleInputForAttachment(token, []byte("\x1b "))
551 awaitFrame(t, sends, ports.MsgOutput)521 awaitFrame(t, sends, ports.MsgOutput)
552- d.handleInput(current, ac, []byte("JRS 3\r"))522+ d.handleInputForAttachment(token, []byte("JRS 3\r"))
553 523
554 require.Same(t, current, ac.currentSession())524 require.Same(t, current, ac.currentSession())
555 require.True(t, ac.overlays.paletteActive())525 require.True(t, ac.overlays.paletteActive())
M internal/usecase/daemon/picker.go +0 -4
@@ -859,7 +859,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
859859 }
860860 }
861861 d.touchMRU(targetSess)
862- token.ac.recordPreviousSession(token.sess)
863862 d.deferAttachmentTransitionCleanups(transition)
864863 d.firstPaintForTransition(transition.published)
865864 return nil
@@ -1203,7 +1202,6 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12031202 return nil, attachmentTransitionResult{}, false
12041203 }
12051204 d.touchMRU(targetSess)
1206- ac.recordPreviousSession(from)
12071205 return targetSess, transition, true
12081206 }
12091207
@@ -1245,7 +1243,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12451243 return targetSess, attachmentTransitionResult{}, false, err
12461244 }
12471245 d.touchMRU(targetSess)
1248- ac.recordPreviousSession(from)
12491246 return targetSess, transition, true, nil
12501247 }
12511248
@@ -1276,7 +1273,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12761273 return targetSess, attachmentTransitionResult{}, false, err
12771274 }
12781275 d.touchMRU(targetSess)
1279- ac.recordPreviousSession(from)
12801276 return targetSess, transition, true, nil
12811277 }
12821278
@@ -859,7 +859,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
859 }859 }
860 }860 }
861 d.touchMRU(targetSess)861 d.touchMRU(targetSess)
862- token.ac.recordPreviousSession(token.sess)
863 d.deferAttachmentTransitionCleanups(transition)862 d.deferAttachmentTransitionCleanups(transition)
864 d.firstPaintForTransition(transition.published)863 d.firstPaintForTransition(transition.published)
865 return nil864 return nil
@@ -1203,7 +1202,6 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1203 return nil, attachmentTransitionResult{}, false1202 return nil, attachmentTransitionResult{}, false
1204 }1203 }
1205 d.touchMRU(targetSess)1204 d.touchMRU(targetSess)
1206- ac.recordPreviousSession(from)
1207 return targetSess, transition, true1205 return targetSess, transition, true
1208 }1206 }
1209 1207
@@ -1245,7 +1243,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1245 return targetSess, attachmentTransitionResult{}, false, err1243 return targetSess, attachmentTransitionResult{}, false, err
1246 }1244 }
1247 d.touchMRU(targetSess)1245 d.touchMRU(targetSess)
1248- ac.recordPreviousSession(from)
1249 return targetSess, transition, true, nil1246 return targetSess, transition, true, nil
1250 }1247 }
1251 1248
@@ -1276,7 +1273,6 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1276 return targetSess, attachmentTransitionResult{}, false, err1273 return targetSess, attachmentTransitionResult{}, false, err
1277 }1274 }
1278 d.touchMRU(targetSess)1275 d.touchMRU(targetSess)
1279- ac.recordPreviousSession(from)
1280 return targetSess, transition, true, nil1276 return targetSess, transition, true, nil
1281 }1277 }
1282 1278
M internal/usecase/daemon/picker_test.go +0 -41
@@ -19,7 +19,6 @@ import (
1919 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
2020 themeui "github.com/bnema/vev/internal/usecase/theme"
2121 "github.com/bnema/vev/pkg/renderer"
22- "github.com/bnema/vev/pkg/vt"
2322 )
2423
2524 var (
@@ -952,46 +951,6 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
952951 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
953952 }
954953
955-func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
956- p1, releasePTY1 := newBlockingPTY(t)
957- p2, releasePTY2 := newBlockingPTY(t)
958- defer releasePTY1()
959- defer releasePTY2()
960- d, source, ac, sends := newManualSessionWithPTYs(t, p1)
961- source.id, source.name = "source", "source"
962- delete(d.sessions, domain.SessionID("manual"))
963- d.sessions[source.id] = source
964- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {},
965- tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
966- }
967- d.sessions[target.id] = target
968-
969- sourcePane := source.tabs[0].focusedPane()
970- targetPane := target.tabs[0].focusedPane()
971- require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
972- require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
973- sourcePane.screen.Write([]byte("SOURCE"))
974- client := vt.NewScreen(80, 25)
975- d.firstPaint(source, ac)
976- sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
977- ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
978-
979- targetPane.screen.Write([]byte("TARGET"))
980- targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
981- require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
982- ac.previousSession.Set(target.id)
983-
984- // Exercise the user-facing previous-session route, which delegates to the
985- // real switchToTarget hand-off and immediately emits its required reset.
986- d.backSession(source, ac)
987- firstReset := awaitFrame(t, sends, ports.MsgOutput)
988- out := mustApplyOutput(t, client, firstReset)
989- require.Zero(t, out.Base, "the first target frame must be the reset, not an eventual repair")
990- frame := strings.Join(frameRows(client.Frame), "\n")
991- require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
992- require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
993-}
994-
995954 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
996955 p1, releasePTY1 := newBlockingPTY(t)
997956 p2, releasePTY2 := newBlockingPTY(t)
@@ -19,7 +19,6 @@ import (
19 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"19 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
20 themeui "github.com/bnema/vev/internal/usecase/theme"20 themeui "github.com/bnema/vev/internal/usecase/theme"
21 "github.com/bnema/vev/pkg/renderer"21 "github.com/bnema/vev/pkg/renderer"
22- "github.com/bnema/vev/pkg/vt"
23 )22 )
24 23
25 var (24 var (
@@ -952,46 +951,6 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
952 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)951 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
953 }952 }
954 953
955-func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
956- p1, releasePTY1 := newBlockingPTY(t)
957- p2, releasePTY2 := newBlockingPTY(t)
958- defer releasePTY1()
959- defer releasePTY2()
960- d, source, ac, sends := newManualSessionWithPTYs(t, p1)
961- source.id, source.name = "source", "source"
962- delete(d.sessions, domain.SessionID("manual"))
963- d.sessions[source.id] = source
964- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {},
965- tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
966- }
967- d.sessions[target.id] = target
968-
969- sourcePane := source.tabs[0].focusedPane()
970- targetPane := target.tabs[0].focusedPane()
971- require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
972- require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
973- sourcePane.screen.Write([]byte("SOURCE"))
974- client := vt.NewScreen(80, 25)
975- d.firstPaint(source, ac)
976- sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
977- ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
978-
979- targetPane.screen.Write([]byte("TARGET"))
980- targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
981- require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
982- ac.previousSession.Set(target.id)
983-
984- // Exercise the user-facing previous-session route, which delegates to the
985- // real switchToTarget hand-off and immediately emits its required reset.
986- d.backSession(source, ac)
987- firstReset := awaitFrame(t, sends, ports.MsgOutput)
988- out := mustApplyOutput(t, client, firstReset)
989- require.Zero(t, out.Base, "the first target frame must be the reset, not an eventual repair")
990- frame := strings.Join(frameRows(client.Frame), "\n")
991- require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
992- require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
993-}
994-
995 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {954 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
996 p1, releasePTY1 := newBlockingPTY(t)955 p1, releasePTY1 := newBlockingPTY(t)
997 p2, releasePTY2 := newBlockingPTY(t)956 p2, releasePTY2 := newBlockingPTY(t)
M internal/usecase/daemon/recent_route_presentation.go +6 -28
@@ -1,8 +1,8 @@
11 package daemon
22
33 // recentRouteKind identifies the origin represented by a presentation value.
4-// It is intentionally daemon-local in this layer; the client route protocol
5-// and its wire kind are introduced by the next stacked branch.
4+// It is intentionally daemon-local in this layer; ports route kinds are
5+// projected into this render-only value at the daemon boundary.
66 type recentRouteKind uint8
77
88 const (
@@ -10,10 +10,10 @@ const (
1010 recentRouteRemote
1111 )
1212
13-// recentRoutePresentation is the compact immutable value shared by collection,
14-// formatting, and the future client-published snapshot. It contains only
15-// display data; it never carries session ownership, selection identity,
16-// transport capabilities, credentials, or attach targets.
13+// recentRoutePresentation is the compact immutable value shared by snapshot
14+// projection and formatting. It contains only display data; it never carries
15+// session ownership, selection identity, transport capabilities, credentials,
16+// or attach targets.
1717 type recentRoutePresentation struct {
1818 name string
1919 hostLabel string
@@ -31,28 +31,6 @@ type recentRouteDisplay struct {
3131 attention bool
3232 }
3333
34-// routePresentation returns the local daemon's display-only view of a
35-// selection value. Selection identity remains in recentSession for palette
36-// execution and is not passed into rendering.
37-func (entry recentSession) routePresentation() recentRoutePresentation {
38- return recentRoutePresentation{
39- name: entry.name,
40- kind: recentRouteLocal,
41- attention: entry.attention,
42- }
43-}
44-
45-func recentRoutePresentations(entries []recentSession) []recentRoutePresentation {
46- if entries == nil {
47- return nil
48- }
49- out := make([]recentRoutePresentation, len(entries))
50- for i, entry := range entries {
51- out[i] = entry.routePresentation()
52- }
53- return out
54-}
55-
5634 // formatRecentRoutePresentations is the single label formatter used by the
5735 // normal MRU bar and ranked recent-session palette rendering. It returns an
5836 // owned slice so callers can retain the result without consulting live state.
@@ -1,8 +1,8 @@
1 package daemon1 package daemon
2 2
3 // recentRouteKind identifies the origin represented by a presentation value.3 // recentRouteKind identifies the origin represented by a presentation value.
4-// It is intentionally daemon-local in this layer; the client route protocol4+// It is intentionally daemon-local in this layer; ports route kinds are
5-// and its wire kind are introduced by the next stacked branch.5+// projected into this render-only value at the daemon boundary.
6 type recentRouteKind uint86 type recentRouteKind uint8
7 7
8 const (8 const (
@@ -10,10 +10,10 @@ const (
10 recentRouteRemote10 recentRouteRemote
11 )11 )
12 12
13-// recentRoutePresentation is the compact immutable value shared by collection,13+// recentRoutePresentation is the compact immutable value shared by snapshot
14-// formatting, and the future client-published snapshot. It contains only14+// projection and formatting. It contains only display data; it never carries
15-// display data; it never carries session ownership, selection identity,15+// session ownership, selection identity, transport capabilities, credentials,
16-// transport capabilities, credentials, or attach targets.16+// or attach targets.
17 type recentRoutePresentation struct {17 type recentRoutePresentation struct {
18 name string18 name string
19 hostLabel string19 hostLabel string
@@ -31,28 +31,6 @@ type recentRouteDisplay struct {
31 attention bool31 attention bool
32 }32 }
33 33
34-// routePresentation returns the local daemon's display-only view of a
35-// selection value. Selection identity remains in recentSession for palette
36-// execution and is not passed into rendering.
37-func (entry recentSession) routePresentation() recentRoutePresentation {
38- return recentRoutePresentation{
39- name: entry.name,
40- kind: recentRouteLocal,
41- attention: entry.attention,
42- }
43-}
44-
45-func recentRoutePresentations(entries []recentSession) []recentRoutePresentation {
46- if entries == nil {
47- return nil
48- }
49- out := make([]recentRoutePresentation, len(entries))
50- for i, entry := range entries {
51- out[i] = entry.routePresentation()
52- }
53- return out
54-}
55-
56 // formatRecentRoutePresentations is the single label formatter used by the34 // formatRecentRoutePresentations is the single label formatter used by the
57 // normal MRU bar and ranked recent-session palette rendering. It returns an35 // normal MRU bar and ranked recent-session palette rendering. It returns an
58 // owned slice so callers can retain the result without consulting live state.36 // owned slice so callers can retain the result without consulting live state.
M internal/usecase/daemon/recent_route_presentation_test.go +11 -17
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "testing"
55
6+ "github.com/bnema/vev/internal/ports"
67 "github.com/bnema/vev/internal/usecase/command"
78 "github.com/bnema/vev/internal/usecase/palette"
89 "github.com/stretchr/testify/require"
@@ -74,26 +75,19 @@ func TestFormatRecentRoutePresentations(t *testing.T) {
7475 }
7576 }
7677
77-func TestRecentRoutePresentationsProjectSelectionValues(t *testing.T) {
78- entries := []recentSession{{id: "session-secret", name: "work", attention: true, mruAt: 17}}
79-
80- got := recentRoutePresentations(entries)
81-
82- require.Equal(t, []recentRoutePresentation{{
83- name: "work", kind: recentRouteLocal, attention: true,
84- }}, got)
85-}
86-
8778 func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) {
88- recent := []recentSession{
89- {name: "logs", attention: true},
90- {name: "logs"},
79+ snapshot := ports.RecentRouteSnapshot{
80+ Generation: 1,
81+ Entries: []ports.RecentRouteEntry{
82+ {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true},
83+ {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal},
84+ },
9185 }
9286
93- hints := recentSessionHints(recent, nil)
87+ hints := recentRouteHints(snapshot, nil)
9488 require.Equal(t, []palette.RecentSessionHint{
95- {Rank: 1, Name: "logs@local"},
96- {Rank: 2, Name: "logs@local"},
89+ {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1},
90+ {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1},
9791 }, hints.Recent)
9892
9993 hints = palette.ContextualHints{
@@ -104,7 +98,7 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
10498 {Rank: 9, Name: "stale-label"},
10599 },
106100 }
107- ranked := rankedRecentForHints(&hints, recent)
101+ ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot)
108102
109103 require.Equal(t, []rankedRecent{
110104 {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true},
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/vev/internal/ports"
6 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
7 "github.com/bnema/vev/internal/usecase/palette"8 "github.com/bnema/vev/internal/usecase/palette"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
@@ -74,26 +75,19 @@ func TestFormatRecentRoutePresentations(t *testing.T) {
74 }75 }
75 }76 }
76 77
77-func TestRecentRoutePresentationsProjectSelectionValues(t *testing.T) {
78- entries := []recentSession{{id: "session-secret", name: "work", attention: true, mruAt: 17}}
79-
80- got := recentRoutePresentations(entries)
81-
82- require.Equal(t, []recentRoutePresentation{{
83- name: "work", kind: recentRouteLocal, attention: true,
84- }}, got)
85-}
86-
87 func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) {78 func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) {
88- recent := []recentSession{79+ snapshot := ports.RecentRouteSnapshot{
89- {name: "logs", attention: true},80+ Generation: 1,
90- {name: "logs"},81+ Entries: []ports.RecentRouteEntry{
82+ {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true},
83+ {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal},
84+ },
91 }85 }
92 86
93- hints := recentSessionHints(recent, nil)87+ hints := recentRouteHints(snapshot, nil)
94 require.Equal(t, []palette.RecentSessionHint{88 require.Equal(t, []palette.RecentSessionHint{
95- {Rank: 1, Name: "logs@local"},89+ {Rank: 1, Name: "logs@local", SnapshotGeneration: 1, Key: 1, Generation: 1},
96- {Rank: 2, Name: "logs@local"},90+ {Rank: 2, Name: "logs@local", SnapshotGeneration: 1, Key: 2, Generation: 1},
97 }, hints.Recent)91 }, hints.Recent)
98 92
99 hints = palette.ContextualHints{93 hints = palette.ContextualHints{
@@ -104,7 +98,7 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T)
104 {Rank: 9, Name: "stale-label"},98 {Rank: 9, Name: "stale-label"},
105 },99 },
106 }100 }
107- ranked := rankedRecentForHints(&hints, recent)101+ ranked := rankedRecentForHintsWithSnapshot(&hints, snapshot)
108 102
109 require.Equal(t, []rankedRecent{103 require.Equal(t, []rankedRecent{
110 {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true},104 {rank: 4, name: "logs@local", kind: recentRouteLocal, attention: true},
A internal/usecase/daemon/recent_route_snapshot.go +28 -0
@@ -0,0 +1,28 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation {
6+ if snapshot.Entries == nil {
7+ return nil
8+ }
9+ out := make([]recentRoutePresentation, len(snapshot.Entries))
10+ for i, entry := range snapshot.Entries {
11+ kind := recentRouteLocal
12+ if entry.Kind == ports.RouteKindRemote {
13+ kind = recentRouteRemote
14+ }
15+ out[i] = recentRoutePresentation{
16+ name: entry.Name,
17+ hostLabel: entry.HostLabel,
18+ kind: kind,
19+ ephemeral: entry.Ephemeral,
20+ attention: entry.Attention,
21+ }
22+ }
23+ return out
24+}
25+
26+func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {
27+ return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))
28+}
@@ -0,0 +1,28 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation {
6+ if snapshot.Entries == nil {
7+ return nil
8+ }
9+ out := make([]recentRoutePresentation, len(snapshot.Entries))
10+ for i, entry := range snapshot.Entries {
11+ kind := recentRouteLocal
12+ if entry.Kind == ports.RouteKindRemote {
13+ kind = recentRouteRemote
14+ }
15+ out[i] = recentRoutePresentation{
16+ name: entry.Name,
17+ hostLabel: entry.HostLabel,
18+ kind: kind,
19+ ephemeral: entry.Ephemeral,
20+ attention: entry.Attention,
21+ }
22+ }
23+ return out
24+}
25+
26+func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay {
27+ return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))
28+}
A internal/usecase/daemon/recent_route_snapshot_test.go +92 -0
@@ -0,0 +1,92 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
11+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
12+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
13+ Generation: 2,
14+ Active: ports.RouteRef{Key: 3, Generation: 2},
15+ Entries: []ports.RecentRouteEntry{
16+ {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote},
17+ {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal},
18+ },
19+ })
20+
21+ state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{})
22+
23+ require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
24+}
25+
26+func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
27+ _, _, ac, _ := newManualSessionWithPTYs(t, nil)
28+ entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
29+ snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries}
30+ ac.setRouteSnapshot(snapshot)
31+
32+ entries[0].Name = "after"
33+ got := ac.routeSnapshotCopy()
34+ require.Equal(t, "before", got.Entries[0].Name)
35+ got.Entries[0].Name = "mutated copy"
36+ require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name)
37+}
38+
39+func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) {
40+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
41+ transport := &closeTrackingTransport{}
42+ ac.replaceTransport(transport)
43+ rc := d.attachCoordinator(source, nil, ac, true)
44+ token := source.attachmentToken(ac, transport)
45+ token.lease = rc.attachmentLease(ac)
46+ ac.publishAttachmentCapability(token)
47+ effect, admitted := ac.beginAttachmentEffect(token)
48+ require.True(t, admitted)
49+ defer effect.End()
50+ token.effect = effect
51+
52+ exec := paletteExec{
53+ d: d, sess: source, attachment: source, ac: ac,
54+ routeSnapshot: ports.RecentRouteSnapshot{
55+ Generation: 4,
56+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}},
57+ },
58+ effect: effect,
59+ }
60+ require.NoError(t, exec.JumpRecentSession(1))
61+
62+ frames := transport.Sends()
63+ var action ports.RouteNavigationAction
64+ found := false
65+ for _, frame := range frames {
66+ if frame.Type != ports.MsgNavigateRecentRoute {
67+ continue
68+ }
69+ var err error
70+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
71+ require.NoError(t, err)
72+ found = true
73+ }
74+ require.True(t, found)
75+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 3}, action)
76+}
77+
78+func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
79+ snapshot := ports.RecentRouteSnapshot{
80+ Generation: 8,
81+ Entries: []ports.RecentRouteEntry{{
82+ Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
83+ }},
84+ }
85+
86+ hints := recentRouteHints(snapshot, nil)
87+
88+ require.Equal(t, "logs@edge", hints.Recent[0].Name)
89+ require.Equal(t, uint64(8), hints.Recent[0].SnapshotGeneration)
90+ require.Equal(t, uint64(11), hints.Recent[0].Key)
91+ require.Equal(t, uint64(7), hints.Recent[0].Generation)
92+}
@@ -0,0 +1,92 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
11+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
12+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
13+ Generation: 2,
14+ Active: ports.RouteRef{Key: 3, Generation: 2},
15+ Entries: []ports.RecentRouteEntry{
16+ {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote},
17+ {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal},
18+ },
19+ })
20+
21+ state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{})
22+
23+ require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
24+}
25+
26+func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
27+ _, _, ac, _ := newManualSessionWithPTYs(t, nil)
28+ entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
29+ snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries}
30+ ac.setRouteSnapshot(snapshot)
31+
32+ entries[0].Name = "after"
33+ got := ac.routeSnapshotCopy()
34+ require.Equal(t, "before", got.Entries[0].Name)
35+ got.Entries[0].Name = "mutated copy"
36+ require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name)
37+}
38+
39+func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) {
40+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
41+ transport := &closeTrackingTransport{}
42+ ac.replaceTransport(transport)
43+ rc := d.attachCoordinator(source, nil, ac, true)
44+ token := source.attachmentToken(ac, transport)
45+ token.lease = rc.attachmentLease(ac)
46+ ac.publishAttachmentCapability(token)
47+ effect, admitted := ac.beginAttachmentEffect(token)
48+ require.True(t, admitted)
49+ defer effect.End()
50+ token.effect = effect
51+
52+ exec := paletteExec{
53+ d: d, sess: source, attachment: source, ac: ac,
54+ routeSnapshot: ports.RecentRouteSnapshot{
55+ Generation: 4,
56+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}},
57+ },
58+ effect: effect,
59+ }
60+ require.NoError(t, exec.JumpRecentSession(1))
61+
62+ frames := transport.Sends()
63+ var action ports.RouteNavigationAction
64+ found := false
65+ for _, frame := range frames {
66+ if frame.Type != ports.MsgNavigateRecentRoute {
67+ continue
68+ }
69+ var err error
70+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
71+ require.NoError(t, err)
72+ found = true
73+ }
74+ require.True(t, found)
75+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 2, Generation: 3}, action)
76+}
77+
78+func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
79+ snapshot := ports.RecentRouteSnapshot{
80+ Generation: 8,
81+ Entries: []ports.RecentRouteEntry{{
82+ Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
83+ }},
84+ }
85+
86+ hints := recentRouteHints(snapshot, nil)
87+
88+ require.Equal(t, "logs@edge", hints.Recent[0].Name)
89+ require.Equal(t, uint64(8), hints.Recent[0].SnapshotGeneration)
90+ require.Equal(t, uint64(11), hints.Recent[0].Key)
91+ require.Equal(t, uint64(7), hints.Recent[0].Generation)
92+}
D internal/usecase/daemon/recent_sessions.go +0 -50
@@ -1,50 +0,0 @@
1-package daemon
2-
3-import (
4- "sort"
5-
6- "github.com/bnema/vev/internal/domain"
7-)
8-
9-// recentSession is the immutable selection snapshot for one interaction. Its
10-// identity and MRU sequence are retained only for daemon-side execution and
11-// ordering; render paths project it into recentRoutePresentation, which has no
12-// selection identity or lifecycle authority.
13-type recentSession struct {
14- id domain.SessionID
15- name string
16- attention bool
17- mruAt uint64
18-}
19-
20-// recentSessions returns the current session's capped named-session MRU list.
21-// Callers may retain the returned values for the lifetime of an interaction.
22-func (d *Daemon) recentSessions(current *session) []recentSession {
23- if d == nil {
24- return nil
25- }
26- d.mu.Lock()
27- recent := make([]recentSession, 0, len(d.sessions))
28- for _, sess := range d.sessions {
29- if sess == nil || sess == current {
30- continue
31- }
32- snap := sess.snapshotView(viewOptions{})
33- if snap.ephemeral {
34- continue
35- }
36- entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention}
37- recent = append(recent, entry)
38- }
39- d.mu.Unlock()
40- sort.SliceStable(recent, func(i, j int) bool {
41- if recent[i].mruAt == recent[j].mruAt {
42- return recent[i].name < recent[j].name
43- }
44- return recent[i].mruAt > recent[j].mruAt
45- })
46- if len(recent) > maxMRUSessions {
47- recent = recent[:maxMRUSessions]
48- }
49- return recent
50-}
@@ -1,50 +0,0 @@
1-package daemon
2-
3-import (
4- "sort"
5-
6- "github.com/bnema/vev/internal/domain"
7-)
8-
9-// recentSession is the immutable selection snapshot for one interaction. Its
10-// identity and MRU sequence are retained only for daemon-side execution and
11-// ordering; render paths project it into recentRoutePresentation, which has no
12-// selection identity or lifecycle authority.
13-type recentSession struct {
14- id domain.SessionID
15- name string
16- attention bool
17- mruAt uint64
18-}
19-
20-// recentSessions returns the current session's capped named-session MRU list.
21-// Callers may retain the returned values for the lifetime of an interaction.
22-func (d *Daemon) recentSessions(current *session) []recentSession {
23- if d == nil {
24- return nil
25- }
26- d.mu.Lock()
27- recent := make([]recentSession, 0, len(d.sessions))
28- for _, sess := range d.sessions {
29- if sess == nil || sess == current {
30- continue
31- }
32- snap := sess.snapshotView(viewOptions{})
33- if snap.ephemeral {
34- continue
35- }
36- entry := recentSession{id: snap.id, name: snap.name, mruAt: snap.mruAt, attention: snap.hasAttention}
37- recent = append(recent, entry)
38- }
39- d.mu.Unlock()
40- sort.SliceStable(recent, func(i, j int) bool {
41- if recent[i].mruAt == recent[j].mruAt {
42- return recent[i].name < recent[j].name
43- }
44- return recent[i].mruAt > recent[j].mruAt
45- })
46- if len(recent) > maxMRUSessions {
47- recent = recent[:maxMRUSessions]
48- }
49- return recent
50-}
M internal/usecase/daemon/remote_attach.go +25 -0
@@ -88,6 +88,31 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok
8888 return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})
8989 }
9090
91+func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {
92+ payload, err := ports.MarshalRouteNavigationAction(action)
93+ if err != nil {
94+ return errAttachmentTransition
95+ }
96+ return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})
97+}
98+
99+func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error {
100+ if token.sess == nil {
101+ return errAttachmentTransition
102+ }
103+ token.sess.mu.Lock()
104+ identity := ports.CommittedRouteIdentity{
105+ Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name},
106+ Ephemeral: token.sess.ephemeral,
107+ }
108+ token.sess.mu.Unlock()
109+ payload, err := ports.MarshalCommittedRouteIdentity(identity)
110+ if err != nil {
111+ return errAttachmentTransition
112+ }
113+ return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
114+}
115+
91116 func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
92117 ac, err := d.finishAttach(sess, tr, sz, term, h)
93118 if err != nil && routeCreated {
@@ -88,6 +88,31 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok
88 return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})88 return token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload})
89 }89 }
90 90
91+func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {
92+ payload, err := ports.MarshalRouteNavigationAction(action)
93+ if err != nil {
94+ return errAttachmentTransition
95+ }
96+ return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})
97+}
98+
99+func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error {
100+ if token.sess == nil {
101+ return errAttachmentTransition
102+ }
103+ token.sess.mu.Lock()
104+ identity := ports.CommittedRouteIdentity{
105+ Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name},
106+ Ephemeral: token.sess.ephemeral,
107+ }
108+ token.sess.mu.Unlock()
109+ payload, err := ports.MarshalCommittedRouteIdentity(identity)
110+ if err != nil {
111+ return errAttachmentTransition
112+ }
113+ return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
114+}
115+
91 func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {116 func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
92 ac, err := d.finishAttach(sess, tr, sz, term, h)117 ac, err := d.finishAttach(sess, tr, sz, term, h)
93 if err != nil && routeCreated {118 if err != nil && routeCreated {
M internal/usecase/daemon/render.go +1 -1
@@ -388,7 +388,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
388388 if statusFeedback == "" && overlays.resizeActive {
389389 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
390390 }
391- bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
391+ bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
392392 applied := ac.getAppliedTheme()
393393 bars.theme = applied.Raw
394394 if local {
@@ -388,7 +388,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
388 if statusFeedback == "" && overlays.resizeActive {388 if statusFeedback == "" && overlays.resizeActive {
389 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"389 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
390 }390 }
391- bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRecent)391+ bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
392 applied := ac.getAppliedTheme()392 applied := ac.getAppliedTheme()
393 bars.theme = applied.Raw393 bars.theme = applied.Raw
394 if local {394 if local {
M internal/usecase/daemon/render_coordinator_test.go +1 -9
@@ -1136,7 +1136,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
11361136 var producerFiles = []string{
11371137 "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
11381138 "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
1139- "session.go", "session_back.go",
1139+ "session.go",
11401140 }
11411141
11421142 func TestProducerInvalidations(t *testing.T) {
@@ -1248,14 +1248,6 @@ func TestProducerInvalidations(t *testing.T) {
12481248 require.NoError(t, d.closeTab(sess, tb, true))
12491249 },
12501250 },
1251- {
1252- file: "session_back.go",
1253- name: "back session fallback without a target",
1254- run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1255- d.backSession(sess, ac)
1256- require.Same(t, sess, ac.currentSession())
1257- },
1258- },
12591251 }
12601252
12611253 exercised := make([]string, 0, len(cases))
@@ -1136,7 +1136,7 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
1136 var producerFiles = []string{1136 var producerFiles = []string{
1137 "attention.go", "client.go", "copymode.go", "floating.go", "input.go",1137 "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
1138 "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",1138 "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
1139- "session.go", "session_back.go",1139+ "session.go",
1140 }1140 }
1141 1141
1142 func TestProducerInvalidations(t *testing.T) {1142 func TestProducerInvalidations(t *testing.T) {
@@ -1248,14 +1248,6 @@ func TestProducerInvalidations(t *testing.T) {
1248 require.NoError(t, d.closeTab(sess, tb, true))1248 require.NoError(t, d.closeTab(sess, tb, true))
1249 },1249 },
1250 },1250 },
1251- {
1252- file: "session_back.go",
1253- name: "back session fallback without a target",
1254- run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1255- d.backSession(sess, ac)
1256- require.Same(t, sess, ac.currentSession())
1257- },
1258- },
1259 }1251 }
1260 1252
1261 exercised := make([]string, 0, len(cases))1253 exercised := make([]string, 0, len(cases))
M internal/usecase/daemon/resume.go +0 -3
@@ -301,7 +301,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
301301 // has verified d.parked[token] still points at parked when that matters.
302302 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
303303 delete(d.parked, token)
304- parked.ac.clearPreviousSession()
305304 parked.ac.resumeToken = 0
306305 parked.ac.parked = false
307306 if parked.timer != nil {
@@ -402,7 +401,6 @@ type parkedAttachmentRetirement struct {
402401
403402 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
404403 delete(d.parked, token)
405- parked.ac.clearPreviousSession()
406404 parked.ac.resumeToken = 0
407405 parked.ac.parked = false
408406 parked.ac.connectionGeneration.Add(1)
@@ -538,7 +536,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
538536 // shutdown cannot leave an orphaned open transport with no owner.
539537 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
540538 d.resetScreenDefaultColors(sess)
541- ac.clearPreviousSession()
542539 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
543540 d.mu.Lock()
544541 closing := d.closing
@@ -301,7 +301,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
301 // has verified d.parked[token] still points at parked when that matters.301 // has verified d.parked[token] still points at parked when that matters.
302 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {302 func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
303 delete(d.parked, token)303 delete(d.parked, token)
304- parked.ac.clearPreviousSession()
305 parked.ac.resumeToken = 0304 parked.ac.resumeToken = 0
306 parked.ac.parked = false305 parked.ac.parked = false
307 if parked.timer != nil {306 if parked.timer != nil {
@@ -402,7 +401,6 @@ type parkedAttachmentRetirement struct {
402 401
403 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {402 func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
404 delete(d.parked, token)403 delete(d.parked, token)
405- parked.ac.clearPreviousSession()
406 parked.ac.resumeToken = 0404 parked.ac.resumeToken = 0
407 parked.ac.parked = false405 parked.ac.parked = false
408 parked.ac.connectionGeneration.Add(1)406 parked.ac.connectionGeneration.Add(1)
@@ -538,7 +536,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
538 // shutdown cannot leave an orphaned open transport with no owner.536 // shutdown cannot leave an orphaned open transport with no owner.
539 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)537 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
540 d.resetScreenDefaultColors(sess)538 d.resetScreenDefaultColors(sess)
541- ac.clearPreviousSession()
542 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))539 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
543 d.mu.Lock()540 d.mu.Lock()
544 closing := d.closing541 closing := d.closing
M internal/usecase/daemon/resume_test.go +2 -11
@@ -1331,7 +1331,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
13311331 require.Zero(t, parked)
13321332 }
13331333
1334-func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1334+func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) {
13351335 pty, release := newBlockingPTY(t)
13361336 defer release()
13371337 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -1339,11 +1339,6 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
13391339 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
13401340 require.NoError(t, err)
13411341
1342- previous := &session{sessionCore: sessionCore{id: "previous"}}
1343- d.mu.Lock()
1344- d.sessions[previous.id] = previous
1345- d.mu.Unlock()
1346- ac.previousSession.Set(previous.id)
13471342 d.clientGone(sess, ac, ac.transport(), false)
13481343
13491344 token := ac.resumeToken
@@ -1351,17 +1346,15 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
13511346 parked := d.parked[token]
13521347 d.mu.Unlock()
13531348 require.NotNil(t, parked)
1354- require.Equal(t, previous.id, ac.previousSession.Get(), "a live parked attachment keeps its toggle")
13551349
13561350 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
13571351 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
13581352 require.NoError(t, err)
13591353 require.Same(t, sess, resumedSess)
13601354 require.Same(t, ac, resumedAC)
1361- require.Equal(t, previous.id, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle")
13621355 }
13631356
1364-func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1357+func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) {
13651358 for _, tc := range []struct {
13661359 name string
13671360 discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
@@ -1388,7 +1381,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13881381 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
13891382 require.NoError(t, err)
13901383
1391- ac.previousSession.Set("previous")
13921384 d.clientGone(sess, ac, ac.transport(), false)
13931385
13941386 d.mu.Lock()
@@ -1398,7 +1390,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13981390 d.mu.Unlock()
13991391 d.finishParkedAttachmentRetirements(retirements)
14001392
1401- require.Empty(t, ac.previousSession.Get())
14021393 })
14031394 }
14041395 }
@@ -1331,7 +1331,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
1331 require.Zero(t, parked)1331 require.Zero(t, parked)
1332 }1332 }
1333 1333
1334-func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {1334+func TestLiveParkAndResumeRetainsAttachmentWithoutDaemonHistory(t *testing.T) {
1335 pty, release := newBlockingPTY(t)1335 pty, release := newBlockingPTY(t)
1336 defer release()1336 defer release()
1337 d := newTestDaemon(t, newFactory(t, pty), stubClock{})1337 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -1339,11 +1339,6 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1339 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1339 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1340 require.NoError(t, err)1340 require.NoError(t, err)
1341 1341
1342- previous := &session{sessionCore: sessionCore{id: "previous"}}
1343- d.mu.Lock()
1344- d.sessions[previous.id] = previous
1345- d.mu.Unlock()
1346- ac.previousSession.Set(previous.id)
1347 d.clientGone(sess, ac, ac.transport(), false)1342 d.clientGone(sess, ac, ac.transport(), false)
1348 1343
1349 token := ac.resumeToken1344 token := ac.resumeToken
@@ -1351,17 +1346,15 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1351 parked := d.parked[token]1346 parked := d.parked[token]
1352 d.mu.Unlock()1347 d.mu.Unlock()
1353 require.NotNil(t, parked)1348 require.NotNil(t, parked)
1354- require.Equal(t, previous.id, ac.previousSession.Get(), "a live parked attachment keeps its toggle")
1355 1349
1356 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))1350 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
1357 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)1351 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
1358 require.NoError(t, err)1352 require.NoError(t, err)
1359 require.Same(t, sess, resumedSess)1353 require.Same(t, sess, resumedSess)
1360 require.Same(t, ac, resumedAC)1354 require.Same(t, ac, resumedAC)
1361- require.Equal(t, previous.id, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle")
1362 }1355 }
1363 1356
1364-func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {1357+func TestDiscardingParkedAttachmentDoesNotOwnPreviousSession(t *testing.T) {
1365 for _, tc := range []struct {1358 for _, tc := range []struct {
1366 name string1359 name string
1367 discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement1360 discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement
@@ -1388,7 +1381,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1388 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1381 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1389 require.NoError(t, err)1382 require.NoError(t, err)
1390 1383
1391- ac.previousSession.Set("previous")
1392 d.clientGone(sess, ac, ac.transport(), false)1384 d.clientGone(sess, ac, ac.transport(), false)
1393 1385
1394 d.mu.Lock()1386 d.mu.Lock()
@@ -1398,7 +1390,6 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1398 d.mu.Unlock()1390 d.mu.Unlock()
1399 d.finishParkedAttachmentRetirements(retirements)1391 d.finishParkedAttachmentRetirements(retirements)
1400 1392
1401- require.Empty(t, ac.previousSession.Get())
1402 })1393 })
1403 }1394 }
1404 }1395 }
M internal/usecase/daemon/session.go +0 -5
@@ -460,7 +460,6 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
460460 }
461461
462462 d.touchMRU(newSess)
463- ac.recordPreviousSession(from)
464463 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
465464 d.deferAttachmentTransitionCleanups(transition)
466465 d.firstPaintForTransition(transition.published)
@@ -515,9 +514,6 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
515514 }
516515
517516 d.touchMRU(created)
518- if token.sess != nil {
519- token.ac.recordPreviousSession(token.sess)
520- }
521517 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
522518 d.deferAttachmentTransitionCleanups(transition)
523519 d.firstPaintForTransition(transition.published)
@@ -1614,7 +1610,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16141610 }
16151611 for _, attachment := range attachments {
16161612 d.unregisterPreview(attachment.ac)
1617- attachment.ac.clearPreviousSession()
16181613 attachment.ac.clearCaptureFrames()
16191614 }
16201615
@@ -460,7 +460,6 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
460 }460 }
461 461
462 d.touchMRU(newSess)462 d.touchMRU(newSess)
463- ac.recordPreviousSession(from)
464 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)463 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
465 d.deferAttachmentTransitionCleanups(transition)464 d.deferAttachmentTransitionCleanups(transition)
466 d.firstPaintForTransition(transition.published)465 d.firstPaintForTransition(transition.published)
@@ -515,9 +514,6 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
515 }514 }
516 515
517 d.touchMRU(created)516 d.touchMRU(created)
518- if token.sess != nil {
519- token.ac.recordPreviousSession(token.sess)
520- }
521 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)517 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
522 d.deferAttachmentTransitionCleanups(transition)518 d.deferAttachmentTransitionCleanups(transition)
523 d.firstPaintForTransition(transition.published)519 d.firstPaintForTransition(transition.published)
@@ -1614,7 +1610,6 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1614 }1610 }
1615 for _, attachment := range attachments {1611 for _, attachment := range attachments {
1616 d.unregisterPreview(attachment.ac)1612 d.unregisterPreview(attachment.ac)
1617- attachment.ac.clearPreviousSession()
1618 attachment.ac.clearCaptureFrames()1613 attachment.ac.clearCaptureFrames()
1619 }1614 }
1620 1615
M internal/usecase/daemon/session_back.go +11 -38
@@ -2,36 +2,31 @@ package daemon
22
33 import (
44 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/usecase/picker"
5+ "github.com/bnema/vev/internal/ports"
66 )
77
8-// backSession toggles this attachment between its current session and the
9-// immediately preceding successfully activated session.
8+// backSessionForAttachment delegates previous-route ownership to the client
9+// once it has published a complete route snapshot. There is no daemon-local
10+// previous-session fallback after the global-history cutover.
1011 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
1112 if d == nil || token.sess == nil || token.ac == nil {
1213 return nil
1314 }
14- targetID := token.ac.previousSession.Get()
15- target := d.sessionByID(targetID)
16- if target == nil || target == token.sess {
17- token.ac.clearPreviousSessionIf(targetID)
15+ snapshot := token.ac.routeSnapshotCopy()
16+ if snapshot.Generation == 0 || snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {
1817 return nil
1918 }
20- pickerTarget := picker.Target{Session: targetID, TabIndex: -1}
21- return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
19+ return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{
20+ SnapshotGeneration: snapshot.Generation,
21+ Key: snapshot.Previous.Key,
22+ Generation: snapshot.Previous.Generation,
23+ })
2224 }
2325
2426 func (d *Daemon) backSession(current *session, ac *attachedClient) {
2527 if d == nil || current == nil || ac == nil {
2628 return
2729 }
28- targetID := ac.previousSession.Get()
29- target := d.sessionByID(targetID)
30- if target == nil || target == current {
31- ac.clearPreviousSessionIf(targetID)
32- d.invalidateRender(current, ac, true, "session_back.go")
33- return
34- }
3530 token := attachmentToken(current, ac, ac.transport())
3631 effect, admitted := ac.beginAttachmentEffect(token)
3732 if !admitted {
@@ -44,25 +39,3 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
4439 d.reportError(current, err)
4540 }
4641 }
47-
48-// clearPreviousSessionIf clears target only if it has not been replaced since
49-// the caller observed it. This preserves a concurrent successful hand-off.
50-func (ac *attachedClient) clearPreviousSessionIf(targetID domain.SessionID) {
51- if ac == nil {
52- return
53- }
54- ac.previousSession.With(func(previous *domain.SessionID) {
55- if *previous == targetID {
56- *previous = ""
57- }
58- })
59-}
60-
61-// recordPreviousSession is called only after a cross-session hand-off has
62-// committed. Keeping this after the commit makes failed/displaced switches
63-// leave the toggle pair intact.
64-func (ac *attachedClient) recordPreviousSession(origin *session) {
65- if ac != nil && origin != nil {
66- ac.previousSession.Set(origin.id)
67- }
68-}
@@ -2,36 +2,31 @@ package daemon
2 2
3 import (3 import (
4 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/usecase/picker"5+ "github.com/bnema/vev/internal/ports"
6 )6 )
7 7
8-// backSession toggles this attachment between its current session and the8+// backSessionForAttachment delegates previous-route ownership to the client
9-// immediately preceding successfully activated session.9+// once it has published a complete route snapshot. There is no daemon-local
10+// previous-session fallback after the global-history cutover.
10 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {11 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
11 if d == nil || token.sess == nil || token.ac == nil {12 if d == nil || token.sess == nil || token.ac == nil {
12 return nil13 return nil
13 }14 }
14- targetID := token.ac.previousSession.Get()15+ snapshot := token.ac.routeSnapshotCopy()
15- target := d.sessionByID(targetID)16+ if snapshot.Generation == 0 || snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {
16- if target == nil || target == token.sess {
17- token.ac.clearPreviousSessionIf(targetID)
18 return nil17 return nil
19 }18 }
20- pickerTarget := picker.Target{Session: targetID, TabIndex: -1}19+ return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{
21- return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")20+ SnapshotGeneration: snapshot.Generation,
21+ Key: snapshot.Previous.Key,
22+ Generation: snapshot.Previous.Generation,
23+ })
22 }24 }
23 25
24 func (d *Daemon) backSession(current *session, ac *attachedClient) {26 func (d *Daemon) backSession(current *session, ac *attachedClient) {
25 if d == nil || current == nil || ac == nil {27 if d == nil || current == nil || ac == nil {
26 return28 return
27 }29 }
28- targetID := ac.previousSession.Get()
29- target := d.sessionByID(targetID)
30- if target == nil || target == current {
31- ac.clearPreviousSessionIf(targetID)
32- d.invalidateRender(current, ac, true, "session_back.go")
33- return
34- }
35 token := attachmentToken(current, ac, ac.transport())30 token := attachmentToken(current, ac, ac.transport())
36 effect, admitted := ac.beginAttachmentEffect(token)31 effect, admitted := ac.beginAttachmentEffect(token)
37 if !admitted {32 if !admitted {
@@ -44,25 +39,3 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
44 d.reportError(current, err)39 d.reportError(current, err)
45 }40 }
46 }41 }
47-
48-// clearPreviousSessionIf clears target only if it has not been replaced since
49-// the caller observed it. This preserves a concurrent successful hand-off.
50-func (ac *attachedClient) clearPreviousSessionIf(targetID domain.SessionID) {
51- if ac == nil {
52- return
53- }
54- ac.previousSession.With(func(previous *domain.SessionID) {
55- if *previous == targetID {
56- *previous = ""
57- }
58- })
59-}
60-
61-// recordPreviousSession is called only after a cross-session hand-off has
62-// committed. Keeping this after the commit makes failed/displaced switches
63-// leave the toggle pair intact.
64-func (ac *attachedClient) recordPreviousSession(origin *session) {
65- if ac != nil && origin != nil {
66- ac.previousSession.Set(origin.id)
67- }
68-}
A internal/usecase/daemon/session_back_route_test.go +47 -0
@@ -0,0 +1,47 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T) {
11+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
12+ transport := &closeTrackingTransport{}
13+ ac.replaceTransport(transport)
14+ rc := d.attachCoordinator(source, nil, ac, true)
15+ token := source.attachmentToken(ac, transport)
16+ token.lease = rc.attachmentLease(ac)
17+ ac.publishAttachmentCapability(token)
18+ effect, admitted := ac.beginAttachmentEffect(token)
19+ require.True(t, admitted)
20+ defer effect.End()
21+ token.effect = effect
22+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
23+ Generation: 5,
24+ Active: ports.RouteRef{Key: 9, Generation: 5},
25+ Previous: ports.RouteRef{Key: 7, Generation: 3},
26+ Entries: []ports.RecentRouteEntry{{
27+ Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,
28+ }},
29+ })
30+
31+ require.NoError(t, d.backSessionForAttachment(token))
32+ frames := transport.Sends()
33+ require.NotEmpty(t, frames)
34+ var action ports.RouteNavigationAction
35+ found := false
36+ for _, frame := range frames {
37+ if frame.Type != ports.MsgNavigateRecentRoute {
38+ continue
39+ }
40+ var err error
41+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
42+ require.NoError(t, err)
43+ found = true
44+ }
45+ require.True(t, found)
46+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 5, Key: 7, Generation: 3}, action)
47+}
@@ -0,0 +1,47 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T) {
11+ d, source, ac, _ := newManualSessionWithPTYs(t, nil)
12+ transport := &closeTrackingTransport{}
13+ ac.replaceTransport(transport)
14+ rc := d.attachCoordinator(source, nil, ac, true)
15+ token := source.attachmentToken(ac, transport)
16+ token.lease = rc.attachmentLease(ac)
17+ ac.publishAttachmentCapability(token)
18+ effect, admitted := ac.beginAttachmentEffect(token)
19+ require.True(t, admitted)
20+ defer effect.End()
21+ token.effect = effect
22+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
23+ Generation: 5,
24+ Active: ports.RouteRef{Key: 9, Generation: 5},
25+ Previous: ports.RouteRef{Key: 7, Generation: 3},
26+ Entries: []ports.RecentRouteEntry{{
27+ Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal,
28+ }},
29+ })
30+
31+ require.NoError(t, d.backSessionForAttachment(token))
32+ frames := transport.Sends()
33+ require.NotEmpty(t, frames)
34+ var action ports.RouteNavigationAction
35+ found := false
36+ for _, frame := range frames {
37+ if frame.Type != ports.MsgNavigateRecentRoute {
38+ continue
39+ }
40+ var err error
41+ action, err = ports.UnmarshalRouteNavigationAction(frame.Payload)
42+ require.NoError(t, err)
43+ found = true
44+ }
45+ require.True(t, found)
46+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 5, Key: 7, Generation: 3}, action)
47+}
M internal/usecase/daemon/session_view.go +3 -3
@@ -24,9 +24,9 @@ type tabView struct {
2424 }
2525
2626 // sessionView is an immutable, value-only description of a session. It
27-// deliberately retains no live pointers, mirroring recentSession
28-// (recent_sessions.go). Listing paths (picker, MRU bar, palette, and
29-// list-sessions) read local session state through snapshotView.
27+// deliberately retains no live pointers, mirroring route snapshot entries.
28+// Listing paths (picker, palette, and list-sessions) read local session state
29+// through snapshotView.
3030 type sessionView struct {
3131 id domain.SessionID
3232 incarnation domain.IncarnationID
@@ -24,9 +24,9 @@ type tabView struct {
24 }24 }
25 25
26 // sessionView is an immutable, value-only description of a session. It26 // sessionView is an immutable, value-only description of a session. It
27-// deliberately retains no live pointers, mirroring recentSession27+// deliberately retains no live pointers, mirroring route snapshot entries.
28-// (recent_sessions.go). Listing paths (picker, MRU bar, palette, and28+// Listing paths (picker, palette, and list-sessions) read local session state
29-// list-sessions) read local session state through snapshotView.29+// through snapshotView.
30 type sessionView struct {30 type sessionView struct {
31 id domain.SessionID31 id domain.SessionID
32 incarnation domain.IncarnationID32 incarnation domain.IncarnationID
M internal/usecase/daemon/status.go +23 -23
@@ -7,6 +7,7 @@ import (
77 "time"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/command"
1112 "github.com/bnema/vev/internal/usecase/palette"
1213 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -224,18 +225,20 @@ func tabDisplayName(tb *tab, index int) string {
224225 return strconv.Itoa(index + 1)
225226 }
226227
227-func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent {
228+func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot ports.RecentRouteSnapshot) []rankedRecent {
228229 if hints == nil || hints.Kind != command.ContextHintRecentSessions {
229230 return nil
230231 }
231- formatted := formatRecentRoutePresentations(recentRoutePresentations(recent))
232+ var formatted []recentRouteDisplay
233+ if snapshot.Generation != 0 {
234+ formatted = formatRecentRouteSnapshot(snapshot)
235+ }
232236 entries := make([]rankedRecent, 0, len(hints.Recent))
233237 for i, hint := range hints.Recent {
234238 display := recentRouteDisplay{name: hint.Name}
235- // recent was copied with the hint under paletteMu, so this only enriches
236- // the render snapshot and never performs a live domain lookup. The
237- // formatted capture is authoritative when available, keeping ranked
238- // JRS labels identical to normal MRU labels.
239+ // The hint and route snapshot are immutable interaction data. The
240+ // formatted snapshot is authoritative when available, keeping ranked
241+ // JRS labels identical to normal MRU labels without a live lookup.
239242 if i < len(formatted) {
240243 display = formatted[i]
241244 }
@@ -252,25 +255,24 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
252255 }
253256
254257 // barStateForPaletteHints selects snapshot-only contextual composition when a
255-// palette interaction has captured recent-session hints. Normal palette state
256-// continues to use the canonical, live MRU list.
257-func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
258- var entry *session
259- if cur != nil {
260- entry = cur
261- }
262- return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
258+// palette interaction has captured recent-route hints.
259+func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
260+ return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints)
263261 }
264262
265263 // barStateForAttachmentPaletteHints composes daemon-owned chrome for an
266264 // attachment. Bar scripts remain local-session-only because
267265 // their existing execution contract depends on local tabs and PTYs.
268-func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
269- return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)
266+func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
267+ return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, ports.RecentRouteSnapshot{})
270268 }
271269
272-func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
273- ranked := rankedRecentForHints(hints, recent)
270+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot ports.RecentRouteSnapshot) barState {
271+ routeSnapshot := capturedRouteSnapshot
272+ if routeSnapshot.Generation == 0 && ac != nil {
273+ routeSnapshot = ac.routeSnapshotCopy()
274+ }
275+ ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot)
274276 state := barState{statusFeedback: statusFeedback}
275277 if d != nil {
276278 state.attentionFrame = d.attentionFrame()
@@ -287,16 +289,14 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
287289 }
288290 if ranked != nil {
289291 state.rankedRecent = ranked
290- } else if d != nil {
291- state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur)))
292+ } else if routeSnapshot.Generation != 0 {
293+ state.mru = formatRecentRouteSnapshot(routeSnapshot)
292294 }
293295 return state
294296 }
295297
296298 func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
297- state := d.barStateBase(cur, statusFeedback)
298- state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur)))
299- return state
299+ return d.barStateBase(cur, statusFeedback)
300300 }
301301
302302 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
@@ -7,6 +7,7 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/command"11 "github.com/bnema/vev/internal/usecase/command"
11 "github.com/bnema/vev/internal/usecase/palette"12 "github.com/bnema/vev/internal/usecase/palette"
12 themeui "github.com/bnema/vev/internal/usecase/theme"13 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -224,18 +225,20 @@ func tabDisplayName(tb *tab, index int) string {
224 return strconv.Itoa(index + 1)225 return strconv.Itoa(index + 1)
225 }226 }
226 227
227-func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent {228+func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot ports.RecentRouteSnapshot) []rankedRecent {
228 if hints == nil || hints.Kind != command.ContextHintRecentSessions {229 if hints == nil || hints.Kind != command.ContextHintRecentSessions {
229 return nil230 return nil
230 }231 }
231- formatted := formatRecentRoutePresentations(recentRoutePresentations(recent))232+ var formatted []recentRouteDisplay
233+ if snapshot.Generation != 0 {
234+ formatted = formatRecentRouteSnapshot(snapshot)
235+ }
232 entries := make([]rankedRecent, 0, len(hints.Recent))236 entries := make([]rankedRecent, 0, len(hints.Recent))
233 for i, hint := range hints.Recent {237 for i, hint := range hints.Recent {
234 display := recentRouteDisplay{name: hint.Name}238 display := recentRouteDisplay{name: hint.Name}
235- // recent was copied with the hint under paletteMu, so this only enriches239+ // The hint and route snapshot are immutable interaction data. The
236- // the render snapshot and never performs a live domain lookup. The240+ // formatted snapshot is authoritative when available, keeping ranked
237- // formatted capture is authoritative when available, keeping ranked241+ // JRS labels identical to normal MRU labels without a live lookup.
238- // JRS labels identical to normal MRU labels.
239 if i < len(formatted) {242 if i < len(formatted) {
240 display = formatted[i]243 display = formatted[i]
241 }244 }
@@ -252,25 +255,24 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
252 }255 }
253 256
254 // barStateForPaletteHints selects snapshot-only contextual composition when a257 // barStateForPaletteHints selects snapshot-only contextual composition when a
255-// palette interaction has captured recent-session hints. Normal palette state258+// palette interaction has captured recent-route hints.
256-// continues to use the canonical, live MRU list.259+func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
257-func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {260+ return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints)
258- var entry *session
259- if cur != nil {
260- entry = cur
261- }
262- return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
263 }261 }
264 262
265 // barStateForAttachmentPaletteHints composes daemon-owned chrome for an263 // barStateForAttachmentPaletteHints composes daemon-owned chrome for an
266 // attachment. Bar scripts remain local-session-only because264 // attachment. Bar scripts remain local-session-only because
267 // their existing execution contract depends on local tabs and PTYs.265 // their existing execution contract depends on local tabs and PTYs.
268-func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {266+func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
269- return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)267+ return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, ports.RecentRouteSnapshot{})
270 }268 }
271 269
272-func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {270+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot ports.RecentRouteSnapshot) barState {
273- ranked := rankedRecentForHints(hints, recent)271+ routeSnapshot := capturedRouteSnapshot
272+ if routeSnapshot.Generation == 0 && ac != nil {
273+ routeSnapshot = ac.routeSnapshotCopy()
274+ }
275+ ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot)
274 state := barState{statusFeedback: statusFeedback}276 state := barState{statusFeedback: statusFeedback}
275 if d != nil {277 if d != nil {
276 state.attentionFrame = d.attentionFrame()278 state.attentionFrame = d.attentionFrame()
@@ -287,16 +289,14 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
287 }289 }
288 if ranked != nil {290 if ranked != nil {
289 state.rankedRecent = ranked291 state.rankedRecent = ranked
290- } else if d != nil {292+ } else if routeSnapshot.Generation != 0 {
291- state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur)))293+ state.mru = formatRecentRouteSnapshot(routeSnapshot)
292 }294 }
293 return state295 return state
294 }296 }
295 297
296 func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {298 func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
297- state := d.barStateBase(cur, statusFeedback)299+ return d.barStateBase(cur, statusFeedback)
298- state.mru = formatRecentRoutePresentations(recentRoutePresentations(d.recentSessions(cur)))
299- return state
300 }300 }
301 301
302 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {302 func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
M internal/usecase/daemon/status_test.go +10 -91
@@ -968,32 +968,33 @@ func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) {
968968 var sess *session
969969
970970 require.NotPanics(t, func() {
971- state := d.barStateForPaletteHints(sess, "feedback", nil, nil)
971+ state := d.barStateForPaletteHints(sess, "feedback", nil)
972972 require.Equal(t, "feedback", state.statusFeedback)
973973 require.Empty(t, state.status.session)
974974 })
975975 }
976976
977-func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {
977+func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
978978 p, releasePTY := newBlockingPTY(t)
979- d, sess, _, _ := newManualSessionWithPTYs(t, p)
979+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
980980 defer releasePTY()
981- live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}}
982- live.mruAt.Store(1)
983- d.sessions[live.id] = live
984981
982+ snapshot := ports.RecentRouteSnapshot{
983+ Generation: 1,
984+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
985+ }
985986 hints := palette.ContextualHints{
986987 Kind: command.ContextHintRecentSessions,
987988 SelectedRank: 1,
988989 Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
989990 }
990- // Hold the registry lock: a call to recentSessions would block here. The
991- // contextual snapshot must still compose because it has no live MRU lookup.
991+ // Hold the registry lock: contextual composition must use the immutable
992+ // attachment snapshot and never consult daemon session history.
992993 var contextual barState
993994 done := make(chan struct{})
994995 d.mu.Lock()
995996 go func() {
996- contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", attention: true}})
997+ contextual = d.barStateForAttachmentPaletteHintsFor(sess, ac, "", &hints, snapshot)
997998 close(done)
998999 }()
9991000 select {
@@ -1006,88 +1007,6 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.
10061007
10071008 require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
10081009 require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent)
1009-
1010- normal := d.barStateFor(sess, "")
1011- require.Len(t, normal.mru, 1)
1012- require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU")
1013-}
1014-
1015-func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
1016- p, releasePTY := newBlockingPTY(t)
1017- d, sess, _, _ := newManualSessionWithPTYs(t, p)
1018- defer releasePTY()
1019- sess.name = "current"
1020- sess.mruAt.Store(100)
1021- for i := range 10 {
1022- other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}}
1023- other.mruAt.Store(uint64(i + 1))
1024- d.sessions[other.id] = other
1025- }
1026-
1027- state := d.barStateFor(sess, "")
1028-
1029- require.Len(t, state.mru, 9)
1030- require.Equal(t, "s9", state.mru[0].name)
1031- require.Equal(t, "s1", state.mru[8].name)
1032- for _, got := range state.mru {
1033- require.NotEqual(t, "current", got.name)
1034- }
1035- require.True(t, state.mru[1].attention, "s8 attention should be carried into MRU state")
1036-}
1037-
1038-func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) {
1039- p, releasePTY := newBlockingPTY(t)
1040- d, current, _, _ := newManualSessionWithPTYs(t, p)
1041- defer releasePTY()
1042-
1043- persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
1044- persistent.mruAt.Store(1)
1045- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
1046- ephemeral.mruAt.Store(2)
1047- d.sessions[persistent.id] = persistent
1048- d.sessions[ephemeral.id] = ephemeral
1049-
1050- state := d.barStateFor(current, "")
1051-
1052- require.Len(t, state.mru, 1)
1053- require.Equal(t, "named", state.mru[0].name)
1054-}
1055-
1056-func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
1057- p, releasePTY := newBlockingPTY(t)
1058- d, sess, _, _ := newManualSessionWithPTYs(t, p)
1059- defer releasePTY()
1060- for _, name := range []string{"bravo", "alpha", "charlie"} {
1061- d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}}
1062- }
1063-
1064- state := d.barStateFor(sess, "")
1065-
1066- require.GreaterOrEqual(t, len(state.mru), 3)
1067- require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
1068-}
1069-
1070-func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
1071- sz := domain.Size{Cols: 80, Rows: 24}
1072- p1, releasePTY1 := newBlockingPTY(t)
1073- defer releasePTY1()
1074- p2, releasePTY2 := newBlockingPTY(t)
1075- defer releasePTY2()
1076- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1077- d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
1078- d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
1079- d.mruSeq.Store(30)
1080- cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}}
1081- d.sessions[cur.id] = cur
1082-
1083- _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
1084- require.NoError(t, err)
1085- _, err = createSessionForTest(d, "alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv)
1086- require.NoError(t, err)
1087-
1088- state := d.barStateFor(cur, "")
1089- require.GreaterOrEqual(t, len(state.mru), 2)
1090- require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
10911010 }
10921011
10931012 func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
@@ -968,32 +968,33 @@ func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) {
968 var sess *session968 var sess *session
969 969
970 require.NotPanics(t, func() {970 require.NotPanics(t, func() {
971- state := d.barStateForPaletteHints(sess, "feedback", nil, nil)971+ state := d.barStateForPaletteHints(sess, "feedback", nil)
972 require.Equal(t, "feedback", state.statusFeedback)972 require.Equal(t, "feedback", state.statusFeedback)
973 require.Empty(t, state.status.session)973 require.Empty(t, state.status.session)
974 })974 })
975 }975 }
976 976
977-func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {977+func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
978 p, releasePTY := newBlockingPTY(t)978 p, releasePTY := newBlockingPTY(t)
979- d, sess, _, _ := newManualSessionWithPTYs(t, p)979+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
980 defer releasePTY()980 defer releasePTY()
981- live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}}
982- live.mruAt.Store(1)
983- d.sessions[live.id] = live
984 981
982+ snapshot := ports.RecentRouteSnapshot{
983+ Generation: 1,
984+ Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}},
985+ }
985 hints := palette.ContextualHints{986 hints := palette.ContextualHints{
986 Kind: command.ContextHintRecentSessions,987 Kind: command.ContextHintRecentSessions,
987 SelectedRank: 1,988 SelectedRank: 1,
988 Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},989 Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
989 }990 }
990- // Hold the registry lock: a call to recentSessions would block here. The991+ // Hold the registry lock: contextual composition must use the immutable
991- // contextual snapshot must still compose because it has no live MRU lookup.992+ // attachment snapshot and never consult daemon session history.
992 var contextual barState993 var contextual barState
993 done := make(chan struct{})994 done := make(chan struct{})
994 d.mu.Lock()995 d.mu.Lock()
995 go func() {996 go func() {
996- contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", attention: true}})997+ contextual = d.barStateForAttachmentPaletteHintsFor(sess, ac, "", &hints, snapshot)
997 close(done)998 close(done)
998 }()999 }()
999 select {1000 select {
@@ -1006,88 +1007,6 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.
1006 1007
1007 require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")1008 require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
1008 require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent)1009 require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent)
1009-
1010- normal := d.barStateFor(sess, "")
1011- require.Len(t, normal.mru, 1)
1012- require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU")
1013-}
1014-
1015-func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
1016- p, releasePTY := newBlockingPTY(t)
1017- d, sess, _, _ := newManualSessionWithPTYs(t, p)
1018- defer releasePTY()
1019- sess.name = "current"
1020- sess.mruAt.Store(100)
1021- for i := range 10 {
1022- other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}}
1023- other.mruAt.Store(uint64(i + 1))
1024- d.sessions[other.id] = other
1025- }
1026-
1027- state := d.barStateFor(sess, "")
1028-
1029- require.Len(t, state.mru, 9)
1030- require.Equal(t, "s9", state.mru[0].name)
1031- require.Equal(t, "s1", state.mru[8].name)
1032- for _, got := range state.mru {
1033- require.NotEqual(t, "current", got.name)
1034- }
1035- require.True(t, state.mru[1].attention, "s8 attention should be carried into MRU state")
1036-}
1037-
1038-func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) {
1039- p, releasePTY := newBlockingPTY(t)
1040- d, current, _, _ := newManualSessionWithPTYs(t, p)
1041- defer releasePTY()
1042-
1043- persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
1044- persistent.mruAt.Store(1)
1045- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
1046- ephemeral.mruAt.Store(2)
1047- d.sessions[persistent.id] = persistent
1048- d.sessions[ephemeral.id] = ephemeral
1049-
1050- state := d.barStateFor(current, "")
1051-
1052- require.Len(t, state.mru, 1)
1053- require.Equal(t, "named", state.mru[0].name)
1054-}
1055-
1056-func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
1057- p, releasePTY := newBlockingPTY(t)
1058- d, sess, _, _ := newManualSessionWithPTYs(t, p)
1059- defer releasePTY()
1060- for _, name := range []string{"bravo", "alpha", "charlie"} {
1061- d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}}
1062- }
1063-
1064- state := d.barStateFor(sess, "")
1065-
1066- require.GreaterOrEqual(t, len(state.mru), 3)
1067- require.Equal(t, []string{"alpha", "bravo", "charlie"}, []string{state.mru[0].name, state.mru[1].name, state.mru[2].name})
1068-}
1069-
1070-func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
1071- sz := domain.Size{Cols: 80, Rows: 24}
1072- p1, releasePTY1 := newBlockingPTY(t)
1073- defer releasePTY1()
1074- p2, releasePTY2 := newBlockingPTY(t)
1075- defer releasePTY2()
1076- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1077- d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
1078- d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
1079- d.mruSeq.Store(30)
1080- cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}}
1081- d.sessions[cur.id] = cur
1082-
1083- _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
1084- require.NoError(t, err)
1085- _, err = createSessionForTest(d, "alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv)
1086- require.NoError(t, err)
1087-
1088- state := d.barStateFor(cur, "")
1089- require.GreaterOrEqual(t, len(state.mru), 2)
1090- require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
1091 }1010 }
1092 1011
1093 func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {1012 func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
M internal/usecase/palette/contextual_hints.go +5 -2
@@ -8,8 +8,11 @@ import (
88
99 // RecentSessionHint is the immutable display data for one captured MRU rank.
1010 type RecentSessionHint struct {
11- Rank int
12- Name string
11+ Rank int
12+ Name string
13+ SnapshotGeneration uint64
14+ Key uint64
15+ Generation uint64
1316 }
1417
1518 // ContextualHints is immutable contextual palette guidance.
@@ -8,8 +8,11 @@ import (
8 8
9 // RecentSessionHint is the immutable display data for one captured MRU rank.9 // RecentSessionHint is the immutable display data for one captured MRU rank.
10 type RecentSessionHint struct {10 type RecentSessionHint struct {
11- Rank int11+ Rank int
12- Name string12+ Name string
13+ SnapshotGeneration uint64
14+ Key uint64
15+ Generation uint64
13 }16 }
14 17
15 // ContextualHints is immutable contextual palette guidance.18 // ContextualHints is immutable contextual palette guidance.

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