Skip to content

Commit 8923a65

Browse files
committed
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.
1 parent d47e6f2 commit 8923a65

3 files changed

Lines changed: 126 additions & 69 deletions

File tree

Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,22 @@ struct AgentDetailRow: View {
5959
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
6060
.onHover { hovering in nameIsSelected = hovering }
6161
.padding(.trailing, 3)
62+
.draggable(agent.primaryHost)
6263
if !ports.isEmpty {
6364
portsMenu
6465
}
6566
if let appsToggle {
6667
Button(action: appsToggle.action) {
6768
Image(systemName: appsToggle.isShown ? "square.grid.2x2.fill" : "square.grid.2x2")
6869
.symbolRenderingMode(.hierarchical)
70+
.contentTransition(.symbolEffect(.replace))
6971
.font(.system(size: 11))
7072
.padding(3)
7173
.contentShape(Rectangle())
7274
}
7375
.buttonStyle(.plain)
74-
.help(appsToggle.isShown ? "Hide parent apps" : "Show parent apps")
76+
.controlSize(.small)
77+
.help(appsToggle.isShown ? "Hide apps" : "Show apps")
7578
}
7679
Button {
7780
copyToClipboard()
@@ -84,6 +87,7 @@ struct AgentDetailRow: View {
8487
.contentShape(Rectangle())
8588
}
8689
.buttonStyle(.plain)
90+
.controlSize(.small)
8791
.padding(.trailing, Theme.Size.trayMargin)
8892
.help("Copy hostname")
8993
}
@@ -103,19 +107,17 @@ struct AgentDetailRow: View {
103107
}
104108
}
105109
} label: {
106-
HStack(spacing: 1) {
107-
Image(systemName: "dot.radiowaves.right")
108-
.symbolRenderingMode(.hierarchical)
109-
.font(.system(size: 9))
110-
.imageScale(.small)
111-
Text("\(ports.count)")
112-
.font(.system(size: 10))
113-
}
114-
.foregroundStyle(.secondary)
115-
.padding(3)
110+
Label("Listening ports", systemImage: "dot.radiowaves.right")
111+
.labelStyle(.iconOnly)
112+
.symbolRenderingMode(.hierarchical)
113+
.font(.system(size: 11))
114+
.foregroundStyle(.secondary)
115+
.padding(3)
116116
}
117+
.badge(ports.count)
117118
.menuStyle(.borderlessButton)
118119
.menuIndicator(.hidden)
120+
.controlSize(.small)
119121
.fixedSize()
120122
.help("Listening ports")
121123
}

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ struct Agents<VPN: VPNService>: View {
2020
VStack(spacing: 0) {
2121
ForEach(Array(visibleGroups.enumerated()), id: \.element.id) { index, group in
2222
if index > 0 {
23-
Divider().padding(.horizontal, Theme.Size.trayMargin)
23+
Divider()
24+
.padding(.horizontal, Theme.Size.trayMargin)
25+
.padding(.vertical, 4)
2426
}
2527
WorkspaceGroupView(
2628
group: group,
@@ -38,11 +40,12 @@ struct Agents<VPN: VPNService>: View {
3840
.scrollBounceBehavior(.basedOnSize)
3941
.frame(maxHeight: 400)
4042
if groups.isEmpty {
41-
Text("No workspaces!")
42-
.font(.body)
43-
.foregroundColor(.secondary)
44-
.padding(.horizontal, Theme.Size.trayInset)
45-
.padding(.top, 2)
43+
ContentUnavailableView(
44+
"No workspaces",
45+
systemImage: "person.crop.circle.badge.questionmark",
46+
description: Text("Workspaces appear here when Coder Connect is on.")
47+
)
48+
.padding(.vertical, 4)
4649
}
4750
if groups.count > Theme.defaultVisibleAgents {
4851
Toggle(isOn: $viewAll) {

Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceGroupView.swift

Lines changed: 104 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ struct WorkspaceGroupView: View {
3030
@State private var portsByAgent: [UUID: [WorkspaceAgentListeningPort]] = [:]
3131
@State private var loadingApps: Bool = false
3232
@State private var hasLoadedApps: Bool = false
33-
/// Parents whose apps the user explicitly chose to reveal (overrides the
34-
/// default "hide parent apps when a child has apps" behavior).
35-
@State private var revealedParents: Set<UUID> = []
33+
/// Per-agent override for the apps section. When present, takes precedence
34+
/// over the default (parents collapsed, children expanded).
35+
@State private var appsOverride: [UUID: Bool] = [:]
3636

3737
private var isExpanded: Binding<Bool> {
3838
Binding(
@@ -47,29 +47,24 @@ struct WorkspaceGroupView: View {
4747
}
4848

4949
var body: some View {
50-
if group.agents.count <= 1 {
51-
// Flat row for single-agent or offline workspaces.
52-
let item: VPNMenuItem = group.agents.first.map { .agent($0) }
53-
?? .offlineWorkspace(group.workspace)
54-
MenuItemView(
55-
item: item,
56-
baseAccessURL: baseAccessURL,
57-
expandedItem: $expandedItem,
58-
userInteracted: $userInteracted,
59-
displayLabel: group.workspace.name
50+
// Always render through DisclosureGroup so the chevron, indent, and
51+
// hover treatment match across single-agent, multi-agent, and offline
52+
// workspaces. The label and expanded content vary per case.
53+
DisclosureGroup(isExpanded: isExpanded) {
54+
expandedContent
55+
} label: {
56+
WorkspaceDisclosureLabel(
57+
name: group.workspace.name,
58+
plainName: "\(group.workspace.name).\(state.hostnameSuffix)",
59+
wsURL: baseAccessURL.appending(path: "@me").appending(path: group.workspace.name),
60+
singleAgent: group.agents.count == 1 ? group.agents.first : nil,
61+
aggregateStatus: group.status,
62+
aggregateStatusString: group.agents.count == 1
63+
? (group.agents.first?.statusString ?? group.status.description)
64+
: group.status.description
6065
)
61-
} else {
62-
DisclosureGroup(isExpanded: isExpanded) {
63-
expandedContent
64-
} label: {
65-
WorkspaceDisclosureLabel(
66-
name: group.workspace.name,
67-
plainName: "\(group.workspace.name).\(state.hostnameSuffix)",
68-
wsURL: baseAccessURL.appending(path: "@me").appending(path: group.workspace.name)
69-
)
70-
}
71-
.padding(.horizontal, Theme.Size.trayPadding)
7266
}
67+
.padding(.horizontal, Theme.Size.trayPadding)
7368
}
7469

7570
@ViewBuilder
@@ -78,13 +73,30 @@ struct WorkspaceGroupView: View {
7873
if loadingApps, !hasLoadedApps {
7974
CircularProgressView(value: nil, strokeWidth: 3, diameter: 15)
8075
.padding(.top, 5)
76+
} else if group.agents.isEmpty {
77+
Text("Workspace is offline.")
78+
.font(.body)
79+
.foregroundStyle(.secondary)
80+
.padding(.horizontal, Theme.Size.trayInset)
81+
.padding(.top, 4)
82+
} else if group.agents.count == 1, let only = group.agents.first {
83+
let apps = appsByAgent[only.id] ?? []
84+
if apps.isEmpty {
85+
Text("No apps available.")
86+
.font(.body)
87+
.foregroundStyle(.secondary)
88+
.padding(.horizontal, Theme.Size.trayInset)
89+
.padding(.top, 4)
90+
} else {
91+
MenuItemCollapsibleView(apps: apps)
92+
}
8193
} else {
8294
ForEach(group.topLevelAgents) { parent in
8395
AgentDetailRow(
8496
agent: parent,
85-
apps: appsToShow(for: parent),
97+
apps: appsToShow(for: parent, isChild: false),
8698
ports: portsByAgent[parent.id] ?? [],
87-
appsToggle: appsToggle(for: parent)
99+
appsToggle: appsToggle(for: parent, isChild: false)
88100
)
89101
// Each sub-agent gets its own GroupBox so multiple
90102
// devcontainers under the same parent stay distinct. The
@@ -95,8 +107,9 @@ struct WorkspaceGroupView: View {
95107
GroupBox {
96108
AgentDetailRow(
97109
agent: child,
98-
apps: appsByAgent[child.id] ?? [],
110+
apps: appsToShow(for: child, isChild: true),
99111
ports: portsByAgent[child.id] ?? [],
112+
appsToggle: appsToggle(for: child, isChild: true),
100113
leadingIcon: "cube",
101114
leadingIconHelp: "Container"
102115
)
@@ -107,33 +120,26 @@ struct WorkspaceGroupView: View {
107120
}.task(id: group.id) { await loadApps() }
108121
}
109122

110-
/// Hide a parent agent's apps when any of its direct children have apps —
111-
/// the child apps are the relevant ones in that case (matches the web UI).
112-
/// The user can override per-parent via the row's apps toggle.
113-
private func appsToShow(for agent: Agent) -> [WorkspaceApp] {
114-
let myApps = appsByAgent[agent.id] ?? []
115-
guard hasHiddenParentApps(for: agent) else { return myApps }
116-
return revealedParents.contains(agent.id) ? myApps : []
123+
/// Default visibility per role: parent agents start collapsed (apps
124+
/// hidden), sub-agents start expanded (apps visible). The `appsOverride`
125+
/// map flips the default once the user clicks the row's toggle.
126+
private func appsAreShown(for agentID: UUID, isChild: Bool) -> Bool {
127+
appsOverride[agentID] ?? isChild
117128
}
118129

119-
/// True when this agent has its own apps but they're being hidden because
120-
/// a child has apps. Used to gate the show/hide toggle in the row.
121-
private func hasHiddenParentApps(for agent: Agent) -> Bool {
122-
let myApps = appsByAgent[agent.id] ?? []
123-
guard !myApps.isEmpty else { return false }
124-
let children = group.children(of: agent.id)
125-
return children.contains { !(appsByAgent[$0.id] ?? []).isEmpty }
130+
private func appsToShow(for agent: Agent, isChild: Bool) -> [WorkspaceApp] {
131+
let apps = appsByAgent[agent.id] ?? []
132+
return appsAreShown(for: agent.id, isChild: isChild) ? apps : []
126133
}
127134

128-
private func appsToggle(for agent: Agent) -> AgentDetailRow.AppsToggle? {
129-
guard hasHiddenParentApps(for: agent) else { return nil }
130-
let isShown = revealedParents.contains(agent.id)
135+
/// Toggle is offered on every agent that has apps — both parent and child.
136+
/// If the agent has no apps there's nothing to show, so we return nil.
137+
private func appsToggle(for agent: Agent, isChild: Bool) -> AgentDetailRow.AppsToggle? {
138+
let apps = appsByAgent[agent.id] ?? []
139+
guard !apps.isEmpty else { return nil }
140+
let isShown = appsAreShown(for: agent.id, isChild: isChild)
131141
return AgentDetailRow.AppsToggle(isShown: isShown) {
132-
if isShown {
133-
revealedParents.remove(agent.id)
134-
} else {
135-
revealedParents.insert(agent.id)
136-
}
142+
appsOverride[agent.id] = !isShown
137143
}
138144
}
139145

@@ -208,23 +214,58 @@ struct WorkspaceGroupView: View {
208214
}
209215
}
210216

211-
/// Label slot for the DisclosureGroup: workspace name + trailing globe button.
212-
/// The Button absorbs taps so opening the workspace page doesn't also toggle
213-
/// the disclosure.
217+
/// Label slot for the DisclosureGroup. Layout is identical for every case so
218+
/// the system chevron lines up; only the trailing controls shift:
219+
/// - single-agent: name + latency + (status, copy, globe) — agent's status
220+
/// - multi-agent : name + (status, globe) — workspace's aggregate status
221+
/// - offline : name + (status[off], globe)
222+
///
223+
/// The status dot lives on the trailing side for every row so an unconnected
224+
/// multi-agent workspace doesn't look indistinguishable from a connected one.
225+
/// Buttons inside use `.borderless` so taps don't toggle the disclosure.
214226
private struct WorkspaceDisclosureLabel: View {
215227
@Environment(\.openURL) private var openURL
216228

217229
let name: String
218230
let plainName: String
219231
let wsURL: URL
232+
let singleAgent: Agent?
233+
let aggregateStatus: AgentStatus
234+
let aggregateStatusString: String
235+
236+
@State private var copyTick: Int = 0
220237

221238
var body: some View {
222239
HStack {
223240
Text(name)
224241
.lineLimit(1)
225242
.truncationMode(.tail)
226243
.help(plainName)
244+
if let latency = singleAgent?.lastPing?.latency {
245+
Text(formatLatency(latency))
246+
.font(.caption)
247+
.foregroundStyle(.secondary)
248+
}
227249
Spacer()
250+
// Trailing icons in [copy?] [status dot] [globe] order so the
251+
// status dot stays the 2nd-from-right icon in every workspace row
252+
// (multi-agent rows have no copy, but the dot still aligns).
253+
if singleAgent != nil {
254+
Button(action: copyToClipboard) {
255+
Image(systemName: "doc.on.doc.fill")
256+
.symbolRenderingMode(.hierarchical)
257+
.symbolEffect(.bounce, value: copyTick)
258+
.font(.system(size: 9))
259+
.padding(3)
260+
.contentShape(Rectangle())
261+
}
262+
.buttonStyle(.borderless)
263+
.controlSize(.small)
264+
.help("Copy hostname")
265+
}
266+
StatusDot(color: aggregateStatus.color)
267+
.help(aggregateStatusString)
268+
.padding(.trailing, 3)
228269
Button {
229270
openURL(wsURL)
230271
} label: {
@@ -238,4 +279,15 @@ private struct WorkspaceDisclosureLabel: View {
238279
}
239280
.contentShape(Rectangle())
240281
}
282+
283+
private func copyToClipboard() {
284+
guard let host = singleAgent?.primaryHost else { return }
285+
NSPasteboard.general.clearContents()
286+
NSPasteboard.general.setString(host, forType: .string)
287+
copyTick &+= 1
288+
}
289+
290+
private func formatLatency(_ seconds: TimeInterval) -> String {
291+
"\(Int((seconds * 1000).rounded()))ms"
292+
}
241293
}

0 commit comments

Comments
 (0)