diff --git a/examples/nextjs-ai-dashboard-reports/.env.example b/examples/nextjs-ai-dashboard-reports/.env.example
index 3a7ff90fad7..b5ea1584466 100644
--- a/examples/nextjs-ai-dashboard-reports/.env.example
+++ b/examples/nextjs-ai-dashboard-reports/.env.example
@@ -1,5 +1,11 @@
# https://liveblocks.io/dashboard/apikeys
LIVEBLOCKS_SECRET_KEY=
+# https://liveblocks.io/dashboard/webhooks
+LIVEBLOCKS_WEBHOOK_SECRET_KEY=
+
+# https://platform.claude.com/settings/keys
+ANTHROPIC_API_KEY=
+
# https://liveblocks.io/dashboard/copilots
NEXT_PUBLIC_LIVEBLOCKS_COPILOT_ID=
diff --git a/examples/nextjs-ai-dashboard-reports/next.config.mjs b/examples/nextjs-ai-dashboard-reports/next.config.mjs
index 3ad9fadabd3..75a764afe85 100644
--- a/examples/nextjs-ai-dashboard-reports/next.config.mjs
+++ b/examples/nextjs-ai-dashboard-reports/next.config.mjs
@@ -2,6 +2,7 @@
const nextConfig = {
turbopack: { root: import.meta.dirname },
+ allowedDevOrigins: ["*.ngrok-free.app", "*.ngrok.io", "*.loca.lt"],
redirects: async () => {
return [
{
diff --git a/examples/nextjs-ai-dashboard-reports/package-lock.json b/examples/nextjs-ai-dashboard-reports/package-lock.json
index 7d4401626fe..06f9d9f0a09 100644
--- a/examples/nextjs-ai-dashboard-reports/package-lock.json
+++ b/examples/nextjs-ai-dashboard-reports/package-lock.json
@@ -8,11 +8,12 @@
"name": "nextjs-ai-dashboard-reports",
"version": "0.1.0",
"dependencies": {
+ "@ai-sdk/anthropic": "^3.0.64",
"@internationalized/date": "^3.7.0",
- "@liveblocks/client": "^3.18.4",
- "@liveblocks/node": "^3.18.4",
- "@liveblocks/react": "^3.18.4",
- "@liveblocks/react-ui": "^3.18.4",
+ "@liveblocks/client": "^3.19.1",
+ "@liveblocks/node": "^3.19.1",
+ "@liveblocks/react": "^3.19.1",
+ "@liveblocks/react-ui": "^3.19.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.15",
@@ -33,6 +34,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tanstack/react-table": "^8.21.3",
"@typescript-eslint/parser": "^8.28.0",
+ "ai": "^6.0.141",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"eslint-plugin-next": "^0.0.0",
@@ -67,6 +69,74 @@
"typescript": "^5.8.3"
}
},
+ "node_modules/@ai-sdk/anthropic": {
+ "version": "3.0.77",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.77.tgz",
+ "integrity": "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "3.0.10",
+ "@ai-sdk/provider-utils": "4.0.27"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/gateway": {
+ "version": "3.0.114",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.114.tgz",
+ "integrity": "sha512-MqkZ5sd+qiq6RgIxELkoFQXg2/JwK+WCMaot7U+rtrZpWJl3fSyYvc28SC03b256o4F7OXjQtdjTqs81B2w+dA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "3.0.10",
+ "@ai-sdk/provider-utils": "4.0.27",
+ "@vercel/oidc": "3.2.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/provider": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
+ "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "json-schema": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@ai-sdk/provider-utils": {
+ "version": "4.0.27",
+ "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
+ "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/provider": "3.0.10",
+ "@standard-schema/spec": "^1.1.0",
+ "eventsource-parser": "^3.0.8"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
+ "node_modules/@ai-sdk/provider-utils/node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -1703,43 +1773,44 @@
"license": "Apache-2.0"
},
"node_modules/@liveblocks/client": {
- "version": "3.18.4",
- "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.18.4.tgz",
- "integrity": "sha512-ARz21wluGQg4PNhTQYWVcsOWhOn0eStKIXGXEv9hBODSWDOQB20U7FjaS9EDRsYSstWnN9Q7FqBIn4w9BNfWpQ==",
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-3.19.1.tgz",
+ "integrity": "sha512-+h645g0o7jCYhkg6b3P8rR8iOacNfIre+GqBkP8G/7WuSMquiEkwW4Ub+N5F74wqC+ssuQWLo6QdJ0ICkWlI5w==",
"license": "Apache-2.0",
"dependencies": {
- "@liveblocks/core": "3.18.4"
+ "@liveblocks/core": "3.19.1"
}
},
"node_modules/@liveblocks/core": {
- "version": "3.18.4",
- "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.18.4.tgz",
- "integrity": "sha512-Pw8vHlUAH0GQmErBG/swq5Jkd6XCKKM9M3uiVZOBEBajaPt7mKSAUWnIT9cFhzdwWjo9TbDhd5/mfJ4JTTNOYQ==",
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-3.19.1.tgz",
+ "integrity": "sha512-OPRPJLq/TkFatL+5ECZQaAKijwCF9j9pQDC9MZGBzD9suvcJ9/rvnkYDVko6JI9HKpLmvkPQJNxC13IpsPUAtw==",
"license": "Apache-2.0",
"peerDependencies": {
"@types/json-schema": "^7"
}
},
"node_modules/@liveblocks/node": {
- "version": "3.18.4",
- "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.18.4.tgz",
- "integrity": "sha512-Pf+zunHIUG36e6XndDQpAqQowl2BBb/vtcjlRuE785naDCFl4+QIj0NAdswT/ZB8q+mWGZ5CxamlXtPthtIxPQ==",
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/node/-/node-3.19.1.tgz",
+ "integrity": "sha512-7N5u6inEos5OgBa5DOnrQvIn6p9PimsKOFpJx1NQEkmsLT/5hh2XyWU53ENE3Txpi6d9HySKHQ9+4k8PsscDPQ==",
"license": "Apache-2.0",
"dependencies": {
- "@liveblocks/core": "3.18.4",
+ "@liveblocks/core": "3.19.1",
"@stablelib/base64": "^1.0.1",
"fast-sha256": "^1.3.0",
+ "marked": "^15.0.11",
"node-fetch": "^2.6.1"
}
},
"node_modules/@liveblocks/react": {
- "version": "3.18.4",
- "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.18.4.tgz",
- "integrity": "sha512-KRenW5ilS5QsWrCVjXriDqE6aWhqBh5CxUs1rfkGRRy+ImcDHVGEPKLT1YCB55/ImmxkxnVNIrt3tstkZ1JafQ==",
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-3.19.1.tgz",
+ "integrity": "sha512-torDKWpXqyu7F/YpsCNTREzZtQVaZq7NzXW9czMFRJtLTjgyGU5H+5WbtRLvnZpEnYtIAerl19qBWUs7I+IE4w==",
"license": "Apache-2.0",
"dependencies": {
- "@liveblocks/client": "3.18.4",
- "@liveblocks/core": "3.18.4"
+ "@liveblocks/client": "3.19.1",
+ "@liveblocks/core": "3.19.1"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
@@ -1756,15 +1827,15 @@
}
},
"node_modules/@liveblocks/react-ui": {
- "version": "3.18.4",
- "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.18.4.tgz",
- "integrity": "sha512-Ip02XZxLiKuc2BMJewwf1Wuk216bh5FCck9WYPYs6BxUnwr2ln8kavhm2ncbphnDFOT6ycugW21gcV2HuIzjbg==",
+ "version": "3.19.1",
+ "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-3.19.1.tgz",
+ "integrity": "sha512-65AfVSh7VbayPv6iosTl5UVC5mCYJH9kjTOMlzyYpHtQ2XW3VCn7s1/MmsN206qtq+0qZYoVy9CNocUx6KXoMA==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/react-dom": "^2.1.0",
- "@liveblocks/client": "3.18.4",
- "@liveblocks/core": "3.18.4",
- "@liveblocks/react": "3.18.4",
+ "@liveblocks/client": "3.19.1",
+ "@liveblocks/core": "3.19.1",
+ "@liveblocks/react": "3.19.1",
"frimousse": "^0.2.0",
"marked": "^15.0.11",
"radix-ui": "^1.4.0",
@@ -1993,6 +2064,15 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -5034,6 +5114,15 @@
"win32"
]
},
+ "node_modules/@vercel/oidc": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
+ "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -5055,6 +5144,24 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/ai": {
+ "version": "6.0.182",
+ "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.182.tgz",
+ "integrity": "sha512-ooJdziFjYrYRcsCx107roqA8gDTI3P82nUfroNWIhVvwrkYzEN3W1l50YK+XNqkUew8AiimaW0/SLBewRXMuHQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@ai-sdk/gateway": "3.0.114",
+ "@ai-sdk/provider": "3.0.10",
+ "@ai-sdk/provider-utils": "4.0.27",
+ "@opentelemetry/api": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.76 || ^4.1.8"
+ }
+ },
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -6739,6 +6846,15 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
+ "node_modules/eventsource-parser": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
+ "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -7827,6 +7943,12 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT"
},
+ "node_modules/json-schema": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+ "license": "(AFL-2.1 OR BSD-3-Clause)"
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
diff --git a/examples/nextjs-ai-dashboard-reports/package.json b/examples/nextjs-ai-dashboard-reports/package.json
index 72fc3830158..ad628b4b3eb 100644
--- a/examples/nextjs-ai-dashboard-reports/package.json
+++ b/examples/nextjs-ai-dashboard-reports/package.json
@@ -11,11 +11,12 @@
"generate:all": "tsx --tsconfig ./tsconfig.scripts.json ./src/data/generateData.ts"
},
"dependencies": {
+ "@ai-sdk/anthropic": "^3.0.64",
"@internationalized/date": "^3.7.0",
- "@liveblocks/client": "^3.18.4",
- "@liveblocks/node": "^3.18.4",
- "@liveblocks/react": "^3.18.4",
- "@liveblocks/react-ui": "^3.18.4",
+ "@liveblocks/client": "^3.19.1",
+ "@liveblocks/node": "^3.19.1",
+ "@liveblocks/react": "^3.19.1",
+ "@liveblocks/react-ui": "^3.19.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.15",
@@ -36,6 +37,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tanstack/react-table": "^8.21.3",
"@typescript-eslint/parser": "^8.28.0",
+ "ai": "^6.0.141",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"eslint-plugin-next": "^0.0.0",
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx
index fb8bfaf5a56..b96d0f676e2 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx
+++ b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/layout.tsx
@@ -1,32 +1,46 @@
-"use client"
-import React from "react"
+"use client";
-import { cx } from "@/lib/utils"
+import React, { Suspense } from "react";
-import { Sidebar } from "@/components/ui/navigation/Sidebar"
+import { CommentsFloatingToggle } from "@/components/comments/CommentsFloatingToggle";
+import { CommentsRoomProvider } from "@/components/comments/CommentsRoomProvider";
+import { CommentsRoomShell } from "@/components/comments/CommentsRoomShell";
+import { cx } from "@/lib/utils";
+
+import { Sidebar } from "@/components/ui/navigation/Sidebar";
export default function Layout({
children,
}: Readonly<{
- children: React.ReactNode
+ children: React.ReactNode;
}>) {
- const [isCollapsed, setIsCollapsed] = React.useState(false)
+ const [isCollapsed, setIsCollapsed] = React.useState(false);
const toggleSidebar = () => {
- setIsCollapsed(!isCollapsed)
- }
+ setIsCollapsed(!isCollapsed);
+ };
return (
-
-
-
- {children}
-
-
+
+
+
+
+
+
+ {children}
+
+
+
+
- )
+ );
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx
index 43bad56048d..bc1c5669b82 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx
+++ b/examples/nextjs-ai-dashboard-reports/src/app/(dashboard)/reports/_components/Header.tsx
@@ -59,7 +59,7 @@ export default function Header() {
u.id === id) || undefined;
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts
index 672be1e7399..fdfb03c5334 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts
+++ b/examples/nextjs-ai-dashboard-reports/src/app/api/invoices/route.ts
@@ -1,70 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
-import { invoices } from "@/data/invoices";
-import type { Invoice } from "@/data/schema";
+import { filterInvoices } from "@/lib/server/filterInvoices";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
- // Parse filters from query params
- const dateFrom = searchParams.get("dateFrom");
- const dateTo = searchParams.get("dateTo");
- const currency = searchParams.get("currency");
- const continent = searchParams.get("continent");
- const country = searchParams.get("country");
+ const dateFrom = searchParams.get("dateFrom") ?? undefined;
+ const dateTo = searchParams.get("dateTo") ?? undefined;
+ const currency = searchParams.get("currency") ?? undefined;
+ const continent = searchParams.get("continent") ?? undefined;
+ const country = searchParams.get("country") ?? undefined;
const minAmount = searchParams.get("minAmount");
const maxAmount = searchParams.get("maxAmount");
- const invoiceStatus = searchParams.get("invoiceStatus");
+ const invoiceStatus = searchParams.get("invoiceStatus") ?? undefined;
const limit = parseInt(searchParams.get("limit") || "20", 10);
- const client = searchParams.get("client");
+ const client = searchParams.get("client") ?? undefined;
- let filtered = invoices;
-
- if (dateFrom) {
- filtered = filtered.filter(
- (i: Invoice) => new Date(i.invoice_date) >= new Date(dateFrom)
- );
- }
- if (dateTo) {
- filtered = filtered.filter(
- (i: Invoice) => new Date(i.invoice_date) <= new Date(dateTo)
- );
- }
- if (currency) {
- filtered = filtered.filter((i: Invoice) => i.currency === currency);
- }
- if (continent) {
- filtered = filtered.filter((i: Invoice) => i.continent === continent);
- }
- if (country) {
- filtered = filtered.filter((i: Invoice) => i.country === country);
- }
- if (minAmount) {
- filtered = filtered.filter(
- (i: Invoice) => i.amount >= parseFloat(minAmount)
- );
- }
- if (maxAmount) {
- filtered = filtered.filter(
- (i: Invoice) => i.amount <= parseFloat(maxAmount)
- );
- }
- if (invoiceStatus) {
- filtered = filtered.filter(
- (i: Invoice) => i.invoice_status === invoiceStatus
- );
- }
- if (client) {
- filtered = filtered.filter((i: Invoice) => i.client === client);
- }
-
- // Sort by date descending (most recent first)
- filtered = filtered.sort(
- (a: Invoice, b: Invoice) =>
- new Date(b.invoice_date).getTime() - new Date(a.invoice_date).getTime()
- );
-
- // Limit the number of results
- const result = filtered.slice(0, limit);
+ const result = filterInvoices({
+ dateFrom,
+ dateTo,
+ currency,
+ continent,
+ country,
+ minAmount: minAmount ? parseFloat(minAmount) : undefined,
+ maxAmount: maxAmount ? parseFloat(maxAmount) : undefined,
+ invoiceStatus,
+ limit,
+ client,
+ });
return NextResponse.json({ invoices: result });
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts
index 97b44a68547..b7ec60145de 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts
+++ b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-auth/route.ts
@@ -1,6 +1,6 @@
import { Liveblocks } from "@liveblocks/node";
import { NextRequest, NextResponse } from "next/server";
-import { users } from "@/data/users";
+import { getRandomUser, getUser } from "@/data/users";
/**
* Authenticating your Liveblocks application
@@ -13,14 +13,17 @@ const liveblocks = new Liveblocks({
export async function POST(request: NextRequest) {
if (!process.env.LIVEBLOCKS_SECRET_KEY) {
- return new NextResponse("Missing LIVEBLOCKS_SECRET_KEY", { status: 403 });
+ return NextResponse.json(
+ { error: "Missing LIVEBLOCKS_SECRET_KEY" },
+ { status: 403 },
+ );
}
// Get the current user's unique id and info from your database
const user = await getSession(request);
if (!user) {
- return new NextResponse("User not found", { status: 404 });
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Create a session for the current user (access token auth)
@@ -43,13 +46,25 @@ export async function POST(request: NextRequest) {
// Imagine this is your auth setup
async function getSession(request: NextRequest) {
- // Used to deploy to https://liveblocks.io/examples
- const { userId } = await request.json();
- const user = users.find((user) => user.email === "charlie.layne@example.com");
-
- if (!user) {
- return null;
+ let parsed: unknown = null;
+ try {
+ parsed = await request.json();
+ } catch {
+ parsed = null;
}
- return { ...user, email: (userId as string) || user.email };
+ const userId =
+ parsed !== null &&
+ typeof parsed === "object" &&
+ "userId" in parsed &&
+ typeof (parsed as { userId: unknown }).userId === "string"
+ ? (parsed as { userId: string }).userId
+ : undefined;
+
+ const fromEmail =
+ typeof userId === "string" && userId.includes("@")
+ ? getUser(userId)
+ : undefined;
+
+ return fromEmail ?? getRandomUser();
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-webhook/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-webhook/route.ts
new file mode 100644
index 00000000000..434035f4b62
--- /dev/null
+++ b/examples/nextjs-ai-dashboard-reports/src/app/api/liveblocks-webhook/route.ts
@@ -0,0 +1,59 @@
+import { isDashboardCommentsRoomId } from "@/lib/comments/constants";
+import { runDashboardCommentAiReply } from "@/lib/comment-ai/run-dashboard-comment-ai";
+import { WebhookHandler } from "@liveblocks/node";
+import { NextResponse } from "next/server";
+
+export const maxDuration = 800;
+
+const WEBHOOK_SECRET = process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY;
+
+const webhookHandler = WEBHOOK_SECRET
+ ? new WebhookHandler(WEBHOOK_SECRET)
+ : null;
+
+export async function POST(request: Request) {
+ if (!WEBHOOK_SECRET || !webhookHandler) {
+ return new NextResponse("Missing LIVEBLOCKS_WEBHOOK_SECRET_KEY", {
+ status: 500,
+ });
+ }
+
+ const rawBody = await request.text();
+ const headers = request.headers;
+
+ let event;
+ try {
+ event = webhookHandler.verifyRequest({
+ headers,
+ rawBody,
+ });
+ } catch (err) {
+ console.error(err);
+ return new NextResponse("Could not verify webhook call", { status: 400 });
+ }
+
+ if (event.type !== "commentCreated") {
+ return NextResponse.json({ message: "Event type not used" });
+ }
+
+ const { roomId, threadId, commentId } = event.data;
+
+ if (!isDashboardCommentsRoomId(roomId)) {
+ return NextResponse.json({ message: "Room not handled by this endpoint" });
+ }
+
+ const result = await runDashboardCommentAiReply({
+ roomId,
+ threadId,
+ commentId,
+ });
+
+ if (result.error) {
+ return NextResponse.json(
+ { message: result.error },
+ { status: result.status }
+ );
+ }
+
+ return NextResponse.json({ message: result.body });
+}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts
index 61ed8c2ca6a..552bcb8e335 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts
+++ b/examples/nextjs-ai-dashboard-reports/src/app/api/plan/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-import { currentPlan } from "@/data/data";
+import { getDashboardPlanKnowledge } from "@/lib/dashboard-ai-knowledge";
export async function GET() {
- return NextResponse.json(currentPlan);
+ return NextResponse.json(getDashboardPlanKnowledge());
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts
index 556d2a141ea..258fc741550 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts
+++ b/examples/nextjs-ai-dashboard-reports/src/app/api/team/route.ts
@@ -1,22 +1,8 @@
import type { AiChatProps } from "@liveblocks/react-ui";
-import { users } from "@/data/users";
-import { departments, roles } from "@/data/data";
+import { getDashboardTeamKnowledge } from "@/lib/dashboard-ai-knowledge";
export async function GET() {
- const aiKnowledge: AiChatProps["knowledge"] = [
- {
- description: "A list of all users added to the team",
- value: JSON.stringify(users),
- },
- {
- description: "Every department in this team",
- value: JSON.stringify(departments),
- },
- {
- description: "Every role in this team",
- value: JSON.stringify(roles),
- },
- ];
+ const aiKnowledge: AiChatProps["knowledge"] = getDashboardTeamKnowledge();
return Response.json(aiKnowledge, { status: 200 });
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts
index 232ff5369d6..08bca79c18a 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts
+++ b/examples/nextjs-ai-dashboard-reports/src/app/api/transactions/route.ts
@@ -1,77 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
-import { transactions } from "../../../../src/data/transactions";
-import type { Transaction } from "../../../../src/data/schema";
+import { filterTransactions } from "@/lib/server/filterTransactions";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
- // Parse filters from query params
- const dateFrom = searchParams.get("dateFrom");
- const dateTo = searchParams.get("dateTo");
- const currency = searchParams.get("currency");
- const continent = searchParams.get("continent");
- const country = searchParams.get("country");
+ const dateFrom = searchParams.get("dateFrom") ?? undefined;
+ const dateTo = searchParams.get("dateTo") ?? undefined;
+ const currency = searchParams.get("currency") ?? undefined;
+ const continent = searchParams.get("continent") ?? undefined;
+ const country = searchParams.get("country") ?? undefined;
const minAmount = searchParams.get("minAmount");
const maxAmount = searchParams.get("maxAmount");
- const expenseStatus = searchParams.get("expenseStatus");
- const paymentStatus = searchParams.get("paymentStatus");
+ const expenseStatus = searchParams.get("expenseStatus") ?? undefined;
+ const paymentStatus = searchParams.get("paymentStatus") ?? undefined;
const limit = parseInt(searchParams.get("limit") || "20", 10);
- const merchant = searchParams.get("merchant");
+ const merchant = searchParams.get("merchant") ?? undefined;
- let filtered = transactions;
-
- if (dateFrom) {
- filtered = filtered.filter(
- (t: Transaction) => new Date(t.transaction_date) >= new Date(dateFrom)
- );
- }
- if (dateTo) {
- filtered = filtered.filter(
- (t: Transaction) => new Date(t.transaction_date) <= new Date(dateTo)
- );
- }
- if (currency) {
- filtered = filtered.filter((t: Transaction) => t.currency === currency);
- }
- if (continent) {
- filtered = filtered.filter((t: Transaction) => t.continent === continent);
- }
- if (country) {
- filtered = filtered.filter((t: Transaction) => t.country === country);
- }
- if (minAmount) {
- filtered = filtered.filter(
- (t: Transaction) => t.amount >= parseFloat(minAmount)
- );
- }
- if (maxAmount) {
- filtered = filtered.filter(
- (t: Transaction) => t.amount <= parseFloat(maxAmount)
- );
- }
- if (expenseStatus) {
- filtered = filtered.filter(
- (t: Transaction) => t.expense_status === expenseStatus
- );
- }
- if (paymentStatus) {
- filtered = filtered.filter(
- (t: Transaction) => t.payment_status === paymentStatus
- );
- }
- if (merchant) {
- filtered = filtered.filter((t: Transaction) => t.merchant === merchant);
- }
-
- // Sort by date descending (most recent first)
- filtered = filtered.sort(
- (a: Transaction, b: Transaction) =>
- new Date(b.transaction_date).getTime() -
- new Date(a.transaction_date).getTime()
- );
-
- // Limit the number of results
- const result = filtered.slice(0, limit);
+ const result = filterTransactions({
+ dateFrom,
+ dateTo,
+ currency,
+ continent,
+ country,
+ minAmount: minAmount ? parseFloat(minAmount) : undefined,
+ maxAmount: maxAmount ? parseFloat(maxAmount) : undefined,
+ expenseStatus,
+ paymentStatus,
+ limit,
+ merchant,
+ });
return NextResponse.json({ transactions: result });
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts b/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts
index e40bb84ed83..3df6f8392f1 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts
+++ b/examples/nextjs-ai-dashboard-reports/src/app/api/users/search/route.ts
@@ -8,11 +8,18 @@ import { getAllUsers } from "../../database";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
- const text = searchParams.get("text") as string;
+ const text = searchParams.get("text") ?? "";
+ const query = text.trim().toLowerCase();
const filteredUserIds = getAllUsers()
.filter((user) => {
- return user.info.name.toLowerCase().includes(text.toLowerCase());
+ if (!query) {
+ return true;
+ }
+
+ const name = user.info.name.toLowerCase();
+ const id = user.id.toLowerCase();
+ return name.includes(query) || id.includes(query);
})
.map((user) => user.id);
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/globals.css b/examples/nextjs-ai-dashboard-reports/src/app/globals.css
index 8fd1e9a86c5..db12b56a67b 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/globals.css
+++ b/examples/nextjs-ai-dashboard-reports/src/app/globals.css
@@ -168,3 +168,34 @@ button,
--lb-dynamic-background: var(--color-neutral-900);
--lb-foreground: var(--color-neutral-50);
}
+
+.comments-sidebar .thread {
+ width: 100%;
+ max-width: none;
+ position: relative;
+}
+
+.comments-sidebar .composer {
+ width: 100%;
+ max-width: none;
+ position: relative;
+}
+
+.comments-sidebar .lb-thread {
+ width: 100%;
+ max-width: none;
+}
+
+.comments-sidebar .lb-comment {
+ background: transparent;
+}
+
+.comments-sidebar .lb-composer {
+ width: 100%;
+ max-width: none;
+}
+
+.dark .comments-sidebar .thread::after,
+.dark .comments-sidebar .composer::after {
+ display: none;
+}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts b/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts
index d97009acb30..6d3b1a05c8a 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts
+++ b/examples/nextjs-ai-dashboard-reports/src/app/liveblocks.config.ts
@@ -8,6 +8,40 @@ declare global {
color: string;
};
};
+
+ CommentMetadata: {
+ feedId?: string;
+ feedComplete?: boolean;
+ pathname?: string;
+ };
+
+ ThreadMetadata: {
+ pathname?: string;
+ };
+
+ FeedMetadata: {
+ type: "ai-comment-reply";
+ threadId: string;
+ commentId: string;
+ };
+
+ FeedMessageData:
+ | {
+ stage: "thinking";
+ response: string;
+ responsePart: string;
+ }
+ | {
+ stage: "writing";
+ response: string;
+ responsePart: string;
+ }
+ | {
+ stage: "complete";
+ response: string;
+ reasoning: string;
+ thinkingTime: number;
+ };
}
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx b/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx
index 9ca5aedc630..56720a5a5c1 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx
+++ b/examples/nextjs-ai-dashboard-reports/src/app/providers.tsx
@@ -3,41 +3,70 @@
import { LiveblocksProvider } from "@liveblocks/react";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
-import { ReactNode } from "react";
+import { ReactNode, Suspense } from "react";
import { Toaster } from "sonner";
import { SWRConfig } from "swr";
+import { CommentsSidebarProvider } from "@/components/comments/CommentsSidebarContext";
import { InvitedUsersProvider } from "@/lib/useInvitedUsers";
export function Providers({ children }: { children: ReactNode }) {
return (
-
-
- fetch(resource, init).then((res) => res.json()),
+
+ {
+ const searchParams = new URLSearchParams(
+ userIds.map((userId) => ["userIds", userId]),
+ );
+ const response = await fetch(`/api/users?${searchParams}`);
+
+ if (!response.ok) {
+ throw new Error("Problem resolving users");
+ }
+
+ return response.json();
}}
+ resolveMentionSuggestions={async ({ text }) => {
+ const response = await fetch(
+ `/api/users/search?text=${encodeURIComponent(text)}`,
+ );
+
+ if (!response.ok) {
+ throw new Error("Problem resolving mention suggestions");
+ }
+
+ return response.json();
+ }}
+ badgeLocation="bottom-left"
>
-
+ fetch(resource, init).then((res) => res.json()),
+ }}
>
-
- {children}
-
-
-
+
+
+
+ {children}
+
+
+
+
+
);
}
// Not needed, just used to deploy to https://liveblocks.io/examples
function authWithExampleId(endpoint: string) {
- return async (room?: string) => {
+ return async (room?: string): Promise<{ token: string }> => {
let userId = localStorage.getItem("liveblocks-example-id");
if (!userId) {
userId = Math.random().toString(36).substring(2);
@@ -50,6 +79,38 @@ function authWithExampleId(endpoint: string) {
},
body: JSON.stringify({ room, userId }),
});
- return await response.json();
+
+ const text = await response.text();
+ let data: unknown;
+ try {
+ data = text ? JSON.parse(text) : null;
+ } catch {
+ throw new Error(
+ text.startsWith("Authentication failed")
+ ? text
+ : `Authentication failed: ${text.slice(0, 120)}`,
+ );
+ }
+
+ if (!response.ok) {
+ const message =
+ typeof data === "object" &&
+ data !== null &&
+ "error" in data &&
+ typeof (data as { error: unknown }).error === "string"
+ ? (data as { error: string }).error
+ : `HTTP ${response.status}`;
+ throw new Error(message);
+ }
+
+ if (typeof data !== "object" || data === null || !("token" in data)) {
+ throw new Error("Invalid authentication response");
+ }
+ const token = (data as { token: unknown }).token;
+ if (typeof token !== "string") {
+ throw new Error("Invalid authentication response");
+ }
+
+ return { token };
};
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx b/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx
index 9e80e191c76..5e7cffd3de5 100644
--- a/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx
+++ b/examples/nextjs-ai-dashboard-reports/src/app/settings/layout.tsx
@@ -1,56 +1,72 @@
-"use client"
-import React from "react"
+"use client";
-import { TabNavigation, TabNavigationLink } from "@/components/TabNavigation"
-import { Sidebar } from "@/components/ui/navigation/Sidebar"
-import { cx } from "@/lib/utils"
-import Link from "next/link"
-import { usePathname } from "next/navigation"
-import { siteConfig } from "../siteConfig"
+import React, { Suspense } from "react";
+
+import { CommentsFloatingToggle } from "@/components/comments/CommentsFloatingToggle";
+import { CommentsRoomProvider } from "@/components/comments/CommentsRoomProvider";
+import { CommentsRoomShell } from "@/components/comments/CommentsRoomShell";
+import { TabNavigation, TabNavigationLink } from "@/components/TabNavigation";
+import { Sidebar } from "@/components/ui/navigation/Sidebar";
+import { cx } from "@/lib/utils";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { siteConfig } from "../siteConfig";
const navigationSettings = [
// { name: "Audit", href: siteConfig.baseLinks.settings.audit },
{ name: "Billing & usage", href: siteConfig.baseLinks.settings.billing },
{ name: "Users", href: siteConfig.baseLinks.settings.users },
-]
+];
export default function Layout({
children,
}: Readonly<{
- children: React.ReactNode
+ children: React.ReactNode;
}>) {
- const [isCollapsed, setIsCollapsed] = React.useState(false)
+ const [isCollapsed, setIsCollapsed] = React.useState(false);
const toggleSidebar = () => {
- setIsCollapsed(!isCollapsed)
- }
- const pathname = usePathname()
+ setIsCollapsed(!isCollapsed);
+ };
+ const pathname = usePathname();
return (
-
-
-
-
- Settings
-
-
- {navigationSettings.map((item) => (
-
- {item.name}
-
- ))}
-
-
{children}
-
-
+
+
+
+
+
+
+
+
+ Settings
+
+
+ {navigationSettings.map((item) => (
+
+ {item.name}
+
+ ))}
+
+ {children}
+
+
+
+
+
- )
+ );
}
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsFloatingToggle.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsFloatingToggle.tsx
new file mode 100644
index 00000000000..012f1bbd547
--- /dev/null
+++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsFloatingToggle.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { MessageSquareIcon } from "lucide-react";
+
+import { useCommentsSidebar } from "@/components/comments/CommentsSidebarContext";
+import { cx } from "@/lib/utils";
+
+const mobileHeaderButtonStyles =
+ "*:text-neutral-600 dark:*:text-neutral-400 size-[38px] relative justify-center border text-center whitespace-nowrap transition-all duration-100 ease-in-out sm:text-sm disabled:pointer-events-none disabled:shadow-none outline-solid outline-offset-2 outline-0 focus-visible:outline-2 outline-blue-500 dark:outline-blue-500 shadow-none border-transparent text-neutral-900 dark:text-neutral-50 bg-transparent disabled:text-neutral-400 dark:disabled:text-neutral-600 group flex items-center rounded-md p-1.5 text-sm font-medium hover:bg-neutral-50 data-[state=open]:bg-neutral-400/10 dark:hover:bg-neutral-400/10";
+
+const desktopFloatingButtonStyles =
+ "mt-px inline-flex size-8 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900";
+
+type CommentsOpenButtonProps = {
+ className?: string;
+ variant?: "mobile-header" | "desktop-floating";
+ hideWhenOpen?: boolean;
+};
+
+export function CommentsOpenButton({
+ className,
+ variant = "mobile-header",
+ hideWhenOpen = false,
+}: CommentsOpenButtonProps) {
+ const { open, setOpen } = useCommentsSidebar();
+
+ if (hideWhenOpen && open) {
+ return null;
+ }
+
+ const isMobile = variant === "mobile-header";
+
+ return (
+
+ );
+}
+
+export function CommentsFloatingToggle() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomProvider.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomProvider.tsx
new file mode 100644
index 00000000000..a463d70c2cc
--- /dev/null
+++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomProvider.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { COMMENTS_ROOM_ID_BASE } from "@/lib/comments/constants";
+import { RoomProvider } from "@liveblocks/react/suspense";
+import { useSearchParams } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+
+export function CommentsRoomProvider({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ const roomId = useExampleRoomId(COMMENTS_ROOM_ID_BASE);
+ return {children};
+}
+
+// Everything below is just used to deploy the example on liveblocks.io
+function useExampleRoomId(baseRoomId: string) {
+ const params = useSearchParams();
+ const exampleIdFromUrl = params?.get("exampleId");
+
+ const [exampleId, setExampleId] = useState(() =>
+ getStoredExampleRoomId()
+ );
+
+ useEffect(() => {
+ if (exampleIdFromUrl) {
+ setStoredExampleRoomId(exampleIdFromUrl);
+ setExampleId(exampleIdFromUrl);
+ return;
+ }
+
+ setExampleId(getStoredExampleRoomId());
+ }, [exampleIdFromUrl]);
+
+ return useMemo(
+ () => resolveCommentsRoomId(baseRoomId, exampleId),
+ [baseRoomId, exampleId]
+ );
+}
+
+const STORAGE_KEY = "liveblocks-dashboard-example-room-id";
+
+export function getStoredExampleRoomId(): string | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ return localStorage.getItem(STORAGE_KEY);
+}
+
+export function setStoredExampleRoomId(exampleId: string) {
+ localStorage.setItem(STORAGE_KEY, exampleId);
+}
+
+export function resolveCommentsRoomId(
+ baseRoomId: string,
+ exampleId: string | null
+): string {
+ return exampleId ? `${baseRoomId}-${exampleId}` : baseRoomId;
+}
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomShell.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomShell.tsx
new file mode 100644
index 00000000000..9848125bcd7
--- /dev/null
+++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsRoomShell.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import {
+ Drawer,
+ DrawerBody,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/Drawer";
+import { useCommentsSidebar } from "@/components/comments/CommentsSidebarContext";
+import { ThreadsPanel } from "@/components/comments/ThreadsPanel";
+import { cx } from "@/lib/utils";
+import { RiLoader2Fill } from "@remixicon/react";
+import { ClientSideSuspense } from "@liveblocks/react/suspense";
+import { XIcon } from "lucide-react";
+import React from "react";
+
+export function CommentsRoomShell({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return {children};
+}
+
+function CommentsSidebarLayout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ const { open, setOpen } = useCommentsSidebar();
+ const isLg = useIsMinWidthLg();
+
+ const threadsSuspense = (
+ }>
+
+
+ );
+
+ return (
+
+
+ {children}
+
+
+ {!isLg ? (
+
+
+
+ Comments
+
+
+
+ {threadsSuspense}
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function CommentsSuspenseFallback() {
+ return (
+
+
+ Loading comments
+
+ );
+}
+
+function subscribeMinWidthLg(onStoreChange: () => void) {
+ const mq = window.matchMedia("(min-width: 1024px)");
+ mq.addEventListener("change", onStoreChange);
+ return () => mq.removeEventListener("change", onStoreChange);
+}
+
+function getMinWidthLgSnapshot() {
+ return window.matchMedia("(min-width: 1024px)").matches;
+}
+
+function useIsMinWidthLg() {
+ return React.useSyncExternalStore(
+ subscribeMinWidthLg,
+ getMinWidthLgSnapshot,
+ () => true
+ );
+}
+
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsSidebarContext.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsSidebarContext.tsx
new file mode 100644
index 00000000000..459ab74de0d
--- /dev/null
+++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/CommentsSidebarContext.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useMemo,
+ useState,
+ type Dispatch,
+ type ReactNode,
+ type SetStateAction,
+} from "react";
+
+type CommentsSidebarContextValue = {
+ open: boolean;
+ setOpen: Dispatch>;
+};
+
+const CommentsSidebarContext =
+ createContext(null);
+
+export function CommentsSidebarProvider({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false);
+
+ const value = useMemo(() => ({ open, setOpen }), [open]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCommentsSidebar(): CommentsSidebarContextValue {
+ const ctx = useContext(CommentsSidebarContext);
+ if (!ctx) {
+ throw new Error(
+ "useCommentsSidebar must be used within CommentsSidebarProvider",
+ );
+ }
+ return ctx;
+}
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/comments/ThreadsPanel.tsx b/examples/nextjs-ai-dashboard-reports/src/components/comments/ThreadsPanel.tsx
new file mode 100644
index 00000000000..d765f7015e9
--- /dev/null
+++ b/examples/nextjs-ai-dashboard-reports/src/components/comments/ThreadsPanel.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import { useFeedMessages, useUser } from "@liveblocks/react";
+import { useThreads } from "@liveblocks/react/suspense";
+import { Composer, Thread, Comment, CommentProps } from "@liveblocks/react-ui";
+import { Markdown } from "@liveblocks/react-ui/_private";
+import {
+ Comment as CommentPrimitive,
+ type CommentBodyLinkProps,
+ type CommentBodyMentionProps,
+} from "@liveblocks/react-ui/primitives";
+import { BrainIcon, ChevronDownIcon } from "lucide-react";
+import { usePathname } from "next/navigation";
+import { useState } from "react";
+
+export function ThreadsPanel() {
+ const { threads } = useThreads();
+ const pathname = usePathname();
+
+ const commentsCardClass =
+ "shrink-0 overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950";
+
+ return (
+
+
+ {threads.map((thread) => (
+
{
+ const feedId = commentProps.comment.metadata?.feedId;
+
+ if (feedId) {
+ return (
+
+ );
+ }
+
+ return ;
+ },
+ }}
+ />
+ ))}
+
+ );
+}
+
+function AiComment({
+ feedId,
+ commentProps,
+}: {
+ feedId: string;
+ commentProps: CommentProps;
+}) {
+ const { messages } = useFeedMessages(feedId);
+ const lastMessage = messages?.[messages.length - 1];
+
+ if (!messages || !lastMessage) {
+ return (
+
+ );
+ }
+
+ if (lastMessage.data.stage === "thinking") {
+ return (
+
+ );
+ }
+
+ if (lastMessage.data.stage === "writing") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function StreamingComment({
+ commentProps,
+ title,
+ responsePart,
+ response,
+}: {
+ commentProps: CommentProps;
+ title: string;
+ responsePart: string;
+ response: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const trimmedResponsePart = responsePart.trim();
+
+ return (
+ setOpen(!open)}
+ >
+
+
+
+
+ {title}
+
+
+
+
+ {trimmedResponsePart.length ? `…${trimmedResponsePart}` : ""}
+
+
+
+ {response}
+
+
+ }
+ />
+ );
+}
+
+function StreamedComment({
+ commentProps,
+ reasoning,
+ response,
+ thinkingTime,
+}: {
+ commentProps: CommentProps;
+ reasoning: string;
+ response: string;
+ thinkingTime: number;
+}) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ setOpen(!open)}
+ >
+
+
+ Thought for {Number(thinkingTime).toFixed(0)} seconds
+
+
+
+
+
+
+
+ {commentProps.comment.metadata.feedComplete ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ >
+ }
+ />
+ );
+}
+
+function CommentMarkdownLink({ href, children }: CommentBodyLinkProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+function CommentMarkdownMention({ mention }: CommentBodyMentionProps) {
+ return (
+
+ @
+
+
+ );
+}
+
+function ResolvedMarkdownMentionName({ userId }: { userId: string }) {
+ const { user, isLoading } = useUser(userId);
+
+ if (isLoading) {
+ return <>…>;
+ }
+
+ return <>{user?.name ?? userId}>;
+}
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx
index 37bb9660344..65d25be72a3 100644
--- a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx
+++ b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/DropdownUserProfile.tsx
@@ -17,7 +17,8 @@ import {
import { ArrowUpRight, Monitor, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import * as React from "react"
-import { users } from "@/data/users"
+
+import { useLiveblocksDashboardUser } from "./useLiveblocksDashboardUser"
export type DropdownUserProfileProps = {
children: React.ReactNode
@@ -28,6 +29,7 @@ export function DropdownUserProfile({
children,
align = "start",
}: DropdownUserProfileProps) {
+ const me = useLiveblocksDashboardUser()
const [mounted, setMounted] = React.useState(false)
const { theme, setTheme } = useTheme()
React.useEffect(() => {
@@ -45,7 +47,9 @@ export function DropdownUserProfile({
align={align}
className="min-w-[calc(var(--radix-dropdown-menu-trigger-width))]!"
>
- {users[0].email}
+
+ {me?.id ?? "Connecting…"}
+
Theme
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx
index 454abe0ec4d..1431e60d2f2 100644
--- a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx
+++ b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/Sidebar.tsx
@@ -1,5 +1,6 @@
"use client"
import { siteConfig } from "@/app/siteConfig"
+import { CommentsOpenButton } from "@/components/comments/CommentsFloatingToggle"
import { Tooltip } from "@/components/Tooltip"
import { cx, focusRing } from "@/lib/utils"
import {
@@ -225,6 +226,7 @@ export function Sidebar({ isCollapsed, toggleSidebar }: SidebarProps) {
+
diff --git a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx
index 295ddd50752..25344641d45 100644
--- a/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx
+++ b/examples/nextjs-ai-dashboard-reports/src/components/ui/navigation/UserProfile.tsx
@@ -2,11 +2,11 @@
import { Button } from "@/components/Button"
import { cx, focusRing } from "@/lib/utils"
-import { ChevronsUpDown, User } from "lucide-react"
+import { ChevronsUpDown, Loader2, User } from "lucide-react"
import Image from "next/image"
-import { users } from "@/data/users"
import { DropdownUserProfile } from "./DropdownUserProfile"
+import { useLiveblocksDashboardUser } from "./useLiveblocksDashboardUser"
interface UserProfileDesktopProps {
isCollapsed?: boolean
@@ -15,6 +15,8 @@ interface UserProfileDesktopProps {
export const UserProfileDesktop = ({
isCollapsed,
}: UserProfileDesktopProps) => {
+ const me = useLiveblocksDashboardUser()
+
return (