@@ -9,12 +9,19 @@ import type {
99 Workspace ,
1010 WorkspaceAgent ,
1111 WorkspaceAgentMetadata ,
12+ WorkspaceApp ,
1213} from "api/typesGenerated" ;
1314import { isAxiosError } from "axios" ;
1415import { 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" ;
1622import { Stack } from "components/Stack/Stack" ;
1723import { useProxy } from "contexts/ProxyContext" ;
24+ import { Folder } from "lucide-react" ;
1825import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility" ;
1926import { AppStatuses } from "pages/WorkspacePage/AppStatuses" ;
2027import {
@@ -29,6 +36,7 @@ import {
2936import { useQuery } from "react-query" ;
3037import AutoSizer from "react-virtualized-auto-sizer" ;
3138import type { FixedSizeList as List , ListOnScrollProps } from "react-window" ;
39+ import { AgentButton } from "./AgentButton" ;
3240import { AgentDevcontainerCard } from "./AgentDevcontainerCard" ;
3341import { AgentLatency } from "./AgentLatency" ;
3442import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine" ;
@@ -59,10 +67,10 @@ export const AgentRow: FC<AgentRowProps> = ({
5967 onUpdateAgent,
6068 initialMetadata,
6169} ) => {
62- // Apps visibility
6370 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 ) ;
6674 const shouldDisplayApps =
6775 ( agent . status === "connected" && hasAppsToDisplay ) ||
6876 agent . status === "connecting" ;
@@ -223,10 +231,10 @@ export const AgentRow: FC<AgentRowProps> = ({
223231 displayApps = { agent . display_apps }
224232 />
225233 ) }
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 }
230238 agent = { agent }
231239 workspace = { workspace }
232240 />
@@ -296,7 +304,7 @@ export const AgentRow: FC<AgentRowProps> = ({
296304 width = { width }
297305 css = { styles . startupLogs }
298306 onScroll = { handleLogScroll }
299- logs = { startupLogs . map < Line > ( ( l ) => ( {
307+ logs = { startupLogs . map ( ( l ) => ( {
300308 id : l . id ,
301309 level : l . level ,
302310 output : l . output ,
@@ -327,6 +335,93 @@ export const AgentRow: FC<AgentRowProps> = ({
327335 ) ;
328336} ;
329337
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+
330425const styles = {
331426 agentRow : ( theme ) => ( {
332427 fontSize : 14 ,
0 commit comments