diff --git a/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte b/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte index 415f1ac578d..ee24013295d 100644 --- a/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte +++ b/web-admin/src/features/dashboards/share/ShareDashboardPopover.svelte @@ -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; @@ -39,9 +41,12 @@ }} > - + + + Share dashboard + diff --git a/web-admin/src/features/projects/user-management/ShareProjectPopover.svelte b/web-admin/src/features/projects/user-management/ShareProjectPopover.svelte index 03094881b88..06987de106e 100644 --- a/web-admin/src/features/projects/user-management/ShareProjectPopover.svelte +++ b/web-admin/src/features/projects/user-management/ShareProjectPopover.svelte @@ -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"; @@ -33,7 +35,12 @@ - + + + Share project + this.enforceMaxConcurrentStreams(), ); + conversation.on("conversation-forked", (newConversationId) => + this.handleConversationForked($conversationId, newConversationId), + ); this.conversations.set($conversationId, conversation); return conversation; }, @@ -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 diff --git a/web-common/src/features/chat/core/conversation.ts b/web-common/src/features/chat/core/conversation.ts index da0d1d64f16..46118b283e1 100644 --- a/web-common/src/features/chat/core/conversation.ts +++ b/web-common/src/features/chat/core/conversation.ts @@ -2,6 +2,7 @@ import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryCl import { getRuntimeServiceGetConversationQueryKey, getRuntimeServiceGetConversationQueryOptions, + runtimeServiceForkConversation, type RpcStatus, type RuntimeServiceCompleteBody, type V1CompleteStreamingResponse, @@ -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"; @@ -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; @@ -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; + 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(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; } /** @@ -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 { @@ -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 { + 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(originalCacheKey); + + if (originalData) { + queryClient.setQueryData(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 @@ -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 diff --git a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte index d2795a8d738..e1f8445f847 100644 --- a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte +++ b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte @@ -1,5 +1,6 @@ + + + + + + Share conversation + + + +
+

+ Share "{truncateTitle(conversationTitle || "Untitled conversation")}" +

+

+ Share this conversation with other project members. They can view and + continue the conversation. +

+ {#if shareError} +

{shareError}

+ {/if} + +
+
+
diff --git a/web-common/src/features/chat/share/index.ts b/web-common/src/features/chat/share/index.ts new file mode 100644 index 00000000000..36c35017da4 --- /dev/null +++ b/web-common/src/features/chat/share/index.ts @@ -0,0 +1 @@ +export { default as ShareChatPopover } from "./ShareChatPopover.svelte";