Merge nucleic/clever-velvet-newt-spy5 into dev

This commit is contained in:
2026-08-03 03:40:03 -07:00
parent 82381f7e49
commit c251e75f02
4 changed files with 94 additions and 26 deletions
@@ -38,6 +38,11 @@ final class HostConnection {
/// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello /// This host's per-provider install/auth state (remote agent sign-in) — pushed post-hello
/// and on every change; feeds Settings ▸ Agent Accounts. /// and on every change; feeds Settings ▸ Agent Accounts.
private(set) var agentAuthStatuses: [WireProviderAuthStatus] = [] private(set) var agentAuthStatuses: [WireProviderAuthStatus] = []
/// Whether this connection has already taken its post-hello auth snapshot. The snapshot is
/// just "how things stand"; every *later* push follows a credential change on that host, which
/// is the only way this phone learns a sign-in happened somewhere else. Reset per client, so a
/// reconnect's snapshot is never mistaken for a change.
private var didReceiveAgentAuthSnapshot = false
/// This host's credential-sealing X25519 public key, from its post-hello `credentialNeeded` /// This host's credential-sealing X25519 public key, from its post-hello `credentialNeeded`
/// push — what the phone seals an API key to (REMOTE_AGENT_LOGIN §8). Nil until pushed. /// push — what the phone seals an API key to (REMOTE_AGENT_LOGIN §8). Nil until pushed.
private(set) var credentialSealingKey: Data? private(set) var credentialSealingKey: Data?
@@ -156,6 +161,10 @@ final class HostConnection {
var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in } var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in }
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID. /// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in } var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in }
/// A credential landed on this host *after* the post-hello snapshot — a sign-in completed
/// on another device, a mesh mirror, an API key set — carrying the providers that now hold
/// one. RemoteStore treats it exactly like this phone's own completed sign-in.
var agentAuthRefreshed: ([AgentLoginProvider]) -> Void = { _ in }
/// The phone vault changed under this connection — kinds landed from a /// The phone vault changed under this connection — kinds landed from a
/// `credentialUpdate` or cleared by a mesh-wide deletion. RemoteStore refreshes the /// `credentialUpdate` or cleared by a mesh-wide deletion. RemoteStore refreshes the
/// held-kinds mirror the Settings surface reads. /// held-kinds mirror the Settings surface reads.
@@ -899,8 +908,19 @@ final class HostConnection {
case .agentAuthStatus(let statuses): case .agentAuthStatus(let statuses):
// Per-provider install/auth state (pushed post-hello and on change) — feeds the // Per-provider install/auth state (pushed post-hello and on change) — feeds the
// Agent Accounts list and the in-chat sign-in affordance. // Agent Accounts list and the in-chat sign-in affordance.
let isSnapshot = !didReceiveAgentAuthSnapshot
didReceiveAgentAuthSnapshot = true
agentAuthStatuses = statuses agentAuthStatuses = statuses
callbacks.didUpdate() callbacks.didUpdate()
// The host only re-pushes this after a credential moved (a sign-in here or on any
// other device, a mirrored login, a key entry, a revocation), so a *non-snapshot*
// push naming an authenticated provider is this phone's one observable "the login
// you were nagged about happened" signal. It has to be the transition, not the flag:
// an expired-but-present credential still reads as authenticated.
if !isSnapshot {
let refreshed = statuses.filter(\.authenticated).map(\.provider)
if !refreshed.isEmpty { callbacks.agentAuthRefreshed(refreshed) }
}
case .directAnswer(let offer): case .directAnswer(let offer):
directBox?.deliver(.answer(offer)) directBox?.deliver(.answer(offer))
case .directGo: case .directGo:
@@ -1284,6 +1304,9 @@ final class HostConnection {
teardownDirect() teardownDirect()
if let client { Task { await client.disconnect() } } if let client { Task { await client.disconnect() } }
client = nil client = nil
// The next connection opens with its own post-hello auth snapshot — arm the latch so that
// one isn't read as a credential change.
didReceiveAgentAuthSnapshot = false
// A dropped connection loses the in-flight prefetch's remaining chunks — settle it empty // A dropped connection loses the in-flight prefetch's remaining chunks — settle it empty
// so RemoteStore's queue isn't left waiting on a completion that will never arrive. // so RemoteStore's queue isn't left waiting on a completion that will never arrive.
finishPrefetch(delivering: false) finishPrefetch(delivering: false)
@@ -694,10 +694,13 @@ final class RemoteStore: ObservableObject {
private var agentLoginListener: OAuthRedirectListener? private var agentLoginListener: OAuthRedirectListener?
/// Per-session high-water seq below which a tail auth failure is considered resolved by a /// Per-session high-water seq below which a tail auth failure is considered resolved by a
/// completed sign-in. Set to the open transcript's newest seq the moment an agent sign-in /// completed sign-in. Anchored at the transcript's tip by `resolveAuthBanners` the moment a
/// succeeds (`agentLoginResultReceived`), so the in-chat "Sign in" banner clears immediately — /// sign-in for that session's provider completes — this phone's own attempt
/// without waiting for a fresh run to push a non-error event onto the tail. A genuinely newer /// (`agentLoginResultReceived`) or one observed landing on a host, which is how a login the
/// auth failure (a token that lapses again) carries a higher seq, so it re-arms the banner. /// user finished on *another* device reaches this banner — so the in-chat "Sign in" banner
/// clears immediately, without waiting for a fresh run to push a non-error event onto the
/// tail. A genuinely newer auth failure (a token that lapses again) carries a higher seq, so
/// it re-arms the banner.
/// Keyed by session because seqs are per-session; this is the *observable* dismissal signal, /// Keyed by session because seqs are per-session; this is the *observable* dismissal signal,
/// deliberately not tied to credential *presence* (an expired-but-present login still reads as /// deliberately not tied to credential *presence* (an expired-but-present login still reads as
/// "authenticated", so presence can't tell a stale token from a fresh sign-in). /// "authenticated", so presence can't tell a stale token from a fresh sign-in).
@@ -1652,6 +1655,14 @@ final class RemoteStore: ObservableObject {
guard let self, hostID == self.agentLoginHostID else { return } guard let self, hostID == self.agentLoginHostID else { return }
self.agentLoginResultReceived(result) self.agentLoginResultReceived(result)
} }
cb.agentAuthRefreshed = { [weak self] providers in
// A sign-in landed on that host without this phone driving it — the user finished the
// login on their Mac, or on another device that shares the mesh credential. Resolve
// the in-chat banner the same way this phone's own sign-in does; from any host,
// because a landed credential syncs to every member (and if some host somehow misses
// it, that host's next run fails again and re-arms the banner at a newer seq).
self?.resolveAuthBanners(for: providers)
}
return cb return cb
} }
@@ -2456,19 +2467,35 @@ final class RemoteStore: ObservableObject {
agentLoginListener?.stop() agentLoginListener?.stop()
agentLoginListener = nil agentLoginListener = nil
agentLoginRequestID = nil agentLoginRequestID = nil
// A successful sign-in resolves any auth failure already in the open transcript's tail: if result.succeeded, let provider = agentLoginProvider {
// anchor the dismissal at the newest seq so the in-chat banner clears now, and only a resolveAuthBanners(for: [provider])
// *newer* failure re-arms it. Scoped to the open session whose provider we just signed in
// — the in-chat "Sign in" CTA is always the open session's, and a provider mismatch (a
// Settings-initiated login for a different provider) must not dismiss an unrelated banner.
if result.succeeded, let sid = openSessionID,
let backend = sessions.first(where: { $0.sessionID == sid })?.backend,
AgentLoginProvider.forBackend(backend) == agentLoginProvider {
authResolvedSeqBySession[sid] = openEvents.map(\.seq).max() ?? 0
} }
agentLogin = .done(success: result.succeeded, message: result.error) agentLogin = .done(success: result.succeeded, message: result.error)
} }
/// A sign-in for `providers` completed — here, or on any other device in the mesh. Resolve
/// every session those providers back: anchor its dismissal at the transcript's current tip so
/// an auth failure already sitting in the tail stops showing the in-chat banner immediately —
/// no waiting for a fresh run to rewrite the tail — while a genuinely newer failure (a token
/// that lapses again) carries a higher seq and re-arms it. Provider-matched, so a login for
/// one provider never dismisses another's banner, and `max` so an older signal can't lower an
/// anchor already set.
private func resolveAuthBanners(for providers: [AgentLoginProvider]) {
guard !providers.isEmpty else { return }
// The open session's on-screen tail can lead its summary's `lastSeq` (the events that
// arrived since the last summary push) — anchor past those too, or the very failure being
// resolved could sit above the anchor.
let openTip = openEvents.map(\.seq).max() ?? 0
for session in sessions {
guard let provider = AgentLoginProvider.forBackend(session.backend),
providers.contains(provider) else { continue }
let tip = session.sessionID == openSessionID
? max(session.lastSeq, openTip) : session.lastSeq
authResolvedSeqBySession[session.sessionID] = max(
authResolvedSeqBySession[session.sessionID] ?? 0, tip)
}
}
/// Whether `hostID` can take an API key from this phone right now (REMOTE_AGENT_LOGIN §8): /// Whether `hostID` can take an API key from this phone right now (REMOTE_AGENT_LOGIN §8):
/// it advertises sealed-credential ingestion AND we hold its sealing key from the /// it advertises sealed-credential ingestion AND we hold its sealing key from the
/// post-hello `credentialNeeded` push. /// post-hello `credentialNeeded` push.
@@ -391,6 +391,8 @@ struct AgentAuthErrorBanner: View {
// sign-in hasn't already resolved. A successful sign-in anchors `authResolvedSeq` at the // sign-in hasn't already resolved. A successful sign-in anchors `authResolvedSeq` at the
// tail's newest seq, so the banner clears the instant login finishes — no waiting for a // tail's newest seq, so the banner clears the instant login finishes — no waiting for a
// fresh run to rewrite the tail — while a genuinely newer failure (a higher seq) re-arms it. // fresh run to rewrite the tail — while a genuinely newer failure (a higher seq) re-arms it.
// "Completed" covers a sign-in finished on any device, not just this phone: the host's
// post-change `agentAuthStatus` push anchors the same seq (see `resolveAuthBanners`).
if AgentLoginProvider.forBackend(backend) != nil, if AgentLoginProvider.forBackend(backend) != nil,
let failSeq = AgentAuthErrors.authFailureSeq(store.openEvents), let failSeq = AgentAuthErrors.authFailureSeq(store.openEvents),
failSeq > store.authResolvedSeq(forSession: sessionID) failSeq > store.authResolvedSeq(forSession: sessionID)
@@ -407,10 +409,12 @@ struct AgentAuthErrorBanner: View {
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.small) .controlSize(.small)
} }
.padding(.horizontal, 12) .padding(.horizontal, 14)
.padding(.vertical, 8) .padding(.vertical, 10)
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) // The same floating glass the disconnected banner and the chat bar it stacks with
.padding(.horizontal, 12) // use, washed warning-orange — it hovers over the transcript right above the
// composer, where a flat translucent fill read as an unfinished sheet.
.glassSurface(cornerRadius: 20, tint: .orange)
} }
} }
} }
+23 -9
View File
@@ -350,18 +350,31 @@ struct KeyboardDismissable: ViewModifier {
/// hairline edge on earlier systems so the bar still reads as a translucent layer. /// hairline edge on earlier systems so the bar still reads as a translucent layer.
struct GlassSurface: ViewModifier { struct GlassSurface: ViewModifier {
var cornerRadius: CGFloat = 24 var cornerRadius: CGFloat = 24
/// An optional semantic wash (the auth banner's warning orange). Carried *by* the glass — a
/// tinted `glassEffect` on iOS 26, a thin veil above the material on the fallback — so a
/// surface that needs to read as a warning stays glass instead of a flat colored fill.
var tint: Color?
func body(content: Content) -> some View { func body(content: Content) -> some View {
if #available(iOS 26.0, *) { if #available(iOS 26.0, *) {
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous)) content.glassEffect(glass, in: .rect(cornerRadius: cornerRadius, style: .continuous))
} else { } else {
content content
.background(.ultraThinMaterial, .background((tint ?? .clear).opacity(0.14), in: shape)
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .background(.ultraThinMaterial, in: shape)
.overlay( .overlay(shape.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
} }
} }
private var shape: RoundedRectangle {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
}
@available(iOS 26.0, *)
private var glass: Glass {
guard let tint else { return .regular }
return .regular.tint(tint)
}
} }
/// A circular Liquid Glass backing for a floating round control (the New-chat FAB). Real /// A circular Liquid Glass backing for a floating round control (the New-chat FAB). Real
@@ -395,9 +408,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`. /// Float content on Liquid Glass (material fallback pre-iOS 26), optionally washed with a
func glassSurface(cornerRadius: CGFloat = 24) -> some View { /// semantic `tint` for a surface that carries a warning. See `GlassSurface`.
modifier(GlassSurface(cornerRadius: cornerRadius)) func glassSurface(cornerRadius: CGFloat = 24, tint: Color? = nil) -> some View {
modifier(GlassSurface(cornerRadius: cornerRadius, tint: tint))
} }
/// Back a circular control (the floating New-chat FAB) with interactive Liquid Glass — the /// Back a circular control (the floating New-chat FAB) with interactive Liquid Glass — the
/// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26. /// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26.