fix(connection-pill): granular degraded reasons + inline hint popover (#53)

Pre-fix the connection-status pill collapsed every config.yaml read
failure to "Connected — can't read Hermes state", forcing users into
the heavy 14-probe Remote Diagnostics sheet to learn why. Multiple
distinct causes (Hermes not installed, not yet set up, permission
denied, profile mismatch) all read identically.

Probe script now emits granular `TIER2:1:<cause>` codes:
- no-home: ~/.hermes itself missing
- missing: config.yaml absent (typically pre-`hermes setup`)
- perm: file exists but unreadable by the SSH user
- profile:<name>: config missing AND ~/.hermes/active_profile points
  at a non-default profile, so Scarf is reading the wrong directory

Status.degraded now carries (reason, hint, cause) instead of just a
short reason. The pill label shows the specific reason
("Hermes profile coder is active", "Hermes hasn't been set up yet",
etc.); clicking opens an inline popover with:
- A one-paragraph actionable hint
- A "Run diagnostics" button (existing path) and a "Retry" button
- For the profile case: a copy-paste affordance for
  `hermes profile use default` to revert

Backwards-compatible: a remote that emits the legacy binary
`TIER2:1` parses to `.unknown` with the prior generic copy. No probe
script breakage on older Hermes installs.

Cross-link with #50 (local profile awareness) — this fix surfaces
the profile-mismatch class of bug for remote contexts. A proper
remote-side profile fix (HermesPathSet.defaultRemoteHome respecting
active_profile) is filed separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-27 13:57:18 +02:00
parent afb1356b27
commit c312a565b6
2 changed files with 194 additions and 17 deletions
@@ -19,9 +19,14 @@ public final class ConnectionStatusViewModel {
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
case connected
/// SSH connects but the follow-up read-access probe failed. Data
/// views will be empty until this is resolved. `reason` is shown
/// in the pill tooltip; users click the pill to open diagnostics.
case degraded(reason: String)
/// views will be empty until this is resolved.
///
/// `reason` is the short pill copy (e.g. `"can't read ~/.hermes/
/// config.yaml"`); `hint` is a longer actionable string surfaced
/// in the pill's quick popover so users see *why* and *what to do*
/// without diving into the diagnostics sheet (issue #53). `cause`
/// classifies the failure for UI branching.
case degraded(reason: String, hint: String, cause: DegradedCause)
/// No probe yet or the previous probe timed out but we haven't
/// confirmed failure. Shown as yellow to tell the user "checking".
case idle
@@ -30,6 +35,29 @@ public final class ConnectionStatusViewModel {
case error(message: String, stderr: String)
}
/// Specific tier-2 failure mode emitted by the probe script. Used to
/// drive both the pill copy and the popover hint (issue #53).
public enum DegradedCause: Equatable {
/// `config.yaml` is missing entirely. Most common cause: Hermes
/// hasn't run `setup` yet on this remote.
case configMissing
/// `~/.hermes` itself doesn't exist. Hermes isn't installed for
/// the SSH user on this host.
case homeMissing
/// File exists but the SSH user can't read it. Permission /
/// ownership mismatch.
case configUnreadable
/// `~/.hermes/active_profile` points at a non-default Hermes
/// profile and the configured Hermes home doesn't carry the
/// real config the user is reading the wrong directory.
/// Carries the active profile name so the hint can name it.
case profileActive(name: String)
/// Probe couldn't classify the failure precisely (e.g. older
/// remote returned a binary `TIER2:1` without a tag). Falls
/// back to a generic hint.
case unknown
}
public private(set) var status: Status = .idle
/// Timestamp of the last successful probe. Used by the UI to show how
/// fresh the status indicator is ("just now", "2m ago").
@@ -97,15 +125,40 @@ public final class ConnectionStatusViewModel {
} else {
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
}
// Probe emits a granular `TIER2:1:<cause>` code so the pill can
// surface a specific hint (issue #53) instead of the prior
// collapsed-to-binary "can't read config.yaml". Causes:
// no-home $H itself doesn't exist
// missing config.yaml absent
// perm exists but unreadable by SSH user
// profile:<name> config missing AND ~/.hermes/active_profile
// points at a Hermes profile, suggesting Scarf
// is reading the wrong dir
let script = """
echo TIER1:0
H=\(homeArg)
if [ -r "$H/config.yaml" ]; then echo TIER2:0; else echo TIER2:1; fi
if [ -r "$H/config.yaml" ]; then
echo TIER2:0
elif [ ! -d "$H" ]; then
echo TIER2:1:no-home
elif [ ! -e "$H/config.yaml" ]; then
ACTIVE=""
if [ -r "$HOME/.hermes/active_profile" ]; then
ACTIVE=$(head -n1 "$HOME/.hermes/active_profile" 2>/dev/null | tr -d ' \\t\\r\\n')
fi
if [ -n "$ACTIVE" ] && [ "$ACTIVE" != "default" ]; then
echo TIER2:1:profile:$ACTIVE
else
echo TIER2:1:missing
fi
else
echo TIER2:1:perm
fi
"""
enum ProbeOutcome {
case connected
case degraded(reason: String)
case degraded(reason: String, hint: String, cause: DegradedCause)
case failure(TransportError)
}
@@ -130,10 +183,12 @@ public final class ConnectionStatusViewModel {
if tier2 {
return .connected
}
// Connected but can't read config.yaml the core issue #19
// symptom. Give the pill a short reason; the full story goes
// into Remote Diagnostics.
return .degraded(reason: "can't read ~/.hermes/config.yaml")
// Connected but tier 2 failed. Parse the granular cause
// code; older remotes that don't emit a tag fall through
// to `.unknown` with a generic hint (issue #53).
let cause = Self.parseDegradedCause(stdout: out)
let (reason, hint) = Self.describe(cause: cause, hermesHome: hermesHome)
return .degraded(reason: reason, hint: hint, cause: cause)
} catch let e as TransportError {
return .failure(e)
} catch {
@@ -146,8 +201,8 @@ public final class ConnectionStatusViewModel {
status = .connected
lastSuccess = Date()
consecutiveFailures = 0
case .degraded(let reason):
status = .degraded(reason: reason)
case .degraded(let reason, let hint, let cause):
status = .degraded(reason: reason, hint: hint, cause: cause)
lastSuccess = Date() // SSH itself is fine, reset failure count
consecutiveFailures = 0
case .failure(let err):
@@ -176,4 +231,59 @@ public final class ConnectionStatusViewModel {
}
}
}
/// Pull a `DegradedCause` out of the probe stdout. Looks for the
/// `TIER2:1:<code>[:detail]` line; falls back to `.unknown` when
/// only the legacy binary `TIER2:1` is present (older remotes,
/// future-proofs against accidental tag drops).
nonisolated static func parseDegradedCause(stdout: String) -> DegradedCause {
for raw in stdout.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("TIER2:1:") else { continue }
let body = String(line.dropFirst("TIER2:1:".count))
if body == "no-home" { return .homeMissing }
if body == "missing" { return .configMissing }
if body == "perm" { return .configUnreadable }
if body.hasPrefix("profile:") {
let name = String(body.dropFirst("profile:".count))
if !name.isEmpty {
return .profileActive(name: name)
}
}
}
return .unknown
}
/// Map a `DegradedCause` into the pill's short `reason` (single line,
/// fits in a tooltip) and longer `hint` (popover body, can carry
/// commands the user can copy).
nonisolated static func describe(cause: DegradedCause, hermesHome: String) -> (reason: String, hint: String) {
switch cause {
case .homeMissing:
return (
"Hermes not installed on remote",
"`\(hermesHome)` doesn't exist on the remote. Install Hermes for the SSH user, or — if Hermes is already installed under a different path — set this server's Hermes home in Manage Servers."
)
case .configMissing:
return (
"Hermes hasn't been set up yet",
"`\(hermesHome)/config.yaml` is missing. Run `hermes setup` (or your first `hermes chat`) on the remote to create it. Scarf will go green automatically once it appears."
)
case .configUnreadable:
return (
"Permission denied on config.yaml",
"`\(hermesHome)/config.yaml` exists but the SSH user can't read it. Check ownership: `ls -l \(hermesHome)/config.yaml`. Either run Hermes as the SSH user, `chmod a+r` the file, or SSH as the Hermes user."
)
case .profileActive(let name):
return (
"Hermes profile \"\(name)\" is active",
"The remote is using Hermes profile `\(name)` — its config lives at `~/.hermes/profiles/\(name)/config.yaml`, not `\(hermesHome)/config.yaml`. Either set this server's Hermes home to `~/.hermes/profiles/\(name)` in Manage Servers → Edit, or run `hermes profile use default` on the remote to revert."
)
case .unknown:
return (
"Can't read Hermes state",
"SSH is fine but Scarf can't reach `\(hermesHome)/config.yaml`. Run diagnostics for a full breakdown."
)
}
}
}
@@ -10,6 +10,7 @@ import ScarfDesign
struct ConnectionStatusPill: View {
let status: ConnectionStatusViewModel
@State private var showDetails = false
@State private var showDegraded = false
@State private var showDiagnostics = false
var body: some View {
@@ -18,9 +19,10 @@ struct ConnectionStatusPill: View {
case .error:
showDetails = true
case .degraded:
// Yellow "can't read" state open the diagnostics sheet
// so the user can see exactly which files fail and why.
showDiagnostics = true
// Show the granular reason + hint inline first (issue
// #53). The user can drill into the full diagnostics
// sheet from the popover if the hint isn't enough.
showDegraded = true
case .connected, .idle:
status.retry()
}
@@ -45,6 +47,9 @@ struct ConnectionStatusPill: View {
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
errorDetails.frame(width: 400)
}
.popover(isPresented: $showDegraded, arrowEdge: .bottom) {
degradedDetails.frame(width: 440)
}
.sheet(isPresented: $showDiagnostics) {
RemoteDiagnosticsView(context: status.context)
}
@@ -75,7 +80,7 @@ struct ConnectionStatusPill: View {
private var labelText: Text {
switch status.status {
case .connected: return Text("Connected")
case .degraded: return Text("Connected — can't read Hermes state")
case .degraded(let reason, _, _): return Text("Connected — \(reason)")
case .idle: return Text("Checking…")
case .error(let message, _): return Text(verbatim: message)
}
@@ -89,13 +94,75 @@ struct ConnectionStatusPill: View {
return Text("Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))")
}
return Text("Connected")
case .degraded(let reason):
return Text("SSH works but \(reason). Click for diagnostics.")
case .degraded(let reason, _, _):
return Text("SSH works but \(reason). Click for details.")
case .idle: return Text("Waiting for first probe")
case .error: return Text("Click for details")
}
}
@ViewBuilder
private var degradedDetails: some View {
if case .degraded(let reason, let hint, let cause) = status.status {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
Label(reason, systemImage: "stethoscope")
.foregroundStyle(ScarfColor.warning)
.scarfStyle(.headline)
Spacer()
}
Divider()
Text(hint)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
if case .profileActive(let name) = cause {
// Specific copy-paste affordance for the profile case
// the most actionable hint, surfaced inline.
profileFixCommand(name: name)
}
HStack {
Button("Run diagnostics") {
showDegraded = false
showDiagnostics = true
}
.buttonStyle(ScarfSecondaryButton())
Spacer()
Button("Retry") {
status.retry()
showDegraded = false
}
.buttonStyle(ScarfPrimaryButton())
}
}
.padding(14)
.frame(width: 440)
}
}
@ViewBuilder
private func profileFixCommand(name _: String) -> some View {
let command = "hermes profile use default"
VStack(alignment: .leading, spacing: 6) {
Text("Or run this on the remote to switch back to the default profile:")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Text(command)
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.padding(6)
.background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
Spacer()
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(command, forType: .string)
}
.buttonStyle(.borderless)
}
}
}
@ViewBuilder
private var errorDetails: some View {
if case .error(let message, let stderr) = status.status {