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>
|
<string></string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<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>`
|
<!-- 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
|
opens a waiting session; `nucleic://sessions` surfaces the sessions list. Handled by
|
||||||
`.onOpenURL` → `RemoteStore.handleDeepLink`. -->
|
`.onOpenURL` → `RemoteStore.handleDeepLink`. -->
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ final class LiveActivityManager {
|
|||||||
private var pendingState: NucleicSessionAttributes.ContentState?
|
private var pendingState: NucleicSessionAttributes.ContentState?
|
||||||
private var updateTask: Task<Void, Never>?
|
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.
|
/// Reconcile the Activity with the current session set.
|
||||||
func sync(hostName: String, sessions: [WireSessionSummary]) {
|
func sync(hostName: String, sessions: [WireSessionSummary]) {
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
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.
|
// recovered one still needs the fresh state, so fall through to the update pipeline.
|
||||||
if let existing = Activity<NucleicSessionAttributes>.activities.first {
|
if let existing = Activity<NucleicSessionAttributes>.activities.first {
|
||||||
activity = existing
|
activity = existing
|
||||||
|
observePushToken(existing)
|
||||||
enqueue(state)
|
enqueue(state)
|
||||||
} else {
|
} 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),
|
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
|
return
|
||||||
}
|
}
|
||||||
enqueue(state)
|
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).
|
/// Hand the newest state to the serial drainer (starting it if idle).
|
||||||
private func enqueue(_ state: NucleicSessionAttributes.ContentState) {
|
private func enqueue(_ state: NucleicSessionAttributes.ContentState) {
|
||||||
pendingState = state
|
pendingState = state
|
||||||
@@ -111,10 +136,13 @@ final class LiveActivityManager {
|
|||||||
func end() {
|
func end() {
|
||||||
updateTask?.cancel()
|
updateTask?.cancel()
|
||||||
updateTask = nil
|
updateTask = nil
|
||||||
|
tokenObservation?.cancel()
|
||||||
|
tokenObservation = nil
|
||||||
pendingState = nil
|
pendingState = nil
|
||||||
lastState = nil
|
lastState = nil
|
||||||
guard let activity else { return }
|
guard let activity else { return }
|
||||||
self.activity = nil
|
self.activity = nil
|
||||||
|
onActivityEnded?(activity.id) // let the Macs stop pushing to this token
|
||||||
Task {
|
Task {
|
||||||
await activity.end(
|
await activity.end(
|
||||||
ActivityContent(state: activity.content.state, staleDate: nil),
|
ActivityContent(state: activity.content.state, staleDate: nil),
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func onAppear() {
|
func onAppear() {
|
||||||
|
setupLiveActivityBridge()
|
||||||
if demoMode { seedDemo(); return }
|
if demoMode { seedDemo(); return }
|
||||||
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
|
// 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".
|
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
|
||||||
@@ -489,6 +490,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
if hostID == self.activeHostID { self.mirrorActive() }
|
if hostID == self.activeHostID { self.mirrorActive() }
|
||||||
self.refreshAggregate()
|
self.refreshAggregate()
|
||||||
self.flushPendingNotificationDecision()
|
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
|
cb.openSnapshot = { [weak self] snap in
|
||||||
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
|
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
|
||||||
@@ -834,6 +838,49 @@ final class RemoteStore: ObservableObject {
|
|||||||
activeConnection?.send(msg)
|
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)
|
// MARK: - Demo simulator (offline, interactive)
|
||||||
//
|
//
|
||||||
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
|
// 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):
|
case .setSessionShipBranch(let id, let branch):
|
||||||
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
|
||||||
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
|
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,
|
// 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.
|
// and demo has no peer Macs, so they're inert here.
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ struct ApprovalCardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
.glassSurface(cornerRadius: 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
private func alwaysLabel(_ scope: AlwaysScope) -> String {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// One session: a segmented Transcript/Diff read view over a status-driven action area
|
/// One session: a full-height Transcript/Diff read view (mode flip in the navigation bar) with
|
||||||
/// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear.
|
/// a status-driven action bar floating over it on Liquid Glass (UX_IOS §4). Subscribes on
|
||||||
|
/// appear, unsubscribes on disappear.
|
||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
let sessionID: SessionID
|
let sessionID: SessionID
|
||||||
@@ -20,19 +21,7 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
Group {
|
||||||
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()
|
|
||||||
|
|
||||||
if tab == 0 {
|
if tab == 0 {
|
||||||
TranscriptList(events: store.openEvents)
|
TranscriptList(events: store.openEvents)
|
||||||
} else if store.capabilities.canFetchDiff || store.openDiff != nil {
|
} 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.
|
// Older host with no on-demand diff — the stat summary is all it projects.
|
||||||
DiffSummaryView(diffStat: summary?.diffStat)
|
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")
|
.navigationTitle(summary?.title ?? "Session")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.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 {
|
if let summary, store.canControl {
|
||||||
ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) }
|
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
|
/// 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
|
@ViewBuilder
|
||||||
private func controlBar(_ summary: WireSessionSummary) -> some View {
|
private func controlRow(_ summary: WireSessionSummary) -> some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
// A session's backend is fixed at creation, so only same-backend models are offered.
|
// A session's backend is fixed at creation, so only same-backend models are offered.
|
||||||
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
||||||
@@ -149,8 +152,6 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
|
.toggleStyle(.button).tint(Palette.accent).font(.caption2)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 6)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@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
|
@ViewBuilder
|
||||||
private var actionArea: some View {
|
private var actionArea: some View {
|
||||||
Divider()
|
Group {
|
||||||
if let approval = store.openApprovals.first {
|
if let approval = store.openApprovals.first {
|
||||||
ApprovalCardView(approval: approval)
|
ApprovalCardView(approval: approval)
|
||||||
.padding()
|
} else if let status = summary?.status {
|
||||||
} else if let status = summary?.status {
|
let running = status == .running
|
||||||
let running = status == .running
|
let canCompose = running || status == .awaitingInput || status.isTerminal
|
||||||
let canCompose = running || status == .awaitingInput || status.isTerminal
|
let queued = summary?.queuedMessages ?? []
|
||||||
VStack(spacing: 8) {
|
if canCompose || store.canControl || !queued.isEmpty {
|
||||||
// Follow-ups submitted mid-turn, held by the host to send when the current turn
|
VStack(spacing: 10) {
|
||||||
// finishes. Each is shown as its own pill (with its attachments) and — at control
|
// Follow-ups submitted mid-turn, held by the host to send when the current
|
||||||
// scope — can be cancelled individually, matching the Mac composer. (They still
|
// turn finishes. Each is shown as its own pill (with its attachments) and —
|
||||||
// send as one combined turn.)
|
// at control scope — can be cancelled individually, matching the Mac
|
||||||
if let queued = summary?.queuedMessages, !queued.isEmpty {
|
// composer. (They still send as one combined turn.)
|
||||||
VStack(spacing: 6) {
|
if !queued.isEmpty {
|
||||||
ForEach(queued) { message in
|
VStack(spacing: 6) {
|
||||||
queuedMessageRow(message)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, 14)
|
||||||
if running {
|
.padding(.vertical, 12)
|
||||||
HStack(spacing: 8) {
|
.glassSurface(cornerRadius: 26)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A queued follow-up shown above the composer: its text (or attachment names) with the
|
/// 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 {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 10) {
|
LazyVStack(alignment: .leading, spacing: 14) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
TranscriptRow(item: item).id(item.id)
|
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`.
|
/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`.
|
||||||
struct CardBackground: ViewModifier {
|
struct CardBackground: ViewModifier {
|
||||||
var padding: CGFloat = 14
|
var padding: CGFloat = 14
|
||||||
@@ -328,6 +347,10 @@ struct CardBackground: ViewModifier {
|
|||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
|
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.
|
/// Apply the teal accent app-wide.
|
||||||
func nucleicTint() -> some View { tint(Palette.accent) }
|
func nucleicTint() -> some View { tint(Palette.accent) }
|
||||||
/// Give a text field a way to release the keyboard. Without this, iOS leaves a focused
|
/// 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
|
/// 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
|
/// (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 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 {
|
struct MessageBubble: View {
|
||||||
let role: TranscriptItem.Role
|
let role: TranscriptItem.Role
|
||||||
let text: String
|
let text: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
if role == .user {
|
||||||
if role == .user { Spacer(minLength: 40) }
|
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)
|
MarkdownText(markdown: text)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.padding(10)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user