merge dev into canary
This commit is contained in:
@@ -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,12 +439,99 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user