···11-# Canvas Exit Terminal Blank Tracking
22-33-Last updated: 2026-04-15
44-Status: Open, intermittent, now confirmed to affect both Canvas exit and Canvas entry via host ownership races
55-66-## Symptom
77-88-When leaving Canvas and returning to the normal worktree terminal view, the terminal area can appear blank.
99-1010-As of 2026-04-15, the reverse direction is also reproducible:
1111-1212-- after the app has been running for a while, entering Canvas from a normal worktree tab can open a blank Canvas card
1313-- the selected tab/worktree remains logically correct
1414-- unlike the earlier exit symptom, tab switching, creating a new tab, or switching away and back does not reliably recover the blank card
1515-1616-Typical behavior:
1717-1818-- The normal terminal view is visible in SwiftUI.
1919-- The selected worktree and tab are correct.
2020-- The terminal becomes visible again only after switching to another tab and back.
2121-2222-## Reproduction Profile
2323-2424-Current evidence suggests this is not a fresh-session deterministic bug.
2525-2626-- In a newly launched Prowl session, the bug is difficult to reproduce.
2727-- After the app has been running for a long time, especially across system sleep/wake, the bug may start happening.
2828-- Once it starts happening in a given app session, it tends to reproduce reliably on every Canvas exit until the app is restarted.
2929-3030-This strongly suggests a sticky stale state in the long-lived terminal/native view stack rather than a simple `toggleCanvas` reducer bug.
3131-3232-## Relevant Areas
3333-3434-- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`
3535-- `supacode/Features/App/Reducer/AppFeature.swift`
3636-- `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift`
3737-- `supacode/Features/Terminal/Models/WorktreeTerminalState.swift`
3838-- `supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift`
3939-- `supacode/Features/Terminal/Views/WindowFocusObserverView.swift`
4040-- `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift`
4141-- `supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift`
4242-- `supacode/Infrastructure/Ghostty/GhosttyRuntime.swift`
4343-- `supacode/Features/Canvas/Views/CanvasView.swift`
4444-4545-## Repair History
4646-4747-Known commits that attempted to address this family of issues:
4848-4949-- `161f38a0` Fix blank surface when exiting canvas via toggle shortcut
5050-- `516103e4` fix: invalidate occlusion cache when exiting canvas to prevent blank surfaces
5151-- `11e7d16c` Fix occlusion cache invalidation on surface reattachment
5252-- `7ed53813` fix: defer occlusion apply until surface is attached
5353-- `26273089` fix: simplify canvas exit occlusion handling
5454-- `d4e59155` Add canvas exit terminal diagnostics and occlusion refresh
5555-- `e2a29b2c` test: cover Ghostty attachment occlusion behavior
5656-- `32f51451` Fix canvas exit terminal occlusion recovery
5757-- current branch `fix/canvas-exit-surface-reattach`
5858- - add detach-intent stack logging before `GhosttySurfaceView` loses its superview/window
5959- - add wrapper host diagnostics (`hostKind`, wrapper id, surface id)
6060- - add terminal-only defensive reattach in `GhosttySurfaceScrollView.ensureSurfaceAttached()`
6161- - add focused tests for terminal-vs-canvas wrapper ownership behavior
6262-6363-Relevant changelog entries:
6464-6565-- `2026.4.2`: occlusion restored whenever a surface is reattached
6666-- `2026.4.5`: surface state refreshed immediately on Canvas exit
6767-- current unreleased `main`: occlusion recovery also resumes from `updateSurfaceSize()`
6868-6969-## Current Working Theory
7070-7171-The highest-probability root cause is no longer "occlusion state got stale while the surface stayed attached".
7272-7373-New evidence from a reduced two-tab repro points to a more concrete failure mode:
7474-7575-- the selected surface (`desired=Optional(true)`, focused, first responder) is briefly attached during Canvas exit
7676-- it reaches the normal terminal layout size
7777-- it is later detached (`attached=false window=false`)
7878-- no subsequent log shows that surface reattached to the final terminal host
7979-8080-This suggests the blank terminal is caused by host ownership loss:
8181-8282-- SwiftUI/AppKit reparenting during Canvas teardown temporarily moves the `GhosttySurfaceView`
8383-- a later teardown or host rebuild removes the surface from the active view tree
8484-- the normal terminal host does not currently guarantee that its `documentView` still owns the surface after updates
8585-- once detached, occlusion recovery is irrelevant because there is no live host left to present the surface
8686-8787-The newly observed Canvas-entry failure sharpens the theory further:
8888-8989-- the canvas host can successfully take ownership of the selected surface
9090-- the previous terminal host may still run a defensive `ensureSurfaceAttached()` while it is already leaving the window hierarchy
9191-- because that reattach path only checked "not attached to my document view", it could steal the surface back from the live canvas host
9292-- once the stale terminal host deinitializes, AppKit removes that stolen surface again, leaving Canvas blank with no active host
9393-9494-What now looks less likely:
9595-9696-- wrong worktree selection
9797-- wrong selected tab restoration
9898-- pure reducer ordering bug in `toggleCanvas`
9999-- occlusion cache invalidation as the sole root cause
100100-101101-## Logs Added For Ongoing Investigation
102102-103103-Two log markers should be collected together:
104104-105105-- `[CanvasExit]`
106106-- `[TerminalWake]`
107107-108108-### `[CanvasExit]`
109109-110110-Existing and previous diagnostics already cover:
111111-112112-- `WorktreeTerminalTabsView.onAppear`
113113-- `WorktreeTerminalTabsView.onDisappear`
114114-- surface attachment changes
115115-- deferred occlusion
116116-- reapply occlusion
117117-- selected worktree transition when leaving Canvas
118118-- surface detach intent (`viewWillMove(toSuperview:/toWindow:)`) with call stack
119119-- host wrapper lifecycle (`hostMake`, `hostInit`, `hostUpdate`, `hostDeinit`, `hostReattach`)
120120-- host reattach completion snapshot (`hostReattachComplete`)
121121-- per-surface host metadata (`hostKind`, wrapper id)
122122-123123-### `[TerminalWake]`
124124-125125-Added on 2026-04-11 to correlate future failures with sleep/wake and long-lived surface state:
126126-127127-- `GhosttyRuntime`
128128- - `workspaceWillSleep`
129129- - `workspaceDidWake`
130130- - `screensDidSleep`
131131- - `screensDidWake`
132132- - runtime surface count
133133-- `GhosttySurfaceView`
134134- - per-surface state snapshot on workspace sleep/wake
135135- - per-surface state snapshot on `viewDidMoveToWindow`
136136- - per-surface state snapshot on `viewDidMoveToSuperview`
137137- - detach-time safety-net request back to the last known terminal host
138138-- `WindowFocusObserverView`
139139- - window activity changes (`key`, `visible`, `force`, `windowNumber`)
140140-141141-Per-surface wake logs include:
142142-143143-- `surface`
144144-- `hasSurface`
145145-- `attached`
146146-- `window`
147147-- `desired`
148148-- `focused`
149149-- `firstResponder`
150150-- `bounds`
151151-- `backing`
152152-- `windowVisible`
153153-- `windowKey`
154154-155155-## How To Collect Logs
156156-157157-Use `make log-stream`, then reproduce the issue and save the section covering:
158158-159159-- the last successful Canvas exit before the bug starts
160160-- the first failed Canvas exit after the bug starts
161161-- any sleep/wake events before that failure
162162-163163-If filtering manually, focus on lines containing:
164164-165165-- `[CanvasExit]`
166166-- `[TerminalWake]`
11+# Canvas Exit Terminal Blank Closure
1672168168-If using `log stream` directly, a useful predicate is:
33+Last updated: 2026-04-29
44+Status: Closed
1695170170-```bash
171171-log stream --style compact \
172172- --predicate 'subsystem == "com.onevcat.prowl" && (eventMessage CONTAINS[c] "[CanvasExit]" || eventMessage CONTAINS[c] "[TerminalWake]")'
173173-```
66+## Outcome
1747175175-## What To Compare Next Time
88+The Canvas exit / entry blank terminal issue has not reappeared after the host
99+ownership fix and occlusion reapply safeguards landed. Treat this investigation
1010+as closed unless a new report includes a fresh reproduction pattern.
17611177177-When the bug reproduces again, compare a healthy exit and a broken exit for:
1212+## Root Cause
17813179179-- whether `workspaceDidWake` or `screensDidWake` happened shortly before failures started
180180-- whether the affected surface logs `detachIntent` before going blank, and which stack removes it
181181-- whether a terminal host logs `hostUpdate` but never `hostReattach` for the affected surface
182182-- whether the terminal host does log `hostReattach`, but the surface still remains blank afterward
183183-- whether `WindowFocusObserverView` still reports the window as visible/key when the blank terminal is shown
184184-185185-## Open Questions
186186-187187-- Is sleep/wake the true trigger, or just the most common way to enter the stale state?
188188-- Which host teardown path actually performs the final detach: Canvas wrapper cleanup, terminal wrapper replacement, or another AppKit rebuild?
189189-- If terminal-side defensive reattach works, is it sufficient as the durable fix or just masking a lower-level host lifecycle race?
190190-- If reattach does not work, is the detached native view still valid, or do we need to recreate the underlying `ghostty_surface_t`?
191191-192192-## Next Step Candidates
193193-194194-Do not do these preemptively unless new logs support them:
1414+The most likely failure mode was host ownership loss during SwiftUI/AppKit
1515+reparenting:
19516196196-- widen defensive reattach beyond the normal terminal host
197197-- recreate a surface when host reattach fails
198198-- add explicit post-wake repair for all active surfaces
199199-- force-resend occlusion and size after wake
200200-- force content-scale/display-id refresh after wake
1717+- Canvas and terminal wrappers both host the same `GhosttySurfaceView`.
1818+- A stale terminal wrapper could attempt to reattach a surface that was already
1919+ owned by the live Canvas wrapper.
2020+- When that stale wrapper later deinitialized, AppKit removed the surface again,
2121+ leaving the active host blank even though reducer selection and tab state were
2222+ still correct.
20123202202-## Notes
2424+## Fixes Kept
20325204204-This document should be updated every time:
2626+- Terminal hosts only defensively reattach orphaned surfaces; they do not steal a
2727+ surface from another live host.
2828+- Occlusion state invalidates on attachment changes so the latest desired value
2929+ is resent after reattachment.
3030+- Un-occluding is deferred until a surface has both a superview and a window;
3131+ occluding remains immediate so detached surfaces do not keep rendering.
3232+- Canvas-managed terminal states avoid normal window-activity sync while Canvas
3333+ owns visibility.
20534206206-- a new hypothesis is formed
207207-- a new instrumentation point is added
208208-- a repro pattern changes
209209-- a candidate fix is attempted
210210-- a failed fix is ruled out
3535+## Remaining Logs
21136212212-## 2026-04-15 Snapshot
3737+Most investigation logs were removed. The retained low-frequency logs are:
21338214214-Latest reduced repro:
3939+- `[CanvasExit] enteringCanvas`
4040+- `[CanvasExit] setSelectedWorktreeID`
4141+- `[CanvasExit] deferOcclusion`
4242+- `[CanvasExit] hostReattach`
4343+- `[CanvasExit] hostReattachComplete`
4444+- `[TerminalWake]` runtime sleep/wake summaries
21545216216-- two tabs only
217217-- selected tab surface detached after briefly reaching terminal-sized bounds
218218-- no reattach log observed afterward
219219-- reverse repro also confirmed: entering Canvas can blank the selected card immediately
220220-- in the failing entry log, `hostReattach wrapper=<terminal>` fires after `host=canvas` is already attached and visible
221221-- the stale terminal wrapper later deinitializes and the surface ends up detached (`attached=false window=false`)
4646+These are enough to identify a regression without keeping wrapper lifecycle,
4747+tab appear/disappear, attachment-change, or call-stack logging in normal builds.
22248223223-Current tactical response:
4949+## Residual Risk
22450225225-- add detach stack logging to identify who removes the surface
226226-- add host wrapper diagnostics to correlate `surface ↔ wrapper ↔ canvas/terminal`
227227-- attempt a narrow fix: terminal host reattaches the surface if updates/layout find it missing
228228-- add a detach-time safety net so a just-detached surface asks its last terminal host to try reattachment on the next main-loop turn
229229-- refine that narrow fix so terminal reattach only runs after the surface has actually left the view tree; it must not steal a surface currently owned by Canvas
5151+The remaining risk is in AppKit view lifecycle ordering. If a future SwiftUI
5252+layout change introduces another host that can own `GhosttySurfaceView`, it must
5353+follow the same rule: only adopt orphaned surfaces and never move a surface away
5454+from another live host.
23055231231-Expected interpretation of the next repro:
5656+Relevant coverage:
23257233233-- if `hostReattach` appears and the terminal becomes visible again, the bug is likely host ownership loss during Canvas teardown
234234-- if `hostReattach` appears but the terminal stays blank, the issue may still involve stale native surface/render state after detach
235235-- if no terminal `hostReattach` appears, the active terminal host may not be rebuilding/updating as expected
5858+- `GhosttySurfaceViewTests.terminalHostDoesNotStealSurfaceFromCanvasHost`
5959+- `GhosttySurfaceViewTests.canvasHostDoesNotStealDetachedSurfaceBack`
6060+- `GhosttySurfaceViewTests.terminalHostReattachesSurfaceOnlyAfterItLeavesTheViewTree`
6161+- occlusion reattachment tests in `GhosttySurfaceViewTests`