@@ -15,6 +15,10 @@ final class AppState {
1515 private let networkMonitor = NWPathMonitor()
1616 private let networkMonitorQueue = DispatchQueue(label: "com.parseableviewer.network-monitor")
1717 private var connectTask: Task<Bool, Never>?
18+ /// The connection `connectTask` is connecting to. `activeConnection` only
19+ /// switches once the new server answers, so this is what identifies an
20+ /// in-flight connect whose target the user deletes (see `removeConnection`).
21+ private var connectingConnectionID: UUID?
1822
1923 // MARK: - Stream State
2024 var streams: [LogStream] = []
@@ -96,6 +100,7 @@ final class AppState {
96100
97101 if let activeID = ConnectionStore.loadActiveConnectionID(),
98102 let connection = connections.first(where: { $0.id == activeID }) {
103+ connectingConnectionID = connection.id
99104 connectTask = Task { @MainActor [weak self] in
100105 await self?.performConnect(to: connection) ?? false
101106 }
@@ -115,6 +120,7 @@ final class AppState {
115120 existing.cancel()
116121 _ = await existing.value
117122 }
123+ connectingConnectionID = connection.id
118124 let task = Task { @MainActor [weak self] in
119125 await self?.performConnect(to: connection) ?? false
120126 }
@@ -124,9 +130,16 @@ final class AppState {
124130
125131 @MainActor
126132 private func performConnect(to connection: ServerConnection) async -> Bool {
133+ defer {
134+ if connectingConnectionID == connection.id { connectingConnectionID = nil }
135+ }
127136 guard !isConnecting else { return false }
128137
129− if !isNetworkAvailable {
138+ // NWPathMonitor ignores loopback, so with no active network interface it
139+ // reports "unsatisfied" even though a local server (the common docker /
140+ // dev setup) is reachable. Don't block those; a genuinely unreachable
141+ // host still fails below with the same offline message.
142+ if !isNetworkAvailable && !Self.isLoopback(connection.baseURL) {
130143 self.errorMessage = "No internet connection. Check your network and try again."
131144 self.showError = true
132145 return false
@@ -134,7 +147,12 @@ final class AppState {
134147
135148 isConnecting = true
136149 defer { isConnecting = false }
150+ // Reset the flag with the message: a previous failed connect left
151+ // `showError` set, and if its alert couldn't present (e.g. behind the
152+ // connection sheet), it would pop up later — after a successful retry —
153+ // reading "An unknown error occurred".
137154 errorMessage = nil
155+ showError = false
138156
139157 // When switching to a different server, clear stream-specific state
140158 // so stale selections don't reference streams that don't exist.
@@ -164,6 +182,7 @@ final class AppState {
164182
165183 // Test connection — throws on non-200 or network failure
166184 try await newClient.checkHealth()
185+ try await Self.verifyCredentials(with: newClient)
167186
168187 // If the task was cancelled while awaiting (e.g. user clicked
169188 // Disconnect during auto-reconnect), bail before installing the
@@ -200,6 +219,12 @@ final class AppState {
200219 case .success(let list):
201220 self.streams = list
202221 self.streamLoadError = nil
222+ // Like refreshStreams: a selection that doesn't exist on this server
223+ // (e.g. one picked from a stale list while the connect was pending)
224+ // would keep every stream tab querying a missing stream.
225+ if let selected = selectedStream, !list.contains(where: { $0.name == selected }) {
226+ selectedStream = nil
227+ }
203228 case .failure(let error):
204229 self.streams = []
205230 self.streamLoadError = ParseableError.userFriendlyMessage(for: error)
@@ -265,8 +290,13 @@ final class AppState {
265290 let loaded = try await client.listStreams()
266291 // Drop results if the active connection changed while awaiting. Still
267292 // clear isLoadingStreams: this task set the spinner, so leaving it true
268− // wedges the indicator and disables Refresh.
269− guard connID == activeConnection?.id else { isLoadingStreams = false; return }
293+ // wedges the indicator and disables Refresh. Compare the client too:
294+ // editing the active connection's URL/credentials keeps its id but
295+ // installs a new client for a possibly different server.
296+ guard connID == activeConnection?.id, client === self.client else {
297+ isLoadingStreams = false
298+ return
299+ }
270300 streams = loaded
271301 // Drop a selection that no longer exists server-side (e.g. the stream
272302 // was deleted from another client); otherwise the detail tabs keep
@@ -275,7 +305,10 @@ final class AppState {
275305 selectedStream = nil
276306 }
277307 } catch {
278− guard connID == activeConnection?.id else { isLoadingStreams = false; return }
308+ guard connID == activeConnection?.id, client === self.client else {
309+ isLoadingStreams = false
310+ return
311+ }
279312 let message = ParseableError.userFriendlyMessage(for: error)
280313 streamLoadError = message
281314 self.errorMessage = "Failed to load streams: \(message)"
@@ -342,9 +375,49 @@ final class AppState {
342375 ConnectionStore.saveConnections(connections)
343376 if activeConnection?.id == connection.id {
344377 disconnect()
378+ } else if connectingConnectionID == connection.id {
379+ // The removed connection is still being connected to (e.g. Cancel in the
380+ // connection sheet, or deleting it in Settings during the launch
381+ // reconnect). `activeConnection` doesn't point at it yet, so without
382+ // this the connect would carry on and install a client for a
383+ // connection that no longer exists (or tear down the current session
384+ // when it fails) after the user abandoned it.
385+ connectTask?.cancel()
386+ connectTask = nil
387+ connectingConnectionID = nil
388+ // Starting that connect already cleared the current server's stream
389+ // list and filters; reload them if we're still connected to it.
390+ if client != nil {
391+ Task { @MainActor [weak self] in
392+ await self?.refreshStreams()
393+ await self?.refreshFilters()
394+ }
395+ }
396+ }
397+ }
398+
399+ /// `/api/v1/liveness` is unauthenticated on Parseable (it answers 200 to
400+ /// anyone, for load-balancer / k8s probes), so a health check alone accepts
401+ /// any username and password. Probe an authenticated endpoint and fail on
402+ /// 401. Other failures — e.g. a 403 for a role without the about permission —
403+ /// don't show the credentials are wrong, so they're ignored here. The client
404+ /// caches the response, so the follow-up `getAbout()` load reuses it.
405+ static func verifyCredentials(with client: ParseableClient) async throws {
406+ do {
407+ _ = try await client.getAbout()
408+ } catch ParseableError.unauthorized {
409+ throw ParseableError.unauthorized
410+ } catch {
411+ // Not an authentication failure; leave it to the best-effort loads.
345412 }
346413 }
347414
415+ private static func isLoopback(_ url: URL?) -> Bool {
416+ guard let host = url?.host?.lowercased() else { return false }
417+ return host == "localhost" || host.hasSuffix(".localhost")
418+ || host == "::1" || host.hasPrefix("127.")
419+ }
420+
348421 // MARK: - Stream Management
349422
350423 @MainActor
@@ -377,7 +450,10 @@ final class AppState {
377450 let connID = activeConnection?.id
378451 isLoadingFilters = true
379452 let loaded = (try? await client.listFilters()) ?? []
380− guard connID == activeConnection?.id else { isLoadingFilters = false; return }
453+ guard connID == activeConnection?.id, client === self.client else {
454+ isLoadingFilters = false
455+ return
456+ }
381457 filters = loaded
382458 isLoadingFilters = false
383459 }