@@ -9,12 +9,19 @@ import type {
9
9
Workspace ,
10
10
WorkspaceAgent ,
11
11
WorkspaceAgentMetadata ,
12
+ WorkspaceApp ,
12
13
} from "api/typesGenerated" ;
13
14
import { isAxiosError } from "axios" ;
14
15
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow" ;
15
- import type { Line } from "components/Logs/LogLine" ;
16
+ import {
17
+ DropdownMenu ,
18
+ DropdownMenuContent ,
19
+ DropdownMenuItem ,
20
+ DropdownMenuTrigger ,
21
+ } from "components/DropdownMenu/DropdownMenu" ;
16
22
import { Stack } from "components/Stack/Stack" ;
17
23
import { useProxy } from "contexts/ProxyContext" ;
24
+ import { Folder } from "lucide-react" ;
18
25
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility" ;
19
26
import { AppStatuses } from "pages/WorkspacePage/AppStatuses" ;
20
27
import {
@@ -29,6 +36,7 @@ import {
29
36
import { useQuery } from "react-query" ;
30
37
import AutoSizer from "react-virtualized-auto-sizer" ;
31
38
import type { FixedSizeList as List , ListOnScrollProps } from "react-window" ;
39
+ import { AgentButton } from "./AgentButton" ;
32
40
import { AgentDevcontainerCard } from "./AgentDevcontainerCard" ;
33
41
import { AgentLatency } from "./AgentLatency" ;
34
42
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine" ;
@@ -59,10 +67,10 @@ export const AgentRow: FC<AgentRowProps> = ({
59
67
onUpdateAgent,
60
68
initialMetadata,
61
69
} ) => {
62
- // Apps visibility
63
70
const { browser_only } = useFeatureVisibility ( ) ;
64
- const visibleApps = agent . apps . filter ( ( app ) => ! app . hidden ) ;
65
- const hasAppsToDisplay = ! browser_only && visibleApps . length > 0 ;
71
+ const appSections = organizeAgentApps ( agent . apps ) ;
72
+ const hasAppsToDisplay =
73
+ ! browser_only || appSections . some ( ( it ) => it . apps . length > 0 ) ;
66
74
const shouldDisplayApps =
67
75
( agent . status === "connected" && hasAppsToDisplay ) ||
68
76
agent . status === "connecting" ;
@@ -223,10 +231,10 @@ export const AgentRow: FC<AgentRowProps> = ({
223
231
displayApps = { agent . display_apps }
224
232
/>
225
233
) }
226
- { visibleApps . map ( ( app ) => (
227
- < AppLink
228
- key = { app . slug }
229
- app = { app }
234
+ { appSections . map ( ( section , i ) => (
235
+ < Apps
236
+ key = { section . group ?? i }
237
+ section = { section }
230
238
agent = { agent }
231
239
workspace = { workspace }
232
240
/>
@@ -296,7 +304,7 @@ export const AgentRow: FC<AgentRowProps> = ({
296
304
width = { width }
297
305
css = { styles . startupLogs }
298
306
onScroll = { handleLogScroll }
299
- logs = { startupLogs . map < Line > ( ( l ) => ( {
307
+ logs = { startupLogs . map ( ( l ) => ( {
300
308
id : l . id ,
301
309
level : l . level ,
302
310
output : l . output ,
@@ -327,6 +335,93 @@ export const AgentRow: FC<AgentRowProps> = ({
327
335
) ;
328
336
} ;
329
337
338
+ type AppSection = {
339
+ /**
340
+ * If there is no `group`, just render all of the apps inline. If there is a
341
+ * group name, show them all in a dropdown.
342
+ */
343
+ group ?: string ;
344
+
345
+ apps : WorkspaceApp [ ] ;
346
+ } ;
347
+
348
+ /**
349
+ * organizeAgentApps returns an ordering of agent apps that accounts for
350
+ * grouping. When we receive the list of apps from the backend, they have
351
+ * already been "ordered" by their `order` attribute, but we are not given that
352
+ * value. We must be careful to preserve that ordering, while also properly
353
+ * grouping together all apps of any given group.
354
+ *
355
+ * The position of the group overall is determined by the `order` position of
356
+ * the first app in the group. There may be several sections returned without
357
+ * a group name, to allow placing grouped apps in between non-grouped apps. Not
358
+ * every ungrouped section is expected to have a group in between, to make the
359
+ * algorithm a little simpler to implement.
360
+ */
361
+ export function organizeAgentApps ( apps : readonly WorkspaceApp [ ] ) : AppSection [ ] {
362
+ let currentSection : AppSection | undefined = undefined ;
363
+ const appGroups : AppSection [ ] = [ ] ;
364
+ const groupsByName = new Map < string , AppSection > ( ) ;
365
+
366
+ for ( const app of apps ) {
367
+ if ( app . hidden ) {
368
+ continue ;
369
+ }
370
+
371
+ if ( ! currentSection || app . group !== currentSection . group ) {
372
+ const existingSection = groupsByName . get ( app . group ! ) ;
373
+ if ( existingSection ) {
374
+ currentSection = existingSection ;
375
+ } else {
376
+ currentSection = {
377
+ group : app . group ,
378
+ apps : [ ] ,
379
+ } ;
380
+ appGroups . push ( currentSection ) ;
381
+ if ( app . group ) {
382
+ groupsByName . set ( app . group , currentSection ) ;
383
+ }
384
+ }
385
+ }
386
+
387
+ currentSection . apps . push ( app ) ;
388
+ }
389
+
390
+ return appGroups ;
391
+ }
392
+
393
+ type AppsProps = {
394
+ section : AppSection ;
395
+ agent : WorkspaceAgent ;
396
+ workspace : Workspace ;
397
+ } ;
398
+
399
+ const Apps : FC < AppsProps > = ( { section, agent, workspace } ) => {
400
+ return section . group ? (
401
+ < DropdownMenu >
402
+ < DropdownMenuTrigger asChild >
403
+ < AgentButton >
404
+ < Folder />
405
+ { section . group }
406
+ </ AgentButton >
407
+ </ DropdownMenuTrigger >
408
+ < DropdownMenuContent align = "start" >
409
+ { section . apps . map ( ( app ) => (
410
+ < DropdownMenuItem key = { app . slug } >
411
+ < AppLink grouped app = { app } agent = { agent } workspace = { workspace } />
412
+ </ DropdownMenuItem >
413
+ ) ) }
414
+ </ DropdownMenuContent >
415
+ </ DropdownMenu >
416
+ ) : (
417
+ < >
418
+ { section . apps . map ( ( app ) => (
419
+ < AppLink key = { app . slug } app = { app } agent = { agent } workspace = { workspace } />
420
+ ) ) }
421
+ </ >
422
+ ) ;
423
+ } ;
424
+
330
425
const styles = {
331
426
agentRow : ( theme ) => ( {
332
427
fontSize : 14 ,
0 commit comments