mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: align responsive agent views (#3688)
## Summary - Keep the Agents header full width while cards reflow independently. - Collapse header actions into an overflow menu at the compact layout threshold. - Apply the same responsive grid rules to Agent Teams. ## Validation - `pnpm -C desktop build:e2e` - Focused Agents Playwright coverage - Pre-push desktop checks and unit tests --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { OctagonX, Settings2 } from "lucide-react";
|
||||
import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react";
|
||||
import {
|
||||
consumePendingSnapshotImport,
|
||||
subscribeSnapshotImport,
|
||||
@@ -20,10 +20,7 @@ import { SecretRevealDialog } from "./SecretRevealDialog";
|
||||
import { TeamDeleteDialog } from "./TeamDeleteDialog";
|
||||
import { TeamDialog } from "./TeamDialog";
|
||||
import { TeamsSection } from "./TeamsSection";
|
||||
import {
|
||||
AGENT_CARD_GRID_COLUMNS_CLASS,
|
||||
UnifiedAgentsSection,
|
||||
} from "./UnifiedAgentsSection";
|
||||
import { UnifiedAgentsSection } from "./UnifiedAgentsSection";
|
||||
import { useManagedAgentActions } from "./useManagedAgentActions";
|
||||
import { usePersonaActions } from "./usePersonaActions";
|
||||
import { useTeamActions } from "./useTeamActions";
|
||||
@@ -32,6 +29,12 @@ import { useBakedBuildEnvQuery } from "@/features/agents/hooks";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { PageHeader } from "@/shared/ui/PageHeader";
|
||||
import { getInheritedAgentDefaults } from "./bakedEnvHelpers";
|
||||
|
||||
@@ -44,6 +47,8 @@ export function AgentsView() {
|
||||
const personas = usePersonaActions();
|
||||
const teamImportInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const fullAiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const compactActionsTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false);
|
||||
// Exclusivity: create never sets `personaDialogState` (edit/dup/import do),
|
||||
// so the create-mode and definition-edit AgentDialog mounts never coexist.
|
||||
@@ -53,6 +58,22 @@ export function AgentsView() {
|
||||
personas.prepareCreate();
|
||||
setIsCreateDialogOpen(true);
|
||||
}
|
||||
|
||||
function openAiDefaults(trigger: HTMLButtonElement | null) {
|
||||
aiDefaultsTriggerRef.current = trigger;
|
||||
setIsAiDefaultsOpen(true);
|
||||
}
|
||||
|
||||
function setAiDefaultsDialogOpen(open: boolean) {
|
||||
if (!open) {
|
||||
aiDefaultsTriggerRef.current =
|
||||
fullAiDefaultsTriggerRef.current?.offsetParent !== null
|
||||
? fullAiDefaultsTriggerRef.current
|
||||
: compactActionsTriggerRef.current;
|
||||
}
|
||||
setIsAiDefaultsOpen(open);
|
||||
}
|
||||
|
||||
const teamActions = useTeamActions(
|
||||
{
|
||||
setActionNoticeMessage: agents.setActionNoticeMessage,
|
||||
@@ -113,43 +134,84 @@ export function AgentsView() {
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-7 sm:px-6 sm:py-8">
|
||||
<div
|
||||
className={`mx-auto grid w-full max-w-6xl ${AGENT_CARD_GRID_COLUMNS_CLASS} justify-start gap-x-3 gap-y-8`}
|
||||
className="mx-auto w-full max-w-6xl space-y-8 [container-type:inline-size]"
|
||||
data-testid="agents-page-content"
|
||||
>
|
||||
<PageHeader
|
||||
className="col-[1/-1]"
|
||||
action={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
data-testid="agent-defaults-button"
|
||||
ref={aiDefaultsTriggerRef}
|
||||
onClick={() => setIsAiDefaultsOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings2 />
|
||||
{hasSavedAgentDefaults
|
||||
? "Agent defaults"
|
||||
: "Set agent defaults"}
|
||||
</Button>
|
||||
{runningAgentCount > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-end gap-2 [@container(max-width:40rem)]:hidden">
|
||||
<Button
|
||||
disabled={isActionPending}
|
||||
onClick={() => {
|
||||
void agents.handleBulkStopRunning();
|
||||
}}
|
||||
data-testid="agent-defaults-button"
|
||||
ref={fullAiDefaultsTriggerRef}
|
||||
onClick={(event) => openAiDefaults(event.currentTarget)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<OctagonX />
|
||||
Stop running agents
|
||||
<Settings2 />
|
||||
{hasSavedAgentDefaults
|
||||
? "Agent defaults"
|
||||
: "Set agent defaults"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{runningAgentCount > 0 ? (
|
||||
<Button
|
||||
disabled={isActionPending}
|
||||
onClick={() => {
|
||||
void agents.handleBulkStopRunning();
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<OctagonX />
|
||||
Stop running agents
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Agent actions"
|
||||
className="hidden [@container(max-width:40rem)]:inline-flex"
|
||||
data-testid="agent-actions-menu-trigger"
|
||||
ref={compactActionsTriggerRef}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
openAiDefaults(compactActionsTriggerRef.current);
|
||||
}}
|
||||
>
|
||||
<Settings2 />
|
||||
{hasSavedAgentDefaults
|
||||
? "Agent defaults"
|
||||
: "Set agent defaults"}
|
||||
</DropdownMenuItem>
|
||||
{runningAgentCount > 0 ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onSelect={() => {
|
||||
void agents.handleBulkStopRunning();
|
||||
}}
|
||||
>
|
||||
<OctagonX />
|
||||
Stop running agents
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
description="Set up and manage your agents."
|
||||
title="Agents"
|
||||
/>
|
||||
<div className="col-[1/-1] flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
<UnifiedAgentsSection
|
||||
defaultModel={inheritedDefaults.model.value}
|
||||
actionErrorMessage={agents.actionErrorMessage}
|
||||
@@ -238,7 +300,7 @@ export function AgentsView() {
|
||||
</div>
|
||||
|
||||
<AgentDefaultsDialog
|
||||
onOpenChange={setIsAiDefaultsOpen}
|
||||
onOpenChange={setAiDefaultsDialogOpen}
|
||||
open={isAiDefaultsOpen}
|
||||
returnFocusRef={aiDefaultsTriggerRef}
|
||||
/>
|
||||
|
||||
@@ -20,9 +20,9 @@ import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
|
||||
import { SectionHeader } from "@/shared/ui/PageHeader";
|
||||
import { CreateIdentityCard } from "./CreateIdentityCard";
|
||||
import { TeamIdentityCard } from "./TeamIdentityCard";
|
||||
import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection";
|
||||
|
||||
const TEAM_CARD_COLUMN_CLASS = "w-full";
|
||||
const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`;
|
||||
|
||||
type TeamsSectionProps = {
|
||||
teams: AgentTeam[];
|
||||
@@ -63,7 +63,7 @@ export function TeamsSection({
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={TEAM_CARD_GRID_CLASS}>
|
||||
<div className={IDENTITY_CARD_GRID_CLASS}>
|
||||
<IdentityCardSkeleton
|
||||
footerSubtitleWidthClass="w-14"
|
||||
footerTitleWidthClass="w-24"
|
||||
@@ -83,7 +83,7 @@ export function TeamsSection({
|
||||
) : null}
|
||||
|
||||
{!isLoading ? (
|
||||
<div className={TEAM_CARD_GRID_CLASS}>
|
||||
<div className={IDENTITY_CARD_GRID_CLASS}>
|
||||
{teams.map((team) => {
|
||||
const resolution = resolveTeamPersonas(team, personas);
|
||||
const missingPersonaCount = resolution.missingPersonaCount;
|
||||
|
||||
@@ -68,7 +68,7 @@ type UnifiedAgentsSectionProps = {
|
||||
const AGENT_CARD_COLUMN_CLASS = "w-full";
|
||||
export const AGENT_CARD_GRID_COLUMNS_CLASS =
|
||||
"grid-cols-[repeat(auto-fill,minmax(220px,240px))]";
|
||||
const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`;
|
||||
export const IDENTITY_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3 [@container(max-width:40rem)]:justify-center`;
|
||||
|
||||
export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
const {
|
||||
@@ -153,7 +153,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
|
||||
{!isLoading ? (
|
||||
<div className="space-y-3" data-testid="unified-agents-groups">
|
||||
<div className={AGENT_CARD_GRID_CLASS}>
|
||||
<div className={IDENTITY_CARD_GRID_CLASS}>
|
||||
{groups.map((group) => {
|
||||
const profileAgent = pickProfileAgent(group.agents);
|
||||
return (
|
||||
@@ -479,7 +479,7 @@ function NewAgentCard({
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className={AGENT_CARD_GRID_CLASS}>
|
||||
<div className={IDENTITY_CARD_GRID_CLASS}>
|
||||
<IdentityCardSkeleton
|
||||
footerSubtitleWidthClass="w-14"
|
||||
footerTitleWidthClass="w-24"
|
||||
@@ -537,7 +537,7 @@ function CollapsibleAgentGroup({
|
||||
<span className="text-xs text-muted-foreground">({agents.length})</span>
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className={AGENT_CARD_GRID_CLASS}>
|
||||
<div className={IDENTITY_CARD_GRID_CLASS}>
|
||||
{agents.map((agent) => (
|
||||
<StandaloneAgentCard
|
||||
agent={agent}
|
||||
|
||||
@@ -416,7 +416,7 @@ test("the new agent card offers create, discover, and import", async ({
|
||||
const cardBoxes = await agentCards.evaluateAll((cards) =>
|
||||
cards.map((card) => {
|
||||
const box = card.getBoundingClientRect();
|
||||
return { right: box.right, top: box.top };
|
||||
return { left: box.left, right: box.right, top: box.top };
|
||||
}),
|
||||
);
|
||||
const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top));
|
||||
@@ -425,12 +425,16 @@ test("the new agent card offers create, discover, and import", async ({
|
||||
.filter(({ top }) => Math.abs(top - firstRowTop) < 1)
|
||||
.map(({ right }) => right),
|
||||
);
|
||||
const leftmostFirstRowCard = Math.min(
|
||||
...cardBoxes
|
||||
.filter(({ top }) => Math.abs(top - firstRowTop) < 1)
|
||||
.map(({ left }) => left),
|
||||
);
|
||||
expect(headerBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
(headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard,
|
||||
),
|
||||
).toBeLessThan(1);
|
||||
expect(Math.abs((headerBox?.x ?? 0) - leftmostFirstRowCard)).toBeLessThan(1);
|
||||
expect(rightmostFirstRowCard).toBeLessThanOrEqual(
|
||||
(headerBox?.x ?? 0) + (headerBox?.width ?? 0) + 1,
|
||||
);
|
||||
|
||||
await newAgentCard.click();
|
||||
await expect(
|
||||
@@ -492,6 +496,63 @@ test("the new team card offers create and import", async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("team cards follow the agents grid alignment at compact widths", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: "custom:team-layout",
|
||||
displayName: "Team layout agent",
|
||||
systemPrompt: "A test agent for team layout alignment.",
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
id: "team-layout",
|
||||
name: "Team layout",
|
||||
personaIds: ["custom:team-layout"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
|
||||
const agentsContent = page.getByTestId("agents-page-content");
|
||||
const firstAgentCard = page.getByTestId(
|
||||
"persona-agent-row-custom:team-layout",
|
||||
);
|
||||
const firstTeamCard = page.getByTestId("team-card-team-layout");
|
||||
const agentGrid = firstAgentCard.locator("xpath=..");
|
||||
const teamGrid = firstTeamCard.locator("xpath=..");
|
||||
const firstAgentGridCard = agentGrid.locator(":scope > *").first();
|
||||
const firstTeamGridCard = teamGrid.locator(":scope > *").first();
|
||||
|
||||
await agentsContent.evaluate((element) => {
|
||||
(element as HTMLElement).style.width = "650px";
|
||||
});
|
||||
const wideAgentBox = await firstAgentGridCard.boundingBox();
|
||||
const wideTeamBox = await firstTeamGridCard.boundingBox();
|
||||
expect(wideAgentBox).not.toBeNull();
|
||||
expect(wideTeamBox).not.toBeNull();
|
||||
expect(Math.abs((wideAgentBox?.x ?? 0) - (wideTeamBox?.x ?? 0))).toBeLessThan(
|
||||
1,
|
||||
);
|
||||
|
||||
await agentsContent.evaluate((element) => {
|
||||
(element as HTMLElement).style.width = "600px";
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await firstAgentGridCard.boundingBox())?.x ?? 0)
|
||||
.toBeGreaterThan(wideAgentBox?.x ?? 0);
|
||||
|
||||
const compactAgentBox = await firstAgentGridCard.boundingBox();
|
||||
const compactTeamBox = await firstTeamGridCard.boundingBox();
|
||||
expect(
|
||||
Math.abs((compactAgentBox?.x ?? 0) - (compactTeamBox?.x ?? 0)),
|
||||
).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("team cards use the thread-style overlapping avatar stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -634,6 +695,62 @@ test("unconfigured agent defaults use the setup label", async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test("moves agent actions into an overflow menu in a narrow view", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: "custom:compact-actions",
|
||||
displayName: "Compact actions agent",
|
||||
isActive: true,
|
||||
systemPrompt: "A test agent for compact header actions.",
|
||||
},
|
||||
],
|
||||
managedAgents: [
|
||||
{
|
||||
name: "Compact actions instance",
|
||||
personaId: "custom:compact-actions",
|
||||
pubkey: "cd".repeat(32),
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
});
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await page.getByTestId("agents-page-content").evaluate((element) => {
|
||||
(element as HTMLElement).style.width = "650px";
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("agent-defaults-button")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Set up and manage your agents.", { exact: true }),
|
||||
).toHaveJSProperty("scrollHeight", 24);
|
||||
|
||||
await page.getByTestId("agents-page-content").evaluate((element) => {
|
||||
(element as HTMLElement).style.width = "600px";
|
||||
});
|
||||
await expect(page.getByTestId("agent-defaults-button")).toBeHidden();
|
||||
await page.getByTestId("agent-actions-menu-trigger").click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Set agent defaults" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Stop running agents" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("menuitem", { name: "Set agent defaults" }).click();
|
||||
await expect(page.getByTestId("agent-ai-defaults-dialog")).toBeVisible();
|
||||
|
||||
await page.getByTestId("agents-page-content").evaluate((element) => {
|
||||
(element as HTMLElement).style.width = "650px";
|
||||
});
|
||||
await expect(page.getByTestId("agent-defaults-button")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("agent-ai-defaults-dialog")).toHaveCount(0);
|
||||
await expect(page.getByTestId("agent-defaults-button")).toBeFocused();
|
||||
});
|
||||
|
||||
test("agent catalog chooser order stays stable when selection changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user