diff --git a/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx b/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx index bdaf2ca9c..75f34fc27 100644 --- a/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx +++ b/desktop/src/features/mesh-compute/ui/CommunityComputeTerritoryMap.tsx @@ -1,6 +1,8 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; +import { getAvatarSnapshotUrl } from "@/shared/lib/animatedAvatar"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { hexTerritoryBoundaryEdges, layoutCommunityComputeHexTerritories, @@ -19,6 +21,7 @@ export type CommunityComputeTerritoryMapProps = { className?: string; /** Tutorial-only simulated activity; live snapshots cannot attribute requests. */ inferenceDeploymentIds?: readonly string[]; + contributorProfiles?: ContributorProfiles; }; type Point = { x: number; y: number }; @@ -46,6 +49,7 @@ export function CommunityComputeTerritoryMap({ onSelectedDeploymentChange, className, inferenceDeploymentIds = [], + contributorProfiles = {}, }: CommunityComputeTerritoryMapProps) { const [internalSelection, setInternalSelection] = React.useState< string | null @@ -86,11 +90,37 @@ export function CommunityComputeTerritoryMap({ [inferenceDeploymentIds], ); const [hoveredId, setHoveredId] = React.useState(null); + const lowerTimerRef = React.useRef | null>( + null, + ); const [deploymentSearch, setDeploymentSearch] = React.useState(""); const hoveredDeployment = hoveredId ? (deploymentById.get(hoveredId) ?? null) : null; const isHeroDeployment = model.deployments.length === 1; + const isDenseMap = model.deployments.length >= 50; + + React.useEffect( + () => () => { + if (lowerTimerRef.current) clearTimeout(lowerTimerRef.current); + }, + [], + ); + + function raiseTerritory(deploymentId: string, territoryElement: SVGGElement) { + if (lowerTimerRef.current) clearTimeout(lowerTimerRef.current); + // SVG paints in DOM order. Move the existing node rather than sorting the + // React children: sorting reconstructs animated SVG content and restarts + // its breathing phase whenever hover changes. + territoryElement.parentNode?.appendChild(territoryElement); + setHoveredId(deploymentId); + } + + function lowerTerritory() { + if (lowerTimerRef.current) clearTimeout(lowerTimerRef.current); + // Delay only the contributor overlay; the CSS scale settles independently. + lowerTimerRef.current = setTimeout(() => setHoveredId(null), 280); + } function selectDeployment(deploymentId: string) { if (selectedDeploymentId === undefined) { @@ -146,7 +176,8 @@ export function CommunityComputeTerritoryMap({ )} data-testid="community-compute-territory-map" > -
+
+ setHoveredId(deployment.id)} - onMouseLeave={() => setHoveredId(null)} + onBlur={lowerTerritory} + onFocus={(event) => + raiseTerritory(deployment.id, event.currentTarget) + } + onMouseEnter={(event) => + raiseTerritory(deployment.id, event.currentTarget) + } + onMouseLeave={lowerTerritory} role="button" + style={territoryHoverStyle( + territory.cells, + viewBox, + isDenseMap, + )} tabIndex={0} > {territoryAccessibleLabel(deployment)} ); @@ -242,6 +295,34 @@ export function CommunityComputeTerritoryMap({ ); } +function MapLegend({ dense }: { dense: boolean }) { + return ( +
+ + + Area = model memory + + + + Gold breath = serving + + + + {dense ? "Hover = contributor" : "Avatar = contributor"} + + + + Gold border = selected + +
+ ); +} + function HeroDeploymentOverlay({ deployment, inferenceActive, @@ -251,7 +332,7 @@ function HeroDeploymentOverlay({ }) { return (
@@ -306,11 +387,21 @@ function HeroMetric({ label, value }: { label: string; value: string }) { } function TerritoryShape({ + avatarUrl, cells, + contributorLabel, inferenceActive, + selected, + showContributor, + territoryCenter, }: { + avatarUrl: string | null; cells: readonly CommunityComputeHexCell[]; + contributorLabel: string; inferenceActive: boolean; + selected: boolean; + showContributor: boolean; + territoryCenter: { q: number; r: number }; }) { const boundary = hexTerritoryBoundaryEdges(cells); const fillClass = "fill-foreground"; @@ -320,8 +411,16 @@ function TerritoryShape({ animationDelay: `${-((stableVisualHash(`${cells[0]?.deploymentId}:phase`) % 37) / 10)}s`, } as React.CSSProperties) : undefined; + const center = axialToPoint(territoryCenter.q, territoryCenter.r); + const avatarRadius = 0.42; + const avatarClipId = `contributor-${testId(cells[0]?.deploymentId ?? "unknown")}`; return ( + + + + + {cells.map((cell) => { const center = axialToPoint(cell.q, cell.r); return ( @@ -341,20 +440,76 @@ function TerritoryShape({ })} + {showContributor ? ( + + ) : null} + {showContributor && avatarUrl ? ( + + ) : showContributor ? ( + + {contributorInitials(contributorLabel)} + + ) : null} ); } +function territoryHoverStyle( + cells: readonly CommunityComputeHexCell[], + viewBox: ViewBox, + dense: boolean, +): React.CSSProperties { + const centers = cells.map((cell) => axialToPoint(cell.q, cell.r)); + const minX = Math.min(...centers.map(({ x }) => x)) - 1; + const maxX = Math.max(...centers.map(({ x }) => x)) + 1; + const minY = Math.min(...centers.map(({ y }) => y)) - 1; + const maxY = Math.max(...centers.map(({ y }) => y)) + 1; + const territorySpan = Math.max(maxX - minX, maxY - minY); + const mapSpan = Math.min(viewBox.width, viewBox.height); + // Normalize every hovered territory toward the same visual footprint. Dense + // maps therefore expand dramatically while already-large sparse territories + // receive only a restrained lift. + const targetSpan = mapSpan * (dense ? 0.18 : 0.16); + const scale = Math.max(1.06, Math.min(8, targetSpan / territorySpan)); + return { "--mesh-hover-scale": scale } as React.CSSProperties; +} + function edgesToPath( edges: ReturnType, ): string { @@ -614,6 +769,45 @@ function shortModelName(modelId: string): string { return tail.length > 34 ? `${tail.slice(0, 31)}…` : tail; } +type ContributorProfiles = Record< + string, + { avatarUrl: string | null; displayName: string | null } +>; + +function contributorProfile( + pubkey: string | null | undefined, + profiles: ContributorProfiles, +) { + return pubkey ? profiles[pubkey.trim().toLowerCase()] : undefined; +} + +function contributorAvatarUrl( + pubkey: string | null | undefined, + profiles: ContributorProfiles, +): string | null { + const avatarUrl = contributorProfile(pubkey, profiles)?.avatarUrl ?? null; + const snapshotUrl = getAvatarSnapshotUrl(avatarUrl); + return snapshotUrl ? rewriteRelayUrl(snapshotUrl) : null; +} + +function contributorLabel( + deployment: CommunityComputeDeployment, + profiles: ContributorProfiles, +): string { + return ( + contributorProfile(deployment.source.memberPubkey, profiles)?.displayName ?? + (deployment.isSelf ? "You" : deployment.deviceLabel) + ); +} + +function contributorInitials(label: string): string { + const words = label.trim().split(/\s+/).filter(Boolean); + return words + .slice(0, 2) + .map((word) => word[0]?.toUpperCase() ?? "") + .join(""); +} + function territoryAccessibleLabel( deployment: CommunityComputeDeployment, ): string { diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx index af7a9dac0..2c329409b 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeCommunityView.tsx @@ -1,5 +1,7 @@ import { Info, RefreshCw } from "lucide-react"; +import * as React from "react"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import { Button } from "@/shared/ui/button"; import type { MeshSnapshot } from "@/shared/api/tauriMesh"; import { @@ -35,6 +37,15 @@ export function MeshComputeCommunityView({ .map((deployment) => deployment.id) : []; const observedAt = (snapshot as MeshSnapshot | null)?.observedAt; + const contributorPubkeys = React.useMemo( + () => + model.deployments + .map((deployment) => deployment.source.memberPubkey?.trim() ?? "") + .filter(Boolean), + [model.deployments], + ); + const contributorProfiles = + useUsersBatchQuery(contributorPubkeys).data?.profiles ?? {}; return (
@@ -92,6 +103,7 @@ export function MeshComputeCommunityView({ ) : null}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index e9fb9cce9..b5a180ebb 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -607,7 +607,6 @@ export function AppSidebar({ > onSelectSettings("compute")} onSelectAgents={onSelectAgents} onSelectHome={onSelectHome} onSelectProjects={onSelectProjects} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index e753153e0..14463060d 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -11,7 +11,6 @@ import { SidebarMenuItem, } from "@/shared/ui/sidebar"; import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label"; -import { SidebarMeshComputeRow } from "@/features/mesh-compute/ui/SidebarMeshComputeRow"; type SidebarSelectedView = | "home" @@ -38,7 +37,6 @@ type AppSidebarPinnedHeaderProps = { type AppSidebarPrimaryMenuProps = { homeBadgeCount: number; - onOpenComputeSettings?: () => void; onSelectAgents: () => void; onSelectHome: () => void; onSelectProjects: () => void; @@ -84,7 +82,6 @@ export function AppSidebarPinnedHeader({ export function AppSidebarPrimaryMenu({ homeBadgeCount, - onOpenComputeSettings, onSelectAgents, onSelectHome, onSelectProjects, @@ -176,7 +173,6 @@ export function AppSidebarPrimaryMenu({ - { +test("Shared Compute is only available from Settings", async ({ page }) => { await installMockBridge(page); await page.goto("/"); - await page.getByTestId("sidebar-mesh-compute-row").click(); + await expect(page.getByTestId("sidebar-mesh-compute-row")).toHaveCount(0); + await page.getByTestId("open-settings").click(); + await page.getByTestId("settings-nav-compute").click(); await expect(page.getByTestId("settings-mesh-compute-page")).toBeVisible(); - await expect(page.getByTestId("compute-tab-community")).toHaveAttribute( - "data-state", - "active", - ); - await expect(page.getByTestId("community-compute-view")).toBeVisible(); - await expect(page.getByTestId("mesh-compute-popover")).toHaveCount(0); }); test("Community Compute shows live KPIs and the tutorial simulation", async ({