From 8b37b8576f4ec0fb6af5d0f9c7c4eb4fc1f15744 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:00:03 -0700 Subject: [PATCH] =?UTF-8?q?nvrsion:=20Add:=20Chat:=20Chat=20View=20Refinem?= =?UTF-8?q?ent=20to=20modernize=20session=20chat=20interface=20by=20removi?= =?UTF-8?q?ng=20vertical=20space=20for=20selectors,=20Fix:=20Chat:=20Remov?= =?UTF-8?q?e=20Face=20ID=20Permission=20Text,=20Refactor:=20Chat:=20Live?= =?UTF-8?q?=20Activity=20Content=20Density=20to=20open=20app=20directly=20?= =?UTF-8?q?into=20waiting=20sessions=20when=20tapped,=20Refactor:=20Chat:?= =?UTF-8?q?=20Live=20Activity=20Content=20Density=20to=20refresh=20live=20?= =?UTF-8?q?activity=20info=20significantly=20faster,=20Fix:=20Chat:=20Live?= =?UTF-8?q?=20Activity=20Content=20Density=20to=20refresh=20live=20activit?= =?UTF-8?q?y=20info=20significantly=20faster,=20Fix:=20Chat:=20Live=20Acti?= =?UTF-8?q?vity=20Content=20Density=20to=20refresh=20live=20activity=20inf?= =?UTF-8?q?o=20significantly=20faster,=20Fix:=20Chat:=20Network=20Type=20T?= =?UTF-8?q?ransition=20Optimization=20to=20reduce=20delay=20during=20netwo?= =?UTF-8?q?rk=20type=20switches,=20Feature=20Enhancement=20Planning:=20All?= =?UTF-8?q?=20devices=20in=20a=20mesh=20group=20should=20see=20and=20be=20?= =?UTF-8?q?aware=20of=20each=20other;=20=E2=80=9Cpair=E2=80=9D=20reframed?= =?UTF-8?q?=20into=20=E2=80=9Cjoin=E2=80=9D;=20scanning,=20Fix:=20Chat:=20?= =?UTF-8?q?Session=20Transcript=20Sync=20to=20show=20only=20current=20fram?= =?UTF-8?q?e=E2=80=99s=20transcript=20when=20opening=20a=20session=20on=20?= =?UTF-8?q?another=20Mac,=20Build:=20Push-update=20path=20to=20stay=20fres?= =?UTF-8?q?h=20while=20locked.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- NucleicRemote/NucleicRemote/Info.plist | 5 + .../NucleicRemote/LiveActivityManager.swift | 32 +++- .../NucleicRemote/Models/RemoteStore.swift | 53 ++++++- .../Views/ApprovalCardView.swift | 2 +- .../Views/SessionDetailView.swift | 147 ++++++++++-------- NucleicRemote/NucleicRemote/Views/Theme.swift | 23 +++ .../NucleicRemote/Views/TranscriptRow.swift | 21 ++- 7 files changed, 205 insertions(+), 78 deletions(-) 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) } }