mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
+121
-11
@@ -19,9 +19,14 @@ public final class ConnectionStatusViewModel {
|
|||||||
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
/// Healthy: SSH connected AND we can read `~/.hermes/config.yaml`.
|
||||||
case connected
|
case connected
|
||||||
/// SSH connects but the follow-up read-access probe failed. Data
|
/// SSH connects but the follow-up read-access probe failed. Data
|
||||||
/// views will be empty until this is resolved. `reason` is shown
|
/// views will be empty until this is resolved.
|
||||||
/// in the pill tooltip; users click the pill to open diagnostics.
|
///
|
||||||
case degraded(reason: String)
|
/// `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
|
/// No probe yet or the previous probe timed out but we haven't
|
||||||
/// confirmed failure. Shown as yellow to tell the user "checking…".
|
/// confirmed failure. Shown as yellow to tell the user "checking…".
|
||||||
case idle
|
case idle
|
||||||
@@ -30,6 +35,29 @@ public final class ConnectionStatusViewModel {
|
|||||||
case error(message: String, stderr: String)
|
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
|
public private(set) var status: Status = .idle
|
||||||
/// Timestamp of the last successful probe. Used by the UI to show how
|
/// Timestamp of the last successful probe. Used by the UI to show how
|
||||||
/// fresh the status indicator is ("just now", "2m ago"…).
|
/// fresh the status indicator is ("just now", "2m ago"…).
|
||||||
@@ -97,15 +125,40 @@ public final class ConnectionStatusViewModel {
|
|||||||
} else {
|
} else {
|
||||||
homeArg = "\"\(hermesHome.replacingOccurrences(of: "\"", with: "\\\""))\""
|
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 = """
|
let script = """
|
||||||
echo TIER1:0
|
echo TIER1:0
|
||||||
H=\(homeArg)
|
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 {
|
enum ProbeOutcome {
|
||||||
case connected
|
case connected
|
||||||
case degraded(reason: String)
|
case degraded(reason: String, hint: String, cause: DegradedCause)
|
||||||
case failure(TransportError)
|
case failure(TransportError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +183,12 @@ public final class ConnectionStatusViewModel {
|
|||||||
if tier2 {
|
if tier2 {
|
||||||
return .connected
|
return .connected
|
||||||
}
|
}
|
||||||
// Connected but can't read config.yaml — the core issue #19
|
// Connected but tier 2 failed. Parse the granular cause
|
||||||
// symptom. Give the pill a short reason; the full story goes
|
// code; older remotes that don't emit a tag fall through
|
||||||
// into Remote Diagnostics.
|
// to `.unknown` with a generic hint (issue #53).
|
||||||
return .degraded(reason: "can't read ~/.hermes/config.yaml")
|
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 {
|
} catch let e as TransportError {
|
||||||
return .failure(e)
|
return .failure(e)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -146,8 +201,8 @@ public final class ConnectionStatusViewModel {
|
|||||||
status = .connected
|
status = .connected
|
||||||
lastSuccess = Date()
|
lastSuccess = Date()
|
||||||
consecutiveFailures = 0
|
consecutiveFailures = 0
|
||||||
case .degraded(let reason):
|
case .degraded(let reason, let hint, let cause):
|
||||||
status = .degraded(reason: reason)
|
status = .degraded(reason: reason, hint: hint, cause: cause)
|
||||||
lastSuccess = Date() // SSH itself is fine, reset failure count
|
lastSuccess = Date() // SSH itself is fine, reset failure count
|
||||||
consecutiveFailures = 0
|
consecutiveFailures = 0
|
||||||
case .failure(let err):
|
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 {
|
struct ConnectionStatusPill: View {
|
||||||
let status: ConnectionStatusViewModel
|
let status: ConnectionStatusViewModel
|
||||||
@State private var showDetails = false
|
@State private var showDetails = false
|
||||||
|
@State private var showDegraded = false
|
||||||
@State private var showDiagnostics = false
|
@State private var showDiagnostics = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -18,9 +19,10 @@ struct ConnectionStatusPill: View {
|
|||||||
case .error:
|
case .error:
|
||||||
showDetails = true
|
showDetails = true
|
||||||
case .degraded:
|
case .degraded:
|
||||||
// Yellow "can't read" state — open the diagnostics sheet
|
// Show the granular reason + hint inline first (issue
|
||||||
// so the user can see exactly which files fail and why.
|
// #53). The user can drill into the full diagnostics
|
||||||
showDiagnostics = true
|
// sheet from the popover if the hint isn't enough.
|
||||||
|
showDegraded = true
|
||||||
case .connected, .idle:
|
case .connected, .idle:
|
||||||
status.retry()
|
status.retry()
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,9 @@ struct ConnectionStatusPill: View {
|
|||||||
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
|
.popover(isPresented: $showDetails, arrowEdge: .bottom) {
|
||||||
errorDetails.frame(width: 400)
|
errorDetails.frame(width: 400)
|
||||||
}
|
}
|
||||||
|
.popover(isPresented: $showDegraded, arrowEdge: .bottom) {
|
||||||
|
degradedDetails.frame(width: 440)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showDiagnostics) {
|
.sheet(isPresented: $showDiagnostics) {
|
||||||
RemoteDiagnosticsView(context: status.context)
|
RemoteDiagnosticsView(context: status.context)
|
||||||
}
|
}
|
||||||
@@ -75,7 +80,7 @@ struct ConnectionStatusPill: View {
|
|||||||
private var labelText: Text {
|
private var labelText: Text {
|
||||||
switch status.status {
|
switch status.status {
|
||||||
case .connected: return Text("Connected")
|
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 .idle: return Text("Checking…")
|
||||||
case .error(let message, _): return Text(verbatim: message)
|
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("Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))")
|
||||||
}
|
}
|
||||||
return Text("Connected")
|
return Text("Connected")
|
||||||
case .degraded(let reason):
|
case .degraded(let reason, _, _):
|
||||||
return Text("SSH works but \(reason). Click for diagnostics.")
|
return Text("SSH works but \(reason). Click for details.")
|
||||||
case .idle: return Text("Waiting for first probe")
|
case .idle: return Text("Waiting for first probe")
|
||||||
case .error: return Text("Click for details")
|
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
|
@ViewBuilder
|
||||||
private var errorDetails: some View {
|
private var errorDetails: some View {
|
||||||
if case .error(let message, let stderr) = status.status {
|
if case .error(let message, let stderr) = status.status {
|
||||||
|
|||||||
Reference in New Issue
Block a user