nvrsion: Add: Investigate Live Activity Session Load logic in SessionDetailView.swift and investigate why Live Activity Session Load doesn’t always trigger when a session is opened; add Stop Button Implementation in iOS app’s composer.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -31,56 +31,66 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TranscriptList(events: store.openEvents,
|
// The detail's height comes from the enclosing GeometryReader — a value fixed by the parent
|
||||||
scrollToBottomRequest: scrollToBottomRequest,
|
// (the navigation content area), never by anything inside it. The attention cards (approval /
|
||||||
isScrolledToBottom: $isScrolledToBottom)
|
// question) cap themselves to this so their buttons stay on-screen. Sourcing it from a
|
||||||
// Measure the transcript's full height (the floating bar overlays it via safeAreaInset,
|
// GeometryReader — rather than measuring the transcript, whose height the cards' own bottom
|
||||||
// so this frame is the whole screen area the card has to live within) and hand it to the
|
// `safeAreaInset` resizes — makes the measurement categorically independent of the card, so
|
||||||
// action card so it can bound itself.
|
// the card can't feed its height back into the value it's sized from. That feedback was an
|
||||||
.background {
|
// unresolved layout cycle that pinned the main thread at 100% CPU during the push transition;
|
||||||
GeometryReader { proxy in
|
// it showed up intermittently because a feedback loop only diverges for some content/card-
|
||||||
Color.clear.preference(key: AvailableHeightKey.self, value: proxy.size.height)
|
// 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 }
|
|
||||||
// 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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise
|
/// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise
|
||||||
@@ -352,6 +362,18 @@ struct SessionDetailView: View {
|
|||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.lineLimit(1...4)
|
.lineLimit(1...4)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
|
// Stop the in-flight turn (the Mac's ⌘. / "Interrupt"). Shown only
|
||||||
|
// while running and at control scope; send stays at the far right so
|
||||||
|
// its position never shifts. Mirrors `interruptShortcut`.
|
||||||
|
if running && store.canControl {
|
||||||
|
Button { store.interrupt(sessionID) } label: {
|
||||||
|
Image(systemName: "stop.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.disabled(!store.connectivity.isLive)
|
||||||
|
.accessibilityLabel("Stop")
|
||||||
|
}
|
||||||
Button {
|
Button {
|
||||||
store.sendInput(draft, to: sessionID)
|
store.sendInput(draft, to: sessionID)
|
||||||
draft = ""
|
draft = ""
|
||||||
@@ -448,15 +470,6 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The transcript's measured height, fed to the floating action card so it can bound itself to the
|
|
||||||
/// screen (see `availableHeight`).
|
|
||||||
private struct AvailableHeightKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGFloat { 0 }
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
||||||
value = max(value, nextValue())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TranscriptList: View {
|
struct TranscriptList: View {
|
||||||
let events: [AgentEvent]
|
let events: [AgentEvent]
|
||||||
/// Bumped by the parent when the user sends a message — a deliberate "show me what happens
|
/// Bumped by the parent when the user sends a message — a deliberate "show me what happens
|
||||||
|
|||||||
Reference in New Issue
Block a user