diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5138fe84..cd62aa6e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,7 +50,7 @@ jobs:
 
       - name: Authenticate to Google Cloud
         id: gcloud_auth
-        uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8
+        uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
         with:
           workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
           service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
@@ -103,7 +103,7 @@ jobs:
           popd
           gsutil -h "Cache-Control:no-cache,max-age=0" cp ./appcast.xml "gs://releases.coder.com/coder-desktop/mac/appcast.xml"
         env:
-          VERSION_DESCRIPTION: ${{ github.event_name == 'release' && github.event.release.body || '' }}
+          VERSION_DESCRIPTION: ${{ (github.event_name == 'release' && github.event.release.body) || (github.event_name == 'push' && github.event.head_commit.message) || '' }}
 
   update-cask:
     name: Update homebrew-coder cask
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png
index cc20c781..7ab987c4 100644
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/1024.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png
index 5e20c554..82746ce3 100644
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128@2x.png
new file mode 100644
index 00000000..bdb8b9ba
Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/128@2x.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png
index 70645cab..72cda2de 100644
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16@2x.png
new file mode 100644
index 00000000..52ebf9d0
Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/16@2x.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png
index 3d5fedb7..bdb8b9ba 100644
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/256.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png
index ee3b6142..52ebf9d0 100644
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32@2x.png
new file mode 100644
index 00000000..1b4d34d8
Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/32@2x.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png
index d4d68ed0..5a3a95b2 100644
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512@2x.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512@2x.png
new file mode 100644
index 00000000..5a3a95b2
Binary files /dev/null and b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/512@2x.png differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/64.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/64.png
deleted file mode 100644
index b3b212ed..00000000
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/64.png and /dev/null differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json
index d4e03efc..417149d7 100644
--- a/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/Coder-Desktop/Coder-Desktop/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,68 +1,68 @@
 {
-  "images" : [
+  "images": [
     {
-      "filename" : "16.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "16x16"
+      "filename": "16.png",
+      "idiom": "mac",
+      "scale": "1x",
+      "size": "16x16"
     },
     {
-      "filename" : "32.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "16x16"
+      "filename": "16@2x.png",
+      "idiom": "mac",
+      "scale": "2x",
+      "size": "16x16"
     },
     {
-      "filename" : "32.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "32x32"
+      "filename": "32.png",
+      "idiom": "mac",
+      "scale": "1x",
+      "size": "32x32"
     },
     {
-      "filename" : "64.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "32x32"
+      "filename": "32@2x.png",
+      "idiom": "mac",
+      "scale": "2x",
+      "size": "32x32"
     },
     {
-      "filename" : "128.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "128x128"
+      "filename": "128.png",
+      "idiom": "mac",
+      "scale": "1x",
+      "size": "128x128"
     },
     {
-      "filename" : "256.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "128x128"
+      "filename": "128@2x.png",
+      "idiom": "mac",
+      "scale": "2x",
+      "size": "128x128"
     },
     {
-      "filename" : "256.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "256x256"
+      "filename": "256.png",
+      "idiom": "mac",
+      "scale": "1x",
+      "size": "256x256"
     },
     {
-      "filename" : "512.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "256x256"
+      "filename": "512.png",
+      "idiom": "mac",
+      "scale": "2x",
+      "size": "256x256"
     },
     {
-      "filename" : "512.png",
-      "idiom" : "mac",
-      "scale" : "1x",
-      "size" : "512x512"
+      "filename": "512@2x.png",
+      "idiom": "mac",
+      "scale": "1x",
+      "size": "512x512"
     },
     {
-      "filename" : "1024.png",
-      "idiom" : "mac",
-      "scale" : "2x",
-      "size" : "512x512"
+      "filename": "1024.png",
+      "idiom": "mac",
+      "scale": "2x",
+      "size": "512x512"
     }
   ],
-  "info" : {
-    "author" : "xcode",
-    "version" : 1
+  "info": {
+    "author": "xcode",
+    "version": 1
   }
 }
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json
index a0327138..5e75486c 100644
--- a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json
+++ b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json
@@ -1,40 +1,26 @@
 {
   "images" : [
     {
-      "filename" : "coder_icon_16_dark.png",
-      "idiom" : "mac",
+      "filename" : "logo.svg",
+      "idiom" : "universal",
       "scale" : "1x"
     },
     {
-      "appearances" : [
-        {
-          "appearance" : "luminosity",
-          "value" : "dark"
-        }
-      ],
-      "filename" : "coder_icon_16.png",
-      "idiom" : "mac",
-      "scale" : "1x"
-    },
-    {
-      "filename" : "coder_icon_32_dark.png",
-      "idiom" : "mac",
+      "filename" : "logo.svg",
+      "idiom" : "universal",
       "scale" : "2x"
     },
     {
-      "appearances" : [
-        {
-          "appearance" : "luminosity",
-          "value" : "dark"
-        }
-      ],
-      "filename" : "coder_icon_32.png",
-      "idiom" : "mac",
-      "scale" : "2x"
+      "filename" : "logo.svg",
+      "idiom" : "universal",
+      "scale" : "3x"
     }
   ],
   "info" : {
     "author" : "xcode",
     "version" : 1
+  },
+  "properties" : {
+    "template-rendering-intent" : "template"
   }
 }
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png
deleted file mode 100644
index 3112e48e..00000000
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png and /dev/null differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16_dark.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16_dark.png
deleted file mode 100644
index 884c9699..00000000
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16_dark.png and /dev/null differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png
deleted file mode 100644
index 1e3ae4b9..00000000
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png and /dev/null differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32_dark.png b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32_dark.png
deleted file mode 100644
index 05bf4d41..00000000
Binary files a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32_dark.png and /dev/null differ
diff --git a/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/logo.svg b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/logo.svg
new file mode 100644
index 00000000..57a37920
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Assets.xcassets/MenuBarIcon.imageset/logo.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25.6 12">
+  <defs>
+    <style>
+      .cls-1 {
+        fill: #090b0b;
+        stroke-width: 0px;
+      }
+    </style>
+  </defs>
+  <g id="Layer_1-2" data-name="Layer 1">
+    <g>
+      <rect class="cls-1" x="15.83" y="0.33" width="9.73" height="11.35"/>
+      <path class="cls-1" d="M0,6c0-3.67,3.11-6,7.4-6s6.69,2.03,6.77,5.02l-3.70.11c-.10-1.66-1.56-2.74-3.06-2.71-2.06.04-3.59,1.41-3.59,3.59s1.53,3.52,3.59,3.52c1.50,0,2.93-1.04,3.10-2.69l3.70.08c-.10,3.04-2.64,5.09-6.79,5.09S0,9.56,0,6Z"/>0,4.24-5.66,4.24S0,7.97,0,5Z"/>
+    </g>
+  </g>
+</svg>
\ No newline at end of file
diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 3080e8c1..de12c6e1 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -84,6 +84,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     }
 
     func applicationDidFinishLaunching(_: Notification) {
+        // We have important file sync and network info behind tooltips,
+        // so the default delay is too long.
+        UserDefaults.standard.setValue(Theme.Animation.tooltipDelay, forKey: "NSInitialToolTipDelay")
         // Init SVG loader
         SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
 
diff --git a/Coder-Desktop/Coder-Desktop/Info.plist b/Coder-Desktop/Coder-Desktop/Info.plist
index f127b2c0..a9555823 100644
--- a/Coder-Desktop/Coder-Desktop/Info.plist
+++ b/Coder-Desktop/Coder-Desktop/Info.plist
@@ -37,5 +37,7 @@
 	<string>$(GIT_COMMIT_HASH)</string>
 	<key>SUFeedURL</key>
 	<string>https://releases.coder.com/coder-desktop/mac/appcast.xml</string>
+	<key>SUAllowsAutomaticUpdates</key>
+	<false/>
 </dict>
 </plist>
diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
index 4d4e9f90..91d5bf5e 100644
--- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift	
+++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift	
@@ -5,21 +5,21 @@ import SwiftUI
 final class PreviewVPN: Coder_Desktop.VPNService {
     @Published var state: Coder_Desktop.VPNServiceState = .connected
     @Published var menuState: VPNMenuState = .init(agents: [
-        UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
+        UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2",
                       wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
                       wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
-        UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
+        UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc",
                       wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
                       wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
                       wsID: UUID(), primaryHost: "asdf.coder"),
-        UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
+        UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2",
                       wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
                       wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
-        UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
+        UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc",
                       wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
                       wsID: UUID(), primaryHost: "asdf.coder"),
diff --git a/Coder-Desktop/Coder-Desktop/Theme.swift b/Coder-Desktop/Coder-Desktop/Theme.swift
index c697f1e3..ca7e77c1 100644
--- a/Coder-Desktop/Coder-Desktop/Theme.swift
+++ b/Coder-Desktop/Coder-Desktop/Theme.swift
@@ -11,10 +11,13 @@ enum Theme {
         static let appIconWidth: CGFloat = 17
         static let appIconHeight: CGFloat = 17
         static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
+
+        static let tableFooterIconSize: CGFloat = 28
     }
 
     enum Animation {
         static let collapsibleDuration = 0.2
+        static let tooltipDelay: Int = 250 // milliseconds
     }
 
     static let defaultVisibleAgents = 5
diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
index c989c1d7..d13be3c6 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
@@ -1,4 +1,5 @@
 import Foundation
+import SwiftProtobuf
 import SwiftUI
 import VPNLib
 
@@ -9,6 +10,29 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
     let hosts: [String]
     let wsName: String
     let wsID: UUID
+    let lastPing: LastPing?
+    let lastHandshake: Date?
+
+    init(id: UUID,
+         name: String,
+         status: AgentStatus,
+         hosts: [String],
+         wsName: String,
+         wsID: UUID,
+         lastPing: LastPing? = nil,
+         lastHandshake: Date? = nil,
+         primaryHost: String)
+    {
+        self.id = id
+        self.name = name
+        self.status = status
+        self.hosts = hosts
+        self.wsName = wsName
+        self.wsID = wsID
+        self.lastPing = lastPing
+        self.lastHandshake = lastHandshake
+        self.primaryHost = primaryHost
+    }
 
     // Agents are sorted by status, and then by name
     static func < (lhs: Agent, rhs: Agent) -> Bool {
@@ -18,21 +42,94 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
         return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
     }
 
+    var statusString: String {
+        switch status {
+        case .okay, .high_latency:
+            break
+        default:
+            return status.description
+        }
+
+        guard let lastPing else {
+            // Either:
+            // - Old coder deployment
+            // - We haven't received any pings yet
+            return status.description
+        }
+
+        let highLatencyWarning = status == .high_latency ? "(High latency)" : ""
+
+        var str: String
+        if lastPing.didP2p {
+            str = """
+            You're connected peer-to-peer. \(highLatencyWarning)
+
+            You ↔ \(lastPing.latency.prettyPrintMs) ↔ \(wsName)
+            """
+        } else {
+            str = """
+            You're connected through a DERP relay. \(highLatencyWarning)
+            We'll switch over to peer-to-peer when available.
+
+            Total latency: \(lastPing.latency.prettyPrintMs)
+            """
+            // We're not guranteed to have the preferred DERP latency
+            if let preferredDerpLatency = lastPing.preferredDerpLatency {
+                str += "\nYou ↔ \(lastPing.preferredDerp): \(preferredDerpLatency.prettyPrintMs)"
+                let derpToWorkspaceEstLatency = lastPing.latency - preferredDerpLatency
+                // We're not guaranteed the preferred derp latency is less than
+                // the total, as they might have been recorded at slightly
+                // different times, and we don't want to show a negative value.
+                if derpToWorkspaceEstLatency > 0 {
+                    str += "\n\(lastPing.preferredDerp) ↔ \(wsName): \(derpToWorkspaceEstLatency.prettyPrintMs)"
+                }
+            }
+        }
+        str += "\n\nLast handshake: \(lastHandshake?.relativeTimeString ?? "Unknown")"
+        return str
+    }
+
     let primaryHost: String
 }
 
+extension TimeInterval {
+    var prettyPrintMs: String {
+        let milliseconds = self * 1000
+        return "\(milliseconds.formatted(.number.precision(.fractionLength(2)))) ms"
+    }
+}
+
+struct LastPing: Equatable, Hashable {
+    let latency: TimeInterval
+    let didP2p: Bool
+    let preferredDerp: String
+    let preferredDerpLatency: TimeInterval?
+}
+
 enum AgentStatus: Int, Equatable, Comparable {
     case okay = 0
-    case warn = 1
-    case error = 2
-    case off = 3
+    case connecting = 1
+    case high_latency = 2
+    case no_recent_handshake = 3
+    case off = 4
+
+    public var description: String {
+        switch self {
+        case .okay: "Connected"
+        case .connecting: "Connecting..."
+        case .high_latency: "Connected, but with high latency" // Message currently unused
+        case .no_recent_handshake: "Could not establish a connection to the agent. Retrying..."
+        case .off: "Offline"
+        }
+    }
 
     public var color: Color {
         switch self {
         case .okay: .green
-        case .warn: .yellow
-        case .error: .red
+        case .high_latency: .yellow
+        case .no_recent_handshake: .red
         case .off: .secondary
+        case .connecting: .yellow
         }
     }
 
@@ -87,14 +184,27 @@ struct VPNMenuState {
         workspace.agents.insert(id)
         workspaces[wsID] = workspace
 
+        var lastPing: LastPing?
+        if agent.hasLastPing {
+            lastPing = LastPing(
+                latency: agent.lastPing.latency.timeInterval,
+                didP2p: agent.lastPing.didP2P,
+                preferredDerp: agent.lastPing.preferredDerp,
+                preferredDerpLatency:
+                agent.lastPing.hasPreferredDerpLatency
+                    ? agent.lastPing.preferredDerpLatency.timeInterval
+                    : nil
+            )
+        }
         agents[id] = Agent(
             id: id,
             name: agent.name,
-            // If last handshake was not within last five minutes, the agent is unhealthy
-            status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
+            status: agent.status,
             hosts: nonEmptyHosts,
             wsName: workspace.name,
             wsID: wsID,
+            lastPing: lastPing,
+            lastHandshake: agent.lastHandshake.maybeDate,
             // Hosts arrive sorted by length, the shortest looks best in the UI.
             primaryHost: nonEmptyHosts.first!
         )
@@ -154,3 +264,49 @@ struct VPNMenuState {
         workspaces.removeAll()
     }
 }
+
+extension Date {
+    var relativeTimeString: String {
+        let formatter = RelativeDateTimeFormatter()
+        formatter.unitsStyle = .full
+        if Date.now.timeIntervalSince(self) < 1.0 {
+            // Instead of showing "in 0 seconds"
+            return "Just now"
+        }
+        return formatter.localizedString(for: self, relativeTo: Date.now)
+    }
+}
+
+extension SwiftProtobuf.Google_Protobuf_Timestamp {
+    var maybeDate: Date? {
+        guard seconds > 0 else { return nil }
+        return date
+    }
+}
+
+extension Vpn_Agent {
+    var healthyLastHandshakeMin: Date {
+        Date.now.addingTimeInterval(-300) // 5 minutes ago
+    }
+
+    var healthyPingMax: TimeInterval { 0.15 } // 150ms
+
+    var status: AgentStatus {
+        // Initially the handshake is missing
+        guard let lastHandshake = lastHandshake.maybeDate else {
+            return .connecting
+        }
+        // If last handshake was not within the last five minutes, the agent
+        // is potentially unhealthy.
+        guard lastHandshake >= healthyLastHandshakeMin else {
+            return .no_recent_handshake
+        }
+        // No ping data, but we have a recent handshake.
+        // We show green for backwards compatibility with old Coder
+        // deployments.
+        guard hasLastPing else {
+            return .okay
+        }
+        return lastPing.latency.timeInterval < healthyPingMax ? .okay : .high_latency
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
index fc359e83..7b143969 100644
--- a/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift
@@ -8,45 +8,35 @@ struct CircularProgressView: View {
     var primaryColor: Color = .secondary
     var backgroundColor: Color = .secondary.opacity(0.3)
 
-    @State private var rotation = 0.0
-    @State private var trimAmount: CGFloat = 0.15
-
     var autoCompleteThreshold: Float?
     var autoCompleteDuration: TimeInterval?
 
     var body: some View {
         ZStack {
-            // Background circle
-            Circle()
-                .stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
-                .frame(width: diameter, height: diameter)
-            Group {
-                if let value {
-                    // Determinate gauge
+            if let value {
+                ZStack {
+                    Circle()
+                        .stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
+
                     Circle()
                         .trim(from: 0, to: CGFloat(displayValue(for: value)))
                         .stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
-                        .frame(width: diameter, height: diameter)
                         .rotationEffect(.degrees(-90))
                         .animation(autoCompleteAnimation(for: value), value: value)
-                } else {
-                    // Indeterminate gauge
-                    Circle()
-                        .trim(from: 0, to: trimAmount)
-                        .stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
-                        .frame(width: diameter, height: diameter)
-                        .rotationEffect(.degrees(rotation))
                 }
+                .frame(width: diameter, height: diameter)
+
+            } else {
+                IndeterminateSpinnerView(
+                    diameter: diameter,
+                    strokeWidth: strokeWidth,
+                    primaryColor: NSColor(primaryColor),
+                    backgroundColor: NSColor(backgroundColor)
+                )
+                .frame(width: diameter, height: diameter)
             }
         }
         .frame(width: diameter + strokeWidth * 2, height: diameter + strokeWidth * 2)
-        .onAppear {
-            if value == nil {
-                withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
-                    rotation = 360
-                }
-            }
-        }
     }
 
     private func displayValue(for value: Float) -> Float {
@@ -78,3 +68,55 @@ extension CircularProgressView {
         return view
     }
 }
+
+// We note a constant >10% CPU usage when using a SwiftUI rotation animation that
+// repeats forever, while this implementation, using Core Animation, uses <1% CPU.
+struct IndeterminateSpinnerView: NSViewRepresentable {
+    var diameter: CGFloat
+    var strokeWidth: CGFloat
+    var primaryColor: NSColor
+    var backgroundColor: NSColor
+
+    func makeNSView(context _: Context) -> NSView {
+        let view = NSView(frame: NSRect(x: 0, y: 0, width: diameter, height: diameter))
+        view.wantsLayer = true
+
+        guard let viewLayer = view.layer else { return view }
+
+        let fullPath = NSBezierPath(
+            ovalIn: NSRect(x: 0, y: 0, width: diameter, height: diameter)
+        ).cgPath
+
+        let backgroundLayer = CAShapeLayer()
+        backgroundLayer.path = fullPath
+        backgroundLayer.strokeColor = backgroundColor.cgColor
+        backgroundLayer.fillColor = NSColor.clear.cgColor
+        backgroundLayer.lineWidth = strokeWidth
+        viewLayer.addSublayer(backgroundLayer)
+
+        let foregroundLayer = CAShapeLayer()
+
+        foregroundLayer.frame = viewLayer.bounds
+        foregroundLayer.path = fullPath
+        foregroundLayer.strokeColor = primaryColor.cgColor
+        foregroundLayer.fillColor = NSColor.clear.cgColor
+        foregroundLayer.lineWidth = strokeWidth
+        foregroundLayer.lineCap = .round
+        foregroundLayer.strokeStart = 0
+        foregroundLayer.strokeEnd = 0.15
+        viewLayer.addSublayer(foregroundLayer)
+
+        let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation")
+        rotationAnimation.fromValue = 0
+        rotationAnimation.toValue = 2 * Double.pi
+        rotationAnimation.duration = 1.0
+        rotationAnimation.repeatCount = .infinity
+        rotationAnimation.isRemovedOnCompletion = false
+
+        foregroundLayer.add(rotationAnimation, forKey: "rotationAnimation")
+
+        return view
+    }
+
+    func updateNSView(_: NSView, context _: Context) {}
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
index 74006359..302bd135 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
@@ -47,7 +47,7 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                 }
             })
             .frame(minWidth: 400, minHeight: 200)
-            .padding(.bottom, 25)
+            .padding(.bottom, Theme.Size.tableFooterIconSize)
             .overlay(alignment: .bottom) {
                 tableFooter
             }
@@ -121,8 +121,8 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                 Button {
                     addingNewSession = true
                 } label: {
-                    Image(systemName: "plus")
-                        .frame(width: 24, height: 24).help("Create")
+                    FooterIcon(systemName: "plus")
+                        .help("Create")
                 }.disabled(vpn.menuState.agents.isEmpty)
                 sessionControls
             }
@@ -139,21 +139,25 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
                     Divider()
                     Button { Task { await delete(session: selectedSession) } }
                         label: {
-                            Image(systemName: "minus").frame(width: 24, height: 24).help("Terminate")
+                            FooterIcon(systemName: "minus")
+                                .help("Terminate")
                         }
                     Divider()
                     Button { Task { await pauseResume(session: selectedSession) } }
                         label: {
                             if selectedSession.status.isResumable {
-                                Image(systemName: "play").frame(width: 24, height: 24).help("Pause")
+                                FooterIcon(systemName: "play")
+                                    .help("Resume")
                             } else {
-                                Image(systemName: "pause").frame(width: 24, height: 24).help("Resume")
+                                FooterIcon(systemName: "pause")
+                                    .help("Pause")
                             }
                         }
                     Divider()
                     Button { Task { await reset(session: selectedSession) } }
                         label: {
-                            Image(systemName: "arrow.clockwise").frame(width: 24, height: 24).help("Reset")
+                            FooterIcon(systemName: "arrow.clockwise")
+                                .help("Reset")
                         }
                 }
             }
@@ -199,6 +203,18 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
     }
 }
 
+struct FooterIcon: View {
+    let systemName: String
+
+    var body: some View {
+        Image(systemName: systemName)
+            .frame(
+                width: Theme.Size.tableFooterIconSize,
+                height: Theme.Size.tableFooterIconSize
+            )
+    }
+}
+
 #if DEBUG
     #Preview {
         FileSyncConfig<PreviewVPN, PreviewFileSync>()
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index 3b92dc9d..880241a0 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -21,6 +21,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
         }
     }
 
+    var statusString: String {
+        switch self {
+        case let .agent(agent): agent.statusString
+        case .offlineWorkspace: status.description
+        }
+    }
+
     var id: UUID {
         switch self {
         case let .agent(agent): agent.id
@@ -224,13 +231,16 @@ struct MenuItemIcons: View {
         StatusDot(color: item.status.color)
             .padding(.trailing, 3)
             .padding(.top, 1)
+            .help(item.statusString)
         MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
             .font(.system(size: 9))
             .symbolVariant(.fill)
+            .help("Copy hostname")
         MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
             .contentShape(Rectangle())
             .font(.system(size: 12))
             .padding(.trailing, Theme.Size.trayMargin)
+            .help("Open in browser")
     }
 }
 
diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
index 741b32e5..8f84ab3d 100644
--- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
@@ -28,6 +28,7 @@ struct AgentsTests {
                 hosts: ["a\($0).coder"],
                 wsName: "ws\($0)",
                 wsID: UUID(),
+                lastPing: nil,
                 primaryHost: "a\($0).coder"
             )
             return (agent.id, agent)
diff --git a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift
index d82aff8e..dbd61a93 100644
--- a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift
@@ -18,6 +18,10 @@ struct VPNMenuStateTests {
             $0.workspaceID = workspaceID.uuidData
             $0.name = "dev"
             $0.lastHandshake = .init(date: Date.now)
+            $0.lastPing = .with {
+                $0.latency = .init(floatLiteral: 0.05)
+                $0.didP2P = true
+            }
             $0.fqdn = ["foo.coder"]
         }
 
@@ -29,6 +33,9 @@ struct VPNMenuStateTests {
         #expect(storedAgent.wsName == "foo")
         #expect(storedAgent.primaryHost == "foo.coder")
         #expect(storedAgent.status == .okay)
+        #expect(storedAgent.statusString.contains("You're connected peer-to-peer."))
+        #expect(storedAgent.statusString.contains("You ↔ 50.00 ms ↔ foo"))
+        #expect(storedAgent.statusString.contains("Last handshake: Just now"))
     }
 
     @Test
@@ -72,6 +79,49 @@ struct VPNMenuStateTests {
         #expect(state.workspaces[workspaceID] == nil)
     }
 
+    @Test
+    mutating func testUpsertAgent_poorConnection() async throws {
+        let agentID = UUID()
+        let workspaceID = UUID()
+        state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
+
+        let agent = Vpn_Agent.with {
+            $0.id = agentID.uuidData
+            $0.workspaceID = workspaceID.uuidData
+            $0.name = "agent1"
+            $0.lastHandshake = .init(date: Date.now)
+            $0.lastPing = .with {
+                $0.latency = .init(seconds: 1)
+            }
+            $0.fqdn = ["foo.coder"]
+        }
+
+        state.upsertAgent(agent)
+
+        let storedAgent = try #require(state.agents[agentID])
+        #expect(storedAgent.status == .high_latency)
+    }
+
+    @Test
+    mutating func testUpsertAgent_connecting() async throws {
+        let agentID = UUID()
+        let workspaceID = UUID()
+        state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
+
+        let agent = Vpn_Agent.with {
+            $0.id = agentID.uuidData
+            $0.workspaceID = workspaceID.uuidData
+            $0.name = "agent1"
+            $0.lastHandshake = .init()
+            $0.fqdn = ["foo.coder"]
+        }
+
+        state.upsertAgent(agent)
+
+        let storedAgent = try #require(state.agents[agentID])
+        #expect(storedAgent.status == .connecting)
+    }
+
     @Test
     mutating func testUpsertAgent_unhealthyAgent() async throws {
         let agentID = UUID()
@@ -89,7 +139,7 @@ struct VPNMenuStateTests {
         state.upsertAgent(agent)
 
         let storedAgent = try #require(state.agents[agentID])
-        #expect(storedAgent.status == .warn)
+        #expect(storedAgent.status == .no_recent_handshake)
     }
 
     @Test
@@ -114,6 +164,9 @@ struct VPNMenuStateTests {
             $0.workspaceID = workspaceID.uuidData
             $0.name = "agent1" // Same name as old agent
             $0.lastHandshake = .init(date: Date.now)
+            $0.lastPing = .with {
+                $0.latency = .init(floatLiteral: 0.05)
+            }
             $0.fqdn = ["foo.coder"]
         }
 
@@ -146,6 +199,10 @@ struct VPNMenuStateTests {
             $0.workspaceID = workspaceID.uuidData
             $0.name = "agent1"
             $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-200))
+            $0.lastPing = .with {
+                $0.didP2P = false
+                $0.latency = .init(floatLiteral: 0.05)
+            }
             $0.fqdn = ["foo.coder"]
         }
         state.upsertAgent(agent)
@@ -155,6 +212,10 @@ struct VPNMenuStateTests {
         #expect(output[0].id == agentID)
         #expect(output[0].wsName == "foo")
         #expect(output[0].status == .okay)
+        let storedAgentFromSort = try #require(state.agents[agentID])
+        #expect(storedAgentFromSort.statusString.contains("You're connected through a DERP relay."))
+        #expect(storedAgentFromSort.statusString.contains("Total latency: 50.00 ms"))
+        #expect(storedAgentFromSort.statusString.contains("Last handshake: 3 minutes ago"))
     }
 
     @Test
diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift
index 649a1612..952e301e 100644
--- a/Coder-Desktop/VPN/Manager.swift
+++ b/Coder-Desktop/VPN/Manager.swift
@@ -40,7 +40,6 @@ actor Manager {
                 dest: dest,
                 urlSession: URLSession(configuration: sessionConfig)
             ) { progress in
-                // TODO: Debounce, somehow
                 pushProgress(stage: .downloading, downloadProgress: progress)
             }
         } catch {
@@ -322,7 +321,7 @@ func writeVpnLog(_ log: Vpn_Log) {
         category: log.loggerNames.joined(separator: ".")
     )
     let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
-    logger.log(level: level, "\(log.message, privacy: .public): \(fields, privacy: .public)")
+    logger.log(level: level, "\(log.message, privacy: .public)\(fields.isEmpty ? "" : ": \(fields)", privacy: .public)")
 }
 
 private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
diff --git a/Coder-Desktop/VPNLib/Download.swift b/Coder-Desktop/VPNLib/Download.swift
index 99febc29..f6ffe5bc 100644
--- a/Coder-Desktop/VPNLib/Download.swift
+++ b/Coder-Desktop/VPNLib/Download.swift
@@ -146,15 +146,15 @@ func etag(data: Data) -> String {
 }
 
 public enum DownloadError: Error {
-    case unexpectedStatusCode(Int)
+    case unexpectedStatusCode(Int, url: String)
     case invalidResponse
     case networkError(any Error, url: String)
     case fileOpError(any Error)
 
     public var description: String {
         switch self {
-        case let .unexpectedStatusCode(code):
-            "Unexpected HTTP status code: \(code)"
+        case let .unexpectedStatusCode(code, url):
+            "Unexpected HTTP status code: \(code) - \(url)"
         case let .networkError(error, url):
             "Network error: \(url) - \(error.localizedDescription)"
         case let .fileOpError(error):
@@ -232,7 +232,12 @@ extension DownloadManager: URLSessionDownloadDelegate {
         }
 
         guard httpResponse.statusCode == 200 else {
-            continuation.resume(throwing: DownloadError.unexpectedStatusCode(httpResponse.statusCode))
+            continuation.resume(
+                throwing: DownloadError.unexpectedStatusCode(
+                    httpResponse.statusCode,
+                    url: httpResponse.url?.absoluteString ?? "Unknown URL"
+                )
+            )
             return
         }
 
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
index 80fa76ff..3ae85b87 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -47,9 +47,6 @@ public extension MutagenDaemon {
             }
         }
         do {
-            // The first creation will need to transfer the agent binary
-            // TODO: Because this is pretty long, we should show progress updates
-            // using the prompter messages
             _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout * 4)))
         } catch {
             throw .grpcFailure(error)
diff --git a/Coder-Desktop/VPNLib/vpn.pb.swift b/Coder-Desktop/VPNLib/vpn.pb.swift
index 3e728045..3f630d0e 100644
--- a/Coder-Desktop/VPNLib/vpn.pb.swift
+++ b/Coder-Desktop/VPNLib/vpn.pb.swift
@@ -520,11 +520,63 @@ public struct Vpn_Agent: @unchecked Sendable {
   /// Clears the value of `lastHandshake`. Subsequent reads from it will return its default value.
   public mutating func clearLastHandshake() {self._lastHandshake = nil}
 
+  /// If unset, a successful ping has not yet been made.
+  public var lastPing: Vpn_LastPing {
+    get {return _lastPing ?? Vpn_LastPing()}
+    set {_lastPing = newValue}
+  }
+  /// Returns true if `lastPing` has been explicitly set.
+  public var hasLastPing: Bool {return self._lastPing != nil}
+  /// Clears the value of `lastPing`. Subsequent reads from it will return its default value.
+  public mutating func clearLastPing() {self._lastPing = nil}
+
   public var unknownFields = SwiftProtobuf.UnknownStorage()
 
   public init() {}
 
   fileprivate var _lastHandshake: SwiftProtobuf.Google_Protobuf_Timestamp? = nil
+  fileprivate var _lastPing: Vpn_LastPing? = nil
+}
+
+public struct Vpn_LastPing: Sendable {
+  // SwiftProtobuf.Message conformance is added in an extension below. See the
+  // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
+  // methods supported on all messages.
+
+  /// latency is the RTT of the ping to the agent.
+  public var latency: SwiftProtobuf.Google_Protobuf_Duration {
+    get {return _latency ?? SwiftProtobuf.Google_Protobuf_Duration()}
+    set {_latency = newValue}
+  }
+  /// Returns true if `latency` has been explicitly set.
+  public var hasLatency: Bool {return self._latency != nil}
+  /// Clears the value of `latency`. Subsequent reads from it will return its default value.
+  public mutating func clearLatency() {self._latency = nil}
+
+  /// did_p2p indicates whether the ping was sent P2P, or over DERP.
+  public var didP2P: Bool = false
+
+  /// preferred_derp is the human readable name of the preferred DERP region,
+  /// or the region used for the last ping, if it was sent over DERP.
+  public var preferredDerp: String = String()
+
+  /// preferred_derp_latency is the last known latency to the preferred DERP
+  /// region. Unset if the region does not appear in the DERP map.
+  public var preferredDerpLatency: SwiftProtobuf.Google_Protobuf_Duration {
+    get {return _preferredDerpLatency ?? SwiftProtobuf.Google_Protobuf_Duration()}
+    set {_preferredDerpLatency = newValue}
+  }
+  /// Returns true if `preferredDerpLatency` has been explicitly set.
+  public var hasPreferredDerpLatency: Bool {return self._preferredDerpLatency != nil}
+  /// Clears the value of `preferredDerpLatency`. Subsequent reads from it will return its default value.
+  public mutating func clearPreferredDerpLatency() {self._preferredDerpLatency = nil}
+
+  public var unknownFields = SwiftProtobuf.UnknownStorage()
+
+  public init() {}
+
+  fileprivate var _latency: SwiftProtobuf.Google_Protobuf_Duration? = nil
+  fileprivate var _preferredDerpLatency: SwiftProtobuf.Google_Protobuf_Duration? = nil
 }
 
 /// NetworkSettingsRequest is based on
@@ -1579,6 +1631,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
     4: .same(proto: "fqdn"),
     5: .standard(proto: "ip_addrs"),
     6: .standard(proto: "last_handshake"),
+    7: .standard(proto: "last_ping"),
   ]
 
   public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@@ -1593,6 +1646,7 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
       case 4: try { try decoder.decodeRepeatedStringField(value: &self.fqdn) }()
       case 5: try { try decoder.decodeRepeatedStringField(value: &self.ipAddrs) }()
       case 6: try { try decoder.decodeSingularMessageField(value: &self._lastHandshake) }()
+      case 7: try { try decoder.decodeSingularMessageField(value: &self._lastPing) }()
       default: break
       }
     }
@@ -1621,6 +1675,9 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
     try { if let v = self._lastHandshake {
       try visitor.visitSingularMessageField(value: v, fieldNumber: 6)
     } }()
+    try { if let v = self._lastPing {
+      try visitor.visitSingularMessageField(value: v, fieldNumber: 7)
+    } }()
     try unknownFields.traverse(visitor: &visitor)
   }
 
@@ -1631,6 +1688,61 @@ extension Vpn_Agent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
     if lhs.fqdn != rhs.fqdn {return false}
     if lhs.ipAddrs != rhs.ipAddrs {return false}
     if lhs._lastHandshake != rhs._lastHandshake {return false}
+    if lhs._lastPing != rhs._lastPing {return false}
+    if lhs.unknownFields != rhs.unknownFields {return false}
+    return true
+  }
+}
+
+extension Vpn_LastPing: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
+  public static let protoMessageName: String = _protobuf_package + ".LastPing"
+  public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
+    1: .same(proto: "latency"),
+    2: .standard(proto: "did_p2p"),
+    3: .standard(proto: "preferred_derp"),
+    4: .standard(proto: "preferred_derp_latency"),
+  ]
+
+  public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
+    while let fieldNumber = try decoder.nextFieldNumber() {
+      // The use of inline closures is to circumvent an issue where the compiler
+      // allocates stack space for every case branch when no optimizations are
+      // enabled. https://github.com/apple/swift-protobuf/issues/1034
+      switch fieldNumber {
+      case 1: try { try decoder.decodeSingularMessageField(value: &self._latency) }()
+      case 2: try { try decoder.decodeSingularBoolField(value: &self.didP2P) }()
+      case 3: try { try decoder.decodeSingularStringField(value: &self.preferredDerp) }()
+      case 4: try { try decoder.decodeSingularMessageField(value: &self._preferredDerpLatency) }()
+      default: break
+      }
+    }
+  }
+
+  public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
+    // The use of inline closures is to circumvent an issue where the compiler
+    // allocates stack space for every if/case branch local when no optimizations
+    // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
+    // https://github.com/apple/swift-protobuf/issues/1182
+    try { if let v = self._latency {
+      try visitor.visitSingularMessageField(value: v, fieldNumber: 1)
+    } }()
+    if self.didP2P != false {
+      try visitor.visitSingularBoolField(value: self.didP2P, fieldNumber: 2)
+    }
+    if !self.preferredDerp.isEmpty {
+      try visitor.visitSingularStringField(value: self.preferredDerp, fieldNumber: 3)
+    }
+    try { if let v = self._preferredDerpLatency {
+      try visitor.visitSingularMessageField(value: v, fieldNumber: 4)
+    } }()
+    try unknownFields.traverse(visitor: &visitor)
+  }
+
+  public static func ==(lhs: Vpn_LastPing, rhs: Vpn_LastPing) -> Bool {
+    if lhs._latency != rhs._latency {return false}
+    if lhs.didP2P != rhs.didP2P {return false}
+    if lhs.preferredDerp != rhs.preferredDerp {return false}
+    if lhs._preferredDerpLatency != rhs._preferredDerpLatency {return false}
     if lhs.unknownFields != rhs.unknownFields {return false}
     return true
   }
diff --git a/Coder-Desktop/VPNLib/vpn.proto b/Coder-Desktop/VPNLib/vpn.proto
index b3fe54c5..59ea1933 100644
--- a/Coder-Desktop/VPNLib/vpn.proto
+++ b/Coder-Desktop/VPNLib/vpn.proto
@@ -3,6 +3,7 @@ option go_package = "github.com/coder/coder/v2/vpn";
 option csharp_namespace = "Coder.Desktop.Vpn.Proto";
 
 import "google/protobuf/timestamp.proto";
+import "google/protobuf/duration.proto";
 
 package vpn;
 
@@ -130,6 +131,21 @@ message Agent {
     // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
     // anything longer than 5 minutes ago means there is a problem.
     google.protobuf.Timestamp last_handshake = 6;
+    // If unset, a successful ping has not yet been made.
+    optional LastPing last_ping = 7;
+}
+
+message LastPing {
+    // latency is the RTT of the ping to the agent.
+    google.protobuf.Duration latency = 1;
+    // did_p2p indicates whether the ping was sent P2P, or over DERP.
+    bool did_p2p = 2;
+    // preferred_derp is the human readable name of the preferred DERP region,
+    // or the region used for the last ping, if it was sent over DERP.
+    string preferred_derp = 3;
+    // preferred_derp_latency is the last known latency to the preferred DERP
+    // region. Unset if the region does not appear in the DERP map.
+    optional google.protobuf.Duration preferred_derp_latency = 4;
 }
 
 // NetworkSettingsRequest is based on
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..53df24d6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+# Coder Desktop for macOS
+
+Coder Desktop allows you to work on your Coder workspaces as though they're
+on your local network, with no port-forwarding required.
+
+## Features:
+
+- Make your workspaces accessible from a `.coder` hostname.
+- Configure bidirectional file sync sessions between local and remote
+  directories.
+- Convenient one-click access to Coder workspace app IDEs, tools and VNC/RDP clients.
+
+Learn more about Coder Desktop in the
+[official documentation](https://coder.com/docs/user-guides/desktop).
+
+This repo contains the Swift source code for Coder Desktop for macOS. You can
+download the latest version from the GitHub releases.
+
+## Contributing
+
+See [CONTRIBUTING.MD](CONTRIBUTING.md)
+
+## License
+
+The Coder Desktop for macOS source is licensed under the GNU Affero General
+Public License v3.0 (AGPL-3.0).
+
+Some vendored files in this repo are licensed separately. The license for these
+files can be found in the same directory as the files.
diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh
index a679fee4..478ea610 100755
--- a/scripts/update-cask.sh
+++ b/scripts/update-cask.sh
@@ -85,8 +85,8 @@ cask "coder-desktop" do
   name "Coder Desktop"
   desc "Native desktop client for Coder"
   homepage "https://github.com/coder/coder-desktop-macos"
-  auto_updates true
 
+  auto_updates true
   depends_on macos: ">= :sonoma"
 
   pkg "Coder-Desktop.pkg"