detached 9a0770d

fix(client): keep ui-driver actions pending across daemon navigation

A driver action that makes the daemon navigate now completes on the destination's first attached publication instead of failing as outcome unknown when the source attachment ends.
commit
9a0770dfb57f1f54a5c74acb5a973e88c4c19813
author
brice · · 2026-09-23 01:01 UTC
parent
1cfa521
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +50 -8
M internal/usecase/client/supervisor_routes.go +19 -0
@@ -71,6 +71,9 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay
7171 if !ok {
7272 return
7373 }
74+ if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
75+ s.cfg.UI.follow(overlay.run.fg.uiGeneration, actionID)
76+ }
7477 if sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil {
7578 _, current, _ := s.attachments.committedView()
7679 if target.tab.preferred != "" && target.tab.preferred != current {
@@ -86,6 +89,19 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay
8689 s.attachments.requestDetach(overlay.run.token)
8790 }
8891
92+func navigationCauseActionID(message protocol.ServerMessage) uint64 {
93+ switch action := message.(type) {
94+ case protocol.RouteNavigationAction:
95+ return action.CauseActionID
96+ case protocol.RouteCreateSessionAction:
97+ return action.CauseActionID
98+ case protocol.AttachTarget:
99+ return action.CauseActionID
100+ default:
101+ return 0
102+ }
103+}
104+
89105 // takeSettledNavigation adopts a navigation request the worker handed over
90106 // just before its attachment ended, such as a close-and-dial handoff whose
91107 // daemon detached the source right after sending it.
@@ -95,6 +111,9 @@ func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token At
95111 return
96112 }
97113 if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok {
114+ if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
115+ s.cfg.UI.follow(s.cfg.UI.generation, actionID)
116+ }
98117 s.pendingSwap = &target
99118 }
100119 }
@@ -71,6 +71,9 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay
71 if !ok {71 if !ok {
72 return72 return
73 }73 }
74+ if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
75+ s.cfg.UI.follow(overlay.run.fg.uiGeneration, actionID)
76+ }
74 if sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil {77 if sameAttachmentTarget(overlay.request, s.attachments.committedTargetOrZero(), target.request) && target.tab.stopped == nil {
75 _, current, _ := s.attachments.committedView()78 _, current, _ := s.attachments.committedView()
76 if target.tab.preferred != "" && target.tab.preferred != current {79 if target.tab.preferred != "" && target.tab.preferred != current {
@@ -86,6 +89,19 @@ func (s *Supervisor) settleDaemonNavigation(service ports.BrokerService, overlay
86 s.attachments.requestDetach(overlay.run.token)89 s.attachments.requestDetach(overlay.run.token)
87 }90 }
88 91
92+func navigationCauseActionID(message protocol.ServerMessage) uint64 {
93+ switch action := message.(type) {
94+ case protocol.RouteNavigationAction:
95+ return action.CauseActionID
96+ case protocol.RouteCreateSessionAction:
97+ return action.CauseActionID
98+ case protocol.AttachTarget:
99+ return action.CauseActionID
100+ default:
101+ return 0
102+ }
103+}
104+
89 // takeSettledNavigation adopts a navigation request the worker handed over105 // takeSettledNavigation adopts a navigation request the worker handed over
90 // just before its attachment ended, such as a close-and-dial handoff whose106 // just before its attachment ended, such as a close-and-dial handoff whose
91 // daemon detached the source right after sending it.107 // daemon detached the source right after sending it.
@@ -95,6 +111,9 @@ func (s *Supervisor) takeSettledNavigation(service ports.BrokerService, token At
95 return111 return
96 }112 }
97 if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok {113 if target, ok := s.resolveDaemonNavigation(service, token, request, message); ok {
114+ if actionID := navigationCauseActionID(message); actionID != 0 && s.cfg.UI != nil {
115+ s.cfg.UI.follow(s.cfg.UI.generation, actionID)
116+ }
98 s.pendingSwap = &target117 s.pendingSwap = &target
99 }118 }
100 }119 }
M internal/usecase/client/ui_actions.go +31 -8
@@ -27,13 +27,30 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu
2727 return u.generation
2828 }
2929
30-// releaseForeground retires exactly the binding installed for generation. It
31-// is called when the supervisor begins finalizing the attachment, before the
32-// pump claim can return to the picker. It publishes nothing itself: the
33-// supervisor owns the presentation fence, and its return to Picker runs after
34-// this teardown (join, revoke, stream close) has drained the last in-flight
35-// transaction, so no unattached presentation is ever committed while a session
36-// frame is still being written.
30+// follow retains the accepted input action across an exact daemon navigation.
31+// It waits for the destination's first committed publication, not merely a
32+// successful send on the source attachment.
33+func (u *UI) follow(generation, actionID uint64) bool {
34+ u.mu.Lock()
35+ defer u.mu.Unlock()
36+ record, ok := u.records[actionID]
37+ if !ok || actionID == 0 || generation != u.generation || record.Context.Generation != generation ||
38+ (record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed) {
39+ return false
40+ }
41+ if u.pending != 0 && u.pending != actionID {
42+ u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
43+ }
44+ record.Status = ports.UIActionPending
45+ u.records[actionID] = record
46+ u.pending = actionID
47+ u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation}
48+ u.signalLocked()
49+ return true
50+}
51+
52+// releaseForeground retires exactly the binding installed for generation.
53+// The supervisor owns the presentation fence after the foreground drains.
3754 func (u *UI) releaseForeground(generation uint64) {
3855 u.mu.Lock()
3956 if generation != u.generation {
@@ -44,7 +61,7 @@ func (u *UI) releaseForeground(generation uint64) {
4461 u.consumer = 0
4562 u.foreground = nil
4663 u.boundary = ports.UIActionResult{}
47- if u.pending != 0 {
64+ if u.pending != 0 && (u.handoff == nil || u.handoff.actionID != u.pending) {
4865 u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
4966 }
5067 u.signalLocked()
@@ -112,6 +129,12 @@ func (u *UI) published(generation uint64) {
112129 return
113130 }
114131 u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
132+ if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached {
133+ u.handoff.boundary = u.boundary
134+ if u.dispatched[u.handoff.actionID] {
135+ u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary)
136+ }
137+ }
115138 u.signalLocked()
116139 }
117140
@@ -27,13 +27,30 @@ func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consu
27 return u.generation27 return u.generation
28 }28 }
29 29
30-// releaseForeground retires exactly the binding installed for generation. It30+// follow retains the accepted input action across an exact daemon navigation.
31-// is called when the supervisor begins finalizing the attachment, before the31+// It waits for the destination's first committed publication, not merely a
32-// pump claim can return to the picker. It publishes nothing itself: the32+// successful send on the source attachment.
33-// supervisor owns the presentation fence, and its return to Picker runs after33+func (u *UI) follow(generation, actionID uint64) bool {
34-// this teardown (join, revoke, stream close) has drained the last in-flight34+ u.mu.Lock()
35-// transaction, so no unattached presentation is ever committed while a session35+ defer u.mu.Unlock()
36-// frame is still being written.36+ record, ok := u.records[actionID]
37+ if !ok || actionID == 0 || generation != u.generation || record.Context.Generation != generation ||
38+ (record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed) {
39+ return false
40+ }
41+ if u.pending != 0 && u.pending != actionID {
42+ u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
43+ }
44+ record.Status = ports.UIActionPending
45+ u.records[actionID] = record
46+ u.pending = actionID
47+ u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation}
48+ u.signalLocked()
49+ return true
50+}
51+
52+// releaseForeground retires exactly the binding installed for generation.
53+// The supervisor owns the presentation fence after the foreground drains.
37 func (u *UI) releaseForeground(generation uint64) {54 func (u *UI) releaseForeground(generation uint64) {
38 u.mu.Lock()55 u.mu.Lock()
39 if generation != u.generation {56 if generation != u.generation {
@@ -44,7 +61,7 @@ func (u *UI) releaseForeground(generation uint64) {
44 u.consumer = 061 u.consumer = 0
45 u.foreground = nil62 u.foreground = nil
46 u.boundary = ports.UIActionResult{}63 u.boundary = ports.UIActionResult{}
47- if u.pending != 0 {64+ if u.pending != 0 && (u.handoff == nil || u.handoff.actionID != u.pending) {
48 u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})65 u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
49 }66 }
50 u.signalLocked()67 u.signalLocked()
@@ -112,6 +129,12 @@ func (u *UI) published(generation uint64) {
112 return129 return
113 }130 }
114 u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}131 u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
132+ if u.handoff != nil && u.handoff.destinationGeneration == generation && snapshot.Context.Status == ports.UIStatusAttached {
133+ u.handoff.boundary = u.boundary
134+ if u.dispatched[u.handoff.actionID] {
135+ u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.boundary)
136+ }
137+ }
115 u.signalLocked()138 u.signalLocked()
116 }139 }
117 140

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