nvrsion: Add: Chat: Chat View Refinement to modernize session chat interface by removing vertical space for selectors, Fix: Chat: Remove Face ID Permission Text, Refactor: Chat: Live Activity Content Density to open app directly into waiting sessions when tapped, Refactor: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Live Activity Content Density to refresh live activity info significantly faster, Fix: Chat: Network Type Transition Optimization to reduce delay during network type switches, Feature Enhancement Planning: All devices in a mesh group should see and be aware of each other; “pair” reframed into “join”; scanning, Fix: Chat: Session Transcript Sync to show only current frame’s transcript when opening a session on another Mac, Build: Push-update path to stay fresh while locked.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 01:00:03 -07:00
co-authored by Nucleic
parent 368978bea8
commit 8b37b8576f
7 changed files with 205 additions and 78 deletions
+5
View File
@@ -22,6 +22,11 @@
<string></string>
<key>NSSupportsLiveActivities</key>
<true/>
<!-- Let the Macs' APNs pushes refresh the glance promptly while the phone is locked — without
this, iOS budget-throttles frequent Live Activity updates and the lock screen lags
(UX_IOS §5.3). -->
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<!-- The `nucleic://` deep-link scheme the Live Activity taps through on: `nucleic://session/<id>`
opens a waiting session; `nucleic://sessions` surfaces the sessions list. Handled by
`.onOpenURL` → `RemoteStore.handleDeepLink`. -->
@@ -28,6 +28,14 @@ final class LiveActivityManager {
private var pendingState: NucleicSessionAttributes.ContentState?
private var updateTask: Task<Void, Never>?
/// Set by `RemoteStore` to ship the activity's APNS push token to the paired Macs (and to tell
/// them it ended). The Macs use the token to keep this glance fresh over APNs while the phone
/// is backgrounded and its sync socket is suspended (UX_IOS §5.3).
var onPushToken: ((_ token: String, _ activityID: String) -> Void)?
var onActivityEnded: ((_ activityID: String) -> Void)?
/// Streams the activity's per-activity APNS update token (it can rotate); cancelled on end.
private var tokenObservation: Task<Void, Never>?
/// Reconcile the Activity with the current session set.
func sync(hostName: String, sessions: [WireSessionSummary]) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
@@ -82,17 +90,34 @@ final class LiveActivityManager {
// recovered one still needs the fresh state, so fall through to the update pipeline.
if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing
observePushToken(existing)
enqueue(state)
} else {
activity = try? Activity.request(
// `pushType: .token` opts the activity into APNs updates — the Macs push new
// content-state to the token so the glance stays fresh while the phone is locked.
let started = try? Activity.request(
attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil))
content: ActivityContent(state: state, staleDate: nil),
pushType: .token)
activity = started
if let started { observePushToken(started) }
}
return
}
enqueue(state)
}
/// Forward the activity's APNS update token (and its rotations) to `RemoteStore`.
private func observePushToken(_ activity: Activity<NucleicSessionAttributes>) {
tokenObservation?.cancel()
tokenObservation = Task { [weak self] in
for await tokenData in activity.pushTokenUpdates {
let hex = tokenData.map { String(format: "%02x", $0) }.joined()
self?.onPushToken?(hex, activity.id)
}
}
}
/// Hand the newest state to the serial drainer (starting it if idle).
private func enqueue(_ state: NucleicSessionAttributes.ContentState) {
pendingState = state
@@ -111,10 +136,13 @@ final class LiveActivityManager {
func end() {
updateTask?.cancel()
updateTask = nil
tokenObservation?.cancel()
tokenObservation = nil
pendingState = nil
lastState = nil
guard let activity else { return }
self.activity = nil
onActivityEnded?(activity.id) // let the Macs stop pushing to this token
Task {
await activity.end(
ActivityContent(state: activity.content.state, staleDate: nil),
@@ -282,6 +282,7 @@ final class RemoteStore: ObservableObject {
// MARK: - Lifecycle
func onAppear() {
setupLiveActivityBridge()
if demoMode { seedDemo(); return }
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
@@ -489,6 +490,9 @@ final class RemoteStore: ObservableObject {
if hostID == self.activeHostID { self.mirrorActive() }
self.refreshAggregate()
self.flushPendingNotificationDecision()
// A host that just connected (or reconnected) needs the current Live Activity token
// so it can push while the phone is away.
self.syncLiveActivityRegistration()
}
cb.openSnapshot = { [weak self] snap in
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
@@ -834,6 +838,49 @@ final class RemoteStore: ObservableObject {
activeConnection?.send(msg)
}
// MARK: - Live Activity push registration (UX_IOS §5.3)
/// The current Live Activity's APNS update token + id, shipped to every capable Mac so it can
/// refresh the lock-screen glance over APNs while the phone is backgrounded and its socket is
/// suspended. Sent to *all* connected hosts (not just the active one) — while the phone is
/// away, whichever Mac has work to report should be able to push.
private var liveActivityReg: (token: String, activityID: String)?
/// Host ids that already have the current token (re-sent to a host that (re)connects, and
/// re-sent to everyone when the token rotates).
private var liveActivitySentTo: Set<String> = []
private func setupLiveActivityBridge() {
LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in
guard let self, self.liveActivityReg?.token != token else { return }
self.liveActivityReg = (token, activityID)
self.liveActivitySentTo.removeAll() // a fresh token must reach every host again
self.syncLiveActivityRegistration()
}
LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in
guard let self else { return }
self.liveActivityReg = nil
self.liveActivitySentTo.removeAll()
for conn in self.connections.values where conn.capabilities.canPushLiveActivity {
conn.send(.endLiveActivity(activityID))
}
}
}
/// Send the current Live Activity token to every live, capable host that hasn't got it yet.
/// Called when the token changes and on every connection update, so a host that just connected
/// — or reconnected while the phone was away — learns the token it needs to push.
private func syncLiveActivityRegistration() {
guard let reg = liveActivityReg else { return }
// A host that dropped should re-register when it returns.
liveActivitySentTo = liveActivitySentTo.filter { connections[$0]?.connectivity.isLive == true }
for (id, conn) in connections {
guard conn.connectivity.isLive, conn.capabilities.canPushLiveActivity,
!liveActivitySentTo.contains(id) else { continue }
conn.send(.registerLiveActivity(reg.token, reg.activityID))
liveActivitySentTo.insert(id)
}
}
// MARK: - Demo simulator (offline, interactive)
//
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
@@ -887,7 +934,11 @@ final class RemoteStore: ObservableObject {
case .setSessionShipBranch(let id, let branch):
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
.ping, .cancelQueuedMessage, .fetchDiff, .listPeers, .addressUpdate, .meshRoster,
.ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers,
.addressUpdate, .meshRoster,
// Live Activity push registration is a real-connection concern (there's no host to
// push in demo), so it's inert here.
.registerLiveActivity, .endLiveActivity,
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
// and demo has no peer Macs, so they're inert here.
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
@@ -82,7 +82,7 @@ struct ApprovalCardView: View {
}
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
.glassSurface(cornerRadius: 20)
}
private func alwaysLabel(_ scope: AlwaysScope) -> String {
@@ -1,8 +1,9 @@
import SwiftUI
import NucleicProtocol
/// One session: a segmented Transcript/Diff read view over a status-driven action area
/// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear.
/// One session: a full-height Transcript/Diff read view (mode flip in the navigation bar) with
/// a status-driven action bar floating over it on Liquid Glass (UX_IOS §4). Subscribes on
/// appear, unsubscribes on disappear.
struct SessionDetailView: View {
@EnvironmentObject var store: RemoteStore
let sessionID: SessionID
@@ -20,19 +21,7 @@ struct SessionDetailView: View {
}
var body: some View {
VStack(spacing: 0) {
Picker("", selection: $tab) {
Text("Transcript").tag(0)
Text("Diff").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
if store.canControl, let summary { controlBar(summary) }
Divider()
Group {
if tab == 0 {
TranscriptList(events: store.openEvents)
} else if store.capabilities.canFetchDiff || store.openDiff != nil {
@@ -42,12 +31,25 @@ struct SessionDetailView: View {
// Older host with no on-demand diff — the stat summary is all it projects.
DiffSummaryView(diffStat: summary?.diffStat)
}
actionArea
}
// 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)
.toolbar {
// Transcript/Diff lives in the navigation bar (icon segments) rather than a
// dedicated row — it's a mode flip, not content, and shouldn't cost chat height.
ToolbarItem(placement: .topBarTrailing) {
Picker("View", selection: $tab) {
Image(systemName: "text.bubble").tag(0)
.accessibilityLabel("Transcript")
Image(systemName: "plus.forwardslash.minus").tag(1)
.accessibilityLabel("Diff")
}
.pickerStyle(.segmented)
.fixedSize()
}
if let summary, store.canControl {
ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) }
}
@@ -124,9 +126,10 @@ struct SessionDetailView: View {
}
/// The model / effort / auto controls for the open session — the mobile echo of the Mac's
/// session header. Reads current state from the summary; each change is a control intent.
/// session header, folded into the glass chat bar so they don't cost a strip of transcript
/// height. Reads current state from the summary; each change is a control intent.
@ViewBuilder
private func controlBar(_ summary: WireSessionSummary) -> some View {
private func controlRow(_ summary: WireSessionSummary) -> some View {
HStack(spacing: 10) {
// A session's backend is fixed at creation, so only same-backend models are offered.
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
@@ -149,8 +152,6 @@ struct SessionDetailView: View {
}
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
}
.padding(.horizontal)
.padding(.bottom, 6)
}
@ViewBuilder
@@ -192,21 +193,25 @@ struct SessionDetailView: View {
}
}
/// The floating action layer: one Liquid Glass bar carrying the queued pills, the working
/// indicator, the session controls, and the composer — or the approval card when a gate is
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
@ViewBuilder
private var actionArea: some View {
Divider()
Group {
if let approval = store.openApprovals.first {
ApprovalCardView(approval: approval)
.padding()
} else if let status = summary?.status {
let running = status == .running
let canCompose = running || status == .awaitingInput || status.isTerminal
VStack(spacing: 8) {
// Follow-ups submitted mid-turn, held by the host to send when the current turn
// finishes. Each is shown as its own pill (with its attachments) and — at control
// scope — can be cancelled individually, matching the Mac composer. (They still
// send as one combined turn.)
if let queued = summary?.queuedMessages, !queued.isEmpty {
let queued = summary?.queuedMessages ?? []
if canCompose || store.canControl || !queued.isEmpty {
VStack(spacing: 10) {
// Follow-ups submitted mid-turn, held by the host to send when the current
// turn finishes. Each is shown as its own pill (with its attachments) and —
// at control scope — can be cancelled individually, matching the Mac
// composer. (They still send as one combined turn.)
if !queued.isEmpty {
VStack(spacing: 6) {
ForEach(queued) { message in
queuedMessageRow(message)
@@ -221,13 +226,15 @@ struct SessionDetailView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if store.canControl, let summary { controlRow(summary) }
if canCompose {
HStack(spacing: 8) {
HStack(alignment: .bottom, spacing: 8) {
TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.lineLimit(1...4)
.keyboardDismissable()
.padding(.vertical, 3)
Button {
store.sendInput(draft, to: sessionID)
draft = ""
@@ -236,15 +243,21 @@ struct SessionDetailView: View {
.font(.title2)
}
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac —
// plain Return stays newline in the multiline field.
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
// Mac — plain Return stays newline in the multiline field.
.keyboardShortcut(.return, modifiers: .command)
}
}
}
.padding()
.padding(.horizontal, 14)
.padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
/// A queued follow-up shown above the composer: its text (or attachment names) with the
/// attachments as chips, and — at control scope — a ✕ that cancels just this message.
@@ -309,7 +322,7 @@ struct TranscriptList: View {
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
LazyVStack(alignment: .leading, spacing: 14) {
ForEach(items) { item in
TranscriptRow(item: item).id(item.id)
}
@@ -313,6 +313,25 @@ struct KeyboardDismissable: ViewModifier {
}
}
/// A floating Liquid Glass surface for bars that hover over scrolling content (the session
/// chat bar, approval card). Real `glassEffect` on iOS 26; an ultra-thin material with a
/// hairline edge on earlier systems so the bar still reads as a translucent layer.
struct GlassSurface: ViewModifier {
var cornerRadius: CGFloat = 24
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous))
} else {
content
.background(.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
}
}
}
/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`.
struct CardBackground: ViewModifier {
var padding: CGFloat = 14
@@ -328,6 +347,10 @@ struct CardBackground: ViewModifier {
extension View {
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
/// Float content on Liquid Glass (material fallback pre-iOS 26). See `GlassSurface`.
func glassSurface(cornerRadius: CGFloat = 24) -> some View {
modifier(GlassSurface(cornerRadius: cornerRadius))
}
/// Apply the teal accent app-wide.
func nucleicTint() -> some View { tint(Palette.accent) }
/// Give a text field a way to release the keyboard. Without this, iOS leaves a focused
@@ -63,20 +63,27 @@ struct TranscriptRow: View {
/// Render an assistant/user message with the full Markdown renderer the Mac transcript uses
/// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving
/// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
/// The user's messages sit right-aligned in a soft accent bubble; the assistant's prose runs
/// open on the background — no box — so the transcript reads as a page, not a stack of cards.
struct MessageBubble: View {
let role: TranscriptItem.Role
let text: String
var body: some View {
if role == .user {
HStack {
if role == .user { Spacer(minLength: 40) }
Spacer(minLength: 48)
MarkdownText(markdown: text)
.textSelection(.enabled)
.padding(10)
.background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12))
.frame(maxWidth: .infinity, alignment: role == .user ? .trailing : .leading)
if role == .assistant { Spacer(minLength: 40) }
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(Palette.accent.opacity(0.15),
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
} else {
MarkdownText(markdown: text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}