@@ -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.
214226private 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