Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
aslilac merged 2 commits intomainfromlilac/app-groups-frontend
May 29, 2025
Merged
Show file tree
Hide file tree
Changes fromall 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 deletionssite/src/components/Button/Button.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/components/DropdownMenu/DropdownMenu.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsite/src/modules/resources/AgentRow.stories.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 deletionssite/src/modules/resources/AgentRow.test.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/modules/resources/AgentRow.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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";
importtype{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";
importAutoSizerfrom"react-virtualized-auto-sizer";
importtype{FixedSizeListasList,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 DownExpand Up@@ -59,10 +67,10 @@ export const AgentRow: FC<AgentRowProps> = ({
onUpdateAgent,
initialMetadata,
})=>{
// Apps visibility
const{ browser_only}=useFeatureVisibility();
constvisibleApps=agent.apps.filter((app)=>!app.hidden);
consthasAppsToDisplay=!browser_only&&visibleApps.length>0;
constappSections=organizeAgentApps(agent.apps);
consthasAppsToDisplay=
!browser_only||appSections.some((it)=>it.apps.length>0);
constshouldDisplayApps=
(agent.status==="connected"&&hasAppsToDisplay)||
agent.status==="connecting";
Expand DownExpand 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 DownExpand 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 DownExpand Up@@ -327,6 +335,93 @@ export const AgentRow: FC<AgentRowProps> = ({
);
};

typeAppSection={
/**
* 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.
*/
exportfunctionorganizeAgentApps(apps:readonlyWorkspaceApp[]):AppSection[]{
letcurrentSection:AppSection|undefined=undefined;
constappGroups:AppSection[]=[];
constgroupsByName=newMap<string,AppSection>();

for(constappofapps){
if(app.hidden){
continue;
}

if(!currentSection||app.group!==currentSection.group){
constexistingSection=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);
}

returnappGroups;
}

typeAppsProps={
section:AppSection;
agent:WorkspaceAgent;
workspace:Workspace;
};

constApps:FC<AppsProps>=({ section, agent, workspace})=>{
returnsection.group ?(
<DropdownMenu>
<DropdownMenuTriggerasChild>
<AgentButton>
<Folder/>
{section.group}
</AgentButton>
</DropdownMenuTrigger>
<DropdownMenuContentalign="start">
{section.apps.map((app)=>(
<DropdownMenuItemkey={app.slug}>
<AppLinkgroupedapp={app}agent={agent}workspace={workspace}/>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) :(
<>
{section.apps.map((app)=>(
<AppLinkkey={app.slug}app={app}agent={agent}workspace={workspace}/>
))}
</>
);
};

conststyles={
agentRow:(theme)=>({
fontSize:14,
Expand Down
19 changes: 17 additions & 2 deletionssite/src/modules/resources/AppLink/AppLink.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 deletionsite/src/testHelpers/entities.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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

[8]ページ先頭

©2009-2025 Movatter.jp