feat: group apps together on workspace page (#18018)

This commit is contained in:
ケイラ
2025-05-29 12:01:51 -06:00
committed by GitHub
parent e906ce2b65
commit 232c72ffd6
7 changed files with 208 additions and 31 deletions

View File

@ -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: {

View File

@ -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,

View File

@ -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";
@ -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"));
},
};

View File

@ -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] },
]);
});
});

View File

@ -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 {
@ -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";
@ -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";
@ -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}
/>
@ -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,
@ -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,

View File

@ -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,
@ -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);
@ -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}

View File

@ -903,7 +903,6 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
health: "disabled",
external: false,
sharing_level: "owner",
group: "",
hidden: false,
open_in: "slim-window",
statuses: [],