Knowledge Isolation Test
+
+ This knowledge is registered via{" "}
+ <RegisterAiKnowledge /> and should be
+ accessible to both chat A and B.
+
From: {partialArgs.origin}
} + {partialArgs.destination &&To: {partialArgs.destination}
} + {partialArgs.departureDate && ( +Date: {partialArgs.departureDate}
+ )} + {partialArgs.passengers && ( +Passengers ({partialArgs.passengers.length}):
++ {args.origin} → {args.destination} on {args.departureDate} +
+{args.passengers.length} passenger(s)
+Booking ID: {result.data.bookingId}
+
+ This knowledge is passed via the knowledge prop to chat A
+ only.
+
+ This chat has no additional local knowledge passed via props. It can + only access the global knowledge. +
+
+ This knowledge is registered via{" "}
+ <RegisterAiKnowledge /> and should be
+ accessible to both chat A and B.
+
- This knowledge is passed via the knowledge prop to chat A
- only.
-
- This chat has no additional local knowledge passed via props. It can - only access the global knowledge. -
-
- This knowledge is registered via{" "}
- <RegisterAiKnowledge /> and should be
- accessible to both chat A and B.
-
Some errors
+A pending message
+A reasoning message with tools
+A Markdown message
+A message with line breaks
+{lineBreakMessage}
+Some errors
-A pending message
-A reasoning message with tools
-A Markdown message
-A message with line breaks
-{lineBreakMessage}
-+ Ask me to generate HTML and watch it stream in real-time! +
+ + {/* Suggestion Tags */} +(roomId, {
initialPresence,
initialStorage,
@@ -82,7 +84,7 @@ async function initializeRoomForTest<
room,
leave,
get ws() {
- if (ws == null) {
+ if (ws === null) {
throw new Error("Websocket should be initialized at this point");
}
return ws;
@@ -115,7 +117,7 @@ export function prepareTestsConflicts {part.text} Another list item with multiple paragraphs. & paste©");
+ expect(root.querySelector("blockquote")?.textContent).toBe("\" '");
+ expect(root.querySelector("li")?.textContent).toBe("© ™");
+ }
+ );
+ });
+
+ test("HTML elements as plain text", () => {
+ assert(
+ `
+ The abbreviation for HyperText Markup Language is HTML.
+
+ Press Ctrl + C to copy.
+
+ This text is highlighted.
+
+ E = mc2
+ `,
+ (root) => {
+ const paragraphs = root.querySelectorAll("p");
+ expect(paragraphs).toHaveLength(4);
+
+ expect(paragraphs[0]?.textContent).toBe(
+ 'The abbreviation for HyperText Markup Language is HTML.'
+ );
+ expect(paragraphs[1]?.textContent).toBe(
+ "Press Ctrl + C to copy."
+ );
+ expect(paragraphs[2]?.textContent).toBe(
+ "This text is highlighted."
+ );
+ expect(paragraphs[3]?.textContent).toBe("E = mc2");
+ }
+ );
+ });
+ });
+
+ test("should handle different newlines", () => {
+ const content =
+ "# Heading 1\n\nA paragraph\r\r---\r\n\r\nAnother paragraph";
- - © ™
- `,
- assertions: (element) => {
- expect(element.querySelector("p")).toHaveTextContent(" &");
- expect(element.querySelector("blockquote")).toHaveTextContent("\" '");
- expect(element.querySelector("li")).toHaveTextContent("© ™");
- },
- },
- {
- description: "HTML elements (as plain text)",
- content: dedent`
- The abbreviation for HyperText Markup Language is HTML.
-
- Press Ctrl + C to copy.
-
- This is highlighted text.
-
- E = mc2
- `,
- assertions: (element) => {
- const paragraphs = element.querySelectorAll("p");
- expect(paragraphs).toHaveLength(4);
-
- expect(paragraphs[0]).toHaveTextContent(
- "The abbreviation for HyperText Markup Language is HTML."
- );
- expect(paragraphs[1]).toHaveTextContent("Press Ctrl + C to copy.");
- expect(paragraphs[2]).toHaveTextContent("This is highlighted text.");
- expect(paragraphs[3]).toHaveTextContent("E = mc2");
- },
- },
- ] satisfies {
- description: string;
- content: string;
- assertions: (element: HTMLElement) => void;
- }[])("should render $description", ({ content, assertions }) => {
const { getByTestId } = render(
{children} {children} Another one which spans multiple paragraphs. is nested. ;
+ // changes to this argument in subsequent renders, except when roomId changes
+ const frozenProps = useInitial(
+ {
+ initialPresence: props.initialPresence,
+ initialStorage: props.initialStorage,
+ autoConnect: props.autoConnect ?? typeof window !== "undefined",
+ },
+ roomId
+ ) as EnterOptions ;
const [{ room }, setRoomLeavePair] = useState(() =>
stableEnterRoom(roomId, {
diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json
index 048786b5984..24bb81ff111 100644
--- a/packages/liveblocks-redux/package.json
+++ b/packages/liveblocks-redux/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/redux",
- "version": "3.3.4",
+ "version": "3.4.0",
"description": "A store enhancer to integrate Liveblocks into Redux stores. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.",
"license": "Apache-2.0",
"type": "module",
@@ -33,8 +33,8 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/client": "3.3.4",
- "@liveblocks/core": "3.3.4"
+ "@liveblocks/client": "3.4.0",
+ "@liveblocks/core": "3.4.0"
},
"peerDependencies": {
"redux": "^4 || ^5"
diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json
index 0b45e19ea46..c61258a9113 100644
--- a/packages/liveblocks-yjs/package.json
+++ b/packages/liveblocks-yjs/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/yjs",
- "version": "3.3.4",
+ "version": "3.4.0",
"description": "Integrate your existing or new Yjs documents with Liveblocks.",
"icense": "Apache-2.0",
"type": "module",
@@ -33,8 +33,8 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/client": "3.3.4",
- "@liveblocks/core": "3.3.4",
+ "@liveblocks/client": "3.4.0",
+ "@liveblocks/core": "3.4.0",
"@noble/hashes": "^1.8.0",
"js-base64": "^3.7.7",
"y-indexeddb": "^9.0.12"
diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json
index eede92bab50..3699c24d1c0 100644
--- a/packages/liveblocks-zustand/package.json
+++ b/packages/liveblocks-zustand/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/zustand",
- "version": "3.3.4",
+ "version": "3.4.0",
"description": "A middleware for Zustand to automatically synchronize your stores with Liveblocks. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.",
"license": "Apache-2.0",
"type": "module",
@@ -34,8 +34,8 @@
"test:watch": "NODE_OPTIONS=\"--no-deprecation\" vitest"
},
"dependencies": {
- "@liveblocks/client": "3.3.4",
- "@liveblocks/core": "3.3.4"
+ "@liveblocks/client": "3.4.0",
+ "@liveblocks/core": "3.4.0"
},
"peerDependencies": {
"zustand": "^5.0.1"
(
}) => Promise(
actor1.ws.resumeSend();
// Waiting until every messages are received by all clients.
// We don't have a public way to know if everything has been received so we have to rely on time
- await wait(1000);
+ await wait(600);
},
flushSocket2Messages: async () => {
actor2.ws.resumeSend();
// Waiting until every messages are received by all clients.
// We don't have a public way to know if everything has been received so we have to rely on time
- await wait(1000);
+ await wait(600);
},
};
// Waiting until every messages are received by all clients.
// We don't have a public way to know if everything has been received so we have to rely on time
- await wait(1000);
+ await wait(600);
actor1.ws.pauseSend();
actor2.ws.pauseSend();
@@ -171,15 +173,21 @@ export function prepareTestsConflicts(
{ isDeep: true }
);
- function assert(immRoot1: ToImmutable, immRoot2?: ToImmutable) {
- if (immRoot2 == null) {
- immRoot2 = immRoot1;
+ function assert(
+ immRoot1: ToImmutable,
+ immRoot2: ToImmutable = immRoot1
+ ) {
+ try {
+ expect(root1.toImmutable()).toEqual(immRoot1);
+ expect(immutableStorage1).toEqual(immRoot1);
+ expect(root2.toImmutable()).toEqual(immRoot2);
+
+ expect(immutableStorage2).toEqual(immRoot2);
+ } catch (error) {
+ // Better stack trace (point to where assert is called instead)
+ Error.captureStackTrace(error as Error, assert);
+ throw error;
}
-
- expect(root1.toImmutable()).toEqual(immRoot1);
- expect(immutableStorage1).toEqual(immRoot1);
- expect(root2.toImmutable()).toEqual(immRoot2);
- expect(immutableStorage2).toEqual(immRoot2);
}
try {
@@ -225,7 +233,7 @@ export function prepareSingleClientTest(
// Waiting until every messages are received by all clients.
// We don't have a public way to know if everything has been received so we have to rely on time
- await wait(1000);
+ await wait(600);
actor.ws.pauseSend();
@@ -241,7 +249,7 @@ export function prepareSingleClientTest(
actor.ws.resumeSend();
// Waiting until every messages are received by all clients.
// We don't have a public way to know if everything has been received so we have to rely on time
- await wait(1000);
+ await wait(600);
},
});
actor.leave();
diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json
index c9ac96302a4..47739733d53 100644
--- a/packages/liveblocks-core/package.json
+++ b/packages/liveblocks-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@liveblocks/core",
- "version": "3.3.4",
+ "version": "3.4.0",
"description": "Private internals for Liveblocks. DO NOT import directly from this package!",
"type": "module",
"main": "./dist/index.cjs",
diff --git a/packages/liveblocks-core/src/__tests__/ai.test.ts b/packages/liveblocks-core/src/__tests__/ai.test.ts
index 51734311857..485003d2858 100644
--- a/packages/liveblocks-core/src/__tests__/ai.test.ts
+++ b/packages/liveblocks-core/src/__tests__/ai.test.ts
@@ -1,6 +1,17 @@
import { describe, expect, test } from "vitest";
import { KnowledgeStack } from "../ai";
+import type {
+ AiAssistantContentPart,
+ AiAssistantDeltaUpdate,
+ AiExecutingToolInvocationPart,
+ AiReasoningDelta,
+ AiReceivingToolInvocationPart,
+ AiTextDelta,
+ AiToolInvocationDelta,
+ AiToolInvocationStreamStart,
+} from "../types/ai";
+import { patchContentWithDelta } from "../types/ai";
describe("KnowledgeStack", () => {
test("should be empty by default", () => {
@@ -145,3 +156,803 @@ describe("KnowledgeStack", () => {
expect(stack.get()).toEqual([]);
});
});
+
+describe("patchContentWithDelta", () => {
+ describe("text-delta", () => {
+ test("appends to existing text part", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "text", text: "Hello " },
+ ];
+ const delta: AiTextDelta = { type: "text-delta", textDelta: "world!" };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([{ type: "text", text: "Hello world!" }]);
+ });
+
+ test("creates new text part when last part is not text", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "reasoning", text: "Some reasoning" },
+ ];
+ const delta: AiTextDelta = { type: "text-delta", textDelta: "Hello" };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([
+ { type: "reasoning", text: "Some reasoning" },
+ { type: "text", text: "Hello" },
+ ]);
+ });
+
+ test("creates new text part when content is empty", () => {
+ const content: AiAssistantContentPart[] = [];
+ const delta: AiTextDelta = { type: "text-delta", textDelta: "Hello" };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([{ type: "text", text: "Hello" }]);
+ });
+
+ test("creates new text part when last part is tool-invocation", () => {
+ const content: AiAssistantContentPart[] = [
+ {
+ type: "tool-invocation",
+ stage: "executed",
+ invocationId: "inv-1",
+ name: "search",
+ args: { query: "test" },
+ result: { type: "success", data: { results: [] } },
+ },
+ ];
+ const delta: AiTextDelta = { type: "text-delta", textDelta: "Done!" };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toHaveLength(2);
+ expect(content[1]).toEqual({ type: "text", text: "Done!" });
+ });
+ });
+
+ describe("reasoning-delta", () => {
+ test("appends to existing reasoning part", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "reasoning", text: "Let me think " },
+ ];
+ const delta: AiReasoningDelta = {
+ type: "reasoning-delta",
+ textDelta: "about this...",
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([
+ { type: "reasoning", text: "Let me think about this..." },
+ ]);
+ });
+
+ test("creates new reasoning part when last part is not reasoning", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "text", text: "Some text" },
+ ];
+ const delta: AiReasoningDelta = {
+ type: "reasoning-delta",
+ textDelta: "Thinking",
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([
+ { type: "text", text: "Some text" },
+ { type: "reasoning", text: "Thinking" },
+ ]);
+ });
+
+ test("creates new reasoning part when content is empty", () => {
+ const content: AiAssistantContentPart[] = [];
+ const delta: AiReasoningDelta = {
+ type: "reasoning-delta",
+ textDelta: "Starting to analyze...",
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([
+ { type: "reasoning", text: "Starting to analyze..." },
+ ]);
+ });
+
+ test("handles empty textDelta in reasoning-delta", () => {
+ const content: AiAssistantContentPart[] = [];
+ const delta: AiReasoningDelta = {
+ type: "reasoning-delta",
+ textDelta: "",
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([{ type: "reasoning", text: "" }]);
+ });
+ });
+
+ describe("tool-stream", () => {
+ test("creates receiving tool invocation with empty partialArgs", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-test",
+ name: "testTool",
+ });
+
+ expect(content).toEqual([
+ expect.objectContaining({
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-test",
+ name: "testTool",
+ partialArgs: {},
+ }),
+ ]);
+ });
+
+ test("appends tool-stream after existing content", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "text", text: "Let me search for that" },
+ ];
+ const delta: AiToolInvocationStreamStart = {
+ type: "tool-stream",
+ invocationId: "inv-456",
+ name: "calculator",
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toHaveLength(2);
+ expect(content[1]).toMatchObject({
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-456",
+ name: "calculator",
+ partialArgs: {},
+ });
+ });
+
+ test("allows multiple tool-streams with different invocationIds", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-1",
+ name: "search",
+ });
+
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-2",
+ name: "calculator",
+ });
+
+ expect(content).toHaveLength(2);
+ expect(content[0]).toMatchObject({
+ invocationId: "inv-1",
+ name: "search",
+ });
+ expect(content[1]).toMatchObject({
+ invocationId: "inv-2",
+ name: "calculator",
+ });
+ });
+
+ test("caches partialArgs computation between accesses", () => {
+ // Setup
+ // =====
+ const content: AiAssistantContentPart[] = [];
+
+ // Create two tool invocations (to test that each has its own cache)
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-cache-test",
+ name: "testTool",
+ });
+
+ const tool1 = content[0] as AiReceivingToolInvocationPart;
+
+ // Actual test
+ // ===========
+
+ expect(tool1.partialArgs).toEqual({});
+ expect(tool1.partialArgs).toBe(tool1.partialArgs);
+ // ^^^^ Referentially equal
+
+ // Add some partial args text
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '{"query": "tes',
+ });
+
+ // Multiple accesses doesn't change the value...
+ expect(tool1.partialArgs).toEqual({ query: "tes" });
+ expect(tool1.partialArgs).toEqual({ query: "tes" });
+
+ // ...in fact it returns the same value
+ const result1 = tool1.partialArgs;
+ expect(tool1.partialArgs).toBe(tool1.partialArgs);
+ // ^^^^ Referentially equal
+
+ // Second tool invocation to test that each has its own cache
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-cache-test-2",
+ name: "testTool2",
+ });
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '{"different": "value"}',
+ });
+
+ const tool2 = content[1] as AiReceivingToolInvocationPart;
+
+ // Each tool should have its own cached values
+ tool2.partialArgs;
+
+ expect(tool2.partialArgs).toEqual({ different: "value" });
+ expect(tool2.partialArgs).toBe(tool2.partialArgs);
+
+ // First tool should still have its original cached value
+ expect(tool1.partialArgs).toBe(result1); // Still the same cached object
+ });
+ });
+
+ describe("tool-delta", () => {
+ test("ignores delta when content is empty (no tool-stream yet)", () => {
+ const content: AiAssistantContentPart[] = [];
+ const delta: AiToolInvocationDelta = {
+ type: "tool-delta",
+ delta: '{"ignored": true}',
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([]);
+ });
+
+ test("ignores delta when last part is text", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "text", text: "Some text" },
+ ];
+ const delta: AiToolInvocationDelta = {
+ type: "tool-delta",
+ delta: '{"ignored": true}',
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([{ type: "text", text: "Some text" }]);
+ });
+
+ test("ignores delta when last part is reasoning", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "reasoning", text: "Thinking..." },
+ ];
+ const delta: AiToolInvocationDelta = {
+ type: "tool-delta",
+ delta: '{"ignored": true}',
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([{ type: "reasoning", text: "Thinking..." }]);
+ });
+
+ test("ignores delta when last tool is executing", () => {
+ const content: AiAssistantContentPart[] = [
+ {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-123",
+ name: "search",
+ args: { query: "test" },
+ },
+ ];
+ const delta: AiToolInvocationDelta = {
+ type: "tool-delta",
+ delta: '{"should": "be ignored"}',
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([
+ {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-123",
+ name: "search",
+ args: { query: "test" },
+ },
+ ]);
+ });
+
+ test("ignores delta when last tool is executed", () => {
+ const content: AiAssistantContentPart[] = [
+ {
+ type: "tool-invocation",
+ stage: "executed",
+ invocationId: "inv-123",
+ name: "search",
+ args: { query: "test" },
+ result: { type: "success", data: { found: true } },
+ },
+ ];
+ const delta: AiToolInvocationDelta = {
+ type: "tool-delta",
+ delta: '{"should": "be ignored"}',
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content[0]).toMatchObject({
+ stage: "executed",
+ invocationId: "inv-123",
+ });
+ });
+
+ test("appends delta to receiving tool invocation", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ // Start the tool stream
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-1",
+ name: "search",
+ });
+
+ // Add delta
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '{"query": "test"}',
+ });
+
+ expect(content).toEqual([
+ expect.objectContaining({
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-1",
+ name: "search",
+ partialArgs: { query: "test" },
+ }),
+ ]);
+ });
+
+ test("builds JSON progressively with multiple deltas", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ // Start the tool stream
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-complex",
+ name: "api_call",
+ });
+
+ // Build up the JSON progressively
+ const deltas = [
+ '{"',
+ 'method": "',
+ 'GET",',
+ ' "url": "https://',
+ "api.example.com",
+ '", "headers": {',
+ '"Accept": "application/json"',
+ "}}",
+ ];
+
+ for (const deltaText of deltas) {
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: deltaText,
+ });
+ }
+
+ const tool = content[0] as AiReceivingToolInvocationPart;
+ expect(tool.partialArgs).toEqual({
+ method: "GET",
+ url: "https://api.example.com",
+ headers: { Accept: "application/json" },
+ });
+ });
+
+ test("only affects the last receiving tool when multiple tools exist", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ // First tool (executed)
+ content.push({
+ type: "tool-invocation",
+ stage: "executed",
+ invocationId: "inv-1",
+ name: "search",
+ args: { query: "first" },
+ result: { type: "success", data: {} },
+ });
+
+ // Second tool (receiving)
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-2",
+ name: "calculator",
+ });
+
+ // Delta should only affect the second tool
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '{"expr": "2+2"}',
+ });
+
+ expect(content).toEqual([
+ expect.objectContaining({
+ type: "tool-invocation",
+ stage: "executed",
+ invocationId: "inv-1",
+ name: "search",
+ args: { query: "first" },
+ }),
+ expect.objectContaining({
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-2",
+ name: "calculator",
+ partialArgs: { expr: "2+2" },
+ }),
+ ]);
+ });
+ });
+
+ describe("tool-invocation (executing/executed)", () => {
+ test("appends new executing tool when no matching invocationId exists", () => {
+ const content: AiAssistantContentPart[] = [];
+ const delta: AiExecutingToolInvocationPart = {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-new",
+ name: "search",
+ args: { query: "test" },
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([delta]);
+ });
+
+ test("replaces receiving tool with executing tool (same invocationId)", () => {
+ const content: AiAssistantContentPart[] = [
+ {
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-123",
+ name: "search",
+ partialArgsText: '{"query": "par"}',
+ partialArgs: { dummy: "not used in this test" },
+ },
+ ];
+
+ const delta: AiExecutingToolInvocationPart = {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-123",
+ name: "search",
+ args: { query: "partial" },
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([delta]);
+ });
+
+ test("replaces executing tool with executed tool (same invocationId)", () => {
+ const content: AiAssistantContentPart[] = [
+ {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-123",
+ name: "search",
+ args: { query: "test" },
+ },
+ ];
+
+ const delta: AiAssistantDeltaUpdate = {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-123",
+ name: "search",
+ args: { query: "test" },
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([delta]);
+ });
+
+ test("replaces tool in middle of content array", () => {
+ const content: AiAssistantContentPart[] = [
+ { type: "text", text: "Before" },
+ {
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-123",
+ name: "search",
+ partialArgsText: '{"q": "test"}',
+ partialArgs: { dummy: "not used in this test" },
+ },
+ { type: "text", text: "After" },
+ ];
+
+ const delta: AiExecutingToolInvocationPart = {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-123",
+ name: "search",
+ args: { query: "complete" },
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toEqual([
+ { type: "text", text: "Before" },
+ delta,
+ { type: "text", text: "After" },
+ ]);
+ });
+
+ test("replaces the LAST matching tool when multiple have same invocationId", () => {
+ const content: AiAssistantContentPart[] = [
+ {
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-dup",
+ name: "first",
+ partialArgsText: "a",
+ partialArgs: { dummy: "not used in this test" },
+ },
+ { type: "text", text: "Middle" },
+ {
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-dup",
+ name: "second",
+ partialArgsText: "b",
+ partialArgs: { dummy: "not used in this test" },
+ },
+ ];
+
+ const delta: AiExecutingToolInvocationPart = {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-dup",
+ name: "replaced",
+ args: { value: "test" },
+ };
+
+ patchContentWithDelta(content, delta);
+
+ // Should replace the LAST one (index 2)
+ expect(content[0]).toMatchObject({ name: "first" });
+ expect(content[1]).toEqual({ type: "text", text: "Middle" });
+ expect(content[2]).toEqual(delta);
+ });
+
+ test("does not affect tools with different invocationIds", () => {
+ const content: AiAssistantContentPart[] = [
+ {
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-1",
+ name: "tool1",
+ partialArgsText: "",
+ partialArgs: { dummy: "not used in this test" },
+ },
+ {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-2",
+ name: "tool2",
+ args: { x: 1 },
+ },
+ ];
+
+ const delta: AiAssistantDeltaUpdate = {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-3",
+ name: "tool3",
+ args: { y: 2 },
+ };
+
+ patchContentWithDelta(content, delta);
+
+ expect(content).toHaveLength(3);
+ expect(content[0]).toMatchObject({ invocationId: "inv-1" });
+ expect(content[1]).toMatchObject({ invocationId: "inv-2" });
+ expect(content[2]).toMatchObject({ invocationId: "inv-3" });
+ });
+ });
+
+ describe("integration scenarios", () => {
+ test("mutates the original content array in-place", () => {
+ const content: AiAssistantContentPart[] = [];
+ const originalContent = content;
+
+ patchContentWithDelta(content, {
+ type: "text-delta",
+ textDelta: "Hello",
+ });
+
+ expect(content).toBe(originalContent);
+ expect(content).toEqual([{ type: "text", text: "Hello" }]);
+ });
+
+ test("handles mixed content flow: reasoning -> text -> tool", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ // Start with reasoning
+ patchContentWithDelta(content, {
+ type: "reasoning-delta",
+ textDelta: "Let me analyze",
+ });
+
+ // Continue reasoning
+ patchContentWithDelta(content, {
+ type: "reasoning-delta",
+ textDelta: " this problem",
+ });
+
+ // Switch to text
+ patchContentWithDelta(content, {
+ type: "text-delta",
+ textDelta: "I'll search for",
+ });
+
+ // Continue text
+ patchContentWithDelta(content, {
+ type: "text-delta",
+ textDelta: " information",
+ });
+
+ // Add tool
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-1",
+ name: "search",
+ });
+
+ expect(content).toEqual([
+ { type: "reasoning", text: "Let me analyze this problem" },
+ { type: "text", text: "I'll search for information" },
+ expect.objectContaining({
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-1",
+ name: "search",
+ }),
+ ]);
+ });
+
+ test("complete tool streaming workflow", () => {
+ const content: AiAssistantContentPart[] = [];
+ const deltas: AiAssistantDeltaUpdate[] = [
+ { type: "tool-stream", invocationId: "inv-123", name: "search" },
+ { type: "tool-delta", delta: '{"query": "' },
+ { type: "tool-delta", delta: "hello" },
+ { type: "tool-delta", delta: ', world"}' },
+ ];
+
+ for (const delta of deltas) {
+ patchContentWithDelta(content, delta);
+ }
+
+ expect(content).toEqual([
+ expect.objectContaining({
+ type: "tool-invocation",
+ stage: "receiving",
+ invocationId: "inv-123",
+ name: "search",
+ partialArgs: { query: "hello, world" },
+ }),
+ ]);
+ });
+
+ test("multiple tools with interleaved text", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ // First tool
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-1",
+ name: "search",
+ });
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '{"q": "test"}',
+ });
+
+ // Text between tools
+ patchContentWithDelta(content, {
+ type: "text-delta",
+ textDelta: "Now calculating...",
+ });
+
+ // Second tool
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-2",
+ name: "calculator",
+ });
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '{"expr": "2+2"}',
+ });
+
+ expect(content).toHaveLength(3);
+ expect(content[0]).toMatchObject({
+ type: "tool-invocation",
+ invocationId: "inv-1",
+ partialArgs: { q: "test" },
+ });
+ expect(content[1]).toEqual({ type: "text", text: "Now calculating..." });
+ expect(content[2]).toMatchObject({
+ type: "tool-invocation",
+ invocationId: "inv-2",
+ partialArgs: { expr: "2+2" },
+ });
+ });
+
+ test("tool lifecycle: receiving -> executing", () => {
+ const content: AiAssistantContentPart[] = [];
+
+ // Start tool stream (receiving)
+ patchContentWithDelta(content, {
+ type: "tool-stream",
+ invocationId: "inv-lifecycle",
+ name: "api",
+ });
+
+ // Build arguments
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '{"endpoint": "/users", ',
+ });
+
+ expect(content).toEqual([
+ expect.objectContaining({
+ stage: "receiving",
+ partialArgs: { endpoint: "/users" },
+ }),
+ ]);
+
+ patchContentWithDelta(content, {
+ type: "tool-delta",
+ delta: '"method":"GET"}',
+ });
+
+ expect(content).toEqual([
+ expect.objectContaining({
+ stage: "receiving",
+ partialArgs: { endpoint: "/users", method: "GET" },
+ }),
+ ]);
+
+ // Transition to executing
+ patchContentWithDelta(content, {
+ type: "tool-invocation",
+ stage: "executing",
+ invocationId: "inv-lifecycle",
+ name: "api",
+ args: { endpoint: "/users", method: "GET" },
+ });
+
+ expect(content).toEqual([
+ expect.objectContaining({
+ stage: "executing",
+ args: { endpoint: "/users", method: "GET" },
+ }),
+ ]);
+ });
+ });
+});
diff --git a/packages/liveblocks-core/src/ai.ts b/packages/liveblocks-core/src/ai.ts
index a19a6fbe81f..28c4a2693a6 100644
--- a/packages/liveblocks-core/src/ai.ts
+++ b/packages/liveblocks-core/src/ai.ts
@@ -28,6 +28,7 @@ import type {
import type {
AbortAiResponse,
AiAssistantDeltaUpdate,
+ AiAssistantMessage,
AiChat,
AiChatMessage,
AiChatsQuery,
@@ -56,7 +57,7 @@ import type {
SetToolResultResponse,
ToolResultResponse,
} from "./types/ai";
-import { appendDelta } from "./types/ai";
+import { patchContentWithDelta } from "./types/ai";
import type { Awaitable } from "./types/Awaitable";
import type {
InferFromSchema,
@@ -120,6 +121,7 @@ export type AiToolInvocationProps<
// Private APIs
[kInternal]: {
execute: AiToolExecuteCallback | undefined;
+ messageStatus: AiAssistantMessage["status"];
};
}
>;
@@ -579,7 +581,7 @@ function createStore_forChatMessages(
const message = lut.get(messageId);
if (message === undefined) return false;
- appendDelta(message.contentSoFar, delta);
+ patchContentWithDelta(message.contentSoFar, delta);
lut.set(messageId, message);
return true;
});
@@ -1361,7 +1363,7 @@ export function makeCreateSocketDelegateForAi(
const url = new URL(baseUrl);
url.protocol = url.protocol === "http:" ? "ws" : "wss";
- url.pathname = "/ai/v4";
+ url.pathname = "/ai/v5";
// TODO: don't allow public key to do this
if (authValue.type === "secret") {
url.searchParams.set("tok", authValue.token.raw);
diff --git a/packages/liveblocks-core/src/index.ts b/packages/liveblocks-core/src/index.ts
index ea977050565..56a3359e2c5 100644
--- a/packages/liveblocks-core/src/index.ts
+++ b/packages/liveblocks-core/src/index.ts
@@ -157,7 +157,7 @@ export { batch, DerivedSignal, MutableSignal, Signal } from "./lib/signals";
export { SortedList } from "./lib/SortedList";
export { stableStringify } from "./lib/stringify";
export type { QueryParams, URLSafeString } from "./lib/url";
-export { generateUrl, sanitizeUrl, url, urljoin } from "./lib/url";
+export { generateUrl, isUrl, sanitizeUrl, url, urljoin } from "./lib/url";
export type {
Brand,
DistributiveOmit,
@@ -168,6 +168,7 @@ export {
b64decode,
compactObject,
entries,
+ findLastIndex,
keys,
mapValues,
memoizeOnSuccess,
diff --git a/packages/liveblocks-core/src/lib/__tests__/parsePartialJsonObject.test.ts b/packages/liveblocks-core/src/lib/__tests__/parsePartialJsonObject.test.ts
new file mode 100644
index 00000000000..edfff1a0f99
--- /dev/null
+++ b/packages/liveblocks-core/src/lib/__tests__/parsePartialJsonObject.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, test } from "vitest";
+
+import { parsePartialJsonObject as p } from "../parsePartialJsonObject";
+
+describe("parsePartialJsonObject", () => {
+ test("basic cases", () => {
+ expect(p("")).toEqual({});
+ expect(p(" ")).toEqual({});
+ expect(p("{")).toEqual({});
+ expect(p('{"key"')).toEqual({});
+ expect(p('{"key')).toEqual({});
+ expect(p('{"key":')).toEqual({});
+ expect(p('{"key":""')).toEqual({ key: "" });
+ expect(p('{"key":"')).toEqual({ key: "" });
+ expect(p('{"key":0')).toEqual({ key: 0 });
+ expect(p('{"key":"hi')).toEqual({ key: "hi" });
+ expect(p('{"key":"value"')).toEqual({ key: "value" });
+ expect(p('{"key":"value"}')).toEqual({ key: "value" });
+ });
+
+ test("arrays", () => {
+ expect(p('{"arr":[')).toEqual({ arr: [] });
+ expect(p('{"arr":[1')).toEqual({ arr: [1] });
+ expect(p('{"arr":[1,')).toEqual({ arr: [1] });
+ expect(p('{"arr":["hi')).toEqual({ arr: ["hi"] });
+ expect(p('{"arr":["hi"')).toEqual({ arr: ["hi"] });
+ expect(p('{"arr":["hi"]')).toEqual({ arr: ["hi"] });
+ expect(p('{"arr":[1,2,3')).toEqual({ arr: [1, 2, 3] });
+ });
+
+ test("nested structures", () => {
+ expect(p('{"arr":[')).toEqual({ arr: [] });
+ expect(p('{"arr":[{')).toEqual({ arr: [{}] });
+ expect(p('{"arr":[{"nested"')).toEqual({ arr: [{}] });
+ expect(p('{"arr":[{"nested":')).toEqual({ arr: [{}] });
+ expect(p('{"arr":[{"nested":42')).toEqual({ arr: [{ nested: 42 }] });
+ });
+
+ test("mixed nesting", () => {
+ expect(p('{"a":[1,{"b":')).toEqual({ a: [1, {}] });
+ expect(p('{"a":[1,{"b":[')).toEqual({ a: [1, { b: [] }] });
+ expect(p('{"a":{"b":["c"')).toEqual({ a: { b: ["c"] } });
+ });
+
+ test("strings with special characters", () => {
+ expect(p('{"key":"val\\n')).toEqual({ key: "val\n" });
+ expect(p('{"key":"val\\"')).toEqual({ key: 'val"' });
+ expect(p('{"key":"val with spaces')).toEqual({ key: "val with spaces" });
+ expect(p('{"unicode":"café')).toEqual({ unicode: "café" });
+ });
+
+ test("numbers", () => {
+ expect(p('{"num":123')).toEqual({ num: 123 });
+ expect(p('{"num":123.')).toEqual({ num: 123 });
+ expect(p('{"num":-42')).toEqual({ num: -42 });
+ expect(p('{"pi":3.')).toEqual({ pi: 3 });
+ expect(p('{"pi":3.14')).toEqual({ pi: 3.14 });
+ });
+
+ test("complex real-world examples", () => {
+ expect(p('{"users":[{"id":1,"name":"John')).toEqual({
+ users: [{ id: 1, name: "John" }],
+ });
+ expect(p('{"config":{"debug":true,"timeout":')).toEqual({
+ config: { debug: true },
+ });
+ expect(p('{"data":[{"items":[{"type":"text","content":')).toEqual({
+ data: [{ items: [{ type: "text" }] }],
+ });
+ });
+
+ test("edge cases for coverage", () => {
+ // Test properly closed nested objects to trigger stack.pop for '}'
+ expect(p('{"a":{"b":{}}')).toEqual({ a: { b: {} } });
+ expect(p('{"nested":{"deep":{"obj":{}}}}')).toEqual({
+ nested: { deep: { obj: {} } },
+ });
+
+ // Test escaped characters at end of incomplete strings
+ expect(p('{"key":"val\\"')).toEqual({ key: 'val"' });
+ expect(p('{"key":"val\\\\"')).toEqual({ key: "val\\" });
+ expect(p('{"esc":"test\\\\')).toEqual({ esc: "test\\" });
+
+ // Test combination of escapes and incomplete structure
+ expect(p('{"a":"b\\\\","c":')).toEqual({ a: "b\\" });
+
+ // Test escape handling in string backtracking
+ expect(p('{"a":"test\\\\value:')).toEqual({ a: "test\\value:" });
+ expect(p('{"a":"test\\\\value":')).toEqual({});
+
+ // Test handling of input ending with colon after numeric value
+ expect(p('{"a":1:')).toEqual({ a: 1 });
+
+ // Cover escaped characters inside strings
+ expect(p('{"msg":"Say \\"hello\\"')).toEqual({ msg: 'Say "hello"' });
+ expect(p('{"path":"C:\\\\Users')).toEqual({ path: "C:\\Users" });
+
+ // Cover array closing bracket stack operations
+ expect(p('{"arr":[1,2]}')).toEqual({ arr: [1, 2] });
+ expect(p('{"nested":[[[]]]}')).toEqual({ nested: [[[]]] });
+ expect(p('{"mixed":[{"a":1}]}')).toEqual({ mixed: [{ a: 1 }] });
+
+ // Cover error recovery logic for malformed JSON
+
+ // Test colon removal for incomplete key-value pairs
+ expect(p('{"a":1,"incomplete":')).toEqual({ a: 1 });
+
+ // Test handling of unmatched quotes
+ expect(p('{"a":1,"bad":"incomplete')).toEqual({ a: 1, bad: "incomplete" });
+
+ // Test comma removal for trailing commas
+ expect(p('{"a":1,"b":2,')).toEqual({ a: 1, b: 2 });
+
+ // Test fallback to {} for completely malformed input
+ expect(p("not json at all")).toEqual({});
+ expect(p('{"completely broken syntax",}')).toEqual({});
+ expect(p('{"key":"completely broken syntax",}')).toEqual({});
+ expect(p('{"key","completely broken syntax",}')).toEqual({});
+ });
+
+ test("handles emojis and newlines", () => {
+ // Complete emojis in strings
+ expect(p('{"message":"Hello 👋 world')).toEqual({
+ message: "Hello 👋 world",
+ });
+ expect(p('{"reaction":"🎉","status":"complete')).toEqual({
+ reaction: "🎉",
+ status: "complete",
+ });
+
+ // Partial/incomplete emojis (multi-byte sequences)
+ expect(p('{"partial":"test 👋')).toEqual({ partial: "test 👋" });
+ expect(p('{"emoji":"🎉🎊')).toEqual({ emoji: "🎉🎊" });
+
+ // Newlines and whitespace in strings
+ expect(p('{"text":"line1\\nline2')).toEqual({ text: "line1\nline2" });
+ expect(p('{"multiline":"first line\\nsecond')).toEqual({
+ multiline: "first line\nsecond",
+ });
+
+ // Mixed emojis, newlines, and regular content
+ expect(p('{"log":"User clicked 👆\\nAction: success ✅')).toEqual({
+ log: "User clicked 👆\nAction: success ✅",
+ });
+
+ // Emojis with arrays and objects
+ expect(p('{"reactions":["👍","👎","❤️')).toEqual({
+ reactions: ["👍", "👎", "❤️"],
+ });
+ expect(p('{"user":{"name":"Alice","status":"🟢 online')).toEqual({
+ user: { name: "Alice", status: "🟢 online" },
+ });
+ });
+});
diff --git a/packages/liveblocks-core/src/lib/__tests__/url.test.ts b/packages/liveblocks-core/src/lib/__tests__/url.test.ts
index 76e0253b5ac..5298aeb79d2 100644
--- a/packages/liveblocks-core/src/lib/__tests__/url.test.ts
+++ b/packages/liveblocks-core/src/lib/__tests__/url.test.ts
@@ -27,6 +27,11 @@ describe("sanitizeUrl", () => {
);
});
+ test("should support hash-only URLs", () => {
+ expect(sanitizeUrl("#")).toBe("#");
+ expect(sanitizeUrl("#hash")).toBe("#hash");
+ });
+
test("should support ports, query params, and a hash", () => {
expect(sanitizeUrl("https://localhost:3000/docs?query=value#hash")).toBe(
"https://localhost:3000/docs?query=value#hash"
@@ -71,7 +76,6 @@ describe("sanitizeUrl", () => {
expect(sanitizeUrl("file:///etc/passwd")).toBe(null);
expect(sanitizeUrl("//liveblocks.io")).toBe(null);
expect(sanitizeUrl("")).toBe(null);
- expect(sanitizeUrl("#")).toBe(null);
});
});
diff --git a/packages/liveblocks-core/src/lib/parsePartialJsonObject.ts b/packages/liveblocks-core/src/lib/parsePartialJsonObject.ts
new file mode 100644
index 00000000000..ee9d3eaa13c
--- /dev/null
+++ b/packages/liveblocks-core/src/lib/parsePartialJsonObject.ts
@@ -0,0 +1,142 @@
+import type { JsonObject } from "./Json";
+import { tryParseJson } from "./utils";
+
+export function parsePartialJsonObject(partial: string): JsonObject {
+ partial = partial.trimStart();
+
+ if (partial.charAt(0) !== "{") {
+ // Not an object, don't even try to parse it
+ return {};
+ }
+
+ // If it's already valid JSON, return as-is
+ if (partial.trimEnd().endsWith("}")) {
+ const quickCheck = tryParseJson(partial);
+ if (quickCheck) {
+ // Due to the '{' check above, we can safely assume it's an object
+ return quickCheck as JsonObject;
+ }
+ }
+
+ let result = partial;
+
+ // 1. Fix unclosed strings by appending a '"' if needed
+ let quoteCount = 0;
+ let escaped = false;
+ for (let i = 0; i < result.length; i++) {
+ const char = result[i];
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === '"') {
+ quoteCount++;
+ }
+ }
+
+ if (quoteCount % 2 === 1) {
+ result += '"';
+ }
+
+ // 2. Trim whitespace at the end
+ result = result.trimEnd();
+
+ // 3. If the last char is a ',', strip it
+ if (result.endsWith(",")) {
+ result = result.slice(0, -1);
+ }
+
+ // 4. If the last char is a '.', strip it
+ if (result.endsWith(".")) {
+ result = result.slice(0, -1);
+ }
+
+ // 5. Go from left to right and collect '[' and '{' in a stack
+ const stack: string[] = [];
+ let inString = false;
+ escaped = false;
+
+ for (let i = 0; i < result.length; i++) {
+ const char = result[i];
+
+ if (inString) {
+ if (escaped) {
+ escaped = false;
+ } else if (char === "\\") {
+ escaped = true;
+ } else if (char === '"') {
+ inString = false;
+ }
+ } else {
+ if (char === '"') {
+ inString = true;
+ } else if (char === "{") {
+ stack.push("}");
+ } else if (char === "[") {
+ stack.push("]");
+ } else if (
+ char === "}" &&
+ stack.length > 0 &&
+ stack[stack.length - 1] === "}"
+ ) {
+ stack.pop();
+ } else if (
+ char === "]" &&
+ stack.length > 0 &&
+ stack[stack.length - 1] === "]"
+ ) {
+ stack.pop();
+ }
+ }
+ }
+
+ // 6. Build the suffix string from the stack
+ const suffix = stack.reverse().join("");
+
+ // 7. Attempt to "just" add the missing ] and }'s.
+ {
+ const attempt = tryParseJson(result + suffix);
+ if (attempt) {
+ // If it parses, return the result
+ return attempt as JsonObject;
+ }
+ }
+
+ // 8. If there is a parse failure, it's likely because we're missing a "value" for a key in an object.
+
+ // a. if the last char is a ":" remove it
+ if (result.endsWith(":")) {
+ result = result.slice(0, -1);
+ }
+
+ // b. if the last char now is a '"', remove that last string
+ if (result.endsWith('"')) {
+ let pos = result.length - 2; // Start before the closing quote
+ escaped = false;
+
+ // Scan back to find the opening quote, accounting for escaping
+ while (pos >= 0) {
+ if (escaped) {
+ escaped = false;
+ } else if (result[pos] === "\\") {
+ escaped = true;
+ } else if (result[pos] === '"') {
+ // Found the opening quote
+ result = result.slice(0, pos);
+ break;
+ }
+ pos--;
+ }
+ }
+
+ // c. if the last char now is a ',', strip it
+ if (result.endsWith(",")) {
+ result = result.slice(0, -1);
+ }
+
+ // Re-add the missing brackets/braces
+ result += suffix;
+
+ // 10. Run JSON.parse on the result again. it should now work!
+ return (tryParseJson(result) as JsonObject | undefined) ?? {}; // Still invalid JSON
+}
diff --git a/packages/liveblocks-core/src/lib/url.ts b/packages/liveblocks-core/src/lib/url.ts
index 1000e560e43..6b0afa91698 100644
--- a/packages/liveblocks-core/src/lib/url.ts
+++ b/packages/liveblocks-core/src/lib/url.ts
@@ -83,6 +83,7 @@ export function url(
* - Absolute URLs with an http or https protocol (e.g. https://liveblocks.io)
* - Absolute URLs with a `www` prefix (e.g. www.liveblocks.io)
* - Relative URLs (e.g. /path/to/page)
+ * - Hash-only URLs (e.g. #hash)
*
* The presence/absence of trailing slashes is preserved.
* Rejected URLs are returned as `null`.
@@ -93,6 +94,11 @@ export function sanitizeUrl(url: string): string | null {
url = "https://" + url;
}
+ // If the URL is an empty hash, return it as is.
+ if (url === "#") {
+ return url;
+ }
+
try {
const isAbsolute = ABSOLUTE_URL_REGEX.test(url);
const urlObject = new URL(
@@ -160,3 +166,12 @@ export function generateUrl(
? urlObject.href
: urlObject.href.replace(PLACEHOLDER_BASE_URL, "");
}
+
+export function isUrl(string: string): boolean {
+ try {
+ new URL(string);
+ return true;
+ } catch (_) {
+ return false;
+ }
+}
diff --git a/packages/liveblocks-core/src/lib/utils.ts b/packages/liveblocks-core/src/lib/utils.ts
index e8558953fdf..da2a75a8419 100644
--- a/packages/liveblocks-core/src/lib/utils.ts
+++ b/packages/liveblocks-core/src/lib/utils.ts
@@ -246,3 +246,18 @@ export function memoizeOnSuccess
* {items.map((item, index) => (
*
{items.map((item, index) => (
-
);
@@ -402,11 +436,15 @@ const defaultComponents: MarkdownComponents = {
};
export const Markdown = forwardRef;
}
+ case "checkbox": {
+ return (
+ <>
+ {" "}
+ >
+ );
+ }
+
case "table": {
const Table = components?.Table ?? defaultComponents.Table;
const headings: MarkdownComponentsTableCell[] = token.header.map(
@@ -646,8 +719,10 @@ export function MarkdownToken({
return
- {items.map((item, index) => (
-
- );
- },
- },
- assertions: (element) => {
- const rootList = element.querySelector(":scope > ul");
- expect(rootList).toHaveAttribute("data-list");
-
- const rootListItems = element.querySelectorAll(":scope > ul > li");
- expect(rootListItems).toHaveLength(3);
-
- expect(rootListItems[0]).toHaveTextContent("A list item");
-
- const firstNestedList = rootListItems[0]?.querySelector("ol");
- expect(firstNestedList).toHaveAttribute("data-list");
-
- const firstNestedListItems = firstNestedList?.querySelectorAll("li");
- expect(firstNestedListItems).toHaveLength(3);
-
- expect(firstNestedListItems?.[0]).toHaveTextContent("A list item");
- expect(firstNestedListItems?.[1]).toHaveTextContent(
- "Another list item"
- );
- expect(firstNestedListItems?.[2]).toHaveTextContent(
- "Yet another list item"
- );
-
- const secondRootListItem = rootListItems[1];
- expect(secondRootListItem).toHaveTextContent("Another list item");
-
- const secondNestedList = secondRootListItem?.querySelector("ul");
- expect(secondNestedList).toHaveAttribute("data-list");
-
- const secondNestedListItems = secondNestedList?.querySelectorAll("li");
- expect(secondNestedListItems).toHaveLength(3);
-
- expect(secondNestedListItems?.[0]).toHaveTextContent(
- "A task list item"
- );
- expect(
- secondNestedListItems?.[0]?.querySelector("input[type='checkbox']")
- ).not.toBeChecked();
- expect(secondNestedListItems?.[1]).toHaveTextContent(
- "A completed task list item"
- );
- expect(
- secondNestedListItems?.[1]?.querySelector("input[type='checkbox']")
- ).toBeChecked();
- expect(secondNestedListItems?.[2]).toHaveTextContent(
- "Another completed task list item"
- );
- expect(
- secondNestedListItems?.[2]?.querySelector("input[type='checkbox']")
- ).toBeChecked();
-
- const thirdRootListItem = rootListItems[2];
- expect(thirdRootListItem).toHaveTextContent("Yet another list item");
-
- const thirdNestedLists = thirdRootListItem?.querySelectorAll("ol, ul");
- expect(thirdNestedLists).toHaveLength(3);
-
- expect(thirdNestedLists?.[0]).toHaveAttribute("data-list", "ordered");
- expect(thirdNestedLists?.[0]?.querySelector("li")).toHaveTextContent(
- "A list item"
- );
-
- expect(thirdNestedLists?.[1]).toHaveAttribute("data-list", "unordered");
- expect(thirdNestedLists?.[1]?.querySelector("li")).toHaveTextContent(
- "Another list item"
- );
-
- expect(thirdNestedLists?.[2]).toHaveAttribute("data-list", "unordered");
- expect(thirdNestedLists?.[2]?.querySelector("li")).toHaveTextContent(
- "Yet another list item"
- );
- expect(
- thirdNestedLists?.[2]?.querySelector("input[type='checkbox']")
- ).toBeChecked();
- },
- },
- {
- description: "blockquotes",
- content: dedent`
- > A blockquote.
-
- > Another one which spans
- >
- > multiple paragraphs.
-
- > Yet another which
- >
- > > is nested.
- `,
- components: {
- Blockquote: ({ children }) => (
- {children}
- ),
- },
- assertions: (element) => {
- const blockquotes = element.querySelectorAll("blockquote");
- expect(blockquotes).toHaveLength(4);
-
- expect(blockquotes[0]).toHaveTextContent("A blockquote.");
- expect(blockquotes[0]).toHaveAttribute("data-blockquote");
- expect(blockquotes[1]).toHaveTextContent(
- "Another one which spansmultiple paragraphs."
- );
- expect(blockquotes[1]).toHaveAttribute("data-blockquote");
- expect(blockquotes[2]).toHaveTextContent("Yet another whichis nested.");
- expect(blockquotes[2]).toHaveAttribute("data-blockquote");
- expect(blockquotes[3]).toHaveTextContent("is nested.");
- expect(blockquotes[3]).toHaveAttribute("data-blockquote");
- },
- },
- {
- description: "code blocks",
- content: dedent`
- \`\`\`
- p {
- color: #000;
+ assert(
+ `
+ No heading
+ ==
+ `,
+ (root) => {
+ expect(root.querySelector("h1")).not.toBeInTheDocument();
+ expect(root.textContent).toBe("No heading");
}
- \`\`\`
+ );
- \`\`\`javascript
- const a = 2;
- \`\`\`
+ assert(
+ `
+ Heading 1
+ ===
`,
- components: {
- CodeBlock: ({ code, language }) => (
-
-
- ),
- },
- assertions: (element) => {
- const codeBlocks = element.querySelectorAll("pre");
- expect(codeBlocks).toHaveLength(2);
-
- expect(codeBlocks[0]).toHaveTextContent("p { color: #000; }");
- expect(codeBlocks[0]).toHaveAttribute("data-code-block");
- expect(codeBlocks[1]).toHaveTextContent("const a = 2;");
- expect(codeBlocks[1]).toHaveAttribute("data-code-block");
- expect(codeBlocks[1]?.querySelector("code")).toHaveAttribute(
- "data-language",
- "javascript"
- );
- },
- },
- {
- description: "images",
- content: dedent`
- 
- `,
- components: {
- Image: ({ src, alt }) => {code}
- ,
- },
- assertions: (element) => {
- const image = element.querySelector("img");
-
- expect(image).toBeInTheDocument();
- expect(image).toHaveAttribute(
- "src",
- "https://www.liveblocks.io/favicon.png"
- );
- expect(image).toHaveAttribute("alt", "An image");
- },
- },
- {
- description: "tables",
- content: dedent`
- | Feature | Example | Notes |
- | ------------- | ------------------------------------ | ------------------------- |
- | Link | [Liveblocks](https://liveblocks.io/) | External link |
- | Inline code | \`const a = 2;\` | Code inside table |
- | Bold text | **Important** | Styling test |
- | Italic text | _Emphasis_ | Test italic inside tables |
- | Strikethrough | ~~Deprecated~~ | Show removal |
- `,
- components: {
- Table: ({ headings, rows }) => (
-
-
-
- ),
- },
- assertions: (element) => {
- const table = element.querySelector("table");
-
- expect(table).toBeInTheDocument();
- expect(table).toHaveAttribute("data-table");
-
- const headings = table?.querySelectorAll("th");
- expect(headings).toHaveLength(3);
-
- expect(headings?.[0]).toHaveTextContent("Feature");
- expect(headings?.[1]).toHaveTextContent("Example");
- expect(headings?.[2]).toHaveTextContent("Notes");
-
- const rows = table?.querySelectorAll("tbody tr");
- expect(rows).toHaveLength(5);
-
- const firstRowCells = rows?.[0]?.querySelectorAll("td");
- expect(firstRowCells).toHaveLength(3);
-
- expect(firstRowCells?.[0]).toHaveTextContent("Link");
- expect(firstRowCells?.[1]).toHaveTextContent("Liveblocks");
- expect(firstRowCells?.[1]?.querySelector("a")).toHaveAttribute(
- "href",
- "https://liveblocks.io/"
- );
- expect(firstRowCells?.[2]).toHaveTextContent("External link");
-
- const secondRowCells = rows?.[1]?.querySelectorAll("td");
- expect(secondRowCells).toHaveLength(3);
-
- expect(secondRowCells?.[0]).toHaveTextContent("Inline code");
- expect(secondRowCells?.[1]).toHaveTextContent("const a = 2;");
- expect(secondRowCells?.[1]?.querySelector("code")).toHaveTextContent(
- "const a = 2;"
- );
- expect(secondRowCells?.[2]).toHaveTextContent("Code inside table");
-
- const thirdRowCells = rows?.[2]?.querySelectorAll("td");
- expect(thirdRowCells).toHaveLength(3);
-
- expect(thirdRowCells?.[0]).toHaveTextContent("Bold text");
- expect(thirdRowCells?.[1]).toHaveTextContent("Important");
- expect(thirdRowCells?.[1]?.querySelector("strong")).toHaveTextContent(
- "Important"
- );
- expect(thirdRowCells?.[2]).toHaveTextContent("Styling test");
-
- const fourthRowCells = rows?.[3]?.querySelectorAll("td");
- expect(fourthRowCells).toHaveLength(3);
-
- expect(fourthRowCells?.[0]).toHaveTextContent("Italic text");
- expect(fourthRowCells?.[1]).toHaveTextContent("Emphasis");
- expect(fourthRowCells?.[1]?.querySelector("em")).toHaveTextContent(
- "Emphasis"
- );
- expect(fourthRowCells?.[2]).toHaveTextContent(
- "Test italic inside tables"
- );
-
- const fifthRowCells = rows?.[4]?.querySelectorAll("td");
- expect(fifthRowCells).toHaveLength(3);
-
- expect(fifthRowCells?.[0]).toHaveTextContent("Strikethrough");
- expect(fifthRowCells?.[1]).toHaveTextContent("Deprecated");
- expect(fifthRowCells?.[1]?.querySelector("del")).toHaveTextContent(
- "Deprecated"
- );
- expect(fifthRowCells?.[2]).toHaveTextContent("Show removal");
- },
- },
- {
- description: "separators",
- content: dedent`
- ***
+ (root) => {
+ expect(root.querySelector("h1")?.textContent).toBe("Heading 1");
+ }
+ );
- ---
+ assert(
+ `
+ No heading
+ -
+ `,
+ (root) => {
+ expect(root.querySelector("h2")).not.toBeInTheDocument();
+ expect(root.textContent).toBe("No heading");
+ }
+ );
- _____
- `,
- components: {
- Separator: () =>
- {headings.map((heading, index) => (
-
-
-
- {rows.map((row, index) => (
- {heading.children}
- ))}
-
- {row.map((cell, index) => (
-
- ))}
-
- {cell.children}
- ))}
-
,
- },
- assertions: (element) => {
- const separators = element.querySelectorAll("hr");
- expect(separators).toHaveLength(3);
-
- separators.forEach((separator) => {
- expect(separator).toHaveAttribute("data-separator");
- });
- },
- },
- ] satisfies {
- description: string;
- content: string;
- components: Partial
+ {items.map((item, index) => (
+
+ );
+ },
+ },
+ (root) => {
+ const rootList = root.querySelector(":scope > ul");
+ expect(rootList).toHaveAttribute("data-list");
+
+ const rootListItems = root.querySelectorAll(":scope > ul > li");
+ expect(rootListItems).toHaveLength(3);
+
+ expect(rootListItems[0]?.textContent).toContain("A list item");
+
+ const firstNestedList = rootListItems[0]?.querySelector("ol");
+ expect(firstNestedList).toHaveAttribute("data-list");
+
+ const firstNestedListItems = firstNestedList?.querySelectorAll("li");
+ expect(firstNestedListItems).toHaveLength(3);
+
+ expect(firstNestedListItems?.[0]?.textContent).toBe("A list item");
+ expect(firstNestedListItems?.[1]?.textContent).toBe(
+ "Another list item"
+ );
+ expect(firstNestedListItems?.[2]?.textContent).toBe(
+ "Yet another list item"
+ );
+
+ const secondRootListItem = rootListItems[1];
+ expect(secondRootListItem?.textContent).toContain(
+ "Another list item"
+ );
+
+ const secondNestedList = secondRootListItem?.querySelector("ul");
+ expect(secondNestedList).toHaveAttribute("data-list");
+
+ const secondNestedListItems =
+ secondNestedList?.querySelectorAll("li");
+ expect(secondNestedListItems).toHaveLength(3);
+
+ expect(secondNestedListItems?.[0]?.textContent).toBe(
+ " A task list item"
+ );
+ expect(
+ secondNestedListItems?.[0]?.querySelector("input[type='checkbox']")
+ ).not.toBeChecked();
+ expect(secondNestedListItems?.[1]?.textContent).toBe(
+ " A completed task list item"
+ );
+ expect(
+ secondNestedListItems?.[1]?.querySelector("input[type='checkbox']")
+ ).toBeChecked();
+ expect(secondNestedListItems?.[2]?.textContent).toBe(
+ " Another completed task list item"
+ );
+ expect(
+ secondNestedListItems?.[2]?.querySelector("input[type='checkbox']")
+ ).toBeChecked();
+
+ const thirdRootListItem = rootListItems[2];
+ expect(thirdRootListItem?.textContent).toContain(
+ "Yet another list item"
+ );
+
+ const thirdNestedLists =
+ thirdRootListItem?.querySelectorAll("ol, ul");
+ expect(thirdNestedLists).toHaveLength(3);
+
+ expect(thirdNestedLists?.[0]).toHaveAttribute("data-list", "ordered");
+ expect(thirdNestedLists?.[0]?.querySelector("li")?.textContent).toBe(
+ "A list item"
+ );
+
+ expect(thirdNestedLists?.[1]).toHaveAttribute(
+ "data-list",
+ "unordered"
+ );
+ expect(thirdNestedLists?.[1]?.querySelector("li")?.textContent).toBe(
+ "Another list item"
+ );
+
+ expect(thirdNestedLists?.[2]).toHaveAttribute(
+ "data-list",
+ "unordered"
+ );
+ expect(thirdNestedLists?.[2]?.querySelector("li")?.textContent).toBe(
+ " Yet another list item"
+ );
+ expect(
+ thirdNestedLists?.[2]?.querySelector("input[type='checkbox']")
+ ).toBeChecked();
+ }
+ );
+ });
+
+ test("blockquotes", () => {
+ assert(
+ `
+ > A blockquote.
+
+ > Another one which spans
+ >
+ > multiple paragraphs.
+
+ > Yet another which
+ >
+ > > is nested.
+ `,
+ {
+ Blockquote: ({ children }) => (
+ {children}
+ ),
+ },
+ (root) => {
+ const blockquotes = root.querySelectorAll("blockquote");
+ expect(blockquotes).toHaveLength(4);
+
+ expect(blockquotes[0]?.textContent).toBe("A blockquote.");
+ expect(blockquotes[0]).toHaveAttribute("data-blockquote");
+ expect(blockquotes[1]?.innerHTML).toEqual(
+ "
+
+ ),
+ },
+ (root) => {
+ const codeBlocks = root.querySelectorAll("pre");
+ expect(codeBlocks).toHaveLength(2);
+
+ expect(codeBlocks[0]?.textContent).toBe("p {\n color: #000;\n}");
+ expect(codeBlocks[0]).toHaveAttribute("data-code-block");
+ expect(codeBlocks[1]?.textContent).toBe("const a = 2;");
+ expect(codeBlocks[1]).toHaveAttribute("data-code-block");
+ expect(codeBlocks[1]?.querySelector("code")).toHaveAttribute(
+ "data-language",
+ "javascript"
+ );
+ }
+ );
+ });
+
+ test("images", () => {
+ assert(
+ `
+ 
+ `,
+ {
+ Image: ({ src, alt }) => {code}
+ ,
+ },
+ (root) => {
+ const image = root.querySelector("img");
+
+ expect(image).toHaveAttribute(
+ "src",
+ "https://www.liveblocks.io/favicon.svg"
+ );
+ expect(image).toHaveAttribute("alt", "An image");
+ }
+ );
+ });
+
+ test("tables", () => {
+ assert(
+ `
+ | Feature | Example | Notes |
+ | ------------- | ------------------------------------ | ------------------------- |
+ | Link | [Liveblocks](https://liveblocks.io/) | External link |
+ | Inline code | \`const a = 2;\` | Code inside table |
+ | Bold text | **Important** | Styling test |
+ | Italic text | _Emphasis_ | Test italic inside tables |
+ | Strikethrough | ~~Deprecated~~ | Show removal |
+ `,
+ {
+ Table: ({ headings, rows }) => (
+
+
+
+ ),
+ },
+ (root) => {
+ const table = root.querySelector("table");
+
+ expect(table).toHaveAttribute("data-table");
+
+ const headings = table?.querySelectorAll("th");
+ expect(headings).toHaveLength(3);
+
+ expect(headings?.[0]?.textContent).toBe("Feature");
+ expect(headings?.[1]?.textContent).toBe("Example");
+ expect(headings?.[2]?.textContent).toBe("Notes");
+
+ const rows = table?.querySelectorAll("tbody tr");
+ expect(rows).toHaveLength(5);
+
+ const firstRowCells = rows?.[0]?.querySelectorAll("td");
+ expect(firstRowCells).toHaveLength(3);
+
+ expect(firstRowCells?.[0]?.textContent).toBe("Link");
+ expect(firstRowCells?.[1]?.textContent).toBe("Liveblocks");
+ expect(firstRowCells?.[1]?.querySelector("a")).toHaveAttribute(
+ "href",
+ "https://liveblocks.io/"
+ );
+ expect(firstRowCells?.[2]?.textContent).toBe("External link");
+
+ const secondRowCells = rows?.[1]?.querySelectorAll("td");
+ expect(secondRowCells).toHaveLength(3);
+
+ expect(secondRowCells?.[0]?.textContent).toBe("Inline code");
+ expect(secondRowCells?.[1]?.textContent).toBe("const a = 2;");
+ expect(secondRowCells?.[1]?.querySelector("code")?.textContent).toBe(
+ "const a = 2;"
+ );
+ expect(secondRowCells?.[2]?.textContent).toBe("Code inside table");
+
+ const thirdRowCells = rows?.[2]?.querySelectorAll("td");
+ expect(thirdRowCells).toHaveLength(3);
+
+ expect(thirdRowCells?.[0]?.textContent).toBe("Bold text");
+ expect(thirdRowCells?.[1]?.textContent).toBe("Important");
+ expect(thirdRowCells?.[1]?.querySelector("strong")?.textContent).toBe(
+ "Important"
+ );
+ expect(thirdRowCells?.[2]?.textContent).toBe("Styling test");
+
+ const fourthRowCells = rows?.[3]?.querySelectorAll("td");
+ expect(fourthRowCells).toHaveLength(3);
+
+ expect(fourthRowCells?.[0]?.textContent).toBe("Italic text");
+ expect(fourthRowCells?.[1]?.textContent).toBe("Emphasis");
+ expect(fourthRowCells?.[1]?.querySelector("em")?.textContent).toBe(
+ "Emphasis"
+ );
+ expect(fourthRowCells?.[2]?.textContent).toBe(
+ "Test italic inside tables"
+ );
+
+ const fifthRowCells = rows?.[4]?.querySelectorAll("td");
+ expect(fifthRowCells).toHaveLength(3);
+
+ expect(fifthRowCells?.[0]?.textContent).toBe("Strikethrough");
+ expect(fifthRowCells?.[1]?.textContent).toBe("Deprecated");
+ expect(fifthRowCells?.[1]?.querySelector("del")?.textContent).toBe(
+ "Deprecated"
+ );
+ expect(fifthRowCells?.[2]?.textContent).toBe("Show removal");
+ }
+ );
+ });
+
+ test("separators", () => {
+ assert(
+ `
+ ***
+
+ ----
+
+ _____
+ `,
+ {
+ Separator: () =>
+ {headings.map((heading, index) => (
+
+
+
+ {rows.map((row, index) => (
+ {heading.children}
+ ))}
+
+ {row.map((cell, index) => (
+
+ ))}
+
+ {cell.children}
+ ))}
+
,
+ },
+ (root) => {
+ const separators = root.querySelectorAll("hr");
+ expect(separators).toHaveLength(3);
+
+ separators.forEach((separator) => {
+ expect(separator).toHaveAttribute("data-separator");
+ });
+ }
+ );
+ });
+ });
});
diff --git a/packages/liveblocks-react-ui/src/styles/dark/index.css b/packages/liveblocks-react-ui/src/styles/dark/index.css
index 9027fb5cb15..b0f0f030e36 100644
--- a/packages/liveblocks-react-ui/src/styles/dark/index.css
+++ b/packages/liveblocks-react-ui/src/styles/dark/index.css
@@ -32,4 +32,30 @@
--lb-background: #2a2a2a;
--lb-foreground-contrast: 10%;
}
+
+ .lb-ai-chat-pending {
+ animation-name: lb-animation-shimmer-text-invert;
+ }
+}
+
+@keyframes lb-animation-shimmer-text-invert {
+ from,
+ to {
+ mask-image: linear-gradient(
+ 90deg,
+ rgb(0 0 0 / 50%) 36%,
+ rgb(0 0 0 / 95%) 48%,
+ rgb(0 0 0 / 95%) 52%,
+ rgb(0 0 0 / 50%) 64%
+ );
+ mask-size: 300% 100%;
+ }
+
+ from {
+ mask-position: 100% 0;
+ }
+
+ to {
+ mask-position: 0% 0;
+ }
}
diff --git a/packages/liveblocks-react-ui/src/styles/index.css b/packages/liveblocks-react-ui/src/styles/index.css
index 777f9749214..4ccc461a60a 100644
--- a/packages/liveblocks-react-ui/src/styles/index.css
+++ b/packages/liveblocks-react-ui/src/styles/index.css
@@ -41,6 +41,12 @@
var(--lb-foreground-contrast),
50
);
+ --lb-background-foreground-subtle: color-mix-scale(
+ var(--lb-background),
+ var(--lb-foreground),
+ var(--lb-foreground-contrast),
+ 100
+ );
--lb-background-accent-faint: color-mix-scale(
var(--lb-background),
var(--lb-accent),
@@ -496,7 +502,7 @@
block-size: calc(0.8 * var(--lb-icon-size));
margin-inline-start: -1px;
margin-block-start: 1px;
- color: var(--lb-foreground-moderate);
+ opacity: 0.65;
}
@media (prefers-reduced-motion: no-preference) {
@@ -1117,7 +1123,7 @@
*************************************/
:is(.lb-avatar, .lb-name):where([data-loading]) {
- animation: lb-animation-shimmer 8s linear infinite;
+ animation: lb-animation-shimmer-skeleton 8s linear infinite;
}
/*************************************
@@ -1183,6 +1189,10 @@
background: var(--lb-accent-subtle);
}
+.lb-mention-symbol {
+ display: contents;
+}
+
/*************************************
* Composer *
*************************************/
@@ -2400,9 +2410,11 @@
*************************************/
.lb-code-block {
+ position: relative;
min-inline-size: 0;
border: 1px solid var(--lb-foreground-subtle);
border-radius: var(--lb-radius);
+ isolation: isolate;
:where(.lb-code-block-header) {
display: flex;
@@ -2535,17 +2547,51 @@
.lb-ai-chat-user-message {
:where(.lb-ai-chat-message-content) {
- min-block-size: calc(1lh + var(--lb-spacing));
- padding: calc(0.5 * var(--lb-spacing)) var(--lb-spacing);
+ --lb-dynamic-background: var(--lb-background-foreground-faint);
+ --lb-ai-chat-message-content-horizontal-padding: calc(
+ 0.9375 * var(--lb-spacing)
+ );
+ --lb-ai-chat-message-content-vertical-padding: calc(
+ 0.5625 * var(--lb-spacing)
+ );
+
+ min-block-size: calc(
+ 1lh + 2 * var(--lb-ai-chat-message-content-vertical-padding)
+ );
+ padding: var(--lb-ai-chat-message-content-vertical-padding)
+ var(--lb-ai-chat-message-content-horizontal-padding);
/**
* Exactly half the height (line-height + padding) of a one line bubble,
* so that it doesn't change when the text wraps and the bubble becomes taller.
*/
- border-radius: calc((1lh + var(--lb-spacing)) / 2);
- background: var(--lb-background-foreground-faint);
+ border-radius: calc(
+ (1lh + 2 * var(--lb-ai-chat-message-content-vertical-padding)) / 2
+ );
+ background: var(--lb-dynamic-background);
color: var(--lb-foreground);
line-height: var(--lb-line-height);
+
+ /**
+ * Rich messages get more padding than plain text ones.
+ */
+ &:where(
+ :has(h1, h2, h3, h4, h5, h6, ul, ol, blockquote, table, .lb-code-block)
+ ) {
+ --lb-ai-chat-message-content-horizontal-padding: calc(
+ 1.375 * var(--lb-spacing)
+ );
+ --lb-ai-chat-message-content-vertical-padding: calc(
+ 1.125 * var(--lb-spacing)
+ );
+ }
+
+ :where(.lb-code-block) {
+ --lb-dynamic-background: var(--lb-background-foreground-subtle);
+
+ border: none;
+ background: var(--lb-dynamic-background);
+ }
}
}
@@ -2762,35 +2808,52 @@
.lb-ai-chat-pending {
user-select: none;
- animation: lb-animation-shimmer-small 5s linear infinite;
+ animation: lb-animation-shimmer-text 2s cubic-bezier(0.1, 0, 0.9, 1) infinite;
}
-.lb-ai-chat-message-thinking,
-.lb-ai-chat-message-reasoning {
+.lb-ai-chat-message-thinking {
inline-size: fit-content;
max-inline-size: 100%;
color: var(--lb-foreground-tertiary);
+ transition-property: color;
- &:where(.lb-ai-chat-pending),
- :where(.lb-ai-chat-pending) {
+ &:where(.lb-ai-chat-pending) {
color: var(--lb-foreground);
}
}
.lb-ai-chat-message-reasoning {
+ position: relative;
margin-block-end: calc(0.75 * var(--lb-spacing));
+ &::after {
+ content: "";
+ position: absolute;
+ inset: calc(-0.375 * var(--lb-spacing));
+ border-radius: calc(0.75 * var(--lb-radius));
+ pointer-events: none;
+ transition-property: box-shadow;
+ }
+
+ &:where(:has(.lb-collapsible-trigger:focus-visible)) {
+ z-index: 1;
+
+ &::after {
+ box-shadow:
+ var(--lb-dynamic-background) 0 0 0 2px,
+ var(--lb-accent) 0 0 0 4px;
+ }
+ }
+
:where(.lb-collapsible-trigger) {
display: flex;
gap: calc(0.25 * var(--lb-spacing));
align-items: center;
+ color: var(--lb-foreground-tertiary);
+ transition-property: color;
- &:where(:focus-visible) {
- color: var(--lb-accent);
-
- :where(.lb-collapsible-chevron) {
- color: var(--lb-accent-moderate);
- }
+ &:where(.lb-ai-chat-pending) {
+ color: var(--lb-foreground);
}
}
@@ -2814,8 +2877,6 @@
.lb-ai-tool {
position: relative;
- border-radius: var(--lb-radius);
- box-shadow: inset 0 0 0 1px var(--lb-foreground-subtle);
&::after {
content: "";
@@ -2839,11 +2900,9 @@
.lb-ai-tool-header {
display: flex;
- gap: calc(0.3125 * var(--lb-spacing));
+ gap: calc(0.25 * var(--lb-spacing));
align-items: center;
inline-size: 100%;
- block-size: calc($lb-button-size + var(--lb-spacing));
- padding-inline: calc(0.5 * var(--lb-spacing));
}
.lb-ai-tool-header-icon-container,
@@ -2872,26 +2931,95 @@
.lb-ai-tool-header-title {
@include truncate;
-
- margin-inline-end: calc(0.25 * var(--lb-spacing));
- color: var(--lb-foreground-secondary);
- font-size: 0.9375em;
-
- &:where(:first-child) {
- margin-inline-start: calc(0.3125 * var(--lb-spacing));
- }
}
.lb-ai-tool-header-status {
flex: none;
- margin-inline-start: auto;
- margin-inline-end: calc(0.1875 * var(--lb-spacing));
color: var(--lb-foreground-moderate);
}
-.lb-ai-tool:where([data-result="error"]) {
- box-shadow: inset 0 0 0 1px var(--lb-destructive-moderate);
+.lb-ai-tool:where(.lb-ai-tool\:variant-block) {
+ border-radius: var(--lb-radius);
+ box-shadow: inset 0 0 0 1px var(--lb-foreground-subtle);
+
+ :where(.lb-ai-tool-header) {
+ block-size: calc($lb-button-size + var(--lb-spacing));
+ padding-inline: calc(0.5 * var(--lb-spacing));
+ color: var(--lb-foreground-secondary);
+ }
+
+ :where(.lb-ai-tool-header-title) {
+ font-size: 0.9375em;
+
+ &:where(:first-child) {
+ margin-inline-start: calc(0.3125 * var(--lb-spacing));
+ }
+ }
+ :where(.lb-ai-tool-header-status) {
+ margin-inline-start: auto;
+ margin-inline-end: calc(0.1875 * var(--lb-spacing));
+ }
+
+ :where(.lb-ai-tool-content:not(:empty)) {
+ padding: 0 calc(0.75 * var(--lb-spacing)) calc(0.75 * var(--lb-spacing));
+ }
+
+ &:where([data-result="error"]) {
+ box-shadow: inset 0 0 0 1px var(--lb-destructive-moderate);
+ }
+}
+
+.lb-ai-tool:where(.lb-ai-tool\:variant-minimal) {
+ &::after {
+ inset: calc(-0.375 * var(--lb-spacing));
+ border-radius: calc(0.75 * var(--lb-radius));
+ }
+
+ :where(.lb-ai-tool-header) {
+ position: relative;
+ inline-size: fit-content;
+ max-inline-size: 100%;
+ block-size: $lb-button-size;
+ color: var(--lb-foreground-tertiary);
+ transition-property: color;
+
+ &:where(.lb-ai-chat-pending),
+ :where(.lb-ai-chat-pending) {
+ color: var(--lb-foreground);
+ }
+
+ /* Invisibly increase the header's hit target size */
+ &::before {
+ content: "";
+ position: absolute;
+ inset: calc(-0.375 * var(--lb-spacing));
+ z-index: -1;
+ }
+ }
+
+ :where(.lb-ai-tool-header-icon-container) {
+ margin-inline-start: calc(-0.125 * var(--lb-spacing));
+ }
+
+ :where(.lb-ai-tool-header-title) {
+ &:where(:not(:first-child)) {
+ margin-inline-start: calc(-0.0625 * var(--lb-spacing));
+ }
+ }
+
+ :where(.lb-ai-tool-content:not(:empty)) {
+ padding-block-start: calc(0.5 * var(--lb-spacing));
+ }
+
+ &:where([data-result="cancelled"]) {
+ :where(.lb-ai-tool-header) {
+ color: var(--lb-foreground-moderate);
+ }
+ }
+}
+
+.lb-ai-tool:where([data-result="error"]) {
&:where(:has(.lb-ai-tool-header:focus-visible)) {
&::after {
box-shadow:
@@ -2900,13 +3028,18 @@
}
}
+ :where(.lb-ai-tool-header),
:where(.lb-ai-tool-header-status) {
color: var(--lb-destructive);
}
-}
-.lb-ai-tool-content:where(:not(:empty)) {
- padding: 0 calc(0.75 * var(--lb-spacing)) calc(0.75 * var(--lb-spacing));
+ :where(.lb-ai-tool-icon) {
+ background: var(--lb-destructive-subtle);
+ }
+
+ :where(.lb-ai-tool-header-icon-container) {
+ color: var(--lb-destructive-secondary);
+ }
}
.lb-ai-tool-inspector {
@@ -2977,7 +3110,7 @@
}
}
-@keyframes lb-animation-shimmer {
+@keyframes lb-animation-shimmer-skeleton {
from,
to {
mask-image: linear-gradient(
@@ -2999,25 +3132,25 @@
}
}
-@keyframes lb-animation-shimmer-small {
+@keyframes lb-animation-shimmer-text {
from,
to {
mask-image: linear-gradient(
90deg,
- #000 0%,
- rgb(0 0 0 / 50%) 18%,
- rgb(0 0 0 / 50%) 82%,
- #000 100%
+ rgb(0 0 0 / 75%) 36%,
+ rgb(0 0 0 / 20%) 48%,
+ rgb(0 0 0 / 20%) 52%,
+ rgb(0 0 0 / 75%) 64%
);
mask-size: 300% 100%;
}
from {
- mask-position: 200% 0;
+ mask-position: 100% 0;
}
to {
- mask-position: -100% 0;
+ mask-position: 0% 0;
}
}
diff --git a/packages/liveblocks-react-ui/src/utils/find-last-index.ts b/packages/liveblocks-react-ui/src/utils/find-last-index.ts
deleted file mode 100644
index 946d81b98bf..00000000000
--- a/packages/liveblocks-react-ui/src/utils/find-last-index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export function findLastIndex