fix(remote): support remote view handoffs
- commit
- 16ea2ba414cfa464458c154e7cdc8f706c54adef
- author
- brice · · 2026-08-07 09:02 UTC
- parent
- a068955
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +250 -9
M internal/usecase/daemon/notify.go +23 -0
| @@ -370,6 +370,29 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, | |||
| 370 | 370 | d.notify(nil, sev, code, msg, cause) | |
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | + | // notifyAttachment publishes a notice to one exact attachment. Remote views do | |
| 374 | + | // not belong to the local-session attachment registry, so a remote selection | |
| 375 | + | // failure must not fall back to daemon-global delivery. | |
| 376 | + | func (d *Daemon) notifyAttachment(ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |
| 377 | + | if d == nil || ac == nil { | |
| 378 | + | return | |
| 379 | + | } | |
| 380 | + | n := d.notices.record(domain.Notification{ | |
| 381 | + | Code: code, | |
| 382 | + | Severity: sev, | |
| 383 | + | Message: msg, | |
| 384 | + | Details: noticeDetails(cause), | |
| 385 | + | Time: d.clock.Now(), | |
| 386 | + | }) | |
| 387 | + | d.log.Log(d.serveCtx, slogLevelFor(sev), "attachment notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |
| 388 | + | d.notices.routingMu.Lock() | |
| 389 | + | published := d.publishToast(ac, n) | |
| 390 | + | d.notices.routingMu.Unlock() | |
| 391 | + | if published { | |
| 392 | + | d.repaintForNotice(ac) | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | ||
| 373 | 396 | // publishToast durably mutates one client's toast state without repainting. | |
| 374 | 397 | // Routing paths call it while routingMu excludes detach, then release routingMu | |
| 375 | 398 | // before rendering so a render failure can safely route another notice. | |
| @@ -370,6 +370,29 @@ func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, | |||||
| 370 | d.notify(nil, sev, code, msg, cause) | 370 | d.notify(nil, sev, code, msg, cause) | ||
| 371 | } | 371 | } | ||
| 372 | 372 | ||||
| 373 | + | // notifyAttachment publishes a notice to one exact attachment. Remote views do | |||
| 374 | + | // not belong to the local-session attachment registry, so a remote selection | |||
| 375 | + | // failure must not fall back to daemon-global delivery. | |||
| 376 | + | func (d *Daemon) notifyAttachment(ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) { | |||
| 377 | + | if d == nil || ac == nil { | |||
| 378 | + | return | |||
| 379 | + | } | |||
| 380 | + | n := d.notices.record(domain.Notification{ | |||
| 381 | + | Code: code, | |||
| 382 | + | Severity: sev, | |||
| 383 | + | Message: msg, | |||
| 384 | + | Details: noticeDetails(cause), | |||
| 385 | + | Time: d.clock.Now(), | |||
| 386 | + | }) | |||
| 387 | + | d.log.Log(d.serveCtx, slogLevelFor(sev), "attachment notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause) | |||
| 388 | + | d.notices.routingMu.Lock() | |||
| 389 | + | published := d.publishToast(ac, n) | |||
| 390 | + | d.notices.routingMu.Unlock() | |||
| 391 | + | if published { | |||
| 392 | + | d.repaintForNotice(ac) | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | ||||
| 373 | // publishToast durably mutates one client's toast state without repainting. | 396 | // publishToast durably mutates one client's toast state without repainting. | ||
| 374 | // Routing paths call it while routingMu excludes detach, then release routingMu | 397 | // Routing paths call it while routingMu excludes detach, then release routingMu | ||
| 375 | // before rendering so a render failure can safely route another notice. | 398 | // before rendering so a render failure can safely route another notice. | ||
M internal/usecase/daemon/recent_sessions.go +20 -2
| @@ -66,7 +66,22 @@ func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClien | |||
| 66 | 66 | if ac == nil { | |
| 67 | 67 | return recent | |
| 68 | 68 | } | |
| 69 | - | return trimRecentSessions(append(recent, ac.remoteRecentSessions()...)) | |
| 69 | + | return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(remoteViewKey{})...)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // recentSessionsForRemoteAttachment merges daemon-local entries with this | |
| 73 | + | // attachment's remote history while omitting the remote view currently rendered | |
| 74 | + | // in the status bar. | |
| 75 | + | func (d *Daemon) recentSessionsForRemoteAttachment(current *remoteView, ac *attachedClient) []recentSession { | |
| 76 | + | recent := d.recentSessions(nil) | |
| 77 | + | if ac == nil { | |
| 78 | + | return recent | |
| 79 | + | } | |
| 80 | + | var excluded remoteViewKey | |
| 81 | + | if current != nil { | |
| 82 | + | excluded = current.key | |
| 83 | + | } | |
| 84 | + | return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(excluded)...)) | |
| 70 | 85 | } | |
| 71 | 86 | ||
| 72 | 87 | func trimRecentSessions(recent []recentSession) []recentSession { | |
| @@ -113,7 +128,7 @@ func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) { | |||
| 113 | 128 | }) | |
| 114 | 129 | } | |
| 115 | 130 | ||
| 116 | - | func (ac *attachedClient) remoteRecentSessions() []recentSession { | |
| 131 | + | func (ac *attachedClient) remoteRecentSessionsExcept(excluded remoteViewKey) []recentSession { | |
| 117 | 132 | if ac == nil { | |
| 118 | 133 | return nil | |
| 119 | 134 | } | |
| @@ -121,6 +136,9 @@ func (ac *attachedClient) remoteRecentSessions() []recentSession { | |||
| 121 | 136 | ac.remoteRecent.With(func(entries *[]remoteRecentSession) { | |
| 122 | 137 | recent = make([]recentSession, 0, len(*entries)) | |
| 123 | 138 | for _, entry := range *entries { | |
| 139 | + | if entry.key == excluded { | |
| 140 | + | continue | |
| 141 | + | } | |
| 124 | 142 | recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt}) | |
| 125 | 143 | } | |
| 126 | 144 | }) | |
| @@ -66,7 +66,22 @@ func (d *Daemon) recentSessionsForAttachment(current *session, ac *attachedClien | |||||
| 66 | if ac == nil { | 66 | if ac == nil { | ||
| 67 | return recent | 67 | return recent | ||
| 68 | } | 68 | } | ||
| 69 | - | return trimRecentSessions(append(recent, ac.remoteRecentSessions()...)) | 69 | + | return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(remoteViewKey{})...)) |
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // recentSessionsForRemoteAttachment merges daemon-local entries with this | |||
| 73 | + | // attachment's remote history while omitting the remote view currently rendered | |||
| 74 | + | // in the status bar. | |||
| 75 | + | func (d *Daemon) recentSessionsForRemoteAttachment(current *remoteView, ac *attachedClient) []recentSession { | |||
| 76 | + | recent := d.recentSessions(nil) | |||
| 77 | + | if ac == nil { | |||
| 78 | + | return recent | |||
| 79 | + | } | |||
| 80 | + | var excluded remoteViewKey | |||
| 81 | + | if current != nil { | |||
| 82 | + | excluded = current.key | |||
| 83 | + | } | |||
| 84 | + | return trimRecentSessions(append(recent, ac.remoteRecentSessionsExcept(excluded)...)) | |||
| 70 | } | 85 | } | ||
| 71 | 86 | ||||
| 72 | func trimRecentSessions(recent []recentSession) []recentSession { | 87 | func trimRecentSessions(recent []recentSession) []recentSession { | ||
| @@ -113,7 +128,7 @@ func (d *Daemon) recordRemoteRecent(ac *attachedClient, view *remoteView) { | |||||
| 113 | }) | 128 | }) | ||
| 114 | } | 129 | } | ||
| 115 | 130 | ||||
| 116 | - | func (ac *attachedClient) remoteRecentSessions() []recentSession { | 131 | + | func (ac *attachedClient) remoteRecentSessionsExcept(excluded remoteViewKey) []recentSession { |
| 117 | if ac == nil { | 132 | if ac == nil { | ||
| 118 | return nil | 133 | return nil | ||
| 119 | } | 134 | } | ||
| @@ -121,6 +136,9 @@ func (ac *attachedClient) remoteRecentSessions() []recentSession { | |||||
| 121 | ac.remoteRecent.With(func(entries *[]remoteRecentSession) { | 136 | ac.remoteRecent.With(func(entries *[]remoteRecentSession) { | ||
| 122 | recent = make([]recentSession, 0, len(*entries)) | 137 | recent = make([]recentSession, 0, len(*entries)) | ||
| 123 | for _, entry := range *entries { | 138 | for _, entry := range *entries { | ||
| 139 | + | if entry.key == excluded { | |||
| 140 | + | continue | |||
| 141 | + | } | |||
| 124 | recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt}) | 142 | recent = append(recent, recentSession{name: entry.name(), mruAt: entry.mruAt}) | ||
| 125 | } | 143 | } | ||
| 126 | }) | 144 | }) | ||
M internal/usecase/daemon/remote_attachment_transition.go +101 -0
| @@ -103,6 +103,107 @@ func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken, | |||
| 103 | 103 | return published, nil | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | // transitionRemoteViewToRemoteViewForPicker moves an attachment between two | |
| 107 | + | // daemon-owned remote views after the selected target has completed its | |
| 108 | + | // proxied handshake. It keeps the original local predecessor intact so local | |
| 109 | + | // navigation and remote-to-local return remain attachment-owned. | |
| 110 | + | func (d *Daemon) transitionRemoteViewToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) { | |
| 111 | + | source, remote := normalizeAttachmentOwner(token.owner).(*remoteView) | |
| 112 | + | if d == nil || !remote || source == nil || target == nil || token.ac == nil || token.transport.transport == nil { | |
| 113 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 114 | + | } | |
| 115 | + | target.mu.Lock() | |
| 116 | + | link, generation := target.link, target.linkGeneration | |
| 117 | + | healthy := remoteViewLinkReusableLocked(target) | |
| 118 | + | target.mu.Unlock() | |
| 119 | + | if !healthy { | |
| 120 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 121 | + | } | |
| 122 | + | return d.transitionRemoteViewToRemoteViewGuarded(token, source, target, selection, link, generation) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (d *Daemon) transitionRemoteViewToRemoteViewGuarded(token attachmentConnectionToken, source, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) { | |
| 126 | + | if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil { | |
| 127 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 128 | + | } | |
| 129 | + | if token.effect != nil { | |
| 130 | + | d.endActionAttachmentEffect(token.effect, "remote-view-transition") | |
| 131 | + | } | |
| 132 | + | frozen := freezeAttachmentEffectGates(token.ac) | |
| 133 | + | defer frozen.unfreeze() | |
| 134 | + | if !frozen.acquired || !frozen.drained || selection == nil || !selection.current() { | |
| 135 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | if source == target { | |
| 139 | + | source.mu.Lock() | |
| 140 | + | current := sameAttachmentOwner(token.owner, source) && | |
| 141 | + | token.generation == token.ac.connectionGeneration.Load() && | |
| 142 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |
| 143 | + | token.ac.transportSnapshotCurrent(token.transport) && | |
| 144 | + | attachmentOwnerRegistered(source, token.ac) && !source.closed && | |
| 145 | + | expectedLink != nil && source.link == expectedLink && | |
| 146 | + | source.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(source) | |
| 147 | + | source.mu.Unlock() | |
| 148 | + | if !current { | |
| 149 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 150 | + | } | |
| 151 | + | d.recordRemoteRecent(token.ac, target) | |
| 152 | + | return token, nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | d.mu.Lock() | |
| 156 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || !d.attachmentOwnerRegisteredByDaemonLocked(target) { | |
| 157 | + | d.mu.Unlock() | |
| 158 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 159 | + | } | |
| 160 | + | d.notices.routingMu.Lock() | |
| 161 | + | // Remote owners are leaf locks. A total ID order prevents ABBA deadlock | |
| 162 | + | // when separate attachments concurrently move between the same two views. | |
| 163 | + | first, second := source, target | |
| 164 | + | if second.id < first.id { | |
| 165 | + | first, second = second, first | |
| 166 | + | } | |
| 167 | + | first.mu.Lock() | |
| 168 | + | second.mu.Lock() | |
| 169 | + | unlock := func() { | |
| 170 | + | second.mu.Unlock() | |
| 171 | + | first.mu.Unlock() | |
| 172 | + | d.notices.routingMu.Unlock() | |
| 173 | + | d.mu.Unlock() | |
| 174 | + | } | |
| 175 | + | _, sourceAttached := source.attachments[token.ac] | |
| 176 | + | _, targetAttached := target.attachments[token.ac] | |
| 177 | + | current := sameAttachmentOwner(token.owner, source) && | |
| 178 | + | token.generation == token.ac.connectionGeneration.Load() && | |
| 179 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |
| 180 | + | token.ac.transportSnapshotCurrent(token.transport) && | |
| 181 | + | sourceAttached && !source.closed && !target.closed && !targetAttached && | |
| 182 | + | expectedLink != nil && target.link == expectedLink && | |
| 183 | + | target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target) | |
| 184 | + | if !current || !target.registerAttachmentLocked(token.ac) { | |
| 185 | + | unlock() | |
| 186 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 187 | + | } | |
| 188 | + | source.unregisterAttachmentLocked(token.ac) | |
| 189 | + | generation := token.ac.connectionGeneration.Add(1) | |
| 190 | + | token.ac.setAttachmentOwner(target) | |
| 191 | + | published := attachmentConnectionToken{ | |
| 192 | + | owner: target, | |
| 193 | + | ac: token.ac, | |
| 194 | + | generation: generation, | |
| 195 | + | transport: token.transport, | |
| 196 | + | rebase: true, | |
| 197 | + | } | |
| 198 | + | token.ac.publishFrozenAttachmentCapability(published) | |
| 199 | + | unlock() | |
| 200 | + | ||
| 201 | + | d.activateRemoteView(target) | |
| 202 | + | d.parkRemoteViewWarm(source) | |
| 203 | + | d.recordRemoteRecent(token.ac, target) | |
| 204 | + | return published, nil | |
| 205 | + | } | |
| 206 | + | ||
| 106 | 207 | // transitionFromRemoteView moves an attachment from one exact daemon-owned | |
| 107 | 208 | // remote view back to its previous local session. The remote view has no | |
| 108 | 209 | // coordinator lease, so only the target session coordinator is attached during | |
| @@ -103,6 +103,107 @@ func (d *Daemon) transitionToRemoteViewGuarded(token attachmentConnectionToken, | |||||
| 103 | return published, nil | 103 | return published, nil | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | + | // transitionRemoteViewToRemoteViewForPicker moves an attachment between two | |||
| 107 | + | // daemon-owned remote views after the selected target has completed its | |||
| 108 | + | // proxied handshake. It keeps the original local predecessor intact so local | |||
| 109 | + | // navigation and remote-to-local return remain attachment-owned. | |||
| 110 | + | func (d *Daemon) transitionRemoteViewToRemoteViewForPicker(token attachmentConnectionToken, target *remoteView, selection *remotePickerSelection) (attachmentConnectionToken, error) { | |||
| 111 | + | source, remote := normalizeAttachmentOwner(token.owner).(*remoteView) | |||
| 112 | + | if d == nil || !remote || source == nil || target == nil || token.ac == nil || token.transport.transport == nil { | |||
| 113 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 114 | + | } | |||
| 115 | + | target.mu.Lock() | |||
| 116 | + | link, generation := target.link, target.linkGeneration | |||
| 117 | + | healthy := remoteViewLinkReusableLocked(target) | |||
| 118 | + | target.mu.Unlock() | |||
| 119 | + | if !healthy { | |||
| 120 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 121 | + | } | |||
| 122 | + | return d.transitionRemoteViewToRemoteViewGuarded(token, source, target, selection, link, generation) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (d *Daemon) transitionRemoteViewToRemoteViewGuarded(token attachmentConnectionToken, source, target *remoteView, selection *remotePickerSelection, expectedLink *remoteLink, expectedLinkGeneration uint64) (attachmentConnectionToken, error) { | |||
| 126 | + | if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil { | |||
| 127 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 128 | + | } | |||
| 129 | + | if token.effect != nil { | |||
| 130 | + | d.endActionAttachmentEffect(token.effect, "remote-view-transition") | |||
| 131 | + | } | |||
| 132 | + | frozen := freezeAttachmentEffectGates(token.ac) | |||
| 133 | + | defer frozen.unfreeze() | |||
| 134 | + | if !frozen.acquired || !frozen.drained || selection == nil || !selection.current() { | |||
| 135 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | if source == target { | |||
| 139 | + | source.mu.Lock() | |||
| 140 | + | current := sameAttachmentOwner(token.owner, source) && | |||
| 141 | + | token.generation == token.ac.connectionGeneration.Load() && | |||
| 142 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |||
| 143 | + | token.ac.transportSnapshotCurrent(token.transport) && | |||
| 144 | + | attachmentOwnerRegistered(source, token.ac) && !source.closed && | |||
| 145 | + | expectedLink != nil && source.link == expectedLink && | |||
| 146 | + | source.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(source) | |||
| 147 | + | source.mu.Unlock() | |||
| 148 | + | if !current { | |||
| 149 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 150 | + | } | |||
| 151 | + | d.recordRemoteRecent(token.ac, target) | |||
| 152 | + | return token, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | d.mu.Lock() | |||
| 156 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || !d.attachmentOwnerRegisteredByDaemonLocked(target) { | |||
| 157 | + | d.mu.Unlock() | |||
| 158 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 159 | + | } | |||
| 160 | + | d.notices.routingMu.Lock() | |||
| 161 | + | // Remote owners are leaf locks. A total ID order prevents ABBA deadlock | |||
| 162 | + | // when separate attachments concurrently move between the same two views. | |||
| 163 | + | first, second := source, target | |||
| 164 | + | if second.id < first.id { | |||
| 165 | + | first, second = second, first | |||
| 166 | + | } | |||
| 167 | + | first.mu.Lock() | |||
| 168 | + | second.mu.Lock() | |||
| 169 | + | unlock := func() { | |||
| 170 | + | second.mu.Unlock() | |||
| 171 | + | first.mu.Unlock() | |||
| 172 | + | d.notices.routingMu.Unlock() | |||
| 173 | + | d.mu.Unlock() | |||
| 174 | + | } | |||
| 175 | + | _, sourceAttached := source.attachments[token.ac] | |||
| 176 | + | _, targetAttached := target.attachments[token.ac] | |||
| 177 | + | current := sameAttachmentOwner(token.owner, source) && | |||
| 178 | + | token.generation == token.ac.connectionGeneration.Load() && | |||
| 179 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |||
| 180 | + | token.ac.transportSnapshotCurrent(token.transport) && | |||
| 181 | + | sourceAttached && !source.closed && !target.closed && !targetAttached && | |||
| 182 | + | expectedLink != nil && target.link == expectedLink && | |||
| 183 | + | target.linkGeneration == expectedLinkGeneration && remoteViewLinkReusableLocked(target) | |||
| 184 | + | if !current || !target.registerAttachmentLocked(token.ac) { | |||
| 185 | + | unlock() | |||
| 186 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 187 | + | } | |||
| 188 | + | source.unregisterAttachmentLocked(token.ac) | |||
| 189 | + | generation := token.ac.connectionGeneration.Add(1) | |||
| 190 | + | token.ac.setAttachmentOwner(target) | |||
| 191 | + | published := attachmentConnectionToken{ | |||
| 192 | + | owner: target, | |||
| 193 | + | ac: token.ac, | |||
| 194 | + | generation: generation, | |||
| 195 | + | transport: token.transport, | |||
| 196 | + | rebase: true, | |||
| 197 | + | } | |||
| 198 | + | token.ac.publishFrozenAttachmentCapability(published) | |||
| 199 | + | unlock() | |||
| 200 | + | ||||
| 201 | + | d.activateRemoteView(target) | |||
| 202 | + | d.parkRemoteViewWarm(source) | |||
| 203 | + | d.recordRemoteRecent(token.ac, target) | |||
| 204 | + | return published, nil | |||
| 205 | + | } | |||
| 206 | + | ||||
| 106 | // transitionFromRemoteView moves an attachment from one exact daemon-owned | 207 | // transitionFromRemoteView moves an attachment from one exact daemon-owned | ||
| 107 | // remote view back to its previous local session. The remote view has no | 208 | // remote view back to its previous local session. The remote view has no | ||
| 108 | // coordinator lease, so only the target session coordinator is attached during | 209 | // coordinator lease, so only the target session coordinator is attached during | ||
M internal/usecase/daemon/remote_picker_selection.go +11 -6
| @@ -35,7 +35,7 @@ func (selection *remotePickerSelection) current() bool { | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 38 | - | if d == nil || token.ac == nil || token.localSession() == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() { | |
| 38 | + | if d == nil || token.ac == nil || normalizeAttachmentOwner(token.owner) == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() { | |
| 39 | 39 | return errAttachmentTransition | |
| 40 | 40 | } | |
| 41 | 41 | if target.RemoteTarget == nil { | |
| @@ -78,7 +78,7 @@ func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, tar | |||
| 78 | 78 | if token.effect != nil { | |
| 79 | 79 | token.effect.bindActionEnd(d, action) | |
| 80 | 80 | } | |
| 81 | - | d.invalidateRender(token.localSession(), token.ac, true, "remote_picker_selection.go") | |
| 81 | + | d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go") | |
| 82 | 82 | go d.completeRemotePickerSelection(selection) | |
| 83 | 83 | return nil | |
| 84 | 84 | } | |
| @@ -106,7 +106,12 @@ func (d *Daemon) completeRemotePickerSelection(selection *remotePickerSelection) | |||
| 106 | 106 | return | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | - | published, err := d.transitionToRemoteViewForPicker(selection.token, view, selection) | |
| 109 | + | var published attachmentConnectionToken | |
| 110 | + | if _, remote := normalizeAttachmentOwner(selection.token.owner).(*remoteView); remote { | |
| 111 | + | published, err = d.transitionRemoteViewToRemoteViewForPicker(selection.token, view, selection) | |
| 112 | + | } else { | |
| 113 | + | published, err = d.transitionToRemoteViewForPicker(selection.token, view, selection) | |
| 114 | + | } | |
| 110 | 115 | if err != nil { | |
| 111 | 116 | d.parkRemoteViewWarm(view) | |
| 112 | 117 | d.failRemotePickerSelection(selection, err) | |
| @@ -140,10 +145,10 @@ func (d *Daemon) failRemotePickerSelection(selection *remotePickerSelection, err | |||
| 140 | 145 | if !errors.Is(err, context.Canceled) { | |
| 141 | 146 | d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err) | |
| 142 | 147 | if selection.token.current() { | |
| 143 | - | d.notify(selection.token.localSession(), domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil) | |
| 148 | + | d.notifyAttachment(selection.token.ac, domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil) | |
| 144 | 149 | } | |
| 145 | 150 | } | |
| 146 | - | if source := selection.token.localSession(); source != nil && selection.token.current() { | |
| 147 | - | d.invalidateRender(source, selection.token.ac, true, "remote_picker_selection.go") | |
| 151 | + | if selection.token.current() { | |
| 152 | + | d.invalidateAttachedOwner(selection.token.ac, true, "remote_picker_selection.go") | |
| 148 | 153 | } | |
| 149 | 154 | } | |
| @@ -35,7 +35,7 @@ func (selection *remotePickerSelection) current() bool { | |||||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | 37 | func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | ||
| 38 | - | if d == nil || token.ac == nil || token.localSession() == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() { | 38 | + | if d == nil || token.ac == nil || normalizeAttachmentOwner(token.owner) == nil || token.ac.overlays == nil || !guard.closePicker || !token.attachmentEffectCurrent() { |
| 39 | return errAttachmentTransition | 39 | return errAttachmentTransition | ||
| 40 | } | 40 | } | ||
| 41 | if target.RemoteTarget == nil { | 41 | if target.RemoteTarget == nil { | ||
| @@ -78,7 +78,7 @@ func (d *Daemon) startRemotePickerSelection(token attachmentConnectionToken, tar | |||||
| 78 | if token.effect != nil { | 78 | if token.effect != nil { | ||
| 79 | token.effect.bindActionEnd(d, action) | 79 | token.effect.bindActionEnd(d, action) | ||
| 80 | } | 80 | } | ||
| 81 | - | d.invalidateRender(token.localSession(), token.ac, true, "remote_picker_selection.go") | 81 | + | d.invalidateAttachedOwner(token.ac, true, "remote_picker_selection.go") |
| 82 | go d.completeRemotePickerSelection(selection) | 82 | go d.completeRemotePickerSelection(selection) | ||
| 83 | return nil | 83 | return nil | ||
| 84 | } | 84 | } | ||
| @@ -106,7 +106,12 @@ func (d *Daemon) completeRemotePickerSelection(selection *remotePickerSelection) | |||||
| 106 | return | 106 | return | ||
| 107 | } | 107 | } | ||
| 108 | 108 | ||||
| 109 | - | published, err := d.transitionToRemoteViewForPicker(selection.token, view, selection) | 109 | + | var published attachmentConnectionToken |
| 110 | + | if _, remote := normalizeAttachmentOwner(selection.token.owner).(*remoteView); remote { | |||
| 111 | + | published, err = d.transitionRemoteViewToRemoteViewForPicker(selection.token, view, selection) | |||
| 112 | + | } else { | |||
| 113 | + | published, err = d.transitionToRemoteViewForPicker(selection.token, view, selection) | |||
| 114 | + | } | |||
| 110 | if err != nil { | 115 | if err != nil { | ||
| 111 | d.parkRemoteViewWarm(view) | 116 | d.parkRemoteViewWarm(view) | ||
| 112 | d.failRemotePickerSelection(selection, err) | 117 | d.failRemotePickerSelection(selection, err) | ||
| @@ -140,10 +145,10 @@ func (d *Daemon) failRemotePickerSelection(selection *remotePickerSelection, err | |||||
| 140 | if !errors.Is(err, context.Canceled) { | 145 | if !errors.Is(err, context.Canceled) { | ||
| 141 | d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err) | 146 | d.log.Warn("remote picker selection failed", "endpoint", selection.target.Endpoint, "session", selection.target.SessionName, "err", err) | ||
| 142 | if selection.token.current() { | 147 | if selection.token.current() { | ||
| 143 | - | d.notify(selection.token.localSession(), domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil) | 148 | + | d.notifyAttachment(selection.token.ac, domain.NoticeWarn, domain.NoticeSessionUnavailable, "couldn't connect to remote session", nil) |
| 144 | } | 149 | } | ||
| 145 | } | 150 | } | ||
| 146 | - | if source := selection.token.localSession(); source != nil && selection.token.current() { | 151 | + | if selection.token.current() { |
| 147 | - | d.invalidateRender(source, selection.token.ac, true, "remote_picker_selection.go") | 152 | + | d.invalidateAttachedOwner(selection.token.ac, true, "remote_picker_selection.go") |
| 148 | } | 153 | } | ||
| 149 | } | 154 | } | ||
M internal/usecase/daemon/remote_picker_test.go +94 -0
| @@ -701,6 +701,100 @@ func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *test | |||
| 701 | 701 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 702 | 702 | } | |
| 703 | 703 | ||
| 704 | + | func TestRemotePickerSelectionTransitionsBetweenRemoteViewsAndSharesAttachmentMRU(t *testing.T) { | |
| 705 | + | d := newRemotePickerDaemon(nil) | |
| 706 | + | first := remoteLinkTestTarget() | |
| 707 | + | second := first | |
| 708 | + | second.LifecycleID = domain.SessionLifecycleID{2} | |
| 709 | + | second.SessionName = "other" | |
| 710 | + | second.LiveTabID = "tab-2" | |
| 711 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |
| 712 | + | Host: first.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{ | |
| 713 | + | {LifecycleID: first.LifecycleID, Name: first.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(first.LiveTabID), Index: 0, Name: "main"}}}, | |
| 714 | + | {LifecycleID: second.LifecycleID, Name: second.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(second.LiveTabID), Index: 0, Name: "main"}}}, | |
| 715 | + | }, | |
| 716 | + | }}) | |
| 717 | + | d.remoteCatalog.mu.Lock() | |
| 718 | + | d.remoteCatalog.status[first.Endpoint] = remoteHostFresh | |
| 719 | + | d.remoteCatalog.mu.Unlock() | |
| 720 | + | ||
| 721 | + | firstTransport := newRemoteLinkTestTransport() | |
| 722 | + | secondTransport := newRemoteLinkTestTransport() | |
| 723 | + | d.remoteDialerFactory = &remoteLinkSequenceFactory{dialers: []ports.Dialer{ | |
| 724 | + | &remoteLinkTestDialer{transport: firstTransport}, | |
| 725 | + | &remoteLinkTestDialer{transport: secondTransport}, | |
| 726 | + | }} | |
| 727 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 728 | + | openPicker := func() { | |
| 729 | + | ac.overlays.pickerMu.Lock() | |
| 730 | + | ac.overlays.picker = picker.New(nil, picker.SelectionConfig{}) | |
| 731 | + | ac.overlays.pickerGeneration++ | |
| 732 | + | ac.overlays.pickerMu.Unlock() | |
| 733 | + | } | |
| 734 | + | selectTarget := func(token attachmentConnectionToken, target domain.RemoteSessionTarget) { | |
| 735 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 736 | + | require.True(t, admitted) | |
| 737 | + | token.effect = effect | |
| 738 | + | key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin} | |
| 739 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &target}, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 740 | + | effect.End() | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | openPicker() | |
| 744 | + | selectTarget(sess.attachmentToken(ac, ac.transport()), first) | |
| 745 | + | enqueueRemoteLinkHandshake(t, firstTransport, first, ac.sizeSnapshot()) | |
| 746 | + | require.Eventually(t, func() bool { | |
| 747 | + | _, remote := ac.currentAttachmentOwner().(*remoteView) | |
| 748 | + | return remote | |
| 749 | + | }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish its view") | |
| 750 | + | firstView, ok := ac.currentAttachmentOwner().(*remoteView) | |
| 751 | + | require.True(t, ok) | |
| 752 | + | ||
| 753 | + | openPicker() | |
| 754 | + | selectTarget(attachmentOwnerToken(firstView, ac, ac.transport()), second) | |
| 755 | + | enqueueRemoteLinkHandshake(t, secondTransport, second, ac.sizeSnapshot()) | |
| 756 | + | require.Eventually(t, func() bool { | |
| 757 | + | view, remote := ac.currentAttachmentOwner().(*remoteView) | |
| 758 | + | return remote && view != firstView && view.key == remoteViewKey{endpoint: second.Endpoint, lifecycleID: second.LifecycleID, sessionName: second.SessionName} | |
| 759 | + | }, time.Second, 5*time.Millisecond, "the selected remote view should replace the current remote owner") | |
| 760 | + | secondView, ok := ac.currentAttachmentOwner().(*remoteView) | |
| 761 | + | require.True(t, ok) | |
| 762 | + | require.False(t, firstView.attachmentRegistered(ac)) | |
| 763 | + | require.True(t, secondView.attachmentRegistered(ac)) | |
| 764 | + | require.Same(t, sess, ac.previousOwner.Get(), "remote-to-remote keeps the retained local return owner") | |
| 765 | + | firstView.mu.Lock() | |
| 766 | + | require.NotNil(t, firstView.warm, "the replaced remote view is retained warm") | |
| 767 | + | firstView.mu.Unlock() | |
| 768 | + | ||
| 769 | + | remoteRecent := d.recentSessionsForRemoteAttachment(secondView, ac) | |
| 770 | + | remoteNames := make([]string, 0, len(remoteRecent)) | |
| 771 | + | for _, entry := range remoteRecent { | |
| 772 | + | remoteNames = append(remoteNames, entry.name) | |
| 773 | + | } | |
| 774 | + | require.Contains(t, remoteNames, "remote@remote.example") | |
| 775 | + | require.NotContains(t, remoteNames, "other@remote.example", "the remote status bar excludes its current view") | |
| 776 | + | select { | |
| 777 | + | case frame := <-sends: | |
| 778 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |
| 779 | + | default: | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | backToken := attachmentOwnerToken(secondView, ac, ac.transport()) | |
| 783 | + | backEffect, admitted := ac.beginAttachmentEffect(backToken) | |
| 784 | + | require.True(t, admitted) | |
| 785 | + | backToken.effect = backEffect | |
| 786 | + | require.NoError(t, d.backSessionForAttachment(backToken)) | |
| 787 | + | backEffect.End() | |
| 788 | + | require.Same(t, sess, ac.currentAttachmentSession()) | |
| 789 | + | localState := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, nil) | |
| 790 | + | localNames := make([]string, 0, len(localState.mru)) | |
| 791 | + | for _, entry := range localState.mru { | |
| 792 | + | localNames = append(localNames, entry.name) | |
| 793 | + | } | |
| 794 | + | require.Equal(t, []string{"other@remote.example", "remote@remote.example"}, localNames) | |
| 795 | + | d.shutdownAll(ports.ReasonServerShutdown) | |
| 796 | + | } | |
| 797 | + | ||
| 704 | 798 | func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) { | |
| 705 | 799 | d := newRemotePickerDaemon(nil) | |
| 706 | 800 | target := remoteLinkTestTarget() | |
| @@ -701,6 +701,100 @@ func TestRemotePickerSelectionKeepsLocalConnectionUntilRemoteViewIsReady(t *test | |||||
| 701 | d.shutdownAll(ports.ReasonServerShutdown) | 701 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 702 | } | 702 | } | ||
| 703 | 703 | ||||
| 704 | + | func TestRemotePickerSelectionTransitionsBetweenRemoteViewsAndSharesAttachmentMRU(t *testing.T) { | |||
| 705 | + | d := newRemotePickerDaemon(nil) | |||
| 706 | + | first := remoteLinkTestTarget() | |||
| 707 | + | second := first | |||
| 708 | + | second.LifecycleID = domain.SessionLifecycleID{2} | |||
| 709 | + | second.SessionName = "other" | |||
| 710 | + | second.LiveTabID = "tab-2" | |||
| 711 | + | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |||
| 712 | + | Host: first.Endpoint, FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{ | |||
| 713 | + | {LifecycleID: first.LifecycleID, Name: first.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(first.LiveTabID), Index: 0, Name: "main"}}}, | |||
| 714 | + | {LifecycleID: second.LifecycleID, Name: second.SessionName, State: "running", Tabs: []ports.RemoteCatalogTab{{ID: string(second.LiveTabID), Index: 0, Name: "main"}}}, | |||
| 715 | + | }, | |||
| 716 | + | }}) | |||
| 717 | + | d.remoteCatalog.mu.Lock() | |||
| 718 | + | d.remoteCatalog.status[first.Endpoint] = remoteHostFresh | |||
| 719 | + | d.remoteCatalog.mu.Unlock() | |||
| 720 | + | ||||
| 721 | + | firstTransport := newRemoteLinkTestTransport() | |||
| 722 | + | secondTransport := newRemoteLinkTestTransport() | |||
| 723 | + | d.remoteDialerFactory = &remoteLinkSequenceFactory{dialers: []ports.Dialer{ | |||
| 724 | + | &remoteLinkTestDialer{transport: firstTransport}, | |||
| 725 | + | &remoteLinkTestDialer{transport: secondTransport}, | |||
| 726 | + | }} | |||
| 727 | + | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |||
| 728 | + | openPicker := func() { | |||
| 729 | + | ac.overlays.pickerMu.Lock() | |||
| 730 | + | ac.overlays.picker = picker.New(nil, picker.SelectionConfig{}) | |||
| 731 | + | ac.overlays.pickerGeneration++ | |||
| 732 | + | ac.overlays.pickerMu.Unlock() | |||
| 733 | + | } | |||
| 734 | + | selectTarget := func(token attachmentConnectionToken, target domain.RemoteSessionTarget) { | |||
| 735 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 736 | + | require.True(t, admitted) | |||
| 737 | + | token.effect = effect | |||
| 738 | + | key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin} | |||
| 739 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &target}, sessionHandoffGuard{closePicker: true}, "picker-select")) | |||
| 740 | + | effect.End() | |||
| 741 | + | } | |||
| 742 | + | ||||
| 743 | + | openPicker() | |||
| 744 | + | selectTarget(sess.attachmentToken(ac, ac.transport()), first) | |||
| 745 | + | enqueueRemoteLinkHandshake(t, firstTransport, first, ac.sizeSnapshot()) | |||
| 746 | + | require.Eventually(t, func() bool { | |||
| 747 | + | _, remote := ac.currentAttachmentOwner().(*remoteView) | |||
| 748 | + | return remote | |||
| 749 | + | }, time.Second, 5*time.Millisecond, "the first remote picker selection should publish its view") | |||
| 750 | + | firstView, ok := ac.currentAttachmentOwner().(*remoteView) | |||
| 751 | + | require.True(t, ok) | |||
| 752 | + | ||||
| 753 | + | openPicker() | |||
| 754 | + | selectTarget(attachmentOwnerToken(firstView, ac, ac.transport()), second) | |||
| 755 | + | enqueueRemoteLinkHandshake(t, secondTransport, second, ac.sizeSnapshot()) | |||
| 756 | + | require.Eventually(t, func() bool { | |||
| 757 | + | view, remote := ac.currentAttachmentOwner().(*remoteView) | |||
| 758 | + | return remote && view != firstView && view.key == remoteViewKey{endpoint: second.Endpoint, lifecycleID: second.LifecycleID, sessionName: second.SessionName} | |||
| 759 | + | }, time.Second, 5*time.Millisecond, "the selected remote view should replace the current remote owner") | |||
| 760 | + | secondView, ok := ac.currentAttachmentOwner().(*remoteView) | |||
| 761 | + | require.True(t, ok) | |||
| 762 | + | require.False(t, firstView.attachmentRegistered(ac)) | |||
| 763 | + | require.True(t, secondView.attachmentRegistered(ac)) | |||
| 764 | + | require.Same(t, sess, ac.previousOwner.Get(), "remote-to-remote keeps the retained local return owner") | |||
| 765 | + | firstView.mu.Lock() | |||
| 766 | + | require.NotNil(t, firstView.warm, "the replaced remote view is retained warm") | |||
| 767 | + | firstView.mu.Unlock() | |||
| 768 | + | ||||
| 769 | + | remoteRecent := d.recentSessionsForRemoteAttachment(secondView, ac) | |||
| 770 | + | remoteNames := make([]string, 0, len(remoteRecent)) | |||
| 771 | + | for _, entry := range remoteRecent { | |||
| 772 | + | remoteNames = append(remoteNames, entry.name) | |||
| 773 | + | } | |||
| 774 | + | require.Contains(t, remoteNames, "remote@remote.example") | |||
| 775 | + | require.NotContains(t, remoteNames, "other@remote.example", "the remote status bar excludes its current view") | |||
| 776 | + | select { | |||
| 777 | + | case frame := <-sends: | |||
| 778 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |||
| 779 | + | default: | |||
| 780 | + | } | |||
| 781 | + | ||||
| 782 | + | backToken := attachmentOwnerToken(secondView, ac, ac.transport()) | |||
| 783 | + | backEffect, admitted := ac.beginAttachmentEffect(backToken) | |||
| 784 | + | require.True(t, admitted) | |||
| 785 | + | backToken.effect = backEffect | |||
| 786 | + | require.NoError(t, d.backSessionForAttachment(backToken)) | |||
| 787 | + | backEffect.End() | |||
| 788 | + | require.Same(t, sess, ac.currentAttachmentSession()) | |||
| 789 | + | localState := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, nil) | |||
| 790 | + | localNames := make([]string, 0, len(localState.mru)) | |||
| 791 | + | for _, entry := range localState.mru { | |||
| 792 | + | localNames = append(localNames, entry.name) | |||
| 793 | + | } | |||
| 794 | + | require.Equal(t, []string{"other@remote.example", "remote@remote.example"}, localNames) | |||
| 795 | + | d.shutdownAll(ports.ReasonServerShutdown) | |||
| 796 | + | } | |||
| 797 | + | ||||
| 704 | func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) { | 798 | func TestRemotePickerReselectUsesWarmRemoteViewWithoutSecondDial(t *testing.T) { | ||
| 705 | d := newRemotePickerDaemon(nil) | 799 | d := newRemotePickerDaemon(nil) | ||
| 706 | target := remoteLinkTestTarget() | 800 | target := remoteLinkTestTarget() | ||
M internal/usecase/daemon/remote_render.go +1 -1
| @@ -188,7 +188,7 @@ func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClie | |||
| 188 | 188 | bars.status = remoteStatusSnapshot(view, snapshot) | |
| 189 | 189 | if d != nil { | |
| 190 | 190 | bars.attentionFrame = d.attentionFrame() | |
| 191 | - | bars.mru = d.recentSessions(nil) | |
| 191 | + | bars.mru = d.recentSessionsForRemoteAttachment(view, ac) | |
| 192 | 192 | } | |
| 193 | 193 | state := &capturedRenderState{ | |
| 194 | 194 | attachment: ac, | |
| @@ -188,7 +188,7 @@ func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClie | |||||
| 188 | bars.status = remoteStatusSnapshot(view, snapshot) | 188 | bars.status = remoteStatusSnapshot(view, snapshot) | ||
| 189 | if d != nil { | 189 | if d != nil { | ||
| 190 | bars.attentionFrame = d.attentionFrame() | 190 | bars.attentionFrame = d.attentionFrame() | ||
| 191 | - | bars.mru = d.recentSessions(nil) | 191 | + | bars.mru = d.recentSessionsForRemoteAttachment(view, ac) |
| 192 | } | 192 | } | ||
| 193 | state := &capturedRenderState{ | 193 | state := &capturedRenderState{ | ||
| 194 | attachment: ac, | 194 | attachment: ac, | ||