Show SSH heartbeat status on blank browser tabs

Propagate workspace remote state + heartbeat into browser panels, add heartbeat emission in remote session controller, extend docker bootstrap regression for heartbeat continuity after blank browser open, and scrub workspace/surface env vars in tagged reload launches.
This commit is contained in:
Lawrence Chen 2026-03-01 19:40:22 -08:00
parent 803912a9e3
commit 0d3d71661d
5 changed files with 279 additions and 9 deletions

View file

@ -10,6 +10,13 @@ struct BrowserProxyEndpoint: Equatable {
let port: Int
}
struct BrowserRemoteWorkspaceStatus: Equatable {
let target: String
let connectionState: WorkspaceRemoteConnectionState
let heartbeatCount: Int
let lastHeartbeatAt: Date?
}
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
case google
case duckduckgo
@ -1305,6 +1312,9 @@ final class BrowserPanel: Panel, ObservableObject {
/// Published loading state
@Published private(set) var isLoading: Bool = false
/// Snapshot of remote SSH connection status for this panel's workspace.
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
/// Published download state for browser downloads (navigation + context menu).
@Published private(set) var isDownloading: Bool = false
@ -1513,6 +1523,11 @@ final class BrowserPanel: Panel, ObservableObject {
applyRemoteProxyConfigurationIfAvailable()
}
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
guard remoteWorkspaceStatus != status else { return }
remoteWorkspaceStatus = status
}
private func applyRemoteProxyConfigurationIfAvailable() {
guard #available(macOS 14.0, *) else { return }

View file

@ -729,19 +729,101 @@ struct BrowserPanelView: View {
}
})
} else {
Color(nsColor: browserChromeBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
addressBarFocused = false
}
ZStack(alignment: .topLeading) {
Color(nsColor: browserChromeBackgroundColor)
if let status = panel.remoteWorkspaceStatus {
remoteWorkspaceBlankStateIndicator(status)
.padding(.top, 12)
.padding(.leading, 12)
.allowsHitTesting(false)
}
}
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
addressBarFocused = false
}
}
}
}
.zIndex(0)
}
@ViewBuilder
private func remoteWorkspaceBlankStateIndicator(_ status: BrowserRemoteWorkspaceStatus) -> some View {
HStack(spacing: 8) {
Image(systemName: remoteStatusSymbolName(status.connectionState))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(remoteStatusColor(status.connectionState))
VStack(alignment: .leading, spacing: 1) {
Text("SSH \(status.target)")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color.primary.opacity(0.92))
.lineLimit(1)
Text(remoteStatusDetailText(status))
.font(.system(size: 10, weight: .regular))
.foregroundStyle(Color.primary.opacity(0.72))
.lineLimit(1)
}
.fixedSize(horizontal: true, vertical: false)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.85))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.primary.opacity(0.12), lineWidth: 1)
)
}
}
private func remoteStatusSymbolName(_ state: WorkspaceRemoteConnectionState) -> String {
switch state {
case .connected:
return "network"
case .connecting:
return "arrow.triangle.2.circlepath"
case .error:
return "exclamationmark.triangle.fill"
case .disconnected:
return "network.slash"
}
}
private func remoteStatusColor(_ state: WorkspaceRemoteConnectionState) -> Color {
switch state {
case .connected:
return .green
case .connecting:
return .orange
case .error:
return .red
case .disconnected:
return .secondary
}
}
private func remoteStatusDetailText(_ status: BrowserRemoteWorkspaceStatus) -> String {
switch status.connectionState {
case .connected:
guard let lastHeartbeat = status.lastHeartbeatAt else {
return "Connected, waiting for heartbeat"
}
let ageSeconds = max(0, Int(Date().timeIntervalSince(lastHeartbeat)))
return "Connected, heartbeat #\(status.heartbeatCount) \(ageSeconds)s ago"
case .connecting:
return "Connecting..."
case .error:
return "Connection error"
case .disconnected:
return "Disconnected"
}
}
private func triggerFocusFlashAnimation() {
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration