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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user