From 922a48a591b08da524a927a01287bd36cf41b78b Mon Sep 17 00:00:00 2001 From: rafavalls Date: Thu, 7 May 2026 10:32:08 -0300 Subject: [PATCH 01/49] feat(home): customisable tile board for org home Adds an opt-in tile-based home board alongside today's chat-centric home. Users keep the existing simple home until they explicitly switch; once switched, the home becomes a 12-column snap grid where tiles can be added, dragged, resized to four presets (S/M/L/W), and removed. The board is per-user-per-org via localStorage today; the [board, setBoard] hook signature is intentionally sync-friendly so a future server backing store is a swap-in. Tile content is mocked end-to-end in this commit (12 tile types across essentials/activity/stats/shortcuts/data/workflow). Real plumbing for agent/MCP-contributed tiles will replace the static catalog in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/components/home/tiles/constants.ts | 30 + .../web/components/home/tiles/grid-utils.ts | 140 ++++ .../web/components/home/tiles/registry.tsx | 211 +++++ .../web/components/home/tiles/renderers.tsx | 728 ++++++++++++++++++ .../src/web/components/home/tiles/seed.ts | 56 ++ .../components/home/tiles/tile-add-sheet.tsx | 148 ++++ .../web/components/home/tiles/tile-board.tsx | 312 ++++++++ .../home/tiles/tile-error-boundary.tsx | 47 ++ .../src/web/components/home/tiles/types.ts | 66 ++ .../components/home/tiles/use-home-board.ts | 83 ++ apps/mesh/src/web/lib/localstorage-keys.ts | 6 + 11 files changed, 1827 insertions(+) create mode 100644 apps/mesh/src/web/components/home/tiles/constants.ts create mode 100644 apps/mesh/src/web/components/home/tiles/grid-utils.ts create mode 100644 apps/mesh/src/web/components/home/tiles/registry.tsx create mode 100644 apps/mesh/src/web/components/home/tiles/renderers.tsx create mode 100644 apps/mesh/src/web/components/home/tiles/seed.ts create mode 100644 apps/mesh/src/web/components/home/tiles/tile-add-sheet.tsx create mode 100644 apps/mesh/src/web/components/home/tiles/tile-board.tsx create mode 100644 apps/mesh/src/web/components/home/tiles/tile-error-boundary.tsx create mode 100644 apps/mesh/src/web/components/home/tiles/types.ts create mode 100644 apps/mesh/src/web/components/home/tiles/use-home-board.ts diff --git a/apps/mesh/src/web/components/home/tiles/constants.ts b/apps/mesh/src/web/components/home/tiles/constants.ts new file mode 100644 index 0000000000..98ea7cdbc2 --- /dev/null +++ b/apps/mesh/src/web/components/home/tiles/constants.ts @@ -0,0 +1,30 @@ +/** + * Grid constants. The board is a 12-column snap grid; tiles only ever + * pick from four sizes so the layout stays tidy. + */ + +import type { TileSize, TileSizeKey } from "./types"; + +export const GRID_COLS = 12; + +export const ROW_HEIGHT_PX = 96; + +export const GRID_GAP_PX = 12; + +export const SIZE_PRESETS: Record = { + S: { w: 3, h: 2 }, + M: { w: 4, h: 3 }, + L: { w: 6, h: 4 }, + W: { w: 12, h: 3 }, +}; + +export const SIZE_LABELS: Record = { + S: "Small", + M: "Medium", + L: "Large", + W: "Wide", +}; + +export const ALL_SIZES: TileSizeKey[] = ["S", "M", "L", "W"]; + +export const MOBILE_BREAKPOINT_COLS = 1; diff --git a/apps/mesh/src/web/components/home/tiles/grid-utils.ts b/apps/mesh/src/web/components/home/tiles/grid-utils.ts new file mode 100644 index 0000000000..64259be829 --- /dev/null +++ b/apps/mesh/src/web/components/home/tiles/grid-utils.ts @@ -0,0 +1,140 @@ +/** + * Pure layout math for the home tile grid. No DOM, no React. + * + * The grid is 12-col, infinite rows tall. Tiles must: + * - stay inside the column bounds (x + w <= 12) + * - never overlap another tile + * After any mutation we run vertical compaction so the user never sees + * gaps that aren't intentional. + */ + +import { GRID_COLS } from "./constants"; +import type { TileInstance } from "./types"; + +function rectsOverlap(a: TileInstance, b: TileInstance): boolean { + if (a.id === b.id) return false; + const aRight = a.x + a.w; + const aBottom = a.y + a.h; + const bRight = b.x + b.w; + const bBottom = b.y + b.h; + return a.x < bRight && aRight > b.x && a.y < bBottom && aBottom > b.y; +} + +function clampX(tile: TileInstance): TileInstance { + const maxX = GRID_COLS - tile.w; + if (maxX < 0) return { ...tile, x: 0, w: GRID_COLS }; + return { ...tile, x: Math.min(Math.max(tile.x, 0), maxX) }; +} + +/** + * Resolves overlaps by pushing the conflicting tile straight down until + * the slot is clear. Stable order: caller passes `pinned` first. + */ +export function resolveCollisions( + pinned: TileInstance, + others: TileInstance[], +): TileInstance[] { + const result: TileInstance[] = [pinned]; + const queue = [...others]; + while (queue.length > 0) { + const next = queue.shift()!; + let candidate = clampX(next); + let safety = 0; + while (result.some((existing) => rectsOverlap(candidate, existing))) { + candidate = { ...candidate, y: candidate.y + 1 }; + if (++safety > 200) break; + } + result.push(candidate); + } + return result; +} + +/** + * Vertical compaction: every tile floats up until it hits another tile + * or the top of the board. Iterates row-major top-to-bottom so order is + * deterministic. + */ +export function compactBoard(tiles: TileInstance[]): TileInstance[] { + const sorted = [...tiles].sort((a, b) => a.y - b.y || a.x - b.x); + const placed: TileInstance[] = []; + for (const tile of sorted) { + let candidate = clampX(tile); + while ( + candidate.y > 0 && + !placed.some((p) => rectsOverlap({ ...candidate, y: candidate.y - 1 }, p)) + ) { + candidate = { ...candidate, y: candidate.y - 1 }; + } + placed.push(candidate); + } + return placed; +} + +/** + * Finds the topmost row at column 0 where a tile of size {w,h} fits + * without colliding with any existing tile. Used by "Add tile". + */ +export function findFirstFreeSlot( + tiles: TileInstance[], + w: number, + h: number, +): { x: number; y: number } { + const safeW = Math.min(w, GRID_COLS); + const maxRows = Math.max(0, ...tiles.map((t) => t.y + t.h)) + h; + for (let y = 0; y <= maxRows; y++) { + for (let x = 0; x <= GRID_COLS - safeW; x++) { + const probe = { id: "__probe", type: "", x, y, w: safeW, h }; + if (!tiles.some((t) => rectsOverlap(probe, t))) { + return { x, y }; + } + } + } + return { x: 0, y: maxRows }; +} + +export function moveTile( + tiles: TileInstance[], + id: string, + to: { x: number; y: number }, +): TileInstance[] { + const target = tiles.find((t) => t.id === id); + if (!target) return tiles; + const moved = clampX({ ...target, x: to.x, y: Math.max(0, to.y) }); + const others = tiles.filter((t) => t.id !== id); + return compactBoard(resolveCollisions(moved, others)); +} + +export function resizeTile( + tiles: TileInstance[], + id: string, + size: { w: number; h: number }, +): TileInstance[] { + const target = tiles.find((t) => t.id === id); + if (!target) return tiles; + const resized = clampX({ ...target, w: size.w, h: size.h }); + const others = tiles.filter((t) => t.id !== id); + return compactBoard(resolveCollisions(resized, others)); +} + +export function insertTile( + tiles: TileInstance[], + tile: Omit, +): TileInstance[] { + const slot = findFirstFreeSlot(tiles, tile.w, tile.h); + return compactBoard([...tiles, { ...tile, x: slot.x, y: slot.y }]); +} + +export function removeTile(tiles: TileInstance[], id: string): TileInstance[] { + return compactBoard(tiles.filter((t) => t.id !== id)); +} + +export function pixelDeltaToCellDelta( + pixelDelta: { x: number; y: number }, + cellWidth: number, + cellHeight: number, +): { dx: number; dy: number } { + return { + dx: Math.round(pixelDelta.x / cellWidth), + dy: Math.round(pixelDelta.y / cellHeight), + }; +} diff --git a/apps/mesh/src/web/components/home/tiles/registry.tsx b/apps/mesh/src/web/components/home/tiles/registry.tsx new file mode 100644 index 0000000000..4c3d705ba1 --- /dev/null +++ b/apps/mesh/src/web/components/home/tiles/registry.tsx @@ -0,0 +1,211 @@ +/** + * Static tile registry. The catalog UI iterates over `TILE_CATALOG`; + * the board renderer looks up by `type` via `getTileDefinition`. + * + * When tile contributions from MCP apps land, this becomes a hook that + * merges static + dynamic definitions. Today every entry ships with the + * app and the icons are mocked. + */ + +import { + AlertCircle, + BookOpen01, + Calendar, + GitBranch01, + Globe02, + Lightning01, + Server01, + Star01, + Stars01, + Clock, + TrendUp02, + Users03, +} from "@untitledui/icons"; +import type { TileDefinition } from "./types"; +import { + AnalyticsChartTile, + CalendarTile, + ConnectionsOverviewTile, + GithubActivityTile, + LinearIssuesTile, + NotesTile, + QuickChatTile, + RecentAgentsTile, + RecentTasksTile, + ShortcutsTile, + StatsTile, + UnknownTile, + WelcomeTile, +} from "./renderers"; + +export const TILE_CATALOG: TileDefinition[] = [ + { + type: "studio.welcome", + source: "system", + title: "Welcome", + description: "Greeting and quick actions for your workspace.", + icon: , + category: "essentials", + supportedSizes: ["W", "L"], + defaultSize: "W", + render: WelcomeTile, + }, + { + type: "studio.recent-agents", + source: "system", + title: "Recent agents", + description: "Jump back into agents you've used recently.", + icon: , + category: "shortcuts", + supportedSizes: ["S", "M", "L"], + defaultSize: "M", + render: RecentAgentsTile, + }, + { + type: "studio.recent-tasks", + source: "system", + title: "Recent tasks", + description: "Your in-progress conversations.", + icon: , + category: "activity", + supportedSizes: ["S", "M", "L"], + defaultSize: "M", + render: RecentTasksTile, + }, + { + type: "studio.quick-chat", + source: "system", + title: "Quick chat", + description: "A compact composer pinned to your home.", + icon: , + category: "essentials", + supportedSizes: ["M", "L", "W"], + defaultSize: "L", + render: QuickChatTile, + }, + { + type: "studio.connections-overview", + source: "system", + title: "Connections", + description: "Status and grid of your connected MCPs.", + icon: , + category: "stats", + supportedSizes: ["M", "L"], + defaultSize: "M", + render: ConnectionsOverviewTile, + }, + { + type: "studio.shortcuts", + source: "system", + title: "Shortcuts", + description: "Pinned destinations inside your workspace.", + icon: , + category: "shortcuts", + supportedSizes: ["M", "L"], + defaultSize: "M", + render: ShortcutsTile, + }, + { + type: "studio.notes", + source: "system", + title: "Notes", + description: "A scratchpad just for you.", + icon: , + category: "essentials", + supportedSizes: ["S", "M", "L"], + defaultSize: "M", + render: NotesTile, + }, + { + type: "studio.stats", + source: "system", + title: "Workspace stats", + description: "KPIs for tasks, tools, and tokens.", + icon: , + category: "stats", + supportedSizes: ["M", "L"], + defaultSize: "M", + render: StatsTile, + }, + { + type: "mock.github.activity", + source: "mcp", + sourceId: "mock-github", + title: "GitHub activity", + description: "Recent commits across your repos. (Mocked)", + icon: , + category: "activity", + supportedSizes: ["M", "L"], + defaultSize: "M", + render: GithubActivityTile, + }, + { + type: "mock.linear.issues", + source: "mcp", + sourceId: "mock-linear", + title: "Linear issues", + description: "Issues assigned to you. (Mocked)", + icon: , + category: "workflow", + supportedSizes: ["M", "L"], + defaultSize: "M", + render: LinearIssuesTile, + }, + { + type: "mock.calendar.upcoming", + source: "mcp", + sourceId: "mock-google-calendar", + title: "Today's calendar", + description: "Your next meetings. (Mocked)", + icon: , + category: "activity", + supportedSizes: ["S", "M"], + defaultSize: "M", + render: CalendarTile, + }, + { + type: "mock.analytics.chart", + source: "mcp", + sourceId: "mock-analytics", + title: "Page views", + description: "Traffic over the last 24h. (Mocked)", + icon: , + category: "data", + supportedSizes: ["M", "L"], + defaultSize: "M", + render: AnalyticsChartTile, + }, +]; + +const BY_TYPE = new Map(TILE_CATALOG.map((t) => [t.type, t])); + +export function getTileDefinition(type: string): TileDefinition | undefined { + return BY_TYPE.get(type); +} + +export function renderTileContent( + type: string, + props: Parameters[0], +) { + const def = getTileDefinition(type); + if (!def) return ; + return def.render(props); +} + +export const CATEGORY_LABELS: Record = { + essentials: "Essentials", + activity: "Activity", + stats: "Stats", + shortcuts: "Shortcuts", + data: "Data", + workflow: "Workflows", +}; + +export const CATEGORY_ORDER: TileDefinition["category"][] = [ + "essentials", + "activity", + "stats", + "shortcuts", + "data", + "workflow", +]; diff --git a/apps/mesh/src/web/components/home/tiles/renderers.tsx b/apps/mesh/src/web/components/home/tiles/renderers.tsx new file mode 100644 index 0000000000..afc351f21c --- /dev/null +++ b/apps/mesh/src/web/components/home/tiles/renderers.tsx @@ -0,0 +1,728 @@ +/** + * All v1 tile renderers live here. Real data plumbing is intentionally + * mocked β€” the goal is to prove the tile contract. Each renderer assumes + * its host provides the right amount of room (the catalog declares + * supportedSizes). + */ + +import type { ReactNode } from "react"; +import { useState } from "react"; +import { authClient } from "@/web/lib/auth-client"; +import { useNavigate } from "@tanstack/react-router"; +import { useProjectContext, useConnections } from "@decocms/mesh-sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { Skeleton } from "@deco/ui/components/skeleton.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { ScrollArea } from "@deco/ui/components/scroll-area.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { useLocalStorage } from "@/web/hooks/use-local-storage"; +import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys"; +import { IntegrationIcon } from "@/web/components/integration-icon"; +import { + Activity, + AlertCircle, + ArrowRight, + BookOpen01, + Calendar, + Clock, + GitBranch01, + Globe02, + Lightning01, + MessageCircle01, + Server01, + Star01, + Stars01, + Tool01, + TrendUp02, + Users03, + Zap, +} from "@untitledui/icons"; +import type { TileRenderProps } from "./types"; + +function TileFrame({ + title, + icon, + badge, + action, + children, + hint, +}: { + title: string; + icon: ReactNode; + badge?: ReactNode; + action?: { label: string; onClick: () => void }; + children: ReactNode; + hint?: string; +}) { + return ( +
+
+
+ + {icon} + + + {title} + + {badge} +
+ {action && ( + + )} +
+
{children}
+ {hint && ( +

{hint}

+ )} +
+ ); +} + +function MockBadge() { + return ( + + Mock + + ); +} + +/* ---------- studio.welcome ---------- */ + +export function WelcomeTile({ instance: _instance }: TileRenderProps) { + const { data: session } = authClient.useSession(); + const navigate = useNavigate(); + const { org } = useProjectContext(); + const userName = session?.user?.name?.split(" ")[0] || "there"; + + const actions = [ + { + label: "Start a chat", + icon: , + onClick: () => { + const taskId = crypto.randomUUID(); + navigate({ to: "/$org/$taskId", params: { org: org.slug, taskId } }); + }, + }, + { + label: "Browse agents", + icon: , + onClick: () => + navigate({ to: "/$org/settings/agents", params: { org: org.slug } }), + }, + { + label: "Connections", + icon: , + onClick: () => + navigate({ + to: "/$org/settings/connections", + params: { org: org.slug }, + }), + }, + ]; + + return ( +
+
+ + Welcome back +
+

+ Hello, {userName}. +

+

+ Your home is yours. Pin the things you check every day, drop tiles from + agents and apps you've connected. +

+
+ {actions.map((a) => ( + + ))} +
+
+ ); +} + +/* ---------- studio.recent-agents ---------- */ + +const MOCK_RECENT_AGENTS = [ + { id: "site-editor", name: "Site Editor", icon: "✏️" }, + { id: "ai-image", name: "AI Image", icon: "🎨" }, + { id: "research", name: "Deep Research", icon: "πŸ”Ž" }, + { id: "lean-canvas", name: "Lean Canvas", icon: "πŸ“‹" }, + { id: "studio-pack", name: "Studio Pack", icon: "πŸ“¦" }, +]; + +export function RecentAgentsTile(_props: TileRenderProps) { + const navigate = useNavigate(); + const { org } = useProjectContext(); + return ( + } + action={{ + label: "All", + onClick: () => + navigate({ to: "/$org/settings/agents", params: { org: org.slug } }), + }} + > +
    + {MOCK_RECENT_AGENTS.map((a) => ( +
  • + +
  • + ))} +
+
+ ); +} + +/* ---------- studio.recent-tasks ---------- */ + +const MOCK_RECENT_TASKS = [ + { id: "t1", title: "Refactor billing pipeline", status: "in-progress" }, + { id: "t2", title: "Draft Q2 launch announcement", status: "review" }, + { id: "t3", title: "Triage Linear inbox", status: "in-progress" }, + { id: "t4", title: "Audit GitHub Actions costs", status: "blocked" }, +]; + +const TASK_STATUS_COLOR: Record = { + "in-progress": "bg-primary/15 text-primary", + review: "bg-warning/15 text-warning", + blocked: "bg-destructive/15 text-destructive", +}; + +export function RecentTasksTile(_props: TileRenderProps) { + return ( + } + badge={} + > +
    + {MOCK_RECENT_TASKS.map((t) => ( +
  • + + {t.title} + + + {t.status.replace("-", " ")} + +
  • + ))} +
+
+ ); +} + +/* ---------- studio.quick-chat ---------- */ + +export function QuickChatTile(_props: TileRenderProps) { + const navigate = useNavigate(); + const { org } = useProjectContext(); + const [draft, setDraft] = useState(""); + + const submit = () => { + if (!draft.trim()) return; + const taskId = crypto.randomUUID(); + navigate({ + to: "/$org/$taskId", + params: { org: org.slug, taskId }, + search: { autosend: draft } as never, + }); + }; + + return ( + }> +
+