From 344108d7eeb14ae60f96623fa4cbb8aad391678a Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 28 Apr 2026 18:46:18 +0500 Subject: [PATCH 1/8] feat: group agents by workspace and nest children The tray shows every agent flat today, which gets unwieldy with many workspaces or workspaces that have sub-agents (devcontainers). Group rows by workspace, and nest child agents under their parent. parent_id is sourced from the HTTP API since the VPN proto doesn't carry it. The Agents view backfills it lazily via a per-workspace task and the menu state preserves the enriched value across subsequent VPN upserts. Single-agent workspaces stay flat so the common case is unchanged. --- .../Preview Content/PreviewVPN.swift | 4 + .../Coder-Desktop/VPN/MenuState.swift | 141 ++++++++++++-- .../Coder-Desktop/VPN/VPNService.swift | 23 ++- .../Coder-Desktop/Views/VPN/Agents.swift | 65 +++++-- .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 134 +++++++++++++- Coder-Desktop/Coder-DesktopTests/Util.swift | 5 + .../VPNMenuStateTests.swift | 175 +++++++++++++++--- Coder-Desktop/CoderSDK/Workspace.swift | 10 +- 8 files changed, 491 insertions(+), 66 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift index 91d5bf5e..92408294 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift @@ -81,5 +81,9 @@ final class PreviewVPN: Coder_Desktop.VPNService { state = .connecting } + func setAgentParentID(agentID: UUID, parentID: UUID?) { + menuState.setAgentParentID(agentID: agentID, parentID: parentID) + } + var startWhenReady: Bool = false } diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index d13be3c6..bd74b7b6 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -10,6 +10,10 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable { let hosts: [String] let wsName: String let wsID: UUID + // parentID is enriched from the HTTP API after the VPN proto delivers the + // agent. It identifies the owning agent for child agents (e.g. devcontainer + // sub-agents). nil means top-level. + var parentID: UUID? let lastPing: LastPing? let lastHandshake: Date? @@ -19,6 +23,7 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable { hosts: [String], wsName: String, wsID: UUID, + parentID: UUID? = nil, lastPing: LastPing? = nil, lastHandshake: Date? = nil, primaryHost: String) @@ -29,17 +34,23 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable { self.hosts = hosts self.wsName = wsName self.wsID = wsID + self.parentID = parentID self.lastPing = lastPing self.lastHandshake = lastHandshake self.primaryHost = primaryHost } - // Agents are sorted by status, and then by name + /// Agents are sorted by status, and then by name. Within a workspace group, + /// top-level agents (parentID == nil) are surfaced before children by the + /// grouping logic — this comparator only orders peers. static func < (lhs: Agent, rhs: Agent) -> Bool { if lhs.status != rhs.status { return lhs.status < rhs.status } - return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending + if lhs.wsName != rhs.wsName { + return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending + } + return lhs.name.localizedCompare(rhs.name) == .orderedAscending } var statusString: String { @@ -113,7 +124,7 @@ enum AgentStatus: Int, Equatable, Comparable { case no_recent_handshake = 3 case off = 4 - public var description: String { + var description: String { switch self { case .okay: "Connected" case .connecting: "Connecting..." @@ -123,7 +134,7 @@ enum AgentStatus: Int, Equatable, Comparable { } } - public var color: Color { + var color: Color { switch self { case .okay: .green case .high_latency: .yellow @@ -148,18 +159,78 @@ struct Workspace: Identifiable, Equatable, Comparable { } } +/// WorkspaceGroup is the unit the tray renders: one workspace with the agents +/// belonging to it, organized into a parent/child tree. +struct WorkspaceGroup: Identifiable, Equatable, Comparable { + let workspace: Workspace + /// All agents in the workspace, unsorted. + let agents: [Agent] + + var id: UUID { + workspace.id + } + + /// Aggregate status: worst-of among agents, or .off if the workspace has + /// none (treated as offline). + var status: AgentStatus { + agents.map(\.status).max() ?? .off + } + + /// Top-level agents are those without a parent, plus any agent whose + /// parentID points outside this group's known agents (orphans render at the + /// top level rather than disappearing). + var topLevelAgents: [Agent] { + let knownIDs = Set(agents.map(\.id)) + return agents + .filter { agent in + guard let parentID = agent.parentID else { return true } + return !knownIDs.contains(parentID) + } + .sorted() + } + + /// Children of the given parent agent, sorted. + func children(of parentID: UUID) -> [Agent] { + agents.filter { $0.parentID == parentID }.sorted() + } + + /// Tree-walked agents paired with their indent depth (1 for top-level). + /// Handles arbitrary parent_id depth; cycles are guarded against by + /// tracking visited IDs. + var indentedAgents: [(agent: Agent, indent: Int)] { + var result: [(Agent, Int)] = [] + var visited: Set = [] + func walk(_ agent: Agent, indent: Int) { + guard visited.insert(agent.id).inserted else { return } + result.append((agent, indent)) + for child in children(of: agent.id) { + walk(child, indent: indent + 1) + } + } + for top in topLevelAgents { + walk(top, indent: 1) + } + return result + } + + static func < (lhs: WorkspaceGroup, rhs: WorkspaceGroup) -> Bool { + if lhs.status != rhs.status { return lhs.status < rhs.status } + return lhs.workspace < rhs.workspace + } +} + struct VPNMenuState { var agents: [UUID: Agent] = [:] var workspaces: [UUID: Workspace] = [:] - // Upserted agents that don't belong to any known workspace, have no FQDNs, - // or have any invalid UUIDs. + /// Upserted agents that don't belong to any known workspace, have no FQDNs, + /// or have any invalid UUIDs. var invalidAgents: [Vpn_Agent] = [] - public func findAgent(workspaceID: UUID, name: String) -> Agent? { + func findAgent(workspaceID: UUID, name: String) -> Agent? { agents.first(where: { $0.value.wsID == workspaceID && $0.value.name == name })?.value } - public func findWorkspace(name: String) -> Workspace? { + func findWorkspace(name: String) -> Workspace? { workspaces .first(where: { $0.value.name == name })?.value } @@ -196,6 +267,9 @@ struct VPNMenuState { : nil ) } + // The proto doesn't carry parent_id, so preserve any value we already + // enriched for this agent from the HTTP API. + let existingParentID = agents[id]?.parentID agents[id] = Agent( id: id, name: agent.name, @@ -203,6 +277,7 @@ struct VPNMenuState { hosts: nonEmptyHosts, wsName: workspace.name, wsID: wsID, + parentID: existingParentID, lastPing: lastPing, lastHandshake: agent.lastHandshake.maybeDate, // Hosts arrive sorted by length, the shortest looks best in the UI. @@ -210,6 +285,12 @@ struct VPNMenuState { ) } + mutating func setAgentParentID(agentID: UUID, parentID: UUID?) { + guard var agent = agents[agentID], agent.parentID != parentID else { return } + agent.parentID = parentID + agents[agentID] = agent + } + mutating func deleteAgent(withId id: Data) { guard let agentUUID = UUID(uuidData: id) else { return } // Update Workspaces @@ -248,16 +329,40 @@ struct VPNMenuState { workspaces[wsID] = nil } - var sorted: [VPNMenuItem] { - var items = agents.values.map { VPNMenuItem.agent($0) } - // Workspaces with no agents are shown as offline - items += workspaces.filter { _, value in - value.agents.isEmpty - }.map { VPNMenuItem.offlineWorkspace(Workspace(id: $0.key, name: $0.value.name, agents: $0.value.agents)) } - return items.sorted() + /// Groups all known agents under their workspace, nesting child agents + /// (those with a parentID) under their parent. Empty workspaces still appear + /// as offline groups. + var grouped: [WorkspaceGroup] { + let agentsByWorkspace = Dictionary(grouping: agents.values, by: \.wsID) + + // Start from the known workspaces, then synthesize one for any wsID + // that shows up in agents without a workspace upsert (test fixtures + // and out-of-order proto delivery do this). + var workspacesByID = workspaces + for (wsID, wsAgents) in agentsByWorkspace where workspacesByID[wsID] == nil { + guard let firstAgent = wsAgents.first else { continue } + workspacesByID[wsID] = Workspace( + id: wsID, + name: firstAgent.wsName, + agents: Set(wsAgents.map(\.id)) + ) + } + + return workspacesByID + .map { wsID, workspace in + // Normalize the workspace id to the dictionary key — matches + // the existing var sorted behavior, and tests rely on it. + WorkspaceGroup( + workspace: Workspace(id: wsID, name: workspace.name, agents: workspace.agents), + agents: agentsByWorkspace[wsID] ?? [] + ) + } + .sorted() } - var onlineAgents: [Agent] { agents.map(\.value) } + var onlineAgents: [Agent] { + agents.map(\.value) + } mutating func clear() { agents.removeAll() @@ -289,7 +394,9 @@ extension Vpn_Agent { Date.now.addingTimeInterval(-300) // 5 minutes ago } - var healthyPingMax: TimeInterval { 0.15 } // 150ms + var healthyPingMax: TimeInterval { + 0.15 + } // 150ms var status: AgentStatus { // Initially the handshake is missing diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 9da39d5b..9de701bc 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -12,6 +12,11 @@ protocol VPNService: ObservableObject { func stop() async func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) var startWhenReady: Bool { get set } + + /// Backfill parent_id for an agent. Sourced from the HTTP API since the VPN + /// proto doesn't carry it. Called by the UI layer after fetching workspace + /// details so child agents can be nested under their parent. + func setAgentParentID(agentID: UUID, parentID: UUID?) } enum VPNServiceState: Equatable { @@ -37,7 +42,7 @@ enum VPNServiceError: Error, Equatable { case systemExtensionError(SystemExtensionState) case networkExtensionError(NetworkExtensionState) - public var description: String { + var description: String { switch self { case let .internalError(description): "Internal Error: \(description)" @@ -48,7 +53,9 @@ enum VPNServiceError: Error, Equatable { } } - public var localizedDescription: String { description } + var localizedDescription: String { + description + } } @MainActor @@ -88,9 +95,9 @@ final class CoderVPNService: NSObject, VPNService { var startWhenReady: Bool = false var onStart: (() -> Void)? - // systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get - // garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework - // only stores a weak reference to the delegate. + /// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get + /// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework + /// only stores a weak reference to the delegate. var systemExtnDelegate: SystemExtensionDelegate? var serverAddress: String? @@ -177,10 +184,14 @@ final class CoderVPNService: NSObject, VPNService { update.upsertedWorkspaces.forEach { menuState.upsertWorkspace($0) } update.upsertedAgents.forEach { menuState.upsertAgent($0) } } + + func setAgentParentID(agentID: UUID, parentID: UUID?) { + menuState.setAgentParentID(agentID: agentID, parentID: parentID) + } } extension CoderVPNService { - public func vpnDidUpdate(_ connection: NETunnelProviderSession) { + func vpnDidUpdate(_ connection: NETunnelProviderSession) { switch (tunnelState, connection.status) { // Any -> Disconnected: Update UI w/ error if present case (_, .disconnected): diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index 58df8d31..8cbd0226 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -4,30 +4,31 @@ struct Agents: View { @EnvironmentObject var vpn: VPN @EnvironmentObject var state: AppState @State private var viewAll = false - @State private var expandedItem: VPNMenuItem.ID? + @State private var expandedItem: UUID? @State private var hasToggledExpansion: Bool = false - private let defaultVisibleRows = 5 + @State private var enrichedWorkspaces: Set = [] let inspection = Inspection() var body: some View { Group { - // Agents List if vpn.state == .connected { - let items = vpn.menuState.sorted - let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows) + let groups = vpn.menuState.grouped + let visibleGroups = viewAll + ? Array(groups) + : Array(groups.prefix(Theme.defaultVisibleAgents)) ScrollView(showsIndicators: false) { - ForEach(visibleItems, id: \.id) { agent in - MenuItemView( - item: agent, + ForEach(visibleGroups, id: \.id) { group in + WorkspaceGroupView( + group: group, 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 { + }.onChange(of: visibleGroups) { + // If no workspaces are online, expand the first one to come online. + if visibleGroups.allSatisfy({ $0.status == .off }) { hasToggledExpansion = false return } @@ -35,22 +36,24 @@ struct Agents: View { return } withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { - expandedItem = visibleItems.first?.id + expandedItem = visibleGroups.first?.defaultExpandID } hasToggledExpansion = true } } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: 400) - if items.count == 0 { + .task(id: Set(groups.map(\.id))) { + await enrichParents(groups: groups) + } + if groups.isEmpty { Text("No workspaces!") .font(.body) .foregroundColor(.secondary) .padding(.horizontal, Theme.Size.trayInset) .padding(.top, 2) } - // Only show the toggle if there are more items to show - if items.count > defaultVisibleRows { + if groups.count > Theme.defaultVisibleAgents { Toggle(isOn: $viewAll) { Text(viewAll ? "Show less" : "Show all") .font(.headline) @@ -62,4 +65,36 @@ struct Agents: View { } }.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector } + + /// Backfill agent.parent_id from the HTTP API. The VPN proto doesn't carry + /// it, so without this children would never nest under their parent in the + /// tray. Best-effort: failures are silent and retried whenever the set of + /// workspace IDs changes. + private func enrichParents(groups: [WorkspaceGroup]) async { + guard let client = state.client else { return } + for group in groups where !enrichedWorkspaces.contains(group.id) { + do { + let workspace = try await client.workspace(group.id) + let agents = workspace.latest_build.resources.compactMap(\.agents).flatMap(\.self) + for agent in agents { + vpn.setAgentParentID(agentID: agent.id, parentID: agent.parent_id) + } + enrichedWorkspaces.insert(group.id) + } catch { + continue + } + } + } +} + +private extension WorkspaceGroup { + /// For the auto-expand-first behavior: single-agent groups expand the + /// agent's app section (existing UX); multi-agent groups expand the + /// workspace itself to reveal nested agents. + var defaultExpandID: UUID { + if agents.count == 1, let only = agents.first { + return only.id + } + return id + } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index 71e753d2..c6c0079e 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -2,7 +2,7 @@ import CoderSDK import os import SwiftUI -// Each row in the workspaces list is an agent or an offline workspace +/// Each row in the workspaces list is an agent or an offline workspace enum VPNMenuItem: Equatable, Comparable, Identifiable { case agent(Agent) case offlineWorkspace(Workspace) @@ -81,7 +81,9 @@ struct MenuItemView: View { @State private var loadingApps: Bool = true - var hasApps: Bool { !apps.isEmpty } + var hasApps: Bool { + !apps.isEmpty + } private var plainItemName: String { item.primaryHost(hostnameSuffix: state.hostnameSuffix) @@ -280,3 +282,131 @@ struct AnimatedChevron: View { .rotationEffect(.degrees(isExpanded ? 90 : 0)) } } + +/// WorkspaceGroupView renders one workspace's agents. Single-agent and offline +/// workspaces fall through to the existing flat MenuItemView; workspaces with +/// multiple agents get a collapsible header with their agents nested below, +/// and child agents (those with a parent_id) are nested under their parent. +struct WorkspaceGroupView: View { + let group: WorkspaceGroup + let baseAccessURL: URL + @Binding var expandedItem: UUID? + @Binding var userInteracted: Bool + + /// Apps-section expansion for nested agent rows is local to the group so it + /// doesn't fight the outer expandedItem (which controls workspace-level + /// expansion for multi-agent groups). + @State private var nestedExpandedAgent: UUID? + + var body: some View { + if group.agents.count <= 1 { + let item: VPNMenuItem = group.agents.first.map { .agent($0) } + ?? .offlineWorkspace(group.workspace) + MenuItemView( + item: item, + baseAccessURL: baseAccessURL, + expandedItem: $expandedItem, + userInteracted: $userInteracted + ) + } else { + VStack(spacing: 0) { + WorkspaceHeaderRow( + group: group, + baseAccessURL: baseAccessURL, + isExpanded: expandedItem == group.id, + onToggle: toggleGroupExpansion + ) + if expandedItem == group.id { + ForEach(group.indentedAgents, id: \.agent.id) { entry in + nestedRow(agent: entry.agent, indent: entry.indent) + } + } + } + } + } + + private func toggleGroupExpansion() { + userInteracted = true + withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { + expandedItem = expandedItem == group.id ? nil : group.id + } + } + + private func nestedRow(agent: Agent, indent: Int) -> some View { + MenuItemView( + item: .agent(agent), + baseAccessURL: baseAccessURL, + expandedItem: $nestedExpandedAgent, + userInteracted: $userInteracted + ) + .padding(.leading, CGFloat(indent) * Theme.Size.trayPadding) + } +} + +struct WorkspaceHeaderRow: View { + @EnvironmentObject var state: AppState + @Environment(\.openURL) private var openURL + + let group: WorkspaceGroup + let baseAccessURL: URL + let isExpanded: Bool + let onToggle: () -> Void + + @State private var nameIsSelected: Bool = false + + private var plainName: String { + "\(group.workspace.name).\(state.hostnameSuffix)" + } + + private var styledName: AttributedString { + var name = AttributedString(plainName) + name.foregroundColor = .primary + if let range = name.range(of: ".\(state.hostnameSuffix)", options: .backwards) { + name[range].foregroundColor = .secondary + } + return name + } + + private var wsURL: URL { + // TODO: CoderVPN currently only supports owned workspaces. + baseAccessURL.appending(path: "@me").appending(path: group.workspace.name) + } + + private func copyToClipboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(plainName, forType: .string) + } + + var body: some View { + HStack(spacing: 3) { + Button(action: onToggle) { + HStack(spacing: Theme.Size.trayPadding) { + AnimatedChevron(isExpanded: isExpanded, color: .secondary) + Text(styledName).lineLimit(1).truncationMode(.tail) + Spacer() + } + .padding(.horizontal, Theme.Size.trayPadding) + .frame(minHeight: 22) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(nameIsSelected ? .white : .primary) + .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) + .onHover { hovering in nameIsSelected = hovering } + .help(plainName) + }.buttonStyle(.plain).padding(.trailing, 3) + StatusDot(color: group.status.color) + .padding(.trailing, 3) + .padding(.top, 1) + .help(group.status.description) + 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/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index 60751274..4e679040 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -25,6 +25,11 @@ class MockVPNService: VPNService, ObservableObject { } func configureTunnelProviderProtocol(proto _: NETunnelProviderProtocol?) {} + + func setAgentParentID(agentID: UUID, parentID: UUID?) { + menuState.setAgentParentID(agentID: agentID, parentID: parentID) + } + var startWhenReady: Bool = false } diff --git a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift index dbd61a93..13a3f965 100644 --- a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift @@ -3,12 +3,11 @@ import Testing @testable import VPNLib @MainActor -@Suite struct VPNMenuStateTests { var state = VPNMenuState() @Test - mutating func testUpsertAgent_addsAgent() async throws { + mutating func upsertAgent_addsAgent() throws { let agentID = UUID() let workspaceID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) @@ -39,7 +38,7 @@ struct VPNMenuStateTests { } @Test - mutating func testDeleteAgent_removesAgent() async throws { + mutating func deleteAgent_removesAgent() { let agentID = UUID() let workspaceID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) @@ -59,7 +58,7 @@ struct VPNMenuStateTests { } @Test - mutating func testDeleteWorkspace_removesWorkspaceAndAgents() async throws { + mutating func deleteWorkspace_removesWorkspaceAndAgents() { let agentID = UUID() let workspaceID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) @@ -80,7 +79,7 @@ struct VPNMenuStateTests { } @Test - mutating func testUpsertAgent_poorConnection() async throws { + mutating func upsertAgent_poorConnection() throws { let agentID = UUID() let workspaceID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) @@ -103,7 +102,7 @@ struct VPNMenuStateTests { } @Test - mutating func testUpsertAgent_connecting() async throws { + mutating func upsertAgent_connecting() throws { let agentID = UUID() let workspaceID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) @@ -123,7 +122,7 @@ struct VPNMenuStateTests { } @Test - mutating func testUpsertAgent_unhealthyAgent() async throws { + mutating func upsertAgent_unhealthyAgent() throws { let agentID = UUID() let workspaceID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) @@ -143,7 +142,7 @@ struct VPNMenuStateTests { } @Test - mutating func testUpsertAgent_replacesOldAgent() async throws { + mutating func upsertAgent_replacesOldAgent() throws { let workspaceID = UUID() let oldAgentID = UUID() let newAgentID = UUID() @@ -181,17 +180,19 @@ struct VPNMenuStateTests { } @Test - mutating func testUpsertWorkspace_addsOfflineWorkspace() async throws { + mutating func upsertWorkspace_addsOfflineWorkspace() throws { let workspaceID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) let storedWorkspace = try #require(state.workspaces[workspaceID]) #expect(storedWorkspace.name == "foo") - var output = state.sorted - #expect(output.count == 1) - #expect(output[0].id == workspaceID) - #expect(output[0].wsName == "foo") + var groups = state.grouped + #expect(groups.count == 1) + #expect(groups[0].id == workspaceID) + #expect(groups[0].workspace.name == "foo") + #expect(groups[0].agents.isEmpty) + #expect(groups[0].status == .off) let agentID = UUID() let agent = Vpn_Agent.with { @@ -207,19 +208,143 @@ struct VPNMenuStateTests { } state.upsertAgent(agent) - output = state.sorted - #expect(output.count == 1) - #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")) + groups = state.grouped + #expect(groups.count == 1) + #expect(groups[0].id == workspaceID) + #expect(groups[0].workspace.name == "foo") + #expect(groups[0].agents.count == 1) + #expect(groups[0].agents[0].id == agentID) + #expect(groups[0].status == .okay) + let stored = try #require(state.agents[agentID]) + #expect(stored.statusString.contains("You're connected through a DERP relay.")) + #expect(stored.statusString.contains("Total latency: 50.00 ms")) + #expect(stored.statusString.contains("Last handshake: 3 minutes ago")) } @Test - mutating func testUpsertAgent_invalidAgent_noUUID() async throws { + mutating func grouped_nestsChildrenUnderParent() throws { + let workspaceID = UUID() + let parentID = UUID() + let childAID = UUID() + let childBID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + for (id, name) in [(parentID, "main"), (childAID, "dev1"), (childBID, "dev2")] { + state.upsertAgent(Vpn_Agent.with { + $0.id = id.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = name + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["\(name).ws.coder"] + }) + } + state.setAgentParentID(agentID: childAID, parentID: parentID) + state.setAgentParentID(agentID: childBID, parentID: parentID) + + let groups = state.grouped + #expect(groups.count == 1) + let group = try #require(groups.first) + #expect(group.agents.count == 3) + #expect(group.topLevelAgents.map(\.id) == [parentID]) + let children = group.children(of: parentID) + #expect(children.count == 2) + #expect(Set(children.map(\.id)) == Set([childAID, childBID])) + } + + @Test + mutating func grouped_indentedAgentsWalksTreeDepthFirst() throws { + // A → B → C (grandchild). All three must appear with increasing indent. + let workspaceID = UUID() + let aID = UUID() + let bID = UUID() + let cID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + for (id, name) in [(aID, "a"), (bID, "b"), (cID, "c")] { + state.upsertAgent(Vpn_Agent.with { + $0.id = id.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = name + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["\(name).ws.coder"] + }) + } + state.setAgentParentID(agentID: bID, parentID: aID) + state.setAgentParentID(agentID: cID, parentID: bID) + + let group = try #require(state.grouped.first) + let walked = group.indentedAgents + #expect(walked.map(\.agent.id) == [aID, bID, cID]) + #expect(walked.map(\.indent) == [1, 2, 3]) + } + + @Test + mutating func grouped_orphanChildSurfacesAtTopLevel() throws { + let workspaceID = UUID() + let agentID = UUID() + let phantomParent = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + state.upsertAgent(Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "lone" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["lone.ws.coder"] + }) + state.setAgentParentID(agentID: agentID, parentID: phantomParent) + + let group = try #require(state.grouped.first) + #expect(group.topLevelAgents.map(\.id) == [agentID]) + } + + @Test + mutating func grouped_aggregateStatusIsWorstOf() throws { + let workspaceID = UUID() + let healthyID = UUID() + let unhealthyID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + state.upsertAgent(Vpn_Agent.with { + $0.id = healthyID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "good" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["good.ws.coder"] + }) + state.upsertAgent(Vpn_Agent.with { + $0.id = unhealthyID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "bad" + $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600)) + $0.fqdn = ["bad.ws.coder"] + }) + + let group = try #require(state.grouped.first) + #expect(group.status == .no_recent_handshake) + } + + @Test + mutating func setAgentParentID_preservedAcrossUpsert() { + let workspaceID = UUID() + let agentID = UUID() + let parentID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + let proto = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "child" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["child.ws.coder"] + } + state.upsertAgent(proto) + state.setAgentParentID(agentID: agentID, parentID: parentID) + #expect(state.agents[agentID]?.parentID == parentID) + + // A subsequent VPN upsert (e.g. a ping update) must not clobber the + // enriched parentID, since the proto doesn't carry it. + state.upsertAgent(proto) + #expect(state.agents[agentID]?.parentID == parentID) + } + + @Test + mutating func upsertAgent_invalidAgent_noUUID() { let agent = Vpn_Agent.with { $0.name = "invalidAgent" $0.fqdn = ["invalid.coder"] @@ -232,7 +357,7 @@ struct VPNMenuStateTests { } @Test - mutating func testUpsertAgent_outOfOrder() async throws { + mutating func upsertAgent_outOfOrder() { let agentID = UUID() let workspaceID = UUID() @@ -251,7 +376,7 @@ struct VPNMenuStateTests { } @Test - mutating func testDeleteInvalidAgent_removesInvalid() async throws { + mutating func deleteInvalidAgent_removesInvalid() { let agentID = UUID() let workspaceID = UUID() diff --git a/Coder-Desktop/CoderSDK/Workspace.swift b/Coder-Desktop/CoderSDK/Workspace.swift index e70820da..968d18b4 100644 --- a/Coder-Desktop/CoderSDK/Workspace.swift +++ b/Coder-Desktop/CoderSDK/Workspace.swift @@ -42,12 +42,20 @@ public struct WorkspaceResource: Codable, Identifiable, Sendable { public struct WorkspaceAgent: Codable, Identifiable, Sendable { public let id: UUID + public let parent_id: UUID? // `omitempty` public let expanded_directory: String? // `omitempty` public let apps: [WorkspaceApp] public let display_apps: [DisplayApp] - public init(id: UUID, expanded_directory: String?, apps: [WorkspaceApp], display_apps: [DisplayApp]) { + public init( + id: UUID, + parent_id: UUID? = nil, + expanded_directory: String?, + apps: [WorkspaceApp], + display_apps: [DisplayApp] + ) { self.id = id + self.parent_id = parent_id self.expanded_directory = expanded_directory self.apps = apps self.display_apps = display_apps From 09ecf47b44c188466f937f58d3e9e054463d626d Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 28 Apr 2026 18:57:30 +0500 Subject: [PATCH 2/8] fix: capture parentID before same-name dedupe; split files for lint upsertAgent removes any agent with the same name+wsID before writing the new entry, which was clobbering the enriched parentID we tried to preserve. Read the existing parentID first. Also splits MenuState.swift, VPNMenuItem.swift, and VPNMenuStateTests.swift to satisfy file_length and type_body_length lint rules. --- .../Coder-Desktop/VPN/MenuState.swift | 68 +-------- .../Coder-Desktop/VPN/WorkspaceGroup.swift | 61 ++++++++ .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 128 ----------------- .../Views/VPN/WorkspaceGroupView.swift | 129 +++++++++++++++++ .../VPNMenuStateTests.swift | 122 ---------------- .../WorkspaceGroupTests.swift | 130 ++++++++++++++++++ 6 files changed, 325 insertions(+), 313 deletions(-) create mode 100644 Coder-Desktop/Coder-Desktop/VPN/WorkspaceGroup.swift create mode 100644 Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift create mode 100644 Coder-Desktop/Coder-DesktopTests/WorkspaceGroupTests.swift diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index bd74b7b6..ac0929ae 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -159,66 +159,6 @@ struct Workspace: Identifiable, Equatable, Comparable { } } -/// WorkspaceGroup is the unit the tray renders: one workspace with the agents -/// belonging to it, organized into a parent/child tree. -struct WorkspaceGroup: Identifiable, Equatable, Comparable { - let workspace: Workspace - /// All agents in the workspace, unsorted. - let agents: [Agent] - - var id: UUID { - workspace.id - } - - /// Aggregate status: worst-of among agents, or .off if the workspace has - /// none (treated as offline). - var status: AgentStatus { - agents.map(\.status).max() ?? .off - } - - /// Top-level agents are those without a parent, plus any agent whose - /// parentID points outside this group's known agents (orphans render at the - /// top level rather than disappearing). - var topLevelAgents: [Agent] { - let knownIDs = Set(agents.map(\.id)) - return agents - .filter { agent in - guard let parentID = agent.parentID else { return true } - return !knownIDs.contains(parentID) - } - .sorted() - } - - /// Children of the given parent agent, sorted. - func children(of parentID: UUID) -> [Agent] { - agents.filter { $0.parentID == parentID }.sorted() - } - - /// Tree-walked agents paired with their indent depth (1 for top-level). - /// Handles arbitrary parent_id depth; cycles are guarded against by - /// tracking visited IDs. - var indentedAgents: [(agent: Agent, indent: Int)] { - var result: [(Agent, Int)] = [] - var visited: Set = [] - func walk(_ agent: Agent, indent: Int) { - guard visited.insert(agent.id).inserted else { return } - result.append((agent, indent)) - for child in children(of: agent.id) { - walk(child, indent: indent + 1) - } - } - for top in topLevelAgents { - walk(top, indent: 1) - } - return result - } - - static func < (lhs: WorkspaceGroup, rhs: WorkspaceGroup) -> Bool { - if lhs.status != rhs.status { return lhs.status < rhs.status } - return lhs.workspace < rhs.workspace - } -} - struct VPNMenuState { var agents: [UUID: Agent] = [:] var workspaces: [UUID: Workspace] = [:] @@ -248,6 +188,11 @@ struct VPNMenuState { // Remove trailing dot if present let nonEmptyHosts = agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 } + // The proto doesn't carry parent_id, so preserve any value we already + // enriched for this agent from the HTTP API. Captured here because the + // same-name dedupe below removes the existing entry. + let existingParentID = agents[id]?.parentID + // An existing agent with the same name, belonging to the same workspace // is from a previous workspace build, and should be removed. agents.filter { $0.value.name == agent.name && $0.value.wsID == wsID } @@ -267,9 +212,6 @@ struct VPNMenuState { : nil ) } - // The proto doesn't carry parent_id, so preserve any value we already - // enriched for this agent from the HTTP API. - let existingParentID = agents[id]?.parentID agents[id] = Agent( id: id, name: agent.name, diff --git a/Coder-Desktop/Coder-Desktop/VPN/WorkspaceGroup.swift b/Coder-Desktop/Coder-Desktop/VPN/WorkspaceGroup.swift new file mode 100644 index 00000000..0b39c19e --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/VPN/WorkspaceGroup.swift @@ -0,0 +1,61 @@ +import Foundation + +/// WorkspaceGroup is the unit the tray renders: one workspace with the agents +/// belonging to it, organized into a parent/child tree. +struct WorkspaceGroup: Identifiable, Equatable, Comparable { + let workspace: Workspace + /// All agents in the workspace, unsorted. + let agents: [Agent] + + var id: UUID { + workspace.id + } + + /// Aggregate status: worst-of among agents, or .off if the workspace has + /// none (treated as offline). + var status: AgentStatus { + agents.map(\.status).max() ?? .off + } + + /// Top-level agents are those without a parent, plus any agent whose + /// parentID points outside this group's known agents (orphans render at + /// the top level rather than disappearing). + var topLevelAgents: [Agent] { + let knownIDs = Set(agents.map(\.id)) + return agents + .filter { agent in + guard let parentID = agent.parentID else { return true } + return !knownIDs.contains(parentID) + } + .sorted() + } + + /// Children of the given parent agent, sorted. + func children(of parentID: UUID) -> [Agent] { + agents.filter { $0.parentID == parentID }.sorted() + } + + /// Tree-walked agents paired with their indent depth (1 for top-level). + /// Handles arbitrary parent_id depth; cycles are guarded against by + /// tracking visited IDs. + var indentedAgents: [(agent: Agent, indent: Int)] { + var result: [(Agent, Int)] = [] + var visited: Set = [] + func walk(_ agent: Agent, indent: Int) { + guard visited.insert(agent.id).inserted else { return } + result.append((agent, indent)) + for child in children(of: agent.id) { + walk(child, indent: indent + 1) + } + } + for top in topLevelAgents { + walk(top, indent: 1) + } + return result + } + + static func < (lhs: WorkspaceGroup, rhs: WorkspaceGroup) -> Bool { + if lhs.status != rhs.status { return lhs.status < rhs.status } + return lhs.workspace < rhs.workspace + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index c6c0079e..d91c8629 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -282,131 +282,3 @@ struct AnimatedChevron: View { .rotationEffect(.degrees(isExpanded ? 90 : 0)) } } - -/// WorkspaceGroupView renders one workspace's agents. Single-agent and offline -/// workspaces fall through to the existing flat MenuItemView; workspaces with -/// multiple agents get a collapsible header with their agents nested below, -/// and child agents (those with a parent_id) are nested under their parent. -struct WorkspaceGroupView: View { - let group: WorkspaceGroup - let baseAccessURL: URL - @Binding var expandedItem: UUID? - @Binding var userInteracted: Bool - - /// Apps-section expansion for nested agent rows is local to the group so it - /// doesn't fight the outer expandedItem (which controls workspace-level - /// expansion for multi-agent groups). - @State private var nestedExpandedAgent: UUID? - - var body: some View { - if group.agents.count <= 1 { - let item: VPNMenuItem = group.agents.first.map { .agent($0) } - ?? .offlineWorkspace(group.workspace) - MenuItemView( - item: item, - baseAccessURL: baseAccessURL, - expandedItem: $expandedItem, - userInteracted: $userInteracted - ) - } else { - VStack(spacing: 0) { - WorkspaceHeaderRow( - group: group, - baseAccessURL: baseAccessURL, - isExpanded: expandedItem == group.id, - onToggle: toggleGroupExpansion - ) - if expandedItem == group.id { - ForEach(group.indentedAgents, id: \.agent.id) { entry in - nestedRow(agent: entry.agent, indent: entry.indent) - } - } - } - } - } - - private func toggleGroupExpansion() { - userInteracted = true - withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { - expandedItem = expandedItem == group.id ? nil : group.id - } - } - - private func nestedRow(agent: Agent, indent: Int) -> some View { - MenuItemView( - item: .agent(agent), - baseAccessURL: baseAccessURL, - expandedItem: $nestedExpandedAgent, - userInteracted: $userInteracted - ) - .padding(.leading, CGFloat(indent) * Theme.Size.trayPadding) - } -} - -struct WorkspaceHeaderRow: View { - @EnvironmentObject var state: AppState - @Environment(\.openURL) private var openURL - - let group: WorkspaceGroup - let baseAccessURL: URL - let isExpanded: Bool - let onToggle: () -> Void - - @State private var nameIsSelected: Bool = false - - private var plainName: String { - "\(group.workspace.name).\(state.hostnameSuffix)" - } - - private var styledName: AttributedString { - var name = AttributedString(plainName) - name.foregroundColor = .primary - if let range = name.range(of: ".\(state.hostnameSuffix)", options: .backwards) { - name[range].foregroundColor = .secondary - } - return name - } - - private var wsURL: URL { - // TODO: CoderVPN currently only supports owned workspaces. - baseAccessURL.appending(path: "@me").appending(path: group.workspace.name) - } - - private func copyToClipboard() { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(plainName, forType: .string) - } - - var body: some View { - HStack(spacing: 3) { - Button(action: onToggle) { - HStack(spacing: Theme.Size.trayPadding) { - AnimatedChevron(isExpanded: isExpanded, color: .secondary) - Text(styledName).lineLimit(1).truncationMode(.tail) - Spacer() - } - .padding(.horizontal, Theme.Size.trayPadding) - .frame(minHeight: 22) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(nameIsSelected ? .white : .primary) - .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear) - .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) - .onHover { hovering in nameIsSelected = hovering } - .help(plainName) - }.buttonStyle(.plain).padding(.trailing, 3) - StatusDot(color: group.status.color) - .padding(.trailing, 3) - .padding(.top, 1) - .help(group.status.description) - 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-Desktop/Views/VPN/WorkspaceGroupView.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift new file mode 100644 index 00000000..8ad3d7a9 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +/// WorkspaceGroupView renders one workspace's agents. Single-agent and offline +/// workspaces fall through to the existing flat MenuItemView; workspaces with +/// multiple agents get a collapsible header with their agents nested below, +/// and child agents (those with a parent_id) are nested under their parent. +struct WorkspaceGroupView: View { + let group: WorkspaceGroup + let baseAccessURL: URL + @Binding var expandedItem: UUID? + @Binding var userInteracted: Bool + + /// Apps-section expansion for nested agent rows is local to the group so it + /// doesn't fight the outer expandedItem (which controls workspace-level + /// expansion for multi-agent groups). + @State private var nestedExpandedAgent: UUID? + + var body: some View { + if group.agents.count <= 1 { + let item: VPNMenuItem = group.agents.first.map { .agent($0) } + ?? .offlineWorkspace(group.workspace) + MenuItemView( + item: item, + baseAccessURL: baseAccessURL, + expandedItem: $expandedItem, + userInteracted: $userInteracted + ) + } else { + VStack(spacing: 0) { + WorkspaceHeaderRow( + group: group, + baseAccessURL: baseAccessURL, + isExpanded: expandedItem == group.id, + onToggle: toggleGroupExpansion + ) + if expandedItem == group.id { + ForEach(group.indentedAgents, id: \.agent.id) { entry in + nestedRow(agent: entry.agent, indent: entry.indent) + } + } + } + } + } + + private func toggleGroupExpansion() { + userInteracted = true + withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { + expandedItem = expandedItem == group.id ? nil : group.id + } + } + + private func nestedRow(agent: Agent, indent: Int) -> some View { + MenuItemView( + item: .agent(agent), + baseAccessURL: baseAccessURL, + expandedItem: $nestedExpandedAgent, + userInteracted: $userInteracted + ) + .padding(.leading, CGFloat(indent) * Theme.Size.trayPadding) + } +} + +struct WorkspaceHeaderRow: View { + @EnvironmentObject var state: AppState + @Environment(\.openURL) private var openURL + + let group: WorkspaceGroup + let baseAccessURL: URL + let isExpanded: Bool + let onToggle: () -> Void + + @State private var nameIsSelected: Bool = false + + private var plainName: String { + "\(group.workspace.name).\(state.hostnameSuffix)" + } + + private var styledName: AttributedString { + var name = AttributedString(plainName) + name.foregroundColor = .primary + if let range = name.range(of: ".\(state.hostnameSuffix)", options: .backwards) { + name[range].foregroundColor = .secondary + } + return name + } + + private var wsURL: URL { + // TODO: CoderVPN currently only supports owned workspaces. + baseAccessURL.appending(path: "@me").appending(path: group.workspace.name) + } + + private func copyToClipboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(plainName, forType: .string) + } + + var body: some View { + HStack(spacing: 3) { + Button(action: onToggle) { + HStack(spacing: Theme.Size.trayPadding) { + AnimatedChevron(isExpanded: isExpanded, color: .secondary) + Text(styledName).lineLimit(1).truncationMode(.tail) + Spacer() + } + .padding(.horizontal, Theme.Size.trayPadding) + .frame(minHeight: 22) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(nameIsSelected ? .white : .primary) + .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) + .onHover { hovering in nameIsSelected = hovering } + .help(plainName) + }.buttonStyle(.plain).padding(.trailing, 3) + StatusDot(color: group.status.color) + .padding(.trailing, 3) + .padding(.top, 1) + .help(group.status.description) + 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/VPNMenuStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift index 13a3f965..2e7a0328 100644 --- a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift @@ -221,128 +221,6 @@ struct VPNMenuStateTests { #expect(stored.statusString.contains("Last handshake: 3 minutes ago")) } - @Test - mutating func grouped_nestsChildrenUnderParent() throws { - let workspaceID = UUID() - let parentID = UUID() - let childAID = UUID() - let childBID = UUID() - state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) - for (id, name) in [(parentID, "main"), (childAID, "dev1"), (childBID, "dev2")] { - state.upsertAgent(Vpn_Agent.with { - $0.id = id.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = name - $0.lastHandshake = .init(date: Date.now) - $0.fqdn = ["\(name).ws.coder"] - }) - } - state.setAgentParentID(agentID: childAID, parentID: parentID) - state.setAgentParentID(agentID: childBID, parentID: parentID) - - let groups = state.grouped - #expect(groups.count == 1) - let group = try #require(groups.first) - #expect(group.agents.count == 3) - #expect(group.topLevelAgents.map(\.id) == [parentID]) - let children = group.children(of: parentID) - #expect(children.count == 2) - #expect(Set(children.map(\.id)) == Set([childAID, childBID])) - } - - @Test - mutating func grouped_indentedAgentsWalksTreeDepthFirst() throws { - // A → B → C (grandchild). All three must appear with increasing indent. - let workspaceID = UUID() - let aID = UUID() - let bID = UUID() - let cID = UUID() - state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) - for (id, name) in [(aID, "a"), (bID, "b"), (cID, "c")] { - state.upsertAgent(Vpn_Agent.with { - $0.id = id.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = name - $0.lastHandshake = .init(date: Date.now) - $0.fqdn = ["\(name).ws.coder"] - }) - } - state.setAgentParentID(agentID: bID, parentID: aID) - state.setAgentParentID(agentID: cID, parentID: bID) - - let group = try #require(state.grouped.first) - let walked = group.indentedAgents - #expect(walked.map(\.agent.id) == [aID, bID, cID]) - #expect(walked.map(\.indent) == [1, 2, 3]) - } - - @Test - mutating func grouped_orphanChildSurfacesAtTopLevel() throws { - let workspaceID = UUID() - let agentID = UUID() - let phantomParent = UUID() - state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) - state.upsertAgent(Vpn_Agent.with { - $0.id = agentID.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = "lone" - $0.lastHandshake = .init(date: Date.now) - $0.fqdn = ["lone.ws.coder"] - }) - state.setAgentParentID(agentID: agentID, parentID: phantomParent) - - let group = try #require(state.grouped.first) - #expect(group.topLevelAgents.map(\.id) == [agentID]) - } - - @Test - mutating func grouped_aggregateStatusIsWorstOf() throws { - let workspaceID = UUID() - let healthyID = UUID() - let unhealthyID = UUID() - state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) - state.upsertAgent(Vpn_Agent.with { - $0.id = healthyID.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = "good" - $0.lastHandshake = .init(date: Date.now) - $0.fqdn = ["good.ws.coder"] - }) - state.upsertAgent(Vpn_Agent.with { - $0.id = unhealthyID.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = "bad" - $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600)) - $0.fqdn = ["bad.ws.coder"] - }) - - let group = try #require(state.grouped.first) - #expect(group.status == .no_recent_handshake) - } - - @Test - mutating func setAgentParentID_preservedAcrossUpsert() { - let workspaceID = UUID() - let agentID = UUID() - let parentID = UUID() - state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) - let proto = Vpn_Agent.with { - $0.id = agentID.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = "child" - $0.lastHandshake = .init(date: Date.now) - $0.fqdn = ["child.ws.coder"] - } - state.upsertAgent(proto) - state.setAgentParentID(agentID: agentID, parentID: parentID) - #expect(state.agents[agentID]?.parentID == parentID) - - // A subsequent VPN upsert (e.g. a ping update) must not clobber the - // enriched parentID, since the proto doesn't carry it. - state.upsertAgent(proto) - #expect(state.agents[agentID]?.parentID == parentID) - } - @Test mutating func upsertAgent_invalidAgent_noUUID() { let agent = Vpn_Agent.with { diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceGroupTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceGroupTests.swift new file mode 100644 index 00000000..3729a6ab --- /dev/null +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceGroupTests.swift @@ -0,0 +1,130 @@ +@testable import Coder_Desktop +import Testing +@testable import VPNLib + +@MainActor +struct WorkspaceGroupTests { + var state = VPNMenuState() + + @Test + mutating func grouped_nestsChildrenUnderParent() throws { + let workspaceID = UUID() + let parentID = UUID() + let childAID = UUID() + let childBID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + for (id, name) in [(parentID, "main"), (childAID, "dev1"), (childBID, "dev2")] { + state.upsertAgent(Vpn_Agent.with { + $0.id = id.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = name + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["\(name).ws.coder"] + }) + } + state.setAgentParentID(agentID: childAID, parentID: parentID) + state.setAgentParentID(agentID: childBID, parentID: parentID) + + let groups = state.grouped + #expect(groups.count == 1) + let group = try #require(groups.first) + #expect(group.agents.count == 3) + #expect(group.topLevelAgents.map(\.id) == [parentID]) + let children = group.children(of: parentID) + #expect(children.count == 2) + #expect(Set(children.map(\.id)) == Set([childAID, childBID])) + } + + @Test + mutating func grouped_indentedAgentsWalksTreeDepthFirst() throws { + // A → B → C (grandchild). All three must appear with increasing indent. + let workspaceID = UUID() + let aID = UUID() + let bID = UUID() + let cID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + for (id, name) in [(aID, "a"), (bID, "b"), (cID, "c")] { + state.upsertAgent(Vpn_Agent.with { + $0.id = id.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = name + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["\(name).ws.coder"] + }) + } + state.setAgentParentID(agentID: bID, parentID: aID) + state.setAgentParentID(agentID: cID, parentID: bID) + + let group = try #require(state.grouped.first) + let walked = group.indentedAgents + #expect(walked.map(\.agent.id) == [aID, bID, cID]) + #expect(walked.map(\.indent) == [1, 2, 3]) + } + + @Test + mutating func grouped_orphanChildSurfacesAtTopLevel() throws { + let workspaceID = UUID() + let agentID = UUID() + let phantomParent = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + state.upsertAgent(Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "lone" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["lone.ws.coder"] + }) + state.setAgentParentID(agentID: agentID, parentID: phantomParent) + + let group = try #require(state.grouped.first) + #expect(group.topLevelAgents.map(\.id) == [agentID]) + } + + @Test + mutating func grouped_aggregateStatusIsWorstOf() throws { + let workspaceID = UUID() + let healthyID = UUID() + let unhealthyID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + state.upsertAgent(Vpn_Agent.with { + $0.id = healthyID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "good" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["good.ws.coder"] + }) + state.upsertAgent(Vpn_Agent.with { + $0.id = unhealthyID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "bad" + $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600)) + $0.fqdn = ["bad.ws.coder"] + }) + + let group = try #require(state.grouped.first) + #expect(group.status == .no_recent_handshake) + } + + @Test + mutating func setAgentParentID_preservedAcrossUpsert() { + let workspaceID = UUID() + let agentID = UUID() + let parentID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "ws" }) + let proto = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "child" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["child.ws.coder"] + } + state.upsertAgent(proto) + state.setAgentParentID(agentID: agentID, parentID: parentID) + #expect(state.agents[agentID]?.parentID == parentID) + + // A subsequent VPN upsert (e.g. a ping update) must not clobber the + // enriched parentID, since the proto doesn't carry it. + state.upsertAgent(proto) + #expect(state.agents[agentID]?.parentID == parentID) + } +} From 377ba4d28644f3f786acd7f26ba8e7695c45318d Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Tue, 28 Apr 2026 19:02:51 +0500 Subject: [PATCH 3/8] chore: drop cosmetic reformatting from existing files Local swiftformat 0.61 was stricter than CI's 0.55, so my prior commits modified comment styles and access modifiers on lines unrelated to the feature. Revert those to keep the diff strictly additive. --- .../Coder-Desktop/VPN/MenuState.swift | 39 ++++++++----------- .../Coder-Desktop/VPN/VPNService.swift | 20 +++++----- .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 6 +-- 3 files changed, 28 insertions(+), 37 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index ac0929ae..351233fa 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -11,8 +11,7 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable { let wsName: String let wsID: UUID // parentID is enriched from the HTTP API after the VPN proto delivers the - // agent. It identifies the owning agent for child agents (e.g. devcontainer - // sub-agents). nil means top-level. + // agent. nil means top-level. var parentID: UUID? let lastPing: LastPing? let lastHandshake: Date? @@ -40,9 +39,9 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable { self.primaryHost = primaryHost } - /// Agents are sorted by status, and then by name. Within a workspace group, - /// top-level agents (parentID == nil) are surfaced before children by the - /// grouping logic — this comparator only orders peers. + // Agents are sorted by status, then by workspace name, then by agent name + // (the last tie-break matters for groups with multiple agents in the same + // workspace). static func < (lhs: Agent, rhs: Agent) -> Bool { if lhs.status != rhs.status { return lhs.status < rhs.status @@ -124,7 +123,7 @@ enum AgentStatus: Int, Equatable, Comparable { case no_recent_handshake = 3 case off = 4 - var description: String { + public var description: String { switch self { case .okay: "Connected" case .connecting: "Connecting..." @@ -134,7 +133,7 @@ enum AgentStatus: Int, Equatable, Comparable { } } - var color: Color { + public var color: Color { switch self { case .okay: .green case .high_latency: .yellow @@ -162,15 +161,15 @@ struct Workspace: Identifiable, Equatable, Comparable { struct VPNMenuState { var agents: [UUID: Agent] = [:] var workspaces: [UUID: Workspace] = [:] - /// Upserted agents that don't belong to any known workspace, have no FQDNs, - /// or have any invalid UUIDs. + // Upserted agents that don't belong to any known workspace, have no FQDNs, + // or have any invalid UUIDs. var invalidAgents: [Vpn_Agent] = [] - func findAgent(workspaceID: UUID, name: String) -> Agent? { + public func findAgent(workspaceID: UUID, name: String) -> Agent? { agents.first(where: { $0.value.wsID == workspaceID && $0.value.name == name })?.value } - func findWorkspace(name: String) -> Workspace? { + public func findWorkspace(name: String) -> Workspace? { workspaces .first(where: { $0.value.name == name })?.value } @@ -189,8 +188,8 @@ struct VPNMenuState { let nonEmptyHosts = agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 } // The proto doesn't carry parent_id, so preserve any value we already - // enriched for this agent from the HTTP API. Captured here because the - // same-name dedupe below removes the existing entry. + // enriched from the HTTP API. Captured before the same-name dedupe + // below clears the existing entry. let existingParentID = agents[id]?.parentID // An existing agent with the same name, belonging to the same workspace @@ -271,9 +270,9 @@ struct VPNMenuState { workspaces[wsID] = nil } - /// Groups all known agents under their workspace, nesting child agents - /// (those with a parentID) under their parent. Empty workspaces still appear - /// as offline groups. + // Groups all known agents under their workspace, nesting child agents + // (those with a parentID) under their parent. Empty workspaces still appear + // as offline groups. var grouped: [WorkspaceGroup] { let agentsByWorkspace = Dictionary(grouping: agents.values, by: \.wsID) @@ -302,9 +301,7 @@ struct VPNMenuState { .sorted() } - var onlineAgents: [Agent] { - agents.map(\.value) - } + var onlineAgents: [Agent] { agents.map(\.value) } mutating func clear() { agents.removeAll() @@ -336,9 +333,7 @@ extension Vpn_Agent { Date.now.addingTimeInterval(-300) // 5 minutes ago } - var healthyPingMax: TimeInterval { - 0.15 - } // 150ms + var healthyPingMax: TimeInterval { 0.15 } // 150ms var status: AgentStatus { // Initially the handshake is missing diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift index 9de701bc..f8c547f9 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift @@ -13,9 +13,9 @@ protocol VPNService: ObservableObject { func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) var startWhenReady: Bool { get set } - /// Backfill parent_id for an agent. Sourced from the HTTP API since the VPN - /// proto doesn't carry it. Called by the UI layer after fetching workspace - /// details so child agents can be nested under their parent. + // Backfill parent_id for an agent. Sourced from the HTTP API since the VPN + // proto doesn't carry it. Called by the UI layer after fetching workspace + // details so child agents can be nested under their parent. func setAgentParentID(agentID: UUID, parentID: UUID?) } @@ -42,7 +42,7 @@ enum VPNServiceError: Error, Equatable { case systemExtensionError(SystemExtensionState) case networkExtensionError(NetworkExtensionState) - var description: String { + public var description: String { switch self { case let .internalError(description): "Internal Error: \(description)" @@ -53,9 +53,7 @@ enum VPNServiceError: Error, Equatable { } } - var localizedDescription: String { - description - } + public var localizedDescription: String { description } } @MainActor @@ -95,9 +93,9 @@ final class CoderVPNService: NSObject, VPNService { var startWhenReady: Bool = false var onStart: (() -> Void)? - /// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get - /// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework - /// only stores a weak reference to the delegate. + // systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get + // garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework + // only stores a weak reference to the delegate. var systemExtnDelegate: SystemExtensionDelegate? var serverAddress: String? @@ -191,7 +189,7 @@ final class CoderVPNService: NSObject, VPNService { } extension CoderVPNService { - func vpnDidUpdate(_ connection: NETunnelProviderSession) { + public func vpnDidUpdate(_ connection: NETunnelProviderSession) { switch (tunnelState, connection.status) { // Any -> Disconnected: Update UI w/ error if present case (_, .disconnected): diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index d91c8629..71e753d2 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -2,7 +2,7 @@ import CoderSDK import os import SwiftUI -/// Each row in the workspaces list is an agent or an offline workspace +// Each row in the workspaces list is an agent or an offline workspace enum VPNMenuItem: Equatable, Comparable, Identifiable { case agent(Agent) case offlineWorkspace(Workspace) @@ -81,9 +81,7 @@ struct MenuItemView: View { @State private var loadingApps: Bool = true - var hasApps: Bool { - !apps.isEmpty - } + var hasApps: Bool { !apps.isEmpty } private var plainItemName: String { item.primaryHost(hostnameSuffix: state.hostnameSuffix) From f118d8b35411ba4a1d5186c05b4ae2c9b14d4594 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Sun, 3 May 2026 18:40:30 +0500 Subject: [PATCH 4/8] feat: trim row labels and tighten top-level spacing Top-level rows (single-agent flat row, multi-agent header) now show just the workspace name. Nested agent rows show only the agent name. Copy-to-clipboard and the hover tooltip retain the full FQDN so the shell hostname is still one click away. Drops the implicit 8pt VStack gap between top-level workspaces in the ScrollView so the rows match the density of nested children. --- .../Coder-Desktop/Views/VPN/Agents.swift | 18 ++++++++++-------- .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 9 +++++++++ .../Views/VPN/WorkspaceGroupView.swift | 19 +++++++++++++------ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index 8cbd0226..c223c1bd 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -18,14 +18,16 @@ struct Agents: View { ? Array(groups) : Array(groups.prefix(Theme.defaultVisibleAgents)) ScrollView(showsIndicators: false) { - ForEach(visibleGroups, id: \.id) { group in - WorkspaceGroupView( - group: group, - baseAccessURL: state.baseAccessURL!, - expandedItem: $expandedItem, - userInteracted: $hasToggledExpansion - ) - .padding(.horizontal, Theme.Size.trayMargin) + VStack(spacing: 0) { + ForEach(visibleGroups, id: \.id) { group in + WorkspaceGroupView( + group: group, + baseAccessURL: state.baseAccessURL!, + expandedItem: $expandedItem, + userInteracted: $hasToggledExpansion + ) + .padding(.horizontal, Theme.Size.trayMargin) + } }.onChange(of: visibleGroups) { // If no workspaces are online, expand the first one to come online. if visibleGroups.allSatisfy({ $0.status == .off }) { diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index 71e753d2..d30f3629 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -74,6 +74,10 @@ struct MenuItemView: View { let baseAccessURL: URL @Binding var expandedItem: VPNMenuItem.ID? @Binding var userInteracted: Bool + // Optional display label override. When set (used for nested child rows), + // we show this instead of the full FQDN to keep the menu readable. The + // copy-to-clipboard action and the hover tooltip still use the full FQDN. + var displayLabel: String? @State private var nameIsSelected: Bool = false @@ -88,6 +92,11 @@ struct MenuItemView: View { } private var itemName: AttributedString { + if let displayLabel { + var label = AttributedString(displayLabel) + label.foregroundColor = .primary + return label + } var formattedName = AttributedString(plainItemName) formattedName.foregroundColor = .primary diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift index 8ad3d7a9..70363c5a 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift @@ -17,13 +17,17 @@ struct WorkspaceGroupView: View { var body: some View { if group.agents.count <= 1 { + // Single-agent or offline: the row represents the workspace, so + // display the workspace name. Copy-to-clipboard and the tooltip + // still use the full FQDN. let item: VPNMenuItem = group.agents.first.map { .agent($0) } ?? .offlineWorkspace(group.workspace) MenuItemView( item: item, baseAccessURL: baseAccessURL, expandedItem: $expandedItem, - userInteracted: $userInteracted + userInteracted: $userInteracted, + displayLabel: group.workspace.name ) } else { VStack(spacing: 0) { @@ -50,11 +54,15 @@ struct WorkspaceGroupView: View { } private func nestedRow(agent: Agent, indent: Int) -> some View { + // Show only the agent's own name in nested rows — the workspace is + // already in the header. Copy-to-clipboard and the hover tooltip + // still use the full FQDN so the user has a usable shell hostname. MenuItemView( item: .agent(agent), baseAccessURL: baseAccessURL, expandedItem: $nestedExpandedAgent, - userInteracted: $userInteracted + userInteracted: $userInteracted, + displayLabel: agent.name ) .padding(.leading, CGFloat(indent) * Theme.Size.trayPadding) } @@ -76,11 +84,10 @@ struct WorkspaceHeaderRow: View { } private var styledName: AttributedString { - var name = AttributedString(plainName) + // Display only the workspace name; the row already represents the + // workspace in the menu hierarchy. Copy/tooltip retain the full FQDN. + var name = AttributedString(group.workspace.name) name.foregroundColor = .primary - if let range = name.range(of: ".\(state.hostnameSuffix)", options: .backwards) { - name[range].foregroundColor = .secondary - } return name } From 59b1414220e9a3495acec72474dec1980fa4641e Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 8 May 2026 17:31:28 +0500 Subject: [PATCH 5/8] feat: expanded agent details with ports, devcontainer boxes, and parent-apps toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a multi-agent workspace is expanded, every agent now renders inline with its own status dot, latency, copy hostname, and apps grid — no more chevron-to-reveal per agent. Sub-agents (devcontainers) live inside their own dashed "Container" box with the cube glyph leading the row, mirroring the dashboard. Parent apps are hidden when any child has apps, with a square.grid.2x2 toggle to reveal them on demand. Adds a listening-ports menu (dot.radiowaves.right + count) per agent, hidden when the agent reports zero ports per backend contract. Each port opens http://:. Single-agent rows stay flat and pick up the workspace name as their label; copy/tooltip retain the full FQDN. Tray width bumped to 320 so longer workspace names don't truncate. Devcontainer sub-agents aren't in the workspace endpoint's resources, so the SDK gains workspaceAgent(_:) and workspaceAgentListeningPorts(_:) for per-agent fallbacks. Parent_id resolution moved entirely into expand-time loadApps — the prior eager pass had a sticky dedupe that wouldn't refire after menuState.clear(). --- .../Coder-Desktop/Coder_DesktopApp.swift | 2 +- .../Views/VPN/AgentDetailRow.swift | 128 ++++++++++ .../Coder-Desktop/Views/VPN/Agents.swift | 54 +--- .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 20 +- .../Views/VPN/WorkspaceGroupView.swift | 232 +++++++++++++++--- Coder-Desktop/CoderSDK/WorkspaceAgents.swift | 48 ++++ 6 files changed, 390 insertions(+), 94 deletions(-) create mode 100644 Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 86b09893..48caf494 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -100,7 +100,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { await self.state.handleTokenExpiry() } }, content: { - VPNMenu().frame(width: 256) + VPNMenu().frame(width: 320) .environmentObject(self.vpn) .environmentObject(self.state) .environmentObject(self.fileSyncDaemon) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift new file mode 100644 index 00000000..cc132e9e --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift @@ -0,0 +1,128 @@ +import CoderSDK +import SwiftUI + +/// Renders a single agent inside an expanded WorkspaceGroupView: status dot, +/// agent name + latency, optional ports menu / parent-apps toggle / copy +/// button, and the agent's apps inline below. +/// +/// `leadingIcon` is rendered ahead of the status dot — used to mark sub-agent +/// rows with a cube glyph so they're identifiable without a dedicated +/// container header. `appsToggle`, when set, exposes the show/hide control +/// for parent apps (mirrors the dashboard's "Show parent apps" affordance). +struct AgentDetailRow: View { + @EnvironmentObject var state: AppState + @Environment(\.openURL) private var openURL + + let agent: Agent + let apps: [WorkspaceApp] + var ports: [WorkspaceAgentListeningPort] = [] + var appsToggle: AppsToggle? + var leadingIcon: String? + var leadingIconHelp: String? + + @State private var nameIsSelected: Bool = false + + struct AppsToggle { + let isShown: Bool + let action: () -> Void + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 3) { + HStack(spacing: Theme.Size.trayPadding) { + if let leadingIcon { + Image(systemName: leadingIcon) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .help(leadingIconHelp ?? "") + } + StatusDot(color: agent.status.color) + .help(agent.statusString) + Text(agent.name).lineLimit(1).truncationMode(.tail) + if let latency = agent.lastPing?.latency { + Text(formatLatency(latency)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, Theme.Size.trayPadding) + .frame(minHeight: 22) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(nameIsSelected ? .white : .primary) + .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) + .onHover { hovering in nameIsSelected = hovering } + .padding(.trailing, 3) + if !ports.isEmpty { + portsMenu + } + if let appsToggle { + Button(action: appsToggle.action) { + Image(systemName: appsToggle.isShown ? "square.grid.2x2.fill" : "square.grid.2x2") + .font(.system(size: 11)) + .padding(3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(appsToggle.isShown ? "Hide parent apps" : "Show parent apps") + } + MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard) + .font(.system(size: 9)) + .symbolVariant(.fill) + .padding(.trailing, Theme.Size.trayMargin) + .help("Copy hostname") + } + if !apps.isEmpty { + MenuItemCollapsibleView(apps: apps) + } + } + } + + private var portsMenu: some View { + Menu { + ForEach(ports) { port in + Button(label(for: port)) { + if let url = URL(string: "http://\(agent.primaryHost):\(port.port)") { + openURL(url) + } + } + } + } label: { + HStack(spacing: 1) { + Image(systemName: "dot.radiowaves.right") + .font(.system(size: 9)) + .imageScale(.small) + Text("\(ports.count)") + .font(.system(size: 10)) + } + .foregroundStyle(.secondary) + .padding(3) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Listening ports") + } + + private func copyToClipboard() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(agent.primaryHost, forType: .string) + } + + /// Compact "18ms" formatting for inline display. The dot's tooltip still + /// carries the full status string with P2P/DERP detail. + private func formatLatency(_ seconds: TimeInterval) -> String { + let ms = Int((seconds * 1000).rounded()) + return "\(ms)ms" + } + + private func label(for port: WorkspaceAgentListeningPort) -> String { + let processName = port.process_name.isEmpty ? nil : port.process_name + if let processName { + return "\(port.port) - \(processName)" + } + return "\(port.port)" + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index c223c1bd..15b5b775 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -6,7 +6,6 @@ struct Agents: View { @State private var viewAll = false @State private var expandedItem: UUID? @State private var hasToggledExpansion: Bool = false - @State private var enrichedWorkspaces: Set = [] let inspection = Inspection() @@ -24,30 +23,17 @@ struct Agents: View { group: group, baseAccessURL: state.baseAccessURL!, expandedItem: $expandedItem, - userInteracted: $hasToggledExpansion + userInteracted: $hasToggledExpansion, + setAgentParentID: { agentID, parentID in + vpn.setAgentParentID(agentID: agentID, parentID: parentID) + } ) .padding(.horizontal, Theme.Size.trayMargin) } - }.onChange(of: visibleGroups) { - // If no workspaces are online, expand the first one to come online. - if visibleGroups.allSatisfy({ $0.status == .off }) { - hasToggledExpansion = false - return - } - if hasToggledExpansion { - return - } - withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { - expandedItem = visibleGroups.first?.defaultExpandID - } - hasToggledExpansion = true } } .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: 400) - .task(id: Set(groups.map(\.id))) { - await enrichParents(groups: groups) - } if groups.isEmpty { Text("No workspaces!") .font(.body) @@ -67,36 +53,4 @@ struct Agents: View { } }.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector } - - /// Backfill agent.parent_id from the HTTP API. The VPN proto doesn't carry - /// it, so without this children would never nest under their parent in the - /// tray. Best-effort: failures are silent and retried whenever the set of - /// workspace IDs changes. - private func enrichParents(groups: [WorkspaceGroup]) async { - guard let client = state.client else { return } - for group in groups where !enrichedWorkspaces.contains(group.id) { - do { - let workspace = try await client.workspace(group.id) - let agents = workspace.latest_build.resources.compactMap(\.agents).flatMap(\.self) - for agent in agents { - vpn.setAgentParentID(agentID: agent.id, parentID: agent.parent_id) - } - enrichedWorkspaces.insert(group.id) - } catch { - continue - } - } - } -} - -private extension WorkspaceGroup { - /// For the auto-expand-first behavior: single-agent groups expand the - /// agent's app section (existing UX); multi-agent groups expand the - /// workspace itself to reveal nested agents. - var defaultExpandID: UUID { - if agents.count == 1, let only = agents.first { - return only.id - } - return id - } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index d30f3629..a6baef5d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -78,6 +78,10 @@ struct MenuItemView: View { // we show this instead of the full FQDN to keep the menu readable. The // copy-to-clipboard action and the hover tooltip still use the full FQDN. var displayLabel: String? + // Hide the trailing open-in-browser button on rows where it would be + // redundant (nested agent rows under a workspace header that already has + // the same link). + var showOpenInBrowser: Bool = true @State private var nameIsSelected: Bool = false @@ -147,7 +151,7 @@ struct MenuItemView: View { } .help(plainItemName) }.buttonStyle(.plain).padding(.trailing, 3) - MenuItemIcons(item: item, wsURL: wsURL) + MenuItemIcons(item: item, wsURL: wsURL, showOpenInBrowser: showOpenInBrowser) } if isExpanded { Group { @@ -230,6 +234,7 @@ struct MenuItemIcons: View { let item: VPNMenuItem let wsURL: URL + var showOpenInBrowser: Bool = true @State private var copyIsSelected: Bool = false @State private var webIsSelected: Bool = false @@ -251,12 +256,15 @@ struct MenuItemIcons: View { MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard) .font(.system(size: 9)) .symbolVariant(.fill) + .padding(.trailing, showOpenInBrowser ? 0 : Theme.Size.trayMargin) .help("Copy hostname") - MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) }) - .contentShape(Rectangle()) - .font(.system(size: 12)) - .padding(.trailing, Theme.Size.trayMargin) - .help("Open in browser") + if showOpenInBrowser { + 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-Desktop/Views/VPN/WorkspaceGroupView.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift index 70363c5a..74822d7d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift @@ -1,25 +1,46 @@ +import CoderSDK +import os import SwiftUI -/// WorkspaceGroupView renders one workspace's agents. Single-agent and offline -/// workspaces fall through to the existing flat MenuItemView; workspaces with -/// multiple agents get a collapsible header with their agents nested below, -/// and child agents (those with a parent_id) are nested under their parent. +/// WorkspaceGroupView renders one workspace's row in the tray. +/// +/// Single-agent and offline workspaces render as a flat row (status dot, +/// copy, browser inline) since the workspace and the agent are effectively +/// the same thing — drilling in adds no information. +/// +/// Multi-agent workspaces render as a collapsible header that, when +/// expanded, lists every top-level agent with its apps inline. Sub-agents +/// (devcontainer agents) are wrapped in a dashed "Container" box right +/// under their parent so the hierarchy is visible without indentation +/// gymnastics — the same shape the dashboard uses. Parent apps are hidden +/// when any child has apps of its own (also matches the dashboard). struct WorkspaceGroupView: View { + @EnvironmentObject var state: AppState + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMenu") + let group: WorkspaceGroup let baseAccessURL: URL @Binding var expandedItem: UUID? @Binding var userInteracted: Bool + /// Callback fired for each agent we resolve from the HTTP API, so the + /// VPN service can update the agent's parentID. Routed through a closure + /// because WorkspaceGroupView is non-generic and can't carry the VPN + /// service as an environment object directly. + var setAgentParentID: (UUID, UUID?) -> Void = { _, _ in } + + @State private var appsByAgent: [UUID: [WorkspaceApp]] = [:] + @State private var portsByAgent: [UUID: [WorkspaceAgentListeningPort]] = [:] + @State private var loadingApps: Bool = false + @State private var hasLoadedApps: Bool = false + /// Parents whose apps the user explicitly chose to reveal (overrides the + /// default "hide parent apps when a child has apps" behavior). + @State private var revealedParents: Set = [] - /// Apps-section expansion for nested agent rows is local to the group so it - /// doesn't fight the outer expandedItem (which controls workspace-level - /// expansion for multi-agent groups). - @State private var nestedExpandedAgent: UUID? + private var isExpanded: Bool { expandedItem == group.id } var body: some View { if group.agents.count <= 1 { - // Single-agent or offline: the row represents the workspace, so - // display the workspace name. Copy-to-clipboard and the tooltip - // still use the full FQDN. + // Flat row for single-agent or offline workspaces. let item: VPNMenuItem = group.agents.first.map { .agent($0) } ?? .offlineWorkspace(group.workspace) MenuItemView( @@ -34,18 +55,50 @@ struct WorkspaceGroupView: View { WorkspaceHeaderRow( group: group, baseAccessURL: baseAccessURL, - isExpanded: expandedItem == group.id, + isExpanded: isExpanded, onToggle: toggleGroupExpansion ) - if expandedItem == group.id { - ForEach(group.indentedAgents, id: \.agent.id) { entry in - nestedRow(agent: entry.agent, indent: entry.indent) - } + if isExpanded { + expandedContent } } } } + @ViewBuilder + private var expandedContent: some View { + Group { + if loadingApps, !hasLoadedApps { + CircularProgressView(value: nil, strokeWidth: 3, diameter: 15) + .padding(.top, 5) + } else { + ForEach(group.topLevelAgents) { parent in + AgentDetailRow( + agent: parent, + apps: appsToShow(for: parent), + ports: portsByAgent[parent.id] ?? [], + appsToggle: appsToggle(for: parent) + ) + // Wrap each sub-agent in its own dashed box so multiple + // devcontainers under the same parent stay visually + // distinct. The cube glyph lives inline on the agent row + // (via leadingIcon) instead of a separate header line. + ForEach(group.children(of: parent.id)) { child in + SubAgentContainer { + AgentDetailRow( + agent: child, + apps: appsByAgent[child.id] ?? [], + ports: portsByAgent[child.id] ?? [], + leadingIcon: "cube", + leadingIconHelp: "Container" + ) + } + } + } + } + }.task(id: group.id) { await loadApps() } + } + private func toggleGroupExpansion() { userInteracted = true withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { @@ -53,18 +106,132 @@ struct WorkspaceGroupView: View { } } - private func nestedRow(agent: Agent, indent: Int) -> some View { - // Show only the agent's own name in nested rows — the workspace is - // already in the header. Copy-to-clipboard and the hover tooltip - // still use the full FQDN so the user has a usable shell hostname. - MenuItemView( - item: .agent(agent), - baseAccessURL: baseAccessURL, - expandedItem: $nestedExpandedAgent, - userInteracted: $userInteracted, - displayLabel: agent.name - ) - .padding(.leading, CGFloat(indent) * Theme.Size.trayPadding) + /// Hide a parent agent's apps when any of its direct children have apps — + /// the child apps are the relevant ones in that case (matches the web UI). + /// The user can override per-parent via the row's apps toggle. + private func appsToShow(for agent: Agent) -> [WorkspaceApp] { + let myApps = appsByAgent[agent.id] ?? [] + guard hasHiddenParentApps(for: agent) else { return myApps } + return revealedParents.contains(agent.id) ? myApps : [] + } + + /// True when this agent has its own apps but they're being hidden because + /// a child has apps. Used to gate the show/hide toggle in the row. + private func hasHiddenParentApps(for agent: Agent) -> Bool { + let myApps = appsByAgent[agent.id] ?? [] + guard !myApps.isEmpty else { return false } + let children = group.children(of: agent.id) + return children.contains { !(appsByAgent[$0.id] ?? []).isEmpty } + } + + private func appsToggle(for agent: Agent) -> AgentDetailRow.AppsToggle? { + guard hasHiddenParentApps(for: agent) else { return nil } + let isShown = revealedParents.contains(agent.id) + return AgentDetailRow.AppsToggle(isShown: isShown) { + if isShown { + revealedParents.remove(agent.id) + } else { + revealedParents.insert(agent.id) + } + } + } + + /// Fetch every agent's data so we can render the tree with apps. Devcontainer + /// sub-agents aren't in the workspace endpoint's `resources` (they're + /// spawned at runtime, outside the Terraform graph), so for any agent we + /// don't see in the workspace response we fall back to a per-agent fetch + /// which always knows about sub-agents. + private func loadApps() async { + guard let client = state.client, + let baseAccessURL = state.baseAccessURL, + let sessionToken = state.sessionToken + else { return } + loadingApps = true + defer { loadingApps = false } + let workspace: CoderSDK.Workspace + do { + workspace = try await retry(floor: .milliseconds(100), ceil: .seconds(10)) { + do { + return try await client.workspace(group.id) + } catch { + logger.error("Failed to load workspace \(group.workspace.name): \(error.localizedDescription)") + throw error + } + } + } catch { return } // Task cancelled + let sdkAgents = workspace.latest_build.resources.compactMap(\.agents).flatMap(\.self) + var result: [UUID: [WorkspaceApp]] = [:] + var seenIDs: Set = [] + for sdkAgent in sdkAgents { + seenIDs.insert(sdkAgent.id) + guard let agent = group.agents.first(where: { $0.id == sdkAgent.id }) else { continue } + result[agent.id] = agentToApps(logger, sdkAgent, agent.primaryHost, baseAccessURL, sessionToken) + setAgentParentID(agent.id, sdkAgent.parent_id) + } + // Fall back to per-agent fetches for anything we know about from the + // VPN proto but didn't see in the workspace response (sub-agents). + for agent in group.agents where !seenIDs.contains(agent.id) { + do { + let sdkAgent = try await client.workspaceAgent(agent.id) + result[agent.id] = agentToApps(logger, sdkAgent, agent.primaryHost, baseAccessURL, sessionToken) + setAgentParentID(agent.id, sdkAgent.parent_id) + } catch { + logger.error("Failed to load agent \(agent.name): \(error.localizedDescription)") + } + } + appsByAgent = result + hasLoadedApps = true + await loadPorts(client: client) + } + + /// Fetch listening ports per agent in parallel. The endpoint is per-agent + /// (no batch), and only Linux agents return ports — others return []. + /// Stored in @State so the row can decide to render a ports button. + private func loadPorts(client: Client) async { + await withTaskGroup(of: (UUID, [WorkspaceAgentListeningPort]).self) { gp in + for agent in group.agents { + gp.addTask { + do { + let res = try await client.workspaceAgentListeningPorts(agent.id) + return (agent.id, res.ports) + } catch { + return (agent.id, []) + } + } + } + var result: [UUID: [WorkspaceAgentListeningPort]] = [:] + for await (id, ports) in gp where !ports.isEmpty { + result[id] = ports + } + portsByAgent = result + } + } +} + +/// Subtle dashed-border box that wraps a sub-agent's row + apps. The cube +/// glyph that signals "container" is rendered inline at the start of the +/// agent row (via AgentDetailRow.leadingIcon) so we don't need a dedicated +/// header line — saves vertical space, matches the dashboard's compact +/// devcontainer treatment. +struct SubAgentContainer: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius) + .strokeBorder( + Color.secondary.opacity(0.35), + style: StrokeStyle(lineWidth: 1, dash: [3]) + ) + ) + .padding(.horizontal, Theme.Size.trayPadding) + .padding(.vertical, 2) } } @@ -96,11 +263,6 @@ struct WorkspaceHeaderRow: View { baseAccessURL.appending(path: "@me").appending(path: group.workspace.name) } - private func copyToClipboard() { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(plainName, forType: .string) - } - var body: some View { HStack(spacing: 3) { Button(action: onToggle) { @@ -122,10 +284,6 @@ struct WorkspaceHeaderRow: View { .padding(.trailing, 3) .padding(.top, 1) .help(group.status.description) - 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)) diff --git a/Coder-Desktop/CoderSDK/WorkspaceAgents.swift b/Coder-Desktop/CoderSDK/WorkspaceAgents.swift index 4144a582..d70f5b36 100644 --- a/Coder-Desktop/CoderSDK/WorkspaceAgents.swift +++ b/Coder-Desktop/CoderSDK/WorkspaceAgents.swift @@ -8,8 +8,56 @@ public extension Client { } return try decode(AgentConnectionInfo.self, from: res.data) } + + /// Fetch a single workspace agent by id. Use when an agent is not in the + /// workspace endpoint's `latest_build.resources` (devcontainer sub-agents + /// are spawned at runtime and don't appear in the Terraform graph). + func workspaceAgent(_ id: UUID) async throws(SDKError) -> WorkspaceAgent { + let res = try await request("/api/v2/workspaceagents/\(id.uuidString)", method: .get) + guard res.resp.statusCode == 200 else { + throw responseAsError(res) + } + return try decode(WorkspaceAgent.self, from: res.data) + } + + /// Listening TCP ports detected inside the agent's network namespace. Only + /// returns ports on Linux agents — macOS/Windows agents return an empty + /// list (port scan unsupported). Per backend contract, empty results must + /// not be surfaced as "0 ports" — hide the affordance entirely instead. + func workspaceAgentListeningPorts(_ id: UUID) async throws(SDKError) -> WorkspaceAgentListeningPortsResponse { + let res = try await request( + "/api/v2/workspaceagents/\(id.uuidString)/listening-ports", + method: .get + ) + guard res.resp.statusCode == 200 else { + throw responseAsError(res) + } + return try decode(WorkspaceAgentListeningPortsResponse.self, from: res.data) + } } public struct AgentConnectionInfo: Codable, Sendable { public let hostname_suffix: String? } + +public struct WorkspaceAgentListeningPortsResponse: Codable, Sendable { + public let ports: [WorkspaceAgentListeningPort] + + public init(ports: [WorkspaceAgentListeningPort]) { + self.ports = ports + } +} + +public struct WorkspaceAgentListeningPort: Codable, Sendable, Identifiable, Hashable { + public let process_name: String + public let network: String + public let port: UInt16 + + public var id: UInt16 { port } + + public init(process_name: String, network: String, port: UInt16) { + self.process_name = process_name + self.network = network + self.port = port + } +} From 927b1b02e085564934fc0e7f72cff1404b4da928 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 8 May 2026 20:25:35 +0500 Subject: [PATCH 6/8] refactor: use macOS-native containers for the workspace tray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the custom collapsible header for `DisclosureGroup` (system chevron + animation, no manual toggle plumbing) and the dashed sub-agent box for `GroupBox` with a "Container" label. Add `Divider()` between top-level workspaces, render system glyphs hierarchically, and bounce the copy icon on each click — same feature surface as the previous render path, just expressed in stock SwiftUI primitives. --- .../Views/VPN/AgentDetailRow.swift | 39 ++-- .../Coder-Desktop/Views/VPN/Agents.swift | 5 +- .../Views/VPN/WorkspaceGroupView.swift | 181 +++++++----------- 3 files changed, 90 insertions(+), 135 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift index cc132e9e..86820709 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift @@ -5,10 +5,10 @@ import SwiftUI /// agent name + latency, optional ports menu / parent-apps toggle / copy /// button, and the agent's apps inline below. /// -/// `leadingIcon` is rendered ahead of the status dot — used to mark sub-agent -/// rows with a cube glyph so they're identifiable without a dedicated -/// container header. `appsToggle`, when set, exposes the show/hide control -/// for parent apps (mirrors the dashboard's "Show parent apps" affordance). +/// Sub-agent rows don't carry any extra glyph here — the surrounding +/// `GroupBox` (wired up in WorkspaceGroupView) handles the "Container" +/// labelling. `appsToggle`, when set, exposes the show/hide control for +/// parent apps (mirrors the dashboard's "Show parent apps" affordance). struct AgentDetailRow: View { @EnvironmentObject var state: AppState @Environment(\.openURL) private var openURL @@ -17,10 +17,10 @@ struct AgentDetailRow: View { let apps: [WorkspaceApp] var ports: [WorkspaceAgentListeningPort] = [] var appsToggle: AppsToggle? - var leadingIcon: String? - var leadingIconHelp: String? @State private var nameIsSelected: Bool = false + /// Bumped on each copy so SwiftUI can drive the bounce symbol effect. + @State private var copyTick: Int = 0 struct AppsToggle { let isShown: Bool @@ -31,12 +31,6 @@ struct AgentDetailRow: View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 3) { HStack(spacing: Theme.Size.trayPadding) { - if let leadingIcon { - Image(systemName: leadingIcon) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .help(leadingIconHelp ?? "") - } StatusDot(color: agent.status.color) .help(agent.statusString) Text(agent.name).lineLimit(1).truncationMode(.tail) @@ -61,6 +55,7 @@ struct AgentDetailRow: View { if let appsToggle { Button(action: appsToggle.action) { Image(systemName: appsToggle.isShown ? "square.grid.2x2.fill" : "square.grid.2x2") + .symbolRenderingMode(.hierarchical) .font(.system(size: 11)) .padding(3) .contentShape(Rectangle()) @@ -68,11 +63,19 @@ struct AgentDetailRow: View { .buttonStyle(.plain) .help(appsToggle.isShown ? "Hide parent apps" : "Show parent apps") } - MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard) - .font(.system(size: 9)) - .symbolVariant(.fill) - .padding(.trailing, Theme.Size.trayMargin) - .help("Copy hostname") + Button { + copyToClipboard() + } label: { + Image(systemName: "doc.on.doc.fill") + .symbolRenderingMode(.hierarchical) + .symbolEffect(.bounce, value: copyTick) + .font(.system(size: 9)) + .padding(3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.trailing, Theme.Size.trayMargin) + .help("Copy hostname") } if !apps.isEmpty { MenuItemCollapsibleView(apps: apps) @@ -92,6 +95,7 @@ struct AgentDetailRow: View { } label: { HStack(spacing: 1) { Image(systemName: "dot.radiowaves.right") + .symbolRenderingMode(.hierarchical) .font(.system(size: 9)) .imageScale(.small) Text("\(ports.count)") @@ -109,6 +113,7 @@ struct AgentDetailRow: View { private func copyToClipboard() { NSPasteboard.general.clearContents() NSPasteboard.general.setString(agent.primaryHost, forType: .string) + copyTick &+= 1 } /// Compact "18ms" formatting for inline display. The dot's tooltip still diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index 15b5b775..a2524f2c 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -18,7 +18,10 @@ struct Agents: View { : Array(groups.prefix(Theme.defaultVisibleAgents)) ScrollView(showsIndicators: false) { VStack(spacing: 0) { - ForEach(visibleGroups, id: \.id) { group in + ForEach(Array(visibleGroups.enumerated()), id: \.element.id) { index, group in + if index > 0 { + Divider().padding(.horizontal, Theme.Size.trayMargin) + } WorkspaceGroupView( group: group, baseAccessURL: state.baseAccessURL!, diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift index 74822d7d..1eedcd94 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift @@ -2,18 +2,17 @@ import CoderSDK import os import SwiftUI -/// WorkspaceGroupView renders one workspace's row in the tray. +/// WorkspaceGroupView renders one workspace's row in the tray using native +/// macOS containers (`DisclosureGroup` for the workspace and `GroupBox` for +/// each sub-agent) so the visual treatment matches the rest of the system. /// -/// Single-agent and offline workspaces render as a flat row (status dot, -/// copy, browser inline) since the workspace and the agent are effectively -/// the same thing — drilling in adds no information. -/// -/// Multi-agent workspaces render as a collapsible header that, when -/// expanded, lists every top-level agent with its apps inline. Sub-agents -/// (devcontainer agents) are wrapped in a dashed "Container" box right -/// under their parent so the hierarchy is visible without indentation -/// gymnastics — the same shape the dashboard uses. Parent apps are hidden -/// when any child has apps of its own (also matches the dashboard). +/// Single-agent and offline workspaces stay flat — drilling in adds nothing. +/// Multi-agent workspaces use `DisclosureGroup`: the system chevron handles +/// expansion, and the disclosure content lists each top-level agent inline +/// with apps. Each sub-agent (devcontainer) is wrapped in its own `GroupBox` +/// labelled "Container" with a cube glyph, mirroring the dashboard's compact +/// devcontainer treatment. Parent apps are hidden by default when any child +/// has apps; a per-row toggle reveals them on demand. struct WorkspaceGroupView: View { @EnvironmentObject var state: AppState private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMenu") @@ -22,10 +21,9 @@ struct WorkspaceGroupView: View { let baseAccessURL: URL @Binding var expandedItem: UUID? @Binding var userInteracted: Bool - /// Callback fired for each agent we resolve from the HTTP API, so the - /// VPN service can update the agent's parentID. Routed through a closure - /// because WorkspaceGroupView is non-generic and can't carry the VPN - /// service as an environment object directly. + /// Callback fired for each agent we resolve from the HTTP API, so the VPN + /// service can update the agent's parentID. WorkspaceGroupView is non- + /// generic and can't carry the VPN service as an environment object. var setAgentParentID: (UUID, UUID?) -> Void = { _, _ in } @State private var appsByAgent: [UUID: [WorkspaceApp]] = [:] @@ -36,7 +34,17 @@ struct WorkspaceGroupView: View { /// default "hide parent apps when a child has apps" behavior). @State private var revealedParents: Set = [] - private var isExpanded: Bool { expandedItem == group.id } + private var isExpanded: Binding { + Binding( + get: { expandedItem == group.id }, + set: { expand in + userInteracted = true + withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { + expandedItem = expand ? group.id : nil + } + } + ) + } var body: some View { if group.agents.count <= 1 { @@ -51,17 +59,16 @@ struct WorkspaceGroupView: View { displayLabel: group.workspace.name ) } else { - VStack(spacing: 0) { - WorkspaceHeaderRow( - group: group, - baseAccessURL: baseAccessURL, - isExpanded: isExpanded, - onToggle: toggleGroupExpansion + DisclosureGroup(isExpanded: isExpanded) { + expandedContent + } label: { + WorkspaceDisclosureLabel( + name: group.workspace.name, + plainName: "\(group.workspace.name).\(state.hostnameSuffix)", + wsURL: baseAccessURL.appending(path: "@me").appending(path: group.workspace.name) ) - if isExpanded { - expandedContent - } } + .padding(.horizontal, Theme.Size.trayPadding) } } @@ -79,19 +86,20 @@ struct WorkspaceGroupView: View { ports: portsByAgent[parent.id] ?? [], appsToggle: appsToggle(for: parent) ) - // Wrap each sub-agent in its own dashed box so multiple - // devcontainers under the same parent stay visually - // distinct. The cube glyph lives inline on the agent row - // (via leadingIcon) instead of a separate header line. + // Each sub-agent gets its own GroupBox so multiple + // devcontainers under the same parent stay distinct. ForEach(group.children(of: parent.id)) { child in - SubAgentContainer { + GroupBox { AgentDetailRow( agent: child, apps: appsByAgent[child.id] ?? [], - ports: portsByAgent[child.id] ?? [], - leadingIcon: "cube", - leadingIconHelp: "Container" + ports: portsByAgent[child.id] ?? [] ) + } label: { + Label("Container", systemImage: "cube") + .font(.caption) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) } } } @@ -99,13 +107,6 @@ struct WorkspaceGroupView: View { }.task(id: group.id) { await loadApps() } } - private func toggleGroupExpansion() { - userInteracted = true - withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) { - expandedItem = expandedItem == group.id ? nil : group.id - } - } - /// Hide a parent agent's apps when any of its direct children have apps — /// the child apps are the relevant ones in that case (matches the web UI). /// The user can override per-parent via the row's apps toggle. @@ -186,7 +187,6 @@ struct WorkspaceGroupView: View { /// Fetch listening ports per agent in parallel. The endpoint is per-agent /// (no batch), and only Linux agents return ports — others return []. - /// Stored in @State so the row can decide to render a ports button. private func loadPorts(client: Client) async { await withTaskGroup(of: (UUID, [WorkspaceAgentListeningPort]).self) { gp in for agent in group.agents { @@ -208,87 +208,34 @@ struct WorkspaceGroupView: View { } } -/// Subtle dashed-border box that wraps a sub-agent's row + apps. The cube -/// glyph that signals "container" is rendered inline at the start of the -/// agent row (via AgentDetailRow.leadingIcon) so we don't need a dedicated -/// header line — saves vertical space, matches the dashboard's compact -/// devcontainer treatment. -struct SubAgentContainer: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - content - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius) - .strokeBorder( - Color.secondary.opacity(0.35), - style: StrokeStyle(lineWidth: 1, dash: [3]) - ) - ) - .padding(.horizontal, Theme.Size.trayPadding) - .padding(.vertical, 2) - } -} - -struct WorkspaceHeaderRow: View { - @EnvironmentObject var state: AppState +/// Label slot for the DisclosureGroup: workspace name + trailing globe button. +/// The Button absorbs taps so opening the workspace page doesn't also toggle +/// the disclosure. +private struct WorkspaceDisclosureLabel: View { @Environment(\.openURL) private var openURL - let group: WorkspaceGroup - let baseAccessURL: URL - let isExpanded: Bool - let onToggle: () -> Void - - @State private var nameIsSelected: Bool = false - - private var plainName: String { - "\(group.workspace.name).\(state.hostnameSuffix)" - } - - private var styledName: AttributedString { - // Display only the workspace name; the row already represents the - // workspace in the menu hierarchy. Copy/tooltip retain the full FQDN. - var name = AttributedString(group.workspace.name) - name.foregroundColor = .primary - return name - } - - private var wsURL: URL { - // TODO: CoderVPN currently only supports owned workspaces. - baseAccessURL.appending(path: "@me").appending(path: group.workspace.name) - } + let name: String + let plainName: String + let wsURL: URL var body: some View { - HStack(spacing: 3) { - Button(action: onToggle) { - HStack(spacing: Theme.Size.trayPadding) { - AnimatedChevron(isExpanded: isExpanded, color: .secondary) - Text(styledName).lineLimit(1).truncationMode(.tail) - Spacer() - } - .padding(.horizontal, Theme.Size.trayPadding) - .frame(minHeight: 22) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(nameIsSelected ? .white : .primary) - .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear) - .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) - .onHover { hovering in nameIsSelected = hovering } + HStack { + Text(name) + .lineLimit(1) + .truncationMode(.tail) .help(plainName) - }.buttonStyle(.plain).padding(.trailing, 3) - StatusDot(color: group.status.color) - .padding(.trailing, 3) - .padding(.top, 1) - .help(group.status.description) - MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) }) - .contentShape(Rectangle()) - .font(.system(size: 12)) - .padding(.trailing, Theme.Size.trayMargin) - .help("Open in browser") + Spacer() + Button { + openURL(wsURL) + } label: { + Image(systemName: "globe") + .symbolRenderingMode(.hierarchical) + .font(.system(size: 12)) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .help("Open in browser") } + .contentShape(Rectangle()) } } From d47e6f2a52971dc4953eab62f3d5172ecf6f0eb4 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 8 May 2026 22:36:54 +0500 Subject: [PATCH 7/8] fix: keep sub-agent row inline with the cube glyph Drop the GroupBox label so the cube + agent name share a single row (matches PR #252's compact treatment). The GroupBox still provides the native rounded background that separates each sub-agent from siblings. --- .../Views/VPN/AgentDetailRow.swift | 18 ++++++++++++++---- .../Views/VPN/WorkspaceGroupView.swift | 14 +++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift index 86820709..3cecb626 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift @@ -5,10 +5,11 @@ import SwiftUI /// agent name + latency, optional ports menu / parent-apps toggle / copy /// button, and the agent's apps inline below. /// -/// Sub-agent rows don't carry any extra glyph here — the surrounding -/// `GroupBox` (wired up in WorkspaceGroupView) handles the "Container" -/// labelling. `appsToggle`, when set, exposes the show/hide control for -/// parent apps (mirrors the dashboard's "Show parent apps" affordance). +/// `leadingIcon` is rendered ahead of the status dot and is used to mark +/// sub-agent rows with a cube glyph so they're recognizable as containers +/// without a dedicated header. `appsToggle`, when set, exposes the show/hide +/// control for parent apps (mirrors the dashboard's "Show parent apps" +/// affordance). struct AgentDetailRow: View { @EnvironmentObject var state: AppState @Environment(\.openURL) private var openURL @@ -17,6 +18,8 @@ struct AgentDetailRow: View { let apps: [WorkspaceApp] var ports: [WorkspaceAgentListeningPort] = [] var appsToggle: AppsToggle? + var leadingIcon: String? + var leadingIconHelp: String? @State private var nameIsSelected: Bool = false /// Bumped on each copy so SwiftUI can drive the bounce symbol effect. @@ -31,6 +34,13 @@ struct AgentDetailRow: View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 3) { HStack(spacing: Theme.Size.trayPadding) { + if let leadingIcon { + Image(systemName: leadingIcon) + .symbolRenderingMode(.hierarchical) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .help(leadingIconHelp ?? "") + } StatusDot(color: agent.status.color) .help(agent.statusString) Text(agent.name).lineLimit(1).truncationMode(.tail) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift index 1eedcd94..09767c5b 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift @@ -87,19 +87,19 @@ struct WorkspaceGroupView: View { appsToggle: appsToggle(for: parent) ) // Each sub-agent gets its own GroupBox so multiple - // devcontainers under the same parent stay distinct. + // devcontainers under the same parent stay distinct. The + // cube glyph rides on the same row as the agent (no + // dedicated header line) — same compactness as the prior + // dashed-box treatment, just in stock SwiftUI. ForEach(group.children(of: parent.id)) { child in GroupBox { AgentDetailRow( agent: child, apps: appsByAgent[child.id] ?? [], - ports: portsByAgent[child.id] ?? [] + ports: portsByAgent[child.id] ?? [], + leadingIcon: "cube", + leadingIconHelp: "Container" ) - } label: { - Label("Container", systemImage: "cube") - .font(.caption) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) } } } From 8923a651ac042065acb3576d65f82f215340409b Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Thu, 28 May 2026 12:30:00 +0500 Subject: [PATCH 8/8] refactor: lean on Xcode 26 SwiftUI idioms across the workspace tray - Always render workspaces through DisclosureGroup so single-agent, multi-agent, and offline rows share the system chevron, indent, and hover treatment. Inline empty/offline copy lives in the disclosure content; the label slot carries name, latency, status dot, copy, and the open-in-browser button. - Wrap each sub-agent in a GroupBox instead of the bespoke dashed border. The cube glyph rides on the same row so the container reads in one line. - Apps visibility now flips per-agent through a single override map with sensible defaults (parents collapsed, sub-agents expanded). Toggle shows on any agent with apps, mirroring the dashboard. - Adopt Xcode 26 affordances on the agent row: hierarchical symbol rendering, .symbolEffect(.bounce) on copy, .contentTransition for the apps toggle swap, .draggable(host) for drag-to-terminal, .badge(ports.count) on the ports menu, and .controlSize(.small) on inline buttons. - Empty-state in Agents view uses ContentUnavailableView; consecutive workspaces get a Divider between them. --- .../Views/VPN/AgentDetailRow.swift | 24 +-- .../Coder-Desktop/Views/VPN/Agents.swift | 15 +- .../Views/VPN/WorkspaceGroupView.swift | 156 ++++++++++++------ 3 files changed, 126 insertions(+), 69 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift index 3cecb626..91cc5cc7 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift @@ -59,6 +59,7 @@ struct AgentDetailRow: View { .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) .onHover { hovering in nameIsSelected = hovering } .padding(.trailing, 3) + .draggable(agent.primaryHost) if !ports.isEmpty { portsMenu } @@ -66,12 +67,14 @@ struct AgentDetailRow: View { Button(action: appsToggle.action) { Image(systemName: appsToggle.isShown ? "square.grid.2x2.fill" : "square.grid.2x2") .symbolRenderingMode(.hierarchical) + .contentTransition(.symbolEffect(.replace)) .font(.system(size: 11)) .padding(3) .contentShape(Rectangle()) } .buttonStyle(.plain) - .help(appsToggle.isShown ? "Hide parent apps" : "Show parent apps") + .controlSize(.small) + .help(appsToggle.isShown ? "Hide apps" : "Show apps") } Button { copyToClipboard() @@ -84,6 +87,7 @@ struct AgentDetailRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .controlSize(.small) .padding(.trailing, Theme.Size.trayMargin) .help("Copy hostname") } @@ -103,19 +107,17 @@ struct AgentDetailRow: View { } } } label: { - HStack(spacing: 1) { - Image(systemName: "dot.radiowaves.right") - .symbolRenderingMode(.hierarchical) - .font(.system(size: 9)) - .imageScale(.small) - Text("\(ports.count)") - .font(.system(size: 10)) - } - .foregroundStyle(.secondary) - .padding(3) + Label("Listening ports", systemImage: "dot.radiowaves.right") + .labelStyle(.iconOnly) + .symbolRenderingMode(.hierarchical) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(3) } + .badge(ports.count) .menuStyle(.borderlessButton) .menuIndicator(.hidden) + .controlSize(.small) .fixedSize() .help("Listening ports") } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift index a2524f2c..3f4ce9ae 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift @@ -20,7 +20,9 @@ struct Agents: View { VStack(spacing: 0) { ForEach(Array(visibleGroups.enumerated()), id: \.element.id) { index, group in if index > 0 { - Divider().padding(.horizontal, Theme.Size.trayMargin) + Divider() + .padding(.horizontal, Theme.Size.trayMargin) + .padding(.vertical, 4) } WorkspaceGroupView( group: group, @@ -38,11 +40,12 @@ struct Agents: View { .scrollBounceBehavior(.basedOnSize) .frame(maxHeight: 400) if groups.isEmpty { - Text("No workspaces!") - .font(.body) - .foregroundColor(.secondary) - .padding(.horizontal, Theme.Size.trayInset) - .padding(.top, 2) + ContentUnavailableView( + "No workspaces", + systemImage: "person.crop.circle.badge.questionmark", + description: Text("Workspaces appear here when Coder Connect is on.") + ) + .padding(.vertical, 4) } if groups.count > Theme.defaultVisibleAgents { Toggle(isOn: $viewAll) { diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift index 09767c5b..0035c44d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift @@ -30,9 +30,9 @@ struct WorkspaceGroupView: View { @State private var portsByAgent: [UUID: [WorkspaceAgentListeningPort]] = [:] @State private var loadingApps: Bool = false @State private var hasLoadedApps: Bool = false - /// Parents whose apps the user explicitly chose to reveal (overrides the - /// default "hide parent apps when a child has apps" behavior). - @State private var revealedParents: Set = [] + /// Per-agent override for the apps section. When present, takes precedence + /// over the default (parents collapsed, children expanded). + @State private var appsOverride: [UUID: Bool] = [:] private var isExpanded: Binding { Binding( @@ -47,29 +47,24 @@ struct WorkspaceGroupView: View { } var body: some View { - if group.agents.count <= 1 { - // Flat row for single-agent or offline workspaces. - let item: VPNMenuItem = group.agents.first.map { .agent($0) } - ?? .offlineWorkspace(group.workspace) - MenuItemView( - item: item, - baseAccessURL: baseAccessURL, - expandedItem: $expandedItem, - userInteracted: $userInteracted, - displayLabel: group.workspace.name + // Always render through DisclosureGroup so the chevron, indent, and + // hover treatment match across single-agent, multi-agent, and offline + // workspaces. The label and expanded content vary per case. + DisclosureGroup(isExpanded: isExpanded) { + expandedContent + } label: { + WorkspaceDisclosureLabel( + name: group.workspace.name, + plainName: "\(group.workspace.name).\(state.hostnameSuffix)", + wsURL: baseAccessURL.appending(path: "@me").appending(path: group.workspace.name), + singleAgent: group.agents.count == 1 ? group.agents.first : nil, + aggregateStatus: group.status, + aggregateStatusString: group.agents.count == 1 + ? (group.agents.first?.statusString ?? group.status.description) + : group.status.description ) - } else { - DisclosureGroup(isExpanded: isExpanded) { - expandedContent - } label: { - WorkspaceDisclosureLabel( - name: group.workspace.name, - plainName: "\(group.workspace.name).\(state.hostnameSuffix)", - wsURL: baseAccessURL.appending(path: "@me").appending(path: group.workspace.name) - ) - } - .padding(.horizontal, Theme.Size.trayPadding) } + .padding(.horizontal, Theme.Size.trayPadding) } @ViewBuilder @@ -78,13 +73,30 @@ struct WorkspaceGroupView: View { if loadingApps, !hasLoadedApps { CircularProgressView(value: nil, strokeWidth: 3, diameter: 15) .padding(.top, 5) + } else if group.agents.isEmpty { + Text("Workspace is offline.") + .font(.body) + .foregroundStyle(.secondary) + .padding(.horizontal, Theme.Size.trayInset) + .padding(.top, 4) + } else if group.agents.count == 1, let only = group.agents.first { + let apps = appsByAgent[only.id] ?? [] + if apps.isEmpty { + Text("No apps available.") + .font(.body) + .foregroundStyle(.secondary) + .padding(.horizontal, Theme.Size.trayInset) + .padding(.top, 4) + } else { + MenuItemCollapsibleView(apps: apps) + } } else { ForEach(group.topLevelAgents) { parent in AgentDetailRow( agent: parent, - apps: appsToShow(for: parent), + apps: appsToShow(for: parent, isChild: false), ports: portsByAgent[parent.id] ?? [], - appsToggle: appsToggle(for: parent) + appsToggle: appsToggle(for: parent, isChild: false) ) // Each sub-agent gets its own GroupBox so multiple // devcontainers under the same parent stay distinct. The @@ -95,8 +107,9 @@ struct WorkspaceGroupView: View { GroupBox { AgentDetailRow( agent: child, - apps: appsByAgent[child.id] ?? [], + apps: appsToShow(for: child, isChild: true), ports: portsByAgent[child.id] ?? [], + appsToggle: appsToggle(for: child, isChild: true), leadingIcon: "cube", leadingIconHelp: "Container" ) @@ -107,33 +120,26 @@ struct WorkspaceGroupView: View { }.task(id: group.id) { await loadApps() } } - /// Hide a parent agent's apps when any of its direct children have apps — - /// the child apps are the relevant ones in that case (matches the web UI). - /// The user can override per-parent via the row's apps toggle. - private func appsToShow(for agent: Agent) -> [WorkspaceApp] { - let myApps = appsByAgent[agent.id] ?? [] - guard hasHiddenParentApps(for: agent) else { return myApps } - return revealedParents.contains(agent.id) ? myApps : [] + /// Default visibility per role: parent agents start collapsed (apps + /// hidden), sub-agents start expanded (apps visible). The `appsOverride` + /// map flips the default once the user clicks the row's toggle. + private func appsAreShown(for agentID: UUID, isChild: Bool) -> Bool { + appsOverride[agentID] ?? isChild } - /// True when this agent has its own apps but they're being hidden because - /// a child has apps. Used to gate the show/hide toggle in the row. - private func hasHiddenParentApps(for agent: Agent) -> Bool { - let myApps = appsByAgent[agent.id] ?? [] - guard !myApps.isEmpty else { return false } - let children = group.children(of: agent.id) - return children.contains { !(appsByAgent[$0.id] ?? []).isEmpty } + private func appsToShow(for agent: Agent, isChild: Bool) -> [WorkspaceApp] { + let apps = appsByAgent[agent.id] ?? [] + return appsAreShown(for: agent.id, isChild: isChild) ? apps : [] } - private func appsToggle(for agent: Agent) -> AgentDetailRow.AppsToggle? { - guard hasHiddenParentApps(for: agent) else { return nil } - let isShown = revealedParents.contains(agent.id) + /// Toggle is offered on every agent that has apps — both parent and child. + /// If the agent has no apps there's nothing to show, so we return nil. + private func appsToggle(for agent: Agent, isChild: Bool) -> AgentDetailRow.AppsToggle? { + let apps = appsByAgent[agent.id] ?? [] + guard !apps.isEmpty else { return nil } + let isShown = appsAreShown(for: agent.id, isChild: isChild) return AgentDetailRow.AppsToggle(isShown: isShown) { - if isShown { - revealedParents.remove(agent.id) - } else { - revealedParents.insert(agent.id) - } + appsOverride[agent.id] = !isShown } } @@ -208,15 +214,26 @@ struct WorkspaceGroupView: View { } } -/// Label slot for the DisclosureGroup: workspace name + trailing globe button. -/// The Button absorbs taps so opening the workspace page doesn't also toggle -/// the disclosure. +/// Label slot for the DisclosureGroup. Layout is identical for every case so +/// the system chevron lines up; only the trailing controls shift: +/// - single-agent: name + latency + (status, copy, globe) — agent's status +/// - multi-agent : name + (status, globe) — workspace's aggregate status +/// - offline : name + (status[off], globe) +/// +/// The status dot lives on the trailing side for every row so an unconnected +/// multi-agent workspace doesn't look indistinguishable from a connected one. +/// Buttons inside use `.borderless` so taps don't toggle the disclosure. private struct WorkspaceDisclosureLabel: View { @Environment(\.openURL) private var openURL let name: String let plainName: String let wsURL: URL + let singleAgent: Agent? + let aggregateStatus: AgentStatus + let aggregateStatusString: String + + @State private var copyTick: Int = 0 var body: some View { HStack { @@ -224,7 +241,31 @@ private struct WorkspaceDisclosureLabel: View { .lineLimit(1) .truncationMode(.tail) .help(plainName) + if let latency = singleAgent?.lastPing?.latency { + Text(formatLatency(latency)) + .font(.caption) + .foregroundStyle(.secondary) + } Spacer() + // Trailing icons in [copy?] [status dot] [globe] order so the + // status dot stays the 2nd-from-right icon in every workspace row + // (multi-agent rows have no copy, but the dot still aligns). + if singleAgent != nil { + Button(action: copyToClipboard) { + Image(systemName: "doc.on.doc.fill") + .symbolRenderingMode(.hierarchical) + .symbolEffect(.bounce, value: copyTick) + .font(.system(size: 9)) + .padding(3) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .controlSize(.small) + .help("Copy hostname") + } + StatusDot(color: aggregateStatus.color) + .help(aggregateStatusString) + .padding(.trailing, 3) Button { openURL(wsURL) } label: { @@ -238,4 +279,15 @@ private struct WorkspaceDisclosureLabel: View { } .contentShape(Rectangle()) } + + private func copyToClipboard() { + guard let host = singleAgent?.primaryHost else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(host, forType: .string) + copyTick &+= 1 + } + + private func formatLatency(_ seconds: TimeInterval) -> String { + "\(Int((seconds * 1000).rounded()))ms" + } }