Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
TabsList,
TabsTrigger,
} from "@rilldata/web-common/components/tabs";
import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte";
import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte";
import { featureFlags } from "@rilldata/web-common/features/feature-flags";

export let createMagicAuthTokens: boolean;
Expand All @@ -39,9 +41,12 @@
}}
>
<PopoverTrigger asChild let:builder>
<Button type="secondary" builders={[builder]} selected={isOpen}
>Share</Button
>
<Tooltip distance={8} suppress={isOpen}>
<Button type="secondary" builders={[builder]} selected={isOpen}
>Share</Button
>
<TooltipContent slot="tooltip-content">Share dashboard</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent align="end" class="w-[402px] p-0">
<Tabs>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
PopoverContent,
PopoverTrigger,
} from "@rilldata/web-common/components/popover";
import Tooltip from "@rilldata/web-common/components/tooltip/Tooltip.svelte";
import TooltipContent from "@rilldata/web-common/components/tooltip/TooltipContent.svelte";
import { copyWithAdditionalArguments } from "@rilldata/web-common/lib/url-utils.ts";
import { onMount } from "svelte";

Expand All @@ -33,7 +35,12 @@

<Popover bind:open>
<PopoverTrigger asChild let:builder>
<Button builders={[builder]} type="secondary" selected={open}>Share</Button>
<Tooltip distance={8} suppress={open}>
<Button builders={[builder]} type="secondary" selected={open}
>Share</Button
>
<TooltipContent slot="tooltip-content">Share project</TooltipContent>
</Tooltip>
</PopoverTrigger>
<PopoverContent align="end" class="w-[520px]" padding="0">
<ShareProjectForm
Expand Down
1 change: 1 addition & 0 deletions web-common/src/components/button/IconButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<button
type="button"
on:click
{disabled}
aria-label={ariaLabel}
class:cursor-auto={disabled}
class:rounded
Expand Down
23 changes: 23 additions & 0 deletions web-common/src/features/chat/core/conversation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ export class ConversationManager {
conversation.on("stream-start", () =>
this.enforceMaxConcurrentStreams(),
);
conversation.on("conversation-forked", (newConversationId) =>
this.handleConversationForked($conversationId, newConversationId),
);
this.conversations.set($conversationId, conversation);
return conversation;
},
Expand Down Expand Up @@ -223,6 +226,26 @@ export class ConversationManager {
void invalidateConversationsList(this.instanceId);
}

/**
* Handle conversation forking - updates state to navigate to the forked conversation
* Called when a non-owner sends a message and the conversation is forked
*/
private handleConversationForked(
originalConversationId: string,
newConversationId: string,
): void {
// Get the forked conversation (which is the updated instance)
const forkedConversation = this.conversations.get(originalConversationId);
if (forkedConversation) {
// Move the conversation to the new ID in our map
this.conversations.delete(originalConversationId);
this.conversations.set(newConversationId, forkedConversation);
}

// Navigate to the new forked conversation
this.conversationSelector.selectConversation(newConversationId);
}

/**
* Rotates the new conversation: moves current "new" conversation to the conversations map
* and creates a fresh "new" conversation instance
Expand Down
249 changes: 249 additions & 0 deletions web-common/src/features/chat/core/conversation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
import {
getRuntimeServiceGetConversationQueryKey,
type V1GetConversationResponse,
type V1Message,
} from "@rilldata/web-common/runtime-client";
import { get } from "svelte/store";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Conversation } from "./conversation";
import { NEW_CONVERSATION_ID } from "./utils";

// =============================================================================
// MOCKS
// =============================================================================

vi.mock("@rilldata/web-common/runtime-client", async (importOriginal) => {
const original =
await importOriginal<
typeof import("@rilldata/web-common/runtime-client")
>();
return {
...original,
runtimeServiceForkConversation: vi.fn(),
};
});

vi.mock("@rilldata/web-common/runtime-client/runtime-store", () => ({
runtime: {
subscribe: (fn: (value: { host: string }) => void) => {
fn({ host: "http://localhost:9009" });
return () => {};
},
},
}));

import { runtimeServiceForkConversation } from "@rilldata/web-common/runtime-client";

// =============================================================================
// TEST CONSTANTS
// =============================================================================

const INSTANCE_ID = "test-instance";
const ORIGINAL_CONVERSATION_ID = "original-conv-123";
const FORKED_CONVERSATION_ID = "forked-conv-456";

// =============================================================================
// HELPERS
// =============================================================================

function getCacheKey(conversationId: string) {
return getRuntimeServiceGetConversationQueryKey(INSTANCE_ID, conversationId);
}

function getCachedData(conversationId: string) {
return queryClient.getQueryData<V1GetConversationResponse>(
getCacheKey(conversationId),
);
}

function seedCache(
conversationId: string,
options: {
isOwner: boolean;
messages?: Partial<V1Message>[];
title?: string;
createdOn?: string;
},
) {
queryClient.setQueryData<V1GetConversationResponse>(
getCacheKey(conversationId),
{
conversation: {
id: conversationId,
title: options.title,
createdOn: options.createdOn,
},
messages: options.messages as V1Message[],
isOwner: options.isOwner,
},
);
}

function mockForkSuccess(forkedId: string = FORKED_CONVERSATION_ID) {
vi.mocked(runtimeServiceForkConversation).mockResolvedValue({
conversationId: forkedId,
});
}

function mockForkFailure(error: Error = new Error("Fork failed")) {
vi.mocked(runtimeServiceForkConversation).mockRejectedValue(error);
}

function mockForkEmptyResponse() {
vi.mocked(runtimeServiceForkConversation).mockResolvedValue({});
}

function createConversation(conversationId: string = ORIGINAL_CONVERSATION_ID) {
return new Conversation(INSTANCE_ID, conversationId);
}

async function sendMessageAndIgnoreStreamError(
conversation: Conversation,
message: string,
) {
conversation.draftMessage.set(message);
await conversation.sendMessage({}).catch(() => {});
}

// =============================================================================
// TESTS
// =============================================================================

describe("Conversation", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: lets move the tests just below mocks. It reads better IMO, tests 1st then all the utils and constants.

beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
});

afterEach(() => {
queryClient.clear();
});

describe("forkConversation", () => {
it("forks and copies messages when non-owner sends a message", async () => {
// Arrange
seedCache(ORIGINAL_CONVERSATION_ID, {
isOwner: false,
title: "Shared conversation",
createdOn: "2024-01-01T00:00:00Z",
messages: [
{ id: "msg-1", role: "user", contentData: "Hello" },
{ id: "msg-2", role: "assistant", contentData: "Hi there!" },
],
});
mockForkSuccess();

const conversation = createConversation();
let forkedId: string | null = null;
conversation.on("conversation-forked", (id) => (forkedId = id));

// Act
conversation.draftMessage.set("My follow-up question");
const sendPromise = conversation.sendMessage({});
await vi.waitFor(() => expect(forkedId).toBe(FORKED_CONVERSATION_ID));

// Assert: fork API called correctly
expect(runtimeServiceForkConversation).toHaveBeenCalledWith(
INSTANCE_ID,
ORIGINAL_CONVERSATION_ID,
{},
);

// Assert: cache updated with forked conversation
const forkedData = getCachedData(FORKED_CONVERSATION_ID);
expect(forkedData?.conversation?.id).toBe(FORKED_CONVERSATION_ID);
expect(forkedData?.conversation?.title).toBe("Shared conversation");
expect(forkedData?.conversation?.createdOn).toBe("2024-01-01T00:00:00Z");
expect(forkedData?.isOwner).toBe(true);

// Assert: messages copied + optimistic message added
expect(forkedData?.messages).toHaveLength(3);
expect(forkedData?.messages?.[0]?.contentData).toBe("Hello");
expect(forkedData?.messages?.[1]?.contentData).toBe("Hi there!");
expect(forkedData?.messages?.[2]?.role).toBe("user");

// Cleanup
conversation.cleanup();
await sendPromise.catch(() => {});
});

it("does NOT fork when owner sends a message", async () => {
// Arrange
seedCache(ORIGINAL_CONVERSATION_ID, { isOwner: true, messages: [] });
const conversation = createConversation();

// Act
await sendMessageAndIgnoreStreamError(conversation, "My message");

// Assert
expect(runtimeServiceForkConversation).not.toHaveBeenCalled();

conversation.cleanup();
});

it("does NOT fork for new conversations", async () => {
// Arrange
const conversation = createConversation(NEW_CONVERSATION_ID);

// Act
await sendMessageAndIgnoreStreamError(conversation, "First message");

// Assert
expect(runtimeServiceForkConversation).not.toHaveBeenCalled();

conversation.cleanup();
});

it("sets error and stops streaming if fork API fails", async () => {
// Arrange
seedCache(ORIGINAL_CONVERSATION_ID, { isOwner: false, messages: [] });
mockForkFailure();
const conversation = createConversation();

// Act
conversation.draftMessage.set("My message");
await conversation.sendMessage({});

// Assert
expect(get(conversation.streamError)).toContain(
"Failed to create your copy",
);
expect(get(conversation.isStreaming)).toBe(false);

conversation.cleanup();
});

it("sets error if fork response is missing conversation ID", async () => {
// Arrange
seedCache(ORIGINAL_CONVERSATION_ID, { isOwner: false, messages: [] });
mockForkEmptyResponse();
const conversation = createConversation();

// Act
conversation.draftMessage.set("My message");
await conversation.sendMessage({});

// Assert
expect(get(conversation.streamError)).toContain(
"Failed to create your copy",
);
expect(get(conversation.isStreaming)).toBe(false);

conversation.cleanup();
});

it("does NOT fork when cache is empty (optimistic ownership)", async () => {
// Arrange: no cache data seeded - ownership defaults to true
const conversation = createConversation();

// Act
await sendMessageAndIgnoreStreamError(conversation, "Test");

// Assert
expect(runtimeServiceForkConversation).not.toHaveBeenCalled();

conversation.cleanup();
});
});
});
Loading
Loading