diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index e020876..d3234e9 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -370,6 +370,9 @@ final class HostConnection { channel: channel, identity: identity, hostStaticKey: hostStaticKey, mode: mode, deviceID: deviceID, deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex, + // Our own bundle id is the exact APNs topic the relay must address; per-channel + // TestFlight builds are suffixed (…`.canary`), so a hardcoded topic would `BadTopic`. + pushTopic: Bundle.main.bundleIdentifier, releaseChannel: BuildInfo.current.channel.releaseChannel, // Mesh "join": advertise roster gossip so a host pushes its group view — the phone // then auto-learns and connects to every Mac in the mesh, not just the one it scanned. diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 62a16c2..8260c82 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -831,6 +831,12 @@ final class RemoteStore: ObservableObject { func open(_ sessionID: SessionID) { openSessionID = sessionID + // Hide the compact shell's floating tab bar the instant a session view opens — from *any* + // entry point (Sessions, Home, Projects, a notification tap), since every one funnels + // through here. Tied to the session view opening rather than the Sessions list being + // navigated away from, so the bar slides out even when the chat wasn't launched from that + // list. `SessionsView` still clears it early on back-swipe for a responsive re-show. + compactDetailPresented = true // Record which Mac owns this session (mesh P3) so its connection forwards the transcript and // the host-specific projected values follow it. openSessionHostID = connection(owningSession: sessionID)?.hostID @@ -985,6 +991,10 @@ final class RemoteStore: ObservableObject { guard openSessionID == target else { return } connection(owningSession: target)?.openSessionID = nil openSessionID = nil + // Session view closed — restore the compact tab bar (the counterpart to `open`'s hide). + // On the Sessions tab the back-swipe has usually already cleared this early for a snappy + // re-show; this is the catch-all for the other entry points and programmatic closes. + compactDetailPresented = false openSessionHostID = nil // Persist the final transcript before clearing it, so it's warm for the next open / offline. flushOpenTranscript(target, openEvents) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 7b29ced..48bccd8 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -50,12 +50,12 @@ struct CompactRootView: View { @State private var barHeight: CGFloat = 72 /// The bar owns the bottom edge on the top-level tabs, but yields it to a pushed session's - /// floating glass chat bar (`compactDetailPresented`, driven by `SessionsView`'s navigation - /// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the - /// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than - /// `openSessionID` because the path flips the instant a back-swipe begins, whereas - /// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop - /// transition — which left the bar sliding back up 1–2s late. + /// floating glass chat bar (`compactDetailPresented`) and to the new-chat composer while it's up + /// (`showNewChat`) — both slide it out, so the composer doesn't just stack on top of a lingering + /// tab bar. `compactDetailPresented` is driven by the session lifecycle (`RemoteStore.open`/ + /// `closeOpen`), so the bar hides whenever a session view opens — no matter which tab launched + /// it, not only the Sessions list. `SessionsView` additionally clears it the instant a + /// back-swipe begins (the path empties before `onDisappear` fires) for a snappy re-show. private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat } /// The "+" is inert without control scope or a project to start in, so it's shown only when a diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index d896f0a..843dbef 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -27,8 +27,12 @@ struct StartChatComposer: View { @State private var useWorktree = true private var projects: [WireProject] { store.dashboard.projects } + /// The picker defaults to "No project" (nil) rather than auto-selecting the first project, so a + /// typed prompt never quietly starts an agent in a repo the user didn't choose — they pick one + /// explicitly before the send button lights. A project page's composer stays scoped via + /// `lockedProject`. private var selected: WireProject? { - lockedProject ?? (projects.first { $0.id == projectID } ?? projects.first) + lockedProject ?? projects.first { $0.id == projectID } } private var controlled: Bool { selected?.isNucleicControlled ?? false } /// Auto-approve for the chat about to start: the user's choice off-control, but forced on for a @@ -42,12 +46,15 @@ struct StartChatComposer: View { HStack { if lockedProject == nil { Menu { + Button("No project") { projectID = nil } + if !projects.isEmpty { Divider() } ForEach(projects) { project in Button(project.name) { projectID = project.id } } } label: { // The model also selects the backend, so the picker offers every project. - Label(selected?.name ?? "Select a project", systemImage: "folder") + // Defaults to "No project" until one is chosen. + Label(selected?.name ?? "No project", systemImage: "folder") .font(.subheadline) } } else { diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 33e4355..2b0ef81 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -15,13 +15,16 @@ struct SessionDetailView: View { @State private var renameDraft = "" @State private var showIntegrate = false @State private var showDiscard = false + // Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to + // read history — sending is a deliberate "show me what happens next" (mirrors the Mac). + @State private var scrollToBottomRequest = 0 private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } } var body: some View { - TranscriptList(events: store.openEvents) + TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest) // 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 } @@ -29,7 +32,8 @@ struct SessionDetailView: View { .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 toggling `store.openSessionID`, not a per-navigation tab-bar + // `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 { @@ -316,6 +320,7 @@ struct SessionDetailView: View { Button { store.sendInput(draft, to: sessionID) draft = "" + scrollToBottomRequest += 1 } label: { Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill") .font(.title2) @@ -388,13 +393,36 @@ struct SessionDetailView: View { struct TranscriptList: View { let events: [AgentEvent] + /// Bumped by the parent when the user sends a message — a deliberate "show me what happens + /// next" that jumps to the bottom even if they'd scrolled up to read history. + var scrollToBottomRequest: Int = 0 @AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showLockEvents") private var showLockEvents = true + /// True while the user is parked at (within a hair of) the bottom, so live output keeps + /// following; once they scroll up it flips false, following stops, and the jump-to-bottom + /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. + @State private var isScrolledToBottom = true + + /// True for a short window right after the transcript appears, while it runs its first + /// layout passes and its events fill in. During it we accept only "at bottom" scroll + /// readings, so the settling layout can't strand the chat above its true bottom or flash the + /// chevron; live scrolls are honored once it clears. Mirrors the Mac's `transcriptSettling`. + @State private var transcriptSettling = true + + /// Total scroll-content height, tracked live from the scroll view's geometry, but only while + /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest. + @State private var transcriptContentHeight: CGFloat = 0 + private var items: [TranscriptItem] { TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) } + /// How much content may still sit below the viewport's bottom edge and still count as "at + /// the bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors + /// the Mac's `bottomFollowThreshold`. + private let bottomFollowThreshold: CGFloat = 24 + var body: some View { ScrollViewReader { proxy in ScrollView { @@ -411,13 +439,100 @@ struct TranscriptList: View { // A drag on the transcript dismisses the keyboard, so a tall multiline composer can // be put away without leaving the session. .scrollDismissesKeyboard(.interactively) + // The single-argument `defaultScrollAnchor(.bottom)` governs both the initial offset + // and how the scroll view re-pins on content-size changes — it sticks the bottom edge + // in view as the transcript grows. That native stickiness is what we want while the + // user is parked at the bottom (the chat opens at the end and follows live output), + // but it's also what yanked them back down whenever anything changed while they'd + // scrolled up to read history. So gate the anchor itself: drop it to `nil` the moment + // they scroll away, which lets new content land off-screen below instead of dragging + // the viewport, and restore `.bottom` once they're back at the end. + .defaultScrollAnchor(isScrolledToBottom ? .bottom : nil) + // Track the live scroll position straight from the scroll view's geometry: how much + // content still sits below the viewport bottom. Within the slack threshold means the + // user is parked at the end (live output keeps following); scrolling up flips this + // false, which drops the anchor (above) and reveals the jump-to-bottom chevron. + .onScrollGeometryChange(for: Bool.self) { geo in + geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y + <= bottomFollowThreshold + } action: { _, atBottom in + // While the chat is opening the layout grows over a few passes and the scroll + // offset lags each growth by a frame — sampling that frame reads "not at bottom" + // even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the + // open window accept only "at bottom" readings; honor real scroll-ups after. + if transcriptSettling { + if atBottom { isScrolledToBottom = true } + } else { + isScrolledToBottom = atBottom + } + } + // Detect when the opening layout has come to rest: track the content height while + // settling and let `.task(id:)` debounce it — the task restarts on every change and + // only reaches its body once the height has held steady. Freezing the tracker after + // settling keeps live streaming (which changes the height every frame) from re-arming. + .onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in + if transcriptSettling { transcriptContentHeight = height } + } + .task(id: transcriptContentHeight) { + guard transcriptSettling, transcriptContentHeight > 0 else { return } + try? await Task.sleep(for: .milliseconds(80)) + if Task.isCancelled { return } + transcriptSettling = false + } // Coalescing means item count lags event count; key the autoscroll on the raw stream - // so every streamed delta keeps the view pinned to the bottom. + // so every streamed delta keeps the view pinned to the bottom — but only while the + // user is already parked there. Scrolling up to read history is never yanked down. .onChange(of: events.count) { - if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } + if isScrolledToBottom { scrollToLast(proxy, animated: !transcriptSettling) } + } + // An explicit jump — the chevron or sending a message — always wins. + .onChange(of: scrollToBottomRequest) { scrollToLast(proxy) } + // Floating chevron above the chat bar, shown only while scrolled up; tap to jump back + // to the latest output. Mirrors the Mac's `JumpToBottomButton`. + .overlay(alignment: .bottom) { + ZStack { + if !isScrolledToBottom { + JumpToBottomButton { scrollToLast(proxy) } + .padding(.bottom, 12) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom) } } } + + /// Scroll the last message to the bottom edge; `animated: false` during the open-settle + /// window so a chat that's still laying out snaps to the tail instead of easing into place. + private func scrollToLast(_ proxy: ScrollViewProxy, animated: Bool = true) { + guard let last = items.last else { return } + if animated { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } else { + proxy.scrollTo(last.id, anchor: .bottom) + } + } +} + +/// The floating "jump to the latest" chevron shown above the chat bar while the user has +/// scrolled up away from the bottom of the transcript. The mobile twin of the Mac's +/// `JumpToBottomButton`. +private struct JumpToBottomButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Palette.accent) + .frame(width: 30, height: 30) + .background(.regularMaterial, in: .circle) + .overlay(Circle().strokeBorder(.separator, lineWidth: 0.5)) + .shadow(color: .black.opacity(0.18), radius: 4, y: 1) + } + .buttonStyle(.plain) + .accessibilityLabel("Jump to the latest message") + } } /// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 4389191..cd4be2a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -96,11 +96,13 @@ struct SessionsView: View { } // A notification tap while this tab is already up. .onChange(of: store.pendingRoute) { consumeRoute() } - // Drive the floating tab bar straight off the navigation path: it empties the instant a - // back-swipe/tap begins, so the bar slides back in immediately instead of waiting for - // `SessionDetailView.onDisappear` (which fires only at the end of the pop transition). + // The tab bar's visibility is owned by the session lifecycle (`RemoteStore.open`/ + // `closeOpen`), so it hides/shows no matter which tab a chat opens from. This handler is + // just the responsiveness refinement for *this* tab: the path empties the instant a + // back-swipe/tap begins, so clearing here slides the bar back in immediately instead of + // waiting for `SessionDetailView.onDisappear` (which fires only at the end of the pop). .onChange(of: path) { _, newPath in - store.compactDetailPresented = !newPath.isEmpty + if newPath.isEmpty { store.compactDetailPresented = false } } } }