From ccdb581e8c983e0bcb7e4d0b3f98ae5607cd3bf2 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sat, 18 Jul 2026 22:12:31 -0700 Subject: [PATCH] Merge nucleic/gentle-yarn-egret-2ljl into dev --- .../NucleicRemote/Models/RemoteStore.swift | 27 +++++++++++++++++++ .../Views/AgentAccountsView.swift | 23 ++++++++++------ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 6116a9f..d4eba25 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -466,6 +466,16 @@ final class RemoteStore: ObservableObject { /// bound port). Stopped on every terminal transition. private var agentLoginListener: OAuthRedirectListener? + /// 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 + /// succeeds (`agentLoginResultReceived`), 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, + /// 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). + @Published private(set) var authResolvedSeqBySession: [SessionID: UInt64] = [:] + /// A transient host-reported error (the mobile echo of the Mac's last-error bubble): /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds. struct LastError: Equatable, Identifiable { @@ -2002,6 +2012,13 @@ final class RemoteStore: ObservableObject { .sorted { $0.1 < $1.1 } } + /// The seq at/below which `sessionID`'s tail auth failure has been resolved by a completed + /// sign-in — 0 (nothing resolved yet) until an agent login succeeds while that session is open. + /// The in-chat banner shows only for an auth failure *newer* than this. + func authResolvedSeq(forSession sessionID: SessionID) -> UInt64 { + authResolvedSeqBySession[sessionID] ?? 0 + } + /// Begin a provider sign-in brokered by `hostID`. Binds the redirect listener FIRST (the /// begin message carries the bound port): Codex's OAuth client only permits the fixed /// `localhost:1455`, Claude accepts any port — and no port at all falls back to Claude's @@ -2120,6 +2137,16 @@ final class RemoteStore: ObservableObject { agentLoginListener?.stop() agentLoginListener = nil agentLoginRequestID = nil + // A successful sign-in resolves any auth failure already in the open transcript's tail: + // anchor the dismissal at the newest seq so the in-chat banner clears now, and only a + // *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) } diff --git a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift index b671b17..af53cf9 100644 --- a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift +++ b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift @@ -359,21 +359,23 @@ enum AgentAuthErrors { && lowered.contains("expired") } - /// Whether the tail of a transcript ended on an auth failure — the banner's trigger. Only - /// the events after the last completed run matter: a re-auth mid-history shouldn't nag. - static func transcriptNeedsLogin(_ events: [AgentEvent]) -> Bool { + /// The seq of the auth failure the transcript's tail ended on, or nil if it didn't — the + /// banner's trigger. Only the events after the last completed run matter: a re-auth mid-history + /// shouldn't nag. Returning the seq (not just a Bool) lets the banner suppress a failure a + /// completed sign-in has already resolved while still re-arming for a *newer* one. + static func authFailureSeq(_ events: [AgentEvent]) -> UInt64? { for event in events.suffix(30).reversed() { switch event.kind { case .runFinished(let finished): - guard finished.outcome == .errored else { return false } - return finished.finalText.map(isAuthError) ?? false + guard finished.outcome == .errored else { return nil } + return (finished.finalText.map(isAuthError) ?? false) ? event.seq : nil case .error(let error): - if isAuthError(error.message) { return true } + if isAuthError(error.message) { return event.seq } default: continue } } - return false + return nil } } @@ -385,8 +387,13 @@ struct AgentAuthErrorBanner: View { let backend: BackendID var body: some View { + // Show only while the transcript's tail still ends on an auth failure that a completed + // 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 + // fresh run to rewrite the tail — while a genuinely newer failure (a higher seq) re-arms it. if AgentLoginProvider.forBackend(backend) != nil, - AgentAuthErrors.transcriptNeedsLogin(store.openEvents) + let failSeq = AgentAuthErrors.authFailureSeq(store.openEvents), + failSeq > store.authResolvedSeq(forSession: sessionID) { HStack(spacing: 10) { Image(systemName: "key.fill")