merge dev into canary
This commit is contained in:
@@ -370,6 +370,9 @@ final class HostConnection {
|
|||||||
channel: channel, identity: identity, hostStaticKey: hostStaticKey,
|
channel: channel, identity: identity, hostStaticKey: hostStaticKey,
|
||||||
mode: mode, deviceID: deviceID,
|
mode: mode, deviceID: deviceID,
|
||||||
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex,
|
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,
|
releaseChannel: BuildInfo.current.channel.releaseChannel,
|
||||||
// Mesh "join": advertise roster gossip so a host pushes its group view — the phone
|
// 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.
|
// 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) {
|
func open(_ sessionID: SessionID) {
|
||||||
openSessionID = 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
|
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
||||||
// the host-specific projected values follow it.
|
// the host-specific projected values follow it.
|
||||||
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
||||||
@@ -985,6 +991,10 @@ final class RemoteStore: ObservableObject {
|
|||||||
guard openSessionID == target else { return }
|
guard openSessionID == target else { return }
|
||||||
connection(owningSession: target)?.openSessionID = nil
|
connection(owningSession: target)?.openSessionID = nil
|
||||||
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
|
openSessionHostID = nil
|
||||||
// Persist the final transcript before clearing it, so it's warm for the next open / offline.
|
// Persist the final transcript before clearing it, so it's warm for the next open / offline.
|
||||||
flushOpenTranscript(target, openEvents)
|
flushOpenTranscript(target, openEvents)
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ struct CompactRootView: View {
|
|||||||
@State private var barHeight: CGFloat = 72
|
@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
|
/// 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
|
/// floating glass chat bar (`compactDetailPresented`) and to the new-chat composer while it's up
|
||||||
/// path) and to the new-chat composer while it's up (`showNewChat`) — both slide it out, so the
|
/// (`showNewChat`) — both slide it out, so the composer doesn't just stack on top of a lingering
|
||||||
/// composer doesn't just stack on top of a lingering tab bar. We key off the path rather than
|
/// tab bar. `compactDetailPresented` is driven by the session lifecycle (`RemoteStore.open`/
|
||||||
/// `openSessionID` because the path flips the instant a back-swipe begins, whereas
|
/// `closeOpen`), so the bar hides whenever a session view opens — no matter which tab launched
|
||||||
/// `openSessionID` isn't cleared until the detail's `onDisappear` fires at the *end* of the pop
|
/// it, not only the Sessions list. `SessionsView` additionally clears it the instant a
|
||||||
/// transition — which left the bar sliding back up 1–2s late.
|
/// back-swipe begins (the path empties before `onDisappear` fires) for a snappy re-show.
|
||||||
private var showsTabBar: Bool { !store.compactDetailPresented && !showNewChat }
|
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
|
/// 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
|
@State private var useWorktree = true
|
||||||
|
|
||||||
private var projects: [WireProject] { store.dashboard.projects }
|
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? {
|
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 }
|
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
|
/// 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 {
|
HStack {
|
||||||
if lockedProject == nil {
|
if lockedProject == nil {
|
||||||
Menu {
|
Menu {
|
||||||
|
Button("No project") { projectID = nil }
|
||||||
|
if !projects.isEmpty { Divider() }
|
||||||
ForEach(projects) { project in
|
ForEach(projects) { project in
|
||||||
Button(project.name) { projectID = project.id }
|
Button(project.name) { projectID = project.id }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
// The model also selects the backend, so the picker offers every project.
|
// 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)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,13 +15,16 @@ struct SessionDetailView: View {
|
|||||||
@State private var renameDraft = ""
|
@State private var renameDraft = ""
|
||||||
@State private var showIntegrate = false
|
@State private var showIntegrate = false
|
||||||
@State private var showDiscard = 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? {
|
private var summary: WireSessionSummary? {
|
||||||
store.sessions.first { $0.sessionID == sessionID }
|
store.sessions.first { $0.sessionID == sessionID }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
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
|
// 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.
|
// in a boxed strip below it, so the transcript runs the full height of the screen.
|
||||||
.safeAreaInset(edge: .bottom) { actionArea }
|
.safeAreaInset(edge: .bottom) { actionArea }
|
||||||
@@ -29,7 +32,8 @@ struct SessionDetailView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
// With a session open the floating chat bar owns the bottom edge, so the compact
|
// 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
|
// 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
|
// toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
|
||||||
// the iPad split, which has no tab bar.
|
// the iPad split, which has no tab bar.
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -316,6 +320,7 @@ struct SessionDetailView: View {
|
|||||||
Button {
|
Button {
|
||||||
store.sendInput(draft, to: sessionID)
|
store.sendInput(draft, to: sessionID)
|
||||||
draft = ""
|
draft = ""
|
||||||
|
scrollToBottomRequest += 1
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill")
|
Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -388,13 +393,36 @@ struct SessionDetailView: View {
|
|||||||
|
|
||||||
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
|
||||||
|
/// 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.showRawEvents") private var showRaw = false
|
||||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
@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] {
|
private var items: [TranscriptItem] {
|
||||||
TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents)
|
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 {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -411,12 +439,99 @@ struct TranscriptList: View {
|
|||||||
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
||||||
// be put away without leaving the session.
|
// be put away without leaving the session.
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.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
|
// 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) {
|
.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.
|
// A notification tap while this tab is already up.
|
||||||
.onChange(of: store.pendingRoute) { consumeRoute() }
|
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||||||
// Drive the floating tab bar straight off the navigation path: it empties the instant a
|
// The tab bar's visibility is owned by the session lifecycle (`RemoteStore.open`/
|
||||||
// back-swipe/tap begins, so the bar slides back in immediately instead of waiting for
|
// `closeOpen`), so it hides/shows no matter which tab a chat opens from. This handler is
|
||||||
// `SessionDetailView.onDisappear` (which fires only at the end of the pop transition).
|
// 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
|
.onChange(of: path) { _, newPath in
|
||||||
store.compactDetailPresented = !newPath.isEmpty
|
if newPath.isEmpty { store.compactDetailPresented = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user