Skip to content

Commit 81cc14e

Browse files
committed
feat: expanded agent details with ports, devcontainer boxes, and parent-apps toggle
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://<agent>:<port>. 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().
1 parent 87464b3 commit 81cc14e

6 files changed

Lines changed: 390 additions & 94 deletions

File tree

‎Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
100100
await self.state.handleTokenExpiry()
101101
}
102102
}, content: {
103-
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
103+
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 320)
104104
.environmentObject(self.vpn)
105105
.environmentObject(self.state)
106106
.environmentObject(self.fileSyncDaemon)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import CoderSDK
2+
import SwiftUI
3+
4+
/// Renders a single agent inside an expanded WorkspaceGroupView: status dot,
5+
/// agent name + latency, optional ports menu / parent-apps toggle / copy
6+
/// button, and the agent's apps inline below.
7+
///
8+
/// `leadingIcon` is rendered ahead of the status dot — used to mark sub-agent
9+
/// rows with a cube glyph so they're identifiable without a dedicated
10+
/// container header. `appsToggle`, when set, exposes the show/hide control
11+
/// for parent apps (mirrors the dashboard's "Show parent apps" affordance).
12+
struct AgentDetailRow: View {
13+
@EnvironmentObject var state: AppState
14+
@Environment(\.openURL) private var openURL
15+
16+
let agent: Agent
17+
let apps: [WorkspaceApp]
18+
var ports: [WorkspaceAgentListeningPort] = []
19+
var appsToggle: AppsToggle?
20+
var leadingIcon: String?
21+
var leadingIconHelp: String?
22+
23+
@State private var nameIsSelected: Bool = false
24+
25+
struct AppsToggle {
26+
let isShown: Bool
27+
let action: () -> Void
28+
}
29+
30+
var body: some View {
31+
VStack(alignment: .leading, spacing: 0) {
32+
HStack(spacing: 3) {
33+
HStack(spacing: Theme.Size.trayPadding) {
34+
if let leadingIcon {
35+
Image(systemName: leadingIcon)
36+
.font(.system(size: 11))
37+
.foregroundStyle(.secondary)
38+
.help(leadingIconHelp ?? "")
39+
}
40+
StatusDot(color: agent.status.color)
41+
.help(agent.statusString)
42+
Text(agent.name).lineLimit(1).truncationMode(.tail)
43+
if let latency = agent.lastPing?.latency {
44+
Text(formatLatency(latency))
45+
.font(.caption)
46+
.foregroundStyle(.secondary)
47+
}
48+
Spacer()
49+
}
50+
.padding(.horizontal, Theme.Size.trayPadding)
51+
.frame(minHeight: 22)
52+
.frame(maxWidth: .infinity, alignment: .leading)
53+
.foregroundStyle(nameIsSelected ? .white : .primary)
54+
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
55+
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
56+
.onHover { hovering in nameIsSelected = hovering }
57+
.padding(.trailing, 3)
58+
if !ports.isEmpty {
59+
portsMenu
60+
}
61+
if let appsToggle {
62+
Button(action: appsToggle.action) {
63+
Image(systemName: appsToggle.isShown ? "square.grid.2x2.fill" : "square.grid.2x2")
64+
.font(.system(size: 11))
65+
.padding(3)
66+
.contentShape(Rectangle())
67+
}
68+
.buttonStyle(.plain)
69+
.help(appsToggle.isShown ? "Hide parent apps" : "Show parent apps")
70+
}
71+
MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
72+
.font(.system(size: 9))
73+
.symbolVariant(.fill)
74+
.padding(.trailing, Theme.Size.trayMargin)
75+
.help("Copy hostname")
76+
}
77+
if !apps.isEmpty {
78+
MenuItemCollapsibleView(apps: apps)
79+
}
80+
}
81+
}
82+
83+
private var portsMenu: some View {
84+
Menu {
85+
ForEach(ports) { port in
86+
Button(label(for: port)) {
87+
if let url = URL(string: "http://\(agent.primaryHost):\(port.port)") {
88+
openURL(url)
89+
}
90+
}
91+
}
92+
} label: {
93+
HStack(spacing: 1) {
94+
Image(systemName: "dot.radiowaves.right")
95+
.font(.system(size: 9))
96+
.imageScale(.small)
97+
Text("\(ports.count)")
98+
.font(.system(size: 10))
99+
}
100+
.foregroundStyle(.secondary)
101+
.padding(3)
102+
}
103+
.menuStyle(.borderlessButton)
104+
.menuIndicator(.hidden)
105+
.fixedSize()
106+
.help("Listening ports")
107+
}
108+
109+
private func copyToClipboard() {
110+
NSPasteboard.general.clearContents()
111+
NSPasteboard.general.setString(agent.primaryHost, forType: .string)
112+
}
113+
114+
/// Compact "18ms" formatting for inline display. The dot's tooltip still
115+
/// carries the full status string with P2P/DERP detail.
116+
private func formatLatency(_ seconds: TimeInterval) -> String {
117+
let ms = Int((seconds * 1000).rounded())
118+
return "\(ms)ms"
119+
}
120+
121+
private func label(for port: WorkspaceAgentListeningPort) -> String {
122+
let processName = port.process_name.isEmpty ? nil : port.process_name
123+
if let processName {
124+
return "\(port.port) - \(processName)"
125+
}
126+
return "\(port.port)"
127+
}
128+
}

‎Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift‎

Lines changed: 4 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ struct Agents<VPN: VPNService>: View {
66
@State private var viewAll = false
77
@State private var expandedItem: UUID?
88
@State private var hasToggledExpansion: Bool = false
9-
@State private var enrichedWorkspaces: Set<UUID> = []
109

1110
let inspection = Inspection<Self>()
1211

@@ -24,30 +23,17 @@ struct Agents<VPN: VPNService>: View {
2423
group: group,
2524
baseAccessURL: state.baseAccessURL!,
2625
expandedItem: $expandedItem,
27-
userInteracted: $hasToggledExpansion
26+
userInteracted: $hasToggledExpansion,
27+
setAgentParentID: { agentID, parentID in
28+
vpn.setAgentParentID(agentID: agentID, parentID: parentID)
29+
}
2830
)
2931
.padding(.horizontal, Theme.Size.trayMargin)
3032
}
31-
}.onChange(of: visibleGroups) {
32-
// If no workspaces are online, expand the first one to come online.
33-
if visibleGroups.allSatisfy({ $0.status == .off }) {
34-
hasToggledExpansion = false
35-
return
36-
}
37-
if hasToggledExpansion {
38-
return
39-
}
40-
withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
41-
expandedItem = visibleGroups.first?.defaultExpandID
42-
}
43-
hasToggledExpansion = true
4433
}
4534
}
4635
.scrollBounceBehavior(.basedOnSize)
4736
.frame(maxHeight: 400)
48-
.task(id: Set(groups.map(\.id))) {
49-
await enrichParents(groups: groups)
50-
}
5137
if groups.isEmpty {
5238
Text("No workspaces!")
5339
.font(.body)
@@ -67,36 +53,4 @@ struct Agents<VPN: VPNService>: View {
6753
}
6854
}.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
6955
}
70-
71-
/// Backfill agent.parent_id from the HTTP API. The VPN proto doesn't carry
72-
/// it, so without this children would never nest under their parent in the
73-
/// tray. Best-effort: failures are silent and retried whenever the set of
74-
/// workspace IDs changes.
75-
private func enrichParents(groups: [WorkspaceGroup]) async {
76-
guard let client = state.client else { return }
77-
for group in groups where !enrichedWorkspaces.contains(group.id) {
78-
do {
79-
let workspace = try await client.workspace(group.id)
80-
let agents = workspace.latest_build.resources.compactMap(\.agents).flatMap(\.self)
81-
for agent in agents {
82-
vpn.setAgentParentID(agentID: agent.id, parentID: agent.parent_id)
83-
}
84-
enrichedWorkspaces.insert(group.id)
85-
} catch {
86-
continue
87-
}
88-
}
89-
}
90-
}
91-
92-
private extension WorkspaceGroup {
93-
/// For the auto-expand-first behavior: single-agent groups expand the
94-
/// agent's app section (existing UX); multi-agent groups expand the
95-
/// workspace itself to reveal nested agents.
96-
var defaultExpandID: UUID {
97-
if agents.count == 1, let only = agents.first {
98-
return only.id
99-
}
100-
return id
101-
}
10256
}

‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ struct MenuItemView: View {
7878
// we show this instead of the full FQDN to keep the menu readable. The
7979
// copy-to-clipboard action and the hover tooltip still use the full FQDN.
8080
var displayLabel: String?
81+
// Hide the trailing open-in-browser button on rows where it would be
82+
// redundant (nested agent rows under a workspace header that already has
83+
// the same link).
84+
var showOpenInBrowser: Bool = true
8185

8286
@State private var nameIsSelected: Bool = false
8387

@@ -147,7 +151,7 @@ struct MenuItemView: View {
147151
}
148152
.help(plainItemName)
149153
}.buttonStyle(.plain).padding(.trailing, 3)
150-
MenuItemIcons(item: item, wsURL: wsURL)
154+
MenuItemIcons(item: item, wsURL: wsURL, showOpenInBrowser: showOpenInBrowser)
151155
}
152156
if isExpanded {
153157
Group {
@@ -230,6 +234,7 @@ struct MenuItemIcons: View {
230234

231235
let item: VPNMenuItem
232236
let wsURL: URL
237+
var showOpenInBrowser: Bool = true
233238

234239
@State private var copyIsSelected: Bool = false
235240
@State private var webIsSelected: Bool = false
@@ -251,12 +256,15 @@ struct MenuItemIcons: View {
251256
MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
252257
.font(.system(size: 9))
253258
.symbolVariant(.fill)
259+
.padding(.trailing, showOpenInBrowser ? 0 : Theme.Size.trayMargin)
254260
.help("Copy hostname")
255-
MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
256-
.contentShape(Rectangle())
257-
.font(.system(size: 12))
258-
.padding(.trailing, Theme.Size.trayMargin)
259-
.help("Open in browser")
261+
if showOpenInBrowser {
262+
MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
263+
.contentShape(Rectangle())
264+
.font(.system(size: 12))
265+
.padding(.trailing, Theme.Size.trayMargin)
266+
.help("Open in browser")
267+
}
260268
}
261269
}
262270

0 commit comments

Comments
 (0)