merge dev into canary

This commit is contained in:
2026-07-06 03:11:36 -07:00
6 changed files with 153 additions and 16 deletions
@@ -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.
@@ -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)
@@ -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
@@ -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 {
@@ -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
@@ -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 }
}
}
}