Investigate Live Activity Session Load

Nucleic-Session: 044A9B93-AB31-4B6B-9A27-3631261269B1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-06 17:35:53 -07:00
co-authored by Nucleic
parent e6fcaf5269
commit 5831d2286b
@@ -31,62 +31,66 @@ struct SessionDetailView: View {
}
var body: some View {
TranscriptList(events: store.openEvents,
scrollToBottomRequest: scrollToBottomRequest,
isScrolledToBottom: $isScrolledToBottom)
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting
// in a boxed strip below it, so the transcript runs the full height of the screen.
.safeAreaInset(edge: .bottom) { actionArea }
// Measure the whole detail's height — the screen area the action card must fit within —
// and hand it to the card so it can cap itself and keep Allow/Deny (or Submit) on-screen.
// Measured *after* the safeAreaInset on purpose: this must be the fixed container height,
// not the transcript's inset-reduced height. The attention cards (approval / question)
// size themselves off `availableHeight` via `.frame(maxHeight:)`, so measuring it from the
// inset-reduced region fed the card's own height back into the value it was sized from — a
// layout cycle SwiftUI never resolved, pinning the main thread at 100% CPU during the push
// transition. It bit only the attention path (a card consumes this value; the plain
// composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung.
.background {
GeometryReader { proxy in
Color.clear.preference(key: AvailableHeightKey.self, value: proxy.size.height)
// The detail's height comes from the enclosing GeometryReader — a value fixed by the parent
// (the navigation content area), never by anything inside it. The attention cards (approval /
// question) cap themselves to this so their buttons stay on-screen. Sourcing it from a
// GeometryReader — rather than measuring the transcript, whose height the cards' own bottom
// `safeAreaInset` resizes — makes the measurement categorically independent of the card, so
// the card can't feed its height back into the value it's sized from. That feedback was an
// unresolved layout cycle that pinned the main thread at 100% CPU during the push transition;
// it showed up intermittently because a feedback loop only diverges for some content/card-
// height combinations. It bit only the attention path (a card consumes this value; the plain
// composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung.
GeometryReader { proxy in
TranscriptList(events: store.openEvents,
scrollToBottomRequest: scrollToBottomRequest,
isScrolledToBottom: $isScrolledToBottom)
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting
// in a boxed strip below it, so the transcript runs the full height of the screen.
.safeAreaInset(edge: .bottom) { actionArea }
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
// With a session open the floating chat bar owns the bottom edge, so the compact
// shell's `NucleicTabBar` slides away while this detail is pushed — driven by the
// `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides
// from any entry point, not just the Sessions list), not a per-navigation tab-bar
// toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
// the iPad split, which has no tab bar.
.toolbar {
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
}
.sheet(isPresented: $showDiff) { diffSheet }
.alert("Rename chat", isPresented: $showRename) {
TextField("Title", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Rename") { store.renameSession(sessionID, to: renameDraft) }
}
.confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) {
Button("Merge") { store.integrate(sessionID, .merge) }
Button("Squash & merge") { store.integrate(sessionID, .squash) }
Button("Rebase") { store.integrate(sessionID, .rebase) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"Discard this session's branch and worktree? Unmerged work is lost.",
isPresented: $showDiscard, titleVisibility: .visible
) {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
// disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) }
.background { interruptShortcut }
// Publish the parent-determined height to the cards. `initial: true` seeds it on the
// first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
// resize). Because `proxy.size.height` never depends on the card, updating this can't
// re-drive the measurement — no cycle.
.onChange(of: proxy.size.height, initial: true) { _, height in
availableHeight = height
}
}
.onPreferenceChange(AvailableHeightKey.self) { availableHeight = $0 }
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
// With a session open the floating chat bar owns the bottom edge, so the compact
// shell's `NucleicTabBar` slides away while this detail is pushed — driven by the
// `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides
// from any entry point, not just the Sessions list), not a per-navigation tab-bar
// toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
// the iPad split, which has no tab bar.
.toolbar {
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
}
.sheet(isPresented: $showDiff) { diffSheet }
.alert("Rename chat", isPresented: $showRename) {
TextField("Title", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Rename") { store.renameSession(sessionID, to: renameDraft) }
}
.confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) {
Button("Merge") { store.integrate(sessionID, .merge) }
Button("Squash & merge") { store.integrate(sessionID, .squash) }
Button("Rebase") { store.integrate(sessionID, .rebase) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"Discard this session's branch and worktree? Unmerged work is lost.",
isPresented: $showDiscard, titleVisibility: .visible
) {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear { store.open(sessionID) }
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
// disappears) unsubscribes A without tearing down B's just-opened state.
.onDisappear { store.closeOpen(sessionID) }
.background { interruptShortcut }
}
/// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise