diff --git a/desktop/src/features/communities/ui/CommunityHome.tsx b/desktop/src/features/communities/ui/CommunityHome.tsx index 25b6d801a..5cb3d524b 100644 --- a/desktop/src/features/communities/ui/CommunityHome.tsx +++ b/desktop/src/features/communities/ui/CommunityHome.tsx @@ -1,13 +1,25 @@ import { openUrl } from "@tauri-apps/plugin-opener"; -import { ArrowRight, Link2, Plus, Settings2, Trash2 } from "lucide-react"; +import { + ArrowRight, + Link2, + Plus, + Settings2, + Sparkles, + Trash2, +} from "lucide-react"; import * as React from "react"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; +import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs"; import type { Community } from "@/features/communities/types"; import { useCommunityIcons } from "@/features/communities/useCommunityIcons"; -import { useElementWidth } from "@/shared/hooks/use-mobile"; -import { getInitials } from "@/shared/lib/initials"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { AlertDialog, AlertDialogAction, @@ -29,59 +41,153 @@ import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; const CREATE_COMMUNITY_URL = "https://app.builderlab.xyz/signup?returnTo=/buzz"; -// Pointy-top hexagon: flat vertical sides (so hexes in a row touch along their -// edges) and points at top/bottom (so alternating rows nest into each other). +// Pointy-top hexagon: flat vertical sides (so hexes in a column touch along +// their edges) and points at top/bottom (so alternating rows nest together). const HEX_CLIP_PATH = "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)"; -// Pointy-top geometry, as ratios of the hex width W. -// height = W / 0.866 (≈ 1.1547·W) -// row nest = rows overlap vertically by 1/4 of the hex height. -const HEX_ASPECT = 0.866; // width / height -const ROW_OVERLAP = 0.2887; // 0.25 · (1 / 0.866) — negative margin between rows +// Pointy-top geometry. Width W is the flat-to-flat measure; height = W / ASPECT. +// Neighboring cells sit exactly ASPECT·W apart vertically and W apart +// horizontally, so the lattice interlocks with no gaps at any scale. +const HEX_ASPECT = 0.866; // width / height = √3 / 2 +const HALF_HEIGHT_UNITS = 0.5 / HEX_ASPECT; // half hex height when W = 1 -/** Pick a hex width + column cap that keeps regular hexagons at any width. */ -function honeycombLayout(containerWidth: number): { - hexWidth: number; - maxColumns: number; -} { - if (containerWidth >= 860) return { hexWidth: 176, maxColumns: 4 }; - if (containerWidth >= 640) return { hexWidth: 156, maxColumns: 4 }; - if (containerWidth >= 440) return { hexWidth: 140, maxColumns: 3 }; - return { hexWidth: 118, maxColumns: 2 }; +const MIN_HEX_WIDTH = 96; +const MAX_HEX_WIDTH = 172; +const MAX_RADIUS = 4; + +// Axial neighbor directions for a pointy-top hex, walked in order to trace a +// ring. Index 4 ([-1, 1]) is the ring's start offset from center. +const AXIAL_DIRECTIONS: ReadonlyArray = [ + [1, 0], + [1, -1], + [0, -1], + [-1, 0], + [-1, 1], + [0, 1], +]; + +/** Total cells in a filled hexagon of the given ring radius. */ +function spiralCount(radius: number): number { + return 1 + 3 * radius * (radius + 1); } -/** Split items into honeycomb rows given how many fit per row. */ -function chunkRows(items: T[], perRow: number): T[][] { - const rows: T[][] = []; - for (let i = 0; i < items.length; i += perRow) { - rows.push(items.slice(i, i + perRow)); +/** Axial coordinates from the center outward, ring by ring, in spiral order. */ +function hexSpiral(radius: number): [number, number][] { + const cells: [number, number][] = [[0, 0]]; + for (let ring = 1; ring <= radius; ring++) { + // Start one ring out along direction 4, then walk each of the 6 edges. + let q = -ring; + let r = ring; + for (let side = 0; side < 6; side++) { + const [dq, dr] = AXIAL_DIRECTIONS[side]; + for (let step = 0; step < ring; step++) { + cells.push([q, r]); + q += dq; + r += dr; + } + } } - return rows; + return cells; } -/** Shared shell: a fixed-width regular hexagon with a hairline edge + fill. */ -function HexShell({ +/** Axial (q, r) → unit-space center (before scaling by hex width W). */ +function axialToUnitCenter(q: number, r: number): { cx: number; cy: number } { + return { cx: q + r / 2, cy: HEX_ASPECT * r }; +} + +type PlacedCell = { + q: number; + r: number; + cx: number; + cy: number; +}; + +type LatticeExtent = { + cells: PlacedCell[]; + minLeft: number; + minTop: number; + unitWidth: number; + unitHeight: number; +}; + +/** Precompute cell centers + the unit bounding box for a given radius. */ +function computeExtent(radius: number): LatticeExtent { + const raw = hexSpiral(radius); + const cells: PlacedCell[] = raw.map(([q, r]) => { + const { cx, cy } = axialToUnitCenter(q, r); + return { q, r, cx, cy }; + }); + let minLeft = Infinity; + let maxRight = -Infinity; + let minTop = Infinity; + let maxBottom = -Infinity; + for (const cell of cells) { + minLeft = Math.min(minLeft, cell.cx - 0.5); + maxRight = Math.max(maxRight, cell.cx + 0.5); + minTop = Math.min(minTop, cell.cy - HALF_HEIGHT_UNITS); + maxBottom = Math.max(maxBottom, cell.cy + HALF_HEIGHT_UNITS); + } + return { + cells, + minLeft, + minTop, + unitWidth: maxRight - minLeft, + unitHeight: maxBottom - minTop, + }; +} + +/** ResizeObserver-backed width + height for fit-to-container scaling. */ +function useElementSize(): [ + React.RefObject, + { width: number; height: number }, +] { + const ref = React.useRef(null); + const [size, setSize] = React.useState({ width: 0, height: 0 }); + + React.useEffect(() => { + const element = ref.current; + if (!element) return; + const update = () => { + const rect = element.getBoundingClientRect(); + setSize({ width: rect.width, height: rect.height }); + }; + update(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + } + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return [ref, size]; +} + +/** Shared hex frame: a hairline edge behind a clipped, filled interior. */ +function HexFrame({ children, edgeClassName, fillClassName, - width, }: { children: React.ReactNode; edgeClassName: string; fillClassName: string; - width: number; }) { return ( <>