From bce3da8d6f6c020a56afe4b93df3bbfd80e2f16e Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 17:38:29 -0700 Subject: [PATCH] =?UTF-8?q?nvrsion:=20Add:=20Investigate=20Live=20Activity?= =?UTF-8?q?=20Session=20Load=20logic=20in=20SessionDetailView.swift=20and?= =?UTF-8?q?=20investigate=20why=20Live=20Activity=20Session=20Load=20doesn?= =?UTF-8?q?=E2=80=99t=20always=20trigger=20when=20a=20session=20is=20opene?= =?UTF-8?q?d;=20add=20Stop=20Button=20Implementation=20in=20iOS=20app?= =?UTF-8?q?=E2=80=99s=20composer.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 127 ++++++++++-------- 1 file changed, 70 insertions(+), 57 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ae2ab37..2343df6 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -31,56 +31,66 @@ struct SessionDetailView: View { } var body: some View { - TranscriptList(events: store.openEvents, - scrollToBottomRequest: scrollToBottomRequest, - isScrolledToBottom: $isScrolledToBottom) - // Measure the transcript's full height (the floating bar overlays it via safeAreaInset, - // so this frame is the whole screen area the card has to live within) and hand it to the - // action card so it can bound itself. - .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 } - // 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 @@ -352,6 +362,18 @@ struct SessionDetailView: View { .textFieldStyle(.plain) .lineLimit(1...4) .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 { store.sendInput(draft, to: sessionID) 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 { let events: [AgentEvent] /// Bumped by the parent when the user sends a message — a deliberate "show me what happens