From a10e95567875d4f0db7f722414a746eaf4cd380d Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Tue, 29 Apr 2025 22:29:32 +1000
Subject: [PATCH 1/4] feat: make workspace apps collapsible

---
 Coder-Desktop/Coder-Desktop/Theme.swift       |   4 +
 .../Coder-Desktop/VPN/MenuState.swift         |   7 +-
 .../Coder-Desktop/Views/VPN/Agents.swift      |   3 +-
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 180 ++++++++++++++----
 .../Views/VPN/WorkspaceAppIcon.swift          |   2 +-
 5 files changed, 149 insertions(+), 47 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Theme.swift b/Coder-Desktop/Coder-Desktop/Theme.swift
index 1c15b086..546242c2 100644
--- a/Coder-Desktop/Coder-Desktop/Theme.swift
+++ b/Coder-Desktop/Coder-Desktop/Theme.swift
@@ -13,5 +13,9 @@ enum Theme {
         static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
     }
 
+    enum Animation {
+        static let collapsibleDuration = 0.2
+    }
+
     static let defaultVisibleAgents = 5
 }
diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
index 59dfae08..f355debb 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
@@ -10,12 +10,9 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
     let wsName: String
     let wsID: UUID
 
-    // Agents are sorted by status, and then by name
+    // Agents are sorted by name
     static func < (lhs: Agent, rhs: Agent) -> Bool {
-        if lhs.status != rhs.status {
-            return lhs.status < rhs.status
-        }
-        return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
+        lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
     }
 
     let primaryHost: String
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
index 0ca65759..e42eab63 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
@@ -4,6 +4,7 @@ struct Agents<VPN: VPNService>: View {
     @EnvironmentObject var vpn: VPN
     @EnvironmentObject var state: AppState
     @State private var viewAll = false
+    @State private var expandedItem: VPNMenuItem.ID?
     private let defaultVisibleRows = 5
 
     let inspection = Inspection<Self>()
@@ -15,7 +16,7 @@ struct Agents<VPN: VPNService>: View {
                 let items = vpn.menuState.sorted
                 let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
                 ForEach(visibleItems, id: \.id) { agent in
-                    MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!)
+                    MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!, expandedItem: $expandedItem)
                         .padding(.horizontal, Theme.Size.trayMargin)
                 }
                 if items.count == 0 {
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index 1bc0b98b..b467933c 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -35,6 +35,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
         }
     }
 
+    func primaryHost(hostnameSuffix: String) -> String {
+        switch self {
+        case let .agent(agent): agent.primaryHost
+        case .offlineWorkspace: "\(wsName).\(hostnameSuffix)"
+        }
+    }
+
     static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
         switch (lhs, rhs) {
         case let (.agent(lhsAgent), .agent(rhsAgent)):
@@ -52,23 +59,22 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
 
 struct MenuItemView: View {
     @EnvironmentObject var state: AppState
+    @Environment(\.openURL) private var openURL
 
     private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMenu")
 
     let item: VPNMenuItem
     let baseAccessURL: URL
+    @Binding var expandedItem: VPNMenuItem.ID?
 
     @State private var nameIsSelected: Bool = false
-    @State private var copyIsSelected: Bool = false
 
-    private let defaultVisibleApps = 5
     @State private var apps: [WorkspaceApp] = []
 
+    var hasApps: Bool { !apps.isEmpty }
+
     private var itemName: AttributedString {
-        let name = switch item {
-        case let .agent(agent): agent.primaryHost
-        case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
-        }
+        let name = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
 
         var formattedName = AttributedString(name)
         formattedName.foregroundColor = .primary
@@ -79,17 +85,33 @@ struct MenuItemView: View {
         return formattedName
     }
 
+    private var isExpanded: Bool {
+        expandedItem == item.id
+    }
+
     private var wsURL: URL {
         // TODO: CoderVPN currently only supports owned workspaces
         baseAccessURL.appending(path: "@me").appending(path: item.wsName)
     }
 
+    private func toggleExpanded() {
+        if isExpanded {
+            withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
+                expandedItem = nil
+            }
+        } else {
+            withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
+                expandedItem = item.id
+            }
+        }
+    }
+
     var body: some View {
         VStack(spacing: 0) {
-            HStack(spacing: 0) {
-                Link(destination: wsURL) {
+            HStack(spacing: 3) {
+                Button(action: toggleExpanded) {
                     HStack(spacing: Theme.Size.trayPadding) {
-                        StatusDot(color: item.status.color)
+                        AnimatedChevron(isExpanded: isExpanded, color: .secondary)
                         Text(itemName).lineLimit(1).truncationMode(.tail)
                         Spacer()
                     }.padding(.horizontal, Theme.Size.trayPadding)
@@ -98,42 +120,24 @@ struct MenuItemView: View {
                         .foregroundStyle(nameIsSelected ? .white : .primary)
                         .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
                         .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
-                        .onHoverWithPointingHand { hovering in
+                        .onHover { hovering in
                             nameIsSelected = hovering
                         }
-                    Spacer()
-                }.buttonStyle(.plain)
-                if case let .agent(agent) = item {
-                    Button {
-                        NSPasteboard.general.clearContents()
-                        NSPasteboard.general.setString(agent.primaryHost, forType: .string)
-                    } label: {
-                        Image(systemName: "doc.on.doc")
-                            .symbolVariant(.fill)
-                            .padding(3)
-                            .contentShape(Rectangle())
-                    }.foregroundStyle(copyIsSelected ? .white : .primary)
-                        .imageScale(.small)
-                        .background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
-                        .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
-                        .onHoverWithPointingHand { hovering in copyIsSelected = hovering }
-                        .buttonStyle(.plain)
-                        .padding(.trailing, Theme.Size.trayMargin)
-                }
+                }.buttonStyle(.plain).padding(.trailing, 3)
+                MenuItemIcons(item: item, wsURL: wsURL)
             }
-            if !apps.isEmpty {
-                HStack(spacing: 17) {
-                    ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
-                        WorkspaceAppIcon(app: app)
-                            .frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
-                    }
-                    if apps.count < defaultVisibleApps {
-                        Spacer()
+            if isExpanded {
+                if hasApps {
+                    MenuItemCollapsibleView(apps: apps)
+                } else {
+                    HStack {
+                        Text(item.status == .off ? "Workspace is offline." : "No apps available.")
+                            .font(.body)
+                            .foregroundColor(.secondary)
+                            .padding(.horizontal, Theme.Size.trayInset)
+                            .padding(.top, 7)
                     }
                 }
-                .padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
-                .padding(.bottom, 5)
-                .padding(.top, 10)
             }
         }
         .task { await loadApps() }
@@ -172,3 +176,99 @@ struct MenuItemView: View {
         }
     }
 }
+
+struct MenuItemCollapsibleView: View {
+    private let defaultVisibleApps = 5
+    let apps: [WorkspaceApp]
+
+    var body: some View {
+        HStack(spacing: 17) {
+            ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
+                WorkspaceAppIcon(app: app)
+                    .frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
+            }
+            if apps.count < defaultVisibleApps {
+                Spacer()
+            }
+        }
+        .padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
+        .padding(.bottom, 5)
+        .padding(.top, 10)
+    }
+}
+
+struct MenuItemIcons: View {
+    @EnvironmentObject var state: AppState
+    @Environment(\.openURL) private var openURL
+
+    let item: VPNMenuItem
+    let wsURL: URL
+
+    @State private var copyIsSelected: Bool = false
+    @State private var webIsSelected: Bool = false
+
+    func copyToClipboard() {
+        let primaryHost = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(primaryHost, forType: .string)
+    }
+
+    var body: some View {
+        StatusDot(color: item.status.color)
+            .padding(.trailing, 3)
+            .padding(.top, 1)
+        MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
+            .font(.system(size: 9))
+            .symbolVariant(.fill)
+        MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
+            .contentShape(Rectangle())
+            .font(.system(size: 12))
+            .padding(.trailing, Theme.Size.trayMargin)
+    }
+}
+
+struct MenuItemIconButton: View {
+    let systemName: String
+    @State var isSelected: Bool = false
+    let action: @MainActor () -> Void
+
+    var body: some View {
+        Button {
+            action()
+        } label: {
+            Image(systemName: systemName)
+                .padding(3)
+                .contentShape(Rectangle())
+        }.foregroundStyle(isSelected ? .white : .primary)
+            .background(isSelected ? Color.accentColor.opacity(0.8) : .clear)
+            .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
+            .onHover { hovering in isSelected = hovering }
+            .buttonStyle(.plain)
+    }
+}
+
+struct AnimatedChevron: View {
+    let isExpanded: Bool
+    let color: Color
+
+    var body: some View {
+        Image(systemName: "chevron.right")
+            .font(.system(size: 12, weight: .semibold))
+            .foregroundColor(color)
+            .rotationEffect(.degrees(isExpanded ? 90 : 0))
+            .animation(.easeInOut(duration: Theme.Animation.collapsibleDuration), value: isExpanded)
+    }
+}
+
+#if DEBUG
+    #Preview {
+        let appState = AppState(persistent: false)
+        appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
+        // appState.clearSession()
+
+        return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
+            .environmentObject(PreviewVPN())
+            .environmentObject(appState)
+            .environmentObject(PreviewFileSync())
+    }
+#endif
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
index 70a20d8b..14a4bd0f 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
@@ -37,7 +37,7 @@ struct WorkspaceAppIcon: View {
             RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
                 .stroke(.secondary, lineWidth: 1)
                 .opacity(isHovering && !isPressed ? 0.6 : 0.3)
-        ).onHoverWithPointingHand { hovering in isHovering = hovering }
+        ).onHover { hovering in isHovering = hovering }
         .simultaneousGesture(
             DragGesture(minimumDistance: 0)
                 .onChanged { _ in

From 6147967536a8d1cab8c859618152a894729d8a5c Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Wed, 30 Apr 2025 12:27:56 +1000
Subject: [PATCH 2/4] fixup

---
 Coder-Desktop/Coder-Desktop/VPN/MenuState.swift |  7 +++++--
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift   | 17 +----------------
 .../Coder-DesktopTests/AgentsTests.swift        |  4 ++--
 3 files changed, 8 insertions(+), 20 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
index f355debb..8d37859a 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
@@ -10,9 +10,12 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
     let wsName: String
     let wsID: UUID
 
-    // Agents are sorted by name
+    // Agents are sorted by stauts, and then by name
     static func < (lhs: Agent, rhs: Agent) -> Bool {
-        lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
+        if lhs.status != rhs.status {
+            return lhs.status < rhs.status
+        }
+        return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
     }
 
     let primaryHost: String
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index b467933c..77ec5aa5 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -233,9 +233,7 @@ struct MenuItemIconButton: View {
     let action: @MainActor () -> Void
 
     var body: some View {
-        Button {
-            action()
-        } label: {
+        Button(action: action) {
             Image(systemName: systemName)
                 .padding(3)
                 .contentShape(Rectangle())
@@ -259,16 +257,3 @@ struct AnimatedChevron: View {
             .animation(.easeInOut(duration: Theme.Animation.collapsibleDuration), value: isExpanded)
     }
 }
-
-#if DEBUG
-    #Preview {
-        let appState = AppState(persistent: false)
-        appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
-        // appState.clearSession()
-
-        return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
-            .environmentObject(PreviewVPN())
-            .environmentObject(appState)
-            .environmentObject(PreviewFileSync())
-    }
-#endif
diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
index 62c1607f..741b32e5 100644
--- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
@@ -62,7 +62,7 @@ struct AgentsTests {
         let forEach = try view.inspect().find(ViewType.ForEach.self)
         #expect(forEach.count == Theme.defaultVisibleAgents)
         // Agents are sorted by status, and then by name in alphabetical order
-        #expect(throws: Never.self) { try view.inspect().find(link: "a1.coder") }
+        #expect(throws: Never.self) { try view.inspect().find(text: "a1.coder") }
     }
 
     @Test
@@ -115,7 +115,7 @@ struct AgentsTests {
             try await sut.inspection.inspect { view in
                 let forEach = try view.find(ViewType.ForEach.self)
                 #expect(forEach.count == Theme.defaultVisibleAgents)
-                #expect(throws: Never.self) { try view.find(link: "offline.coder") }
+                #expect(throws: Never.self) { try view.find(text: "offline.coder") }
             }
         }
     }

From cd5877ac2588a3b7bc9ed6c38542755bc37f38e5 Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Wed, 30 Apr 2025 12:36:51 +1000
Subject: [PATCH 3/4] typo

---
 Coder-Desktop/Coder-Desktop/VPN/MenuState.swift | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
index 8d37859a..59dfae08 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
@@ -10,7 +10,7 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
     let wsName: String
     let wsID: UUID
 
-    // Agents are sorted by stauts, and then by name
+    // Agents are sorted by status, and then by name
     static func < (lhs: Agent, rhs: Agent) -> Bool {
         if lhs.status != rhs.status {
             return lhs.status < rhs.status

From d4fe07e0ded329f9a1cf25b063326810713de31f Mon Sep 17 00:00:00 2001
From: Ethan Dickson <ethan@coder.com>
Date: Wed, 30 Apr 2025 15:32:00 +1000
Subject: [PATCH 4/4] expand first

---
 .../Coder-Desktop/Views/VPN/Agents.swift      | 21 +++++++++++++++++--
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift |  3 ++-
 2 files changed, 21 insertions(+), 3 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
index e42eab63..fb3928f6 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
@@ -5,6 +5,7 @@ struct Agents<VPN: VPNService>: View {
     @EnvironmentObject var state: AppState
     @State private var viewAll = false
     @State private var expandedItem: VPNMenuItem.ID?
+    @State private var hasToggledExpansion: Bool = false
     private let defaultVisibleRows = 5
 
     let inspection = Inspection<Self>()
@@ -16,8 +17,24 @@ struct Agents<VPN: VPNService>: View {
                 let items = vpn.menuState.sorted
                 let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
                 ForEach(visibleItems, id: \.id) { agent in
-                    MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!, expandedItem: $expandedItem)
-                        .padding(.horizontal, Theme.Size.trayMargin)
+                    MenuItemView(
+                        item: agent,
+                        baseAccessURL: state.baseAccessURL!,
+                        expandedItem: $expandedItem,
+                        userInteracted: $hasToggledExpansion
+                    )
+                    .padding(.horizontal, Theme.Size.trayMargin)
+                }.onChange(of: visibleItems) {
+                    // If no workspaces are online, we should expand the first one to come online
+                    if visibleItems.filter({ $0.status != .off }).isEmpty {
+                        hasToggledExpansion = false
+                        return
+                    }
+                    if hasToggledExpansion {
+                        return
+                    }
+                    expandedItem = visibleItems.first?.id
+                    hasToggledExpansion = true
                 }
                 if items.count == 0 {
                     Text("No workspaces!")
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index 77ec5aa5..d67e34ff 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -66,6 +66,7 @@ struct MenuItemView: View {
     let item: VPNMenuItem
     let baseAccessURL: URL
     @Binding var expandedItem: VPNMenuItem.ID?
+    @Binding var userInteracted: Bool
 
     @State private var nameIsSelected: Bool = false
 
@@ -95,6 +96,7 @@ struct MenuItemView: View {
     }
 
     private func toggleExpanded() {
+        userInteracted = true
         if isExpanded {
             withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
                 expandedItem = nil
@@ -254,6 +256,5 @@ struct AnimatedChevron: View {
             .font(.system(size: 12, weight: .semibold))
             .foregroundColor(color)
             .rotationEffect(.degrees(isExpanded ? 90 : 0))
-            .animation(.easeInOut(duration: Theme.Animation.collapsibleDuration), value: isExpanded)
     }
 }