diff --git a/NucleicRemote/NucleicRemote/Info.plist b/NucleicRemote/NucleicRemote/Info.plist
index 66f21c2..dccb57d 100644
--- a/NucleicRemote/NucleicRemote/Info.plist
+++ b/NucleicRemote/NucleicRemote/Info.plist
@@ -22,6 +22,11 @@
NSSupportsLiveActivities
+
+ NSSupportsLiveActivitiesFrequentUpdates
+
diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift
index 55838f1..04f5cb6 100644
--- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift
+++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift
@@ -28,6 +28,14 @@ final class LiveActivityManager {
private var pendingState: NucleicSessionAttributes.ContentState?
private var updateTask: Task?
+ /// 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?
+
/// 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.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) {
+ 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),
diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
index 6c4f67b..df9c481 100644
--- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
+++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
@@ -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 = []
+
+ 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:
diff --git a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift
index a9c7cbc..c833396 100644
--- a/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift
+++ b/NucleicRemote/NucleicRemote/Views/ApprovalCardView.swift
@@ -82,7 +82,7 @@ struct ApprovalCardView: View {
}
}
.padding()
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
+ .glassSurface(cornerRadius: 20)
}
private func alwaysLabel(_ scope: AlwaysScope) -> String {
diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
index 0fcbf47..8068246 100644
--- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
+++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
@@ -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,58 +193,70 @@ 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()
- 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 {
- VStack(spacing: 6) {
- ForEach(queued) { message in
- queuedMessageRow(message)
+ Group {
+ if let approval = store.openApprovals.first {
+ ApprovalCardView(approval: approval)
+ } else if let status = summary?.status {
+ let running = status == .running
+ let canCompose = running || status == .awaitingInput || status.isTerminal
+ 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)
+ }
+ }
+ }
+ if running {
+ HStack(spacing: 8) {
+ ProgressView().controlSize(.small)
+ Text("Agent is working…").font(.footnote).foregroundStyle(.secondary)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ if store.canControl, let summary { controlRow(summary) }
+ if canCompose {
+ HStack(alignment: .bottom, spacing: 8) {
+ TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
+ text: $draft, axis: .vertical)
+ .textFieldStyle(.plain)
+ .lineLimit(1...4)
+ .keyboardDismissable()
+ .padding(.vertical, 3)
+ Button {
+ store.sendInput(draft, to: sessionID)
+ draft = ""
+ } label: {
+ Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill")
+ .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.
+ .keyboardShortcut(.return, modifiers: .command)
+ }
}
}
- }
- if running {
- HStack(spacing: 8) {
- ProgressView().controlSize(.small)
- Text("Agent is working…").font(.footnote).foregroundStyle(.secondary)
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- if canCompose {
- HStack(spacing: 8) {
- TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
- text: $draft, axis: .vertical)
- .textFieldStyle(.roundedBorder)
- .lineLimit(1...4)
- .keyboardDismissable()
- Button {
- store.sendInput(draft, to: sessionID)
- draft = ""
- } label: {
- Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill")
- .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.
- .keyboardShortcut(.return, modifiers: .command)
- }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .glassSurface(cornerRadius: 26)
}
}
- .padding()
}
+ .padding(.horizontal, 12)
+ .padding(.bottom, 8)
}
/// A queued follow-up shown above the composer: its text (or attachment names) with the
@@ -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)
}
diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift
index e50b5fd..5c2a21f 100644
--- a/NucleicRemote/NucleicRemote/Views/Theme.swift
+++ b/NucleicRemote/NucleicRemote/Views/Theme.swift
@@ -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
diff --git a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift
index b73264b..5781050 100644
--- a/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift
+++ b/NucleicRemote/NucleicRemote/Views/TranscriptRow.swift
@@ -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 {
- HStack {
- if role == .user { Spacer(minLength: 40) }
+ if role == .user {
+ HStack {
+ Spacer(minLength: 48)
+ MarkdownText(markdown: text)
+ .textSelection(.enabled)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 9)
+ .background(Palette.accent.opacity(0.15),
+ in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ }
+ } else {
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) }
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}