Skip to content
Merged
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 @@ -34,9 +36,12 @@

<Popover bind:open={isOpen}>
<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
Copy Markdown
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We have setup-before-tests through our codebase. Mind if we just keep as is? Something feels weird about putting constants near the bottom of the file.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hmmm its a separate conversation I guess. It doesnt help to read constants without knowing what the tests are doing.

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