Skip to content

feat: group apps together on workspace page #18018

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions site/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,45 @@ import { forwardRef } from "react";
import { cn } from "utils/cn";

const buttonVariants = cva(
`inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
`
inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
border-solid rounded-md transition-colors
text-sm font-semibold font-medium cursor-pointer no-underline
text-sm font-medium cursor-pointer no-underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
[&:is(a):not([href])]:pointer-events-none [&:is(a):not([href])]:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5
[&>img]:pointer-events-none [&>img]:shrink-0 [&>img]:p-0.5`,
[&_img]:pointer-events-none [&_img]:shrink-0 [&_img]:p-0.5
`,
{
variants: {
variant: {
default:
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold",
outline:
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
subtle:
"border-none bg-transparent text-content-secondary hover:text-content-primary",
destructive:
"border border-border-destructive text-content-primary bg-surface-destructive hover:bg-transparent disabled:bg-transparent disabled:text-content-disabled font-semibold",
default: `
border-none bg-surface-invert-primary font-semibold text-content-invert
hover:bg-surface-invert-secondary
disabled:bg-surface-secondary
`,
outline: `
border border-border-default bg-transparent text-content-primary
hover:bg-surface-secondary
`,
subtle: `
border-none bg-transparent text-content-secondary
hover:text-content-primary
`,
destructive: `
border border-border-destructive font-semibold text-content-primary bg-surface-destructive
hover:bg-transparent
disabled:bg-transparent disabled:text-content-disabled
`,
},

size: {
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&>img]:size-icon-sm",
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg",
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&_img]:size-icon-sm",
xs: "min-w-8 py-1 px-2 text-2xs rounded-md",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&>img]:size-icon-sm",
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&_img]:size-icon-sm",
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg",
},
},
defaultVariants: {
Expand Down
12 changes: 9 additions & 3 deletions site/src/components/DropdownMenu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,15 @@ export const DropdownMenuItem = forwardRef<
ref={ref}
className={cn(
[
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-2 text-sm text-content-secondary font-medium outline-none transition-colors",
"focus:bg-surface-secondary focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&>svg]:size-4 [&>svg]:shrink-0 [&>img]:size-4 [&>img]:shrink-0 no-underline",
`
relative flex cursor-default select-none items-center gap-2 rounded-sm
px-2 py-1.5 text-sm text-content-secondary font-medium outline-none
no-underline
focus:bg-surface-secondary focus:text-content-primary
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
[&_svg]:size-icon-sm [&>svg]:shrink-0
[&_img]:size-icon-sm [&>img]:shrink-0
`,
inset && "pl-8",
],
className,
Expand Down
21 changes: 20 additions & 1 deletion site/src/modules/resources/AgentRow.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { spyOn } from "@storybook/test";
import { spyOn, userEvent, within } from "@storybook/test";
import { API } from "api/api";
import { getPreferredProxy } from "contexts/ProxyContext";
import { chromatic } from "testHelpers/chromatic";
Expand Down Expand Up @@ -265,3 +265,22 @@ export const HideApp: Story = {
},
},
};

export const GroupApp: Story = {
args: {
agent: {
...M.MockWorkspaceAgent,
apps: [
{
...M.MockWorkspaceApp,
group: "group",
},
],
},
},

play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("group"));
},
};
31 changes: 31 additions & 0 deletions site/src/modules/resources/AgentRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { MockWorkspaceApp } from "testHelpers/entities";
import { organizeAgentApps } from "./AgentRow";

describe("organizeAgentApps", () => {
test("returns one ungrouped app", () => {
const result = organizeAgentApps([{ ...MockWorkspaceApp }]);

expect(result).toEqual([{ apps: [MockWorkspaceApp] }]);
});

test("handles ordering correctly", () => {
const bugApp = { ...MockWorkspaceApp, slug: "bug", group: "creatures" };
const birdApp = { ...MockWorkspaceApp, slug: "bird", group: "creatures" };
const fishApp = { ...MockWorkspaceApp, slug: "fish", group: "creatures" };
const riderApp = { ...MockWorkspaceApp, slug: "rider" };
const zedApp = { ...MockWorkspaceApp, slug: "zed" };
const result = organizeAgentApps([
bugApp,
riderApp,
birdApp,
zedApp,
fishApp,
]);

expect(result).toEqual([
{ group: "creatures", apps: [bugApp, birdApp, fishApp] },
{ apps: [riderApp] },
{ apps: [zedApp] },
]);
});
});
113 changes: 104 additions & 9 deletions site/src/modules/resources/AgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
WorkspaceApp,
} from "api/typesGenerated";
import { isAxiosError } from "axios";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import type { Line } from "components/Logs/LogLine";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Stack } from "components/Stack/Stack";
import { useProxy } from "contexts/ProxyContext";
import { Folder } from "lucide-react";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
import {
Expand All @@ -29,6 +36,7 @@ import {
import { useQuery } from "react-query";
import AutoSizer from "react-virtualized-auto-sizer";
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
import { AgentButton } from "./AgentButton";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
import { AgentLatency } from "./AgentLatency";
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
Expand Down Expand Up @@ -59,10 +67,10 @@ export const AgentRow: FC<AgentRowProps> = ({
onUpdateAgent,
initialMetadata,
}) => {
// Apps visibility
const { browser_only } = useFeatureVisibility();
const visibleApps = agent.apps.filter((app) => !app.hidden);
const hasAppsToDisplay = !browser_only && visibleApps.length > 0;
const appSections = organizeAgentApps(agent.apps);
const hasAppsToDisplay =
!browser_only || appSections.some((it) => it.apps.length > 0);
const shouldDisplayApps =
(agent.status === "connected" && hasAppsToDisplay) ||
agent.status === "connecting";
Expand Down Expand Up @@ -223,10 +231,10 @@ export const AgentRow: FC<AgentRowProps> = ({
displayApps={agent.display_apps}
/>
)}
{visibleApps.map((app) => (
<AppLink
key={app.slug}
app={app}
{appSections.map((section, i) => (
<Apps
key={section.group ?? i}
section={section}
agent={agent}
workspace={workspace}
/>
Expand Down Expand Up @@ -296,7 +304,7 @@ export const AgentRow: FC<AgentRowProps> = ({
width={width}
css={styles.startupLogs}
onScroll={handleLogScroll}
logs={startupLogs.map<Line>((l) => ({
logs={startupLogs.map((l) => ({
id: l.id,
level: l.level,
output: l.output,
Expand Down Expand Up @@ -327,6 +335,93 @@ export const AgentRow: FC<AgentRowProps> = ({
);
};

type AppSection = {
/**
* If there is no `group`, just render all of the apps inline. If there is a
* group name, show them all in a dropdown.
*/
group?: string;

apps: WorkspaceApp[];
};

/**
* organizeAgentApps returns an ordering of agent apps that accounts for
* grouping. When we receive the list of apps from the backend, they have
* already been "ordered" by their `order` attribute, but we are not given that
* value. We must be careful to preserve that ordering, while also properly
* grouping together all apps of any given group.
*
* The position of the group overall is determined by the `order` position of
* the first app in the group. There may be several sections returned without
* a group name, to allow placing grouped apps in between non-grouped apps. Not
* every ungrouped section is expected to have a group in between, to make the
* algorithm a little simpler to implement.
*/
export function organizeAgentApps(apps: readonly WorkspaceApp[]): AppSection[] {
let currentSection: AppSection | undefined = undefined;
const appGroups: AppSection[] = [];
const groupsByName = new Map<string, AppSection>();

for (const app of apps) {
if (app.hidden) {
continue;
}

if (!currentSection || app.group !== currentSection.group) {
const existingSection = groupsByName.get(app.group!);
if (existingSection) {
currentSection = existingSection;
} else {
currentSection = {
group: app.group,
apps: [],
};
appGroups.push(currentSection);
if (app.group) {
groupsByName.set(app.group, currentSection);
}
}
}

currentSection.apps.push(app);
}

return appGroups;
}

type AppsProps = {
section: AppSection;
agent: WorkspaceAgent;
workspace: Workspace;
};

const Apps: FC<AppsProps> = ({ section, agent, workspace }) => {
return section.group ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AgentButton>
<Folder />
{section.group}
</AgentButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{section.apps.map((app) => (
<DropdownMenuItem key={app.slug}>
<AppLink grouped app={app} agent={agent} workspace={workspace} />
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{section.apps.map((app) => (
<AppLink key={app.slug} app={app} agent={agent} workspace={workspace} />
))}
</>
);
};

const styles = {
agentRow: (theme) => ({
fontSize: 14,
Expand Down
19 changes: 17 additions & 2 deletions site/src/modules/resources/AppLink/AppLink.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useTheme } from "@emotion/react";
import type * as TypesGen from "api/typesGenerated";
import { DropdownMenuItem } from "components/DropdownMenu/DropdownMenu";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
Expand Down Expand Up @@ -28,9 +29,15 @@ interface AppLinkProps {
workspace: TypesGen.Workspace;
app: TypesGen.WorkspaceApp;
agent: TypesGen.WorkspaceAgent;
grouped?: boolean;
}

export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
export const AppLink: FC<AppLinkProps> = ({
app,
workspace,
agent,
grouped,
}) => {
const { proxy } = useProxy();
const host = proxy.preferredWildcardHostname;
const [iconError, setIconError] = useState(false);
Expand Down Expand Up @@ -90,7 +97,15 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {

const canShare = app.sharing_level !== "owner";

const button = (
const button = grouped ? (
<DropdownMenuItem asChild>
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
{icon}
{link.label}
{canShare && <ShareIcon app={app} />}
</a>
</DropdownMenuItem>
) : (
<AgentButton asChild>
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
{icon}
Expand Down
1 change: 0 additions & 1 deletion site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,6 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
health: "disabled",
external: false,
sharing_level: "owner",
group: "",
hidden: false,
open_in: "slim-window",
statuses: [],
Expand Down
Loading