Skip to content
Draft
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
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
156 changes: 138 additions & 18 deletions web-common/src/features/chat/core/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryCl
import {
getRuntimeServiceGetConversationQueryKey,
getRuntimeServiceGetConversationQueryOptions,
runtimeServiceForkConversation,
type RpcStatus,
type RuntimeServiceCompleteBody,
type V1CompleteStreamingResponse,
Expand All @@ -15,7 +16,13 @@ import {
type SSEMessage,
} from "@rilldata/web-common/runtime-client/sse-fetch-client";
import { createQuery, type CreateQueryResult } from "@tanstack/svelte-query";
import { derived, get, writable, type Readable } from "svelte/store";
import {
derived,
get,
writable,
type Readable,
type Writable,
} from "svelte/store";
import type { HTTPError } from "../../../runtime-client/fetchWrapper";
import { transformToBlocks, type Block } from "./messages/block-transform";
import { MessageContentType, MessageType, ToolName } from "./types";
Expand All @@ -28,6 +35,7 @@ import { EventEmitter } from "@rilldata/web-common/lib/event-emitter.ts";

type ConversationEvents = {
"conversation-created": string;
"conversation-forked": string;
"stream-start": void;
message: V1Message;
"stream-complete": string;
Expand Down Expand Up @@ -58,33 +66,78 @@ export class Conversation {
private sseClient: SSEFetchClient | null = null;
private hasReceivedFirstMessage = false;

// Reactive conversation ID - enables query to auto-update when ID changes (e.g., after fork)
private readonly conversationIdStore: Writable<string>;
private readonly conversationQuery: CreateQueryResult<
V1GetConversationResponse,
RpcStatus
>;

public get conversationId(): string {
return get(this.conversationIdStore);
}

private set conversationId(value: string) {
this.conversationIdStore.set(value);
}

constructor(
private readonly instanceId: string,
public conversationId: string,
private readonly agent: string = ToolName.ANALYST_AGENT, // Hardcoded default for now
) {}
initialConversationId: string,
private readonly agent: string = ToolName.ANALYST_AGENT,
) {
this.conversationIdStore = writable(initialConversationId);

// Create query with reactive options that respond to conversationId changes
const queryOptionsStore = derived(
this.conversationIdStore,
($conversationId) =>
getRuntimeServiceGetConversationQueryOptions(
this.instanceId,
$conversationId,
{
query: {
enabled: $conversationId !== NEW_CONVERSATION_ID,
staleTime: Infinity, // We manage cache manually during streaming
},
},
),
);
this.conversationQuery = createQuery(queryOptionsStore, queryClient);
}

/**
* Get ownership status by checking the query cache.
* Returns true if the current user owns this conversation or if ownership is unknown.
*/
private getIsOwner(): boolean {
if (this.conversationId === NEW_CONVERSATION_ID) {
return true; // New conversations are always owned by the creator
}

// Check the query cache for ownership information
const cacheKey = getRuntimeServiceGetConversationQueryKey(
this.instanceId,
this.conversationId,
);
const cachedData =
queryClient.getQueryData<V1GetConversationResponse>(cacheKey);

// Default to true if not in cache (optimistic assumption)
return cachedData?.isOwner ?? true;
}

// ===== PUBLIC API =====

/**
* Get a reactive query for this conversation's data
* Get a reactive query for this conversation's data.
* The query reacts to conversationId changes (e.g., after fork).
*/
public getConversationQuery(): CreateQueryResult<
V1GetConversationResponse,
RpcStatus
> {
return createQuery(
getRuntimeServiceGetConversationQueryOptions(
this.instanceId,
this.conversationId,
{
query: {
enabled: this.conversationId !== NEW_CONVERSATION_ID,
},
},
),
queryClient,
);
return this.conversationQuery;
}

/**
Expand Down Expand Up @@ -140,6 +193,24 @@ export class Conversation {
this.isStreaming.set(true);
this.hasReceivedFirstMessage = false;

// Fork conversation if user is not the owner (viewing a shared conversation)
const isOwner = this.getIsOwner();
if (!isOwner && this.conversationId !== NEW_CONVERSATION_ID) {
try {
const forkedConversationId = await this.forkConversation();
// Update to the forked conversation (setter updates the reactive store)
this.conversationId = forkedConversationId;
this.events.emit("conversation-forked", forkedConversationId);
} catch (error) {
console.error("[Conversation] Fork failed:", error);
this.isStreaming.set(false);
this.streamError.set(
"Failed to create your copy of this conversation. Please try again.",
);
return;
}
}

const userMessage = this.addOptimisticUserMessage(prompt);

try {
Expand Down Expand Up @@ -323,6 +394,55 @@ export class Conversation {

// ----- Conversation Lifecycle -----

/**
* Fork the current conversation to create a copy owned by the current user.
* Used when a non-owner wants to continue a shared conversation.
*/
private async forkConversation(): Promise<string> {
const originalConversationId = this.conversationId;

const response = await runtimeServiceForkConversation(
this.instanceId,
this.conversationId,
{},
);

if (!response.conversationId) {
throw new Error("Fork response missing conversation ID");
}

const forkedConversationId = response.conversationId;

// Copy cached messages from original conversation to forked conversation
// This ensures the UI shows the conversation history immediately
const originalCacheKey = getRuntimeServiceGetConversationQueryKey(
this.instanceId,
originalConversationId,
);
const forkedCacheKey = getRuntimeServiceGetConversationQueryKey(
this.instanceId,
forkedConversationId,
);
const originalData =
queryClient.getQueryData<V1GetConversationResponse>(originalCacheKey);

if (originalData) {
queryClient.setQueryData<V1GetConversationResponse>(forkedCacheKey, {
conversation: {
...originalData.conversation,
id: forkedConversationId,
},
messages: originalData.messages || [],
isOwner: true, // User now owns the forked conversation
});
}

// Invalidate the conversations list to show the new forked conversation
void invalidateConversationsList(this.instanceId);

return forkedConversationId;
}

/**
* Transition from NEW_CONVERSATION_ID to real conversation ID
* Transfers all cached data to the new conversation cache
Expand Down Expand Up @@ -356,7 +476,7 @@ export class Conversation {
// Clean up the old "new" conversation cache
queryClient.removeQueries({ queryKey: oldCacheKey });

// Update the conversation ID
// Update the conversation ID (setter updates the reactive store)
this.conversationId = realConversationId;

// Notify that conversation was created
Expand Down
37 changes: 37 additions & 0 deletions web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { beforeNavigate } from "$app/navigation";
import { page } from "$app/stores";
import { onMount } from "svelte";
import { runtime } from "../../../../runtime-client/runtime-store";
import {
Expand All @@ -8,6 +9,7 @@
} from "../../core/conversation-manager";
import ChatInput from "../../core/input/ChatInput.svelte";
import Messages from "../../core/messages/Messages.svelte";
import { ShareChatPopover } from "../../share";
import ConversationSidebar from "./ConversationSidebar.svelte";
import {
conversationSidebarCollapsed,
Expand All @@ -16,11 +18,18 @@
import { projectChat } from "@rilldata/web-common/features/project/chat-context.ts";

$: ({ instanceId } = $runtime);
$: organization = $page.params.organization;
$: project = $page.params.project;

$: conversationManager = getConversationManager(instanceId, {
conversationState: "url",
});

$: currentConversationStore = conversationManager.getCurrentConversation();
$: getConversationQuery = $currentConversationStore?.getConversationQuery();
$: currentConversation = $getConversationQuery?.data?.conversation ?? null;
$: hasExistingConversation = !!currentConversation?.id;

let chatInputComponent: ChatInput;

function onMessageSend() {
Expand Down Expand Up @@ -64,6 +73,17 @@

<!-- Main Chat Area -->
<div class="chat-main">
{#if hasExistingConversation && currentConversation?.id && organization && project}
<div class="chat-header">
<ShareChatPopover
conversationId={currentConversation.id}
conversationTitle={currentConversation.title ?? ""}
{instanceId}
{organization}
{project}
/>
</div>
{/if}
<div class="chat-content">
<div class="chat-messages-wrapper">
<Messages
Expand Down Expand Up @@ -97,13 +117,30 @@

/* Main Chat Area */
.chat-main {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface);
}

.chat-header {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.5rem 1rem;
z-index: 10;
pointer-events: none;
}

.chat-header :global(*) {
pointer-events: auto;
}

.chat-content {
flex: 1;
overflow: hidden;
Expand Down
Loading
Loading