Merge nucleic/gentle-yarn-egret-2ljl into dev

This commit is contained in:
2026-07-18 22:12:31 -07:00
parent 7e46002b20
commit ccdb581e8c
2 changed files with 42 additions and 8 deletions
@@ -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)
}
@@ -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")