detached 5d071d6

fix(daemon): publish headless preview invalidations

commit
5d071d643b119125edfa37d89d2d5a788fa03c85
author
brice · · 2026-07-11 21:44 UTC
parent
1a1a903
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +11 -5
M internal/usecase/daemon/render_coordinator.go +11 -5
@@ -252,9 +252,11 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
252252 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {
253253 c.mu.Lock()
254254 // An unbound coordinator is the manual/headless harness state; there is no
255- // published production attachment to protect there. Once an attachment has
256- // been bound (or detached), identity must match exactly.
257- if c.torndown || c.detached || (source != nil && c.attachment != nil && c.attachment != source) {
255+ // published production attachment to protect there. A parked target may
256+ // still publish its own PTY/session mutations to picker observers, but no
257+ // attachment-owned callback may revive that target's render path.
258+ detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
259+ if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) {
258260 c.mu.Unlock()
259261 return
260262 }
@@ -797,7 +799,11 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
797799 attachmentEpoch = c.attachmentEpoch
798800 }
799801 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment, attachmentEpoch: attachmentEpoch}
800- if !ready || (c.attachment != nil && !c.attachmentReady) {
802+ // A detached target has no primary transport or ACK window. Its pending
803+ // state belongs exclusively to picker previews and must be consumed after
804+ // notification rather than accumulating ackDeferred forever.
805+ headlessPreviewOnly := c.detached && c.attachment == nil
806+ if !headlessPreviewOnly && (!ready || (c.attachment != nil && !c.attachmentReady)) {
801807 if !ready && deadline {
802808 c.ackDeferred = true
803809 }
@@ -824,7 +830,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
824830 wake := c.opts.wake
825831 c.mu.Unlock()
826832 stopTimer(timer)
827- if wake != nil {
833+ if wake != nil && !headlessPreviewOnly {
828834 wake(w)
829835 }
830836 c.notifyPreviews(w, preview, previews)
@@ -252,9 +252,11 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
252 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {252 func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {
253 c.mu.Lock()253 c.mu.Lock()
254 // An unbound coordinator is the manual/headless harness state; there is no254 // An unbound coordinator is the manual/headless harness state; there is no
255- // published production attachment to protect there. Once an attachment has255+ // published production attachment to protect there. A parked target may
256- // been bound (or detached), identity must match exactly.256+ // still publish its own PTY/session mutations to picker observers, but no
257- if c.torndown || c.detached || (source != nil && c.attachment != nil && c.attachment != source) {257+ // attachment-owned callback may revive that target's render path.
258+ detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
259+ if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) {
258 c.mu.Unlock()260 c.mu.Unlock()
259 return261 return
260 }262 }
@@ -797,7 +799,11 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
797 attachmentEpoch = c.attachmentEpoch799 attachmentEpoch = c.attachmentEpoch
798 }800 }
799 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment, attachmentEpoch: attachmentEpoch}801 w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment, attachmentEpoch: attachmentEpoch}
800- if !ready || (c.attachment != nil && !c.attachmentReady) {802+ // A detached target has no primary transport or ACK window. Its pending
803+ // state belongs exclusively to picker previews and must be consumed after
804+ // notification rather than accumulating ackDeferred forever.
805+ headlessPreviewOnly := c.detached && c.attachment == nil
806+ if !headlessPreviewOnly && (!ready || (c.attachment != nil && !c.attachmentReady)) {
801 if !ready && deadline {807 if !ready && deadline {
802 c.ackDeferred = true808 c.ackDeferred = true
803 }809 }
@@ -824,7 +830,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
824 wake := c.opts.wake830 wake := c.opts.wake
825 c.mu.Unlock()831 c.mu.Unlock()
826 stopTimer(timer)832 stopTimer(timer)
827- if wake != nil {833+ if wake != nil && !headlessPreviewOnly {
828 wake(w)834 wake(w)
829 }835 }
830 c.notifyPreviews(w, preview, previews)836 c.notifyPreviews(w, preview, previews)

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