From 736338a5606aa84931024cb4d98b9f549cd8ed0f Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Fri, 16 Jan 2026 15:27:07 -0500 Subject: [PATCH 1/6] feat (AI): UI for sharing AI conversations --- .../share/ShareDashboardPopover.svelte | 11 +- .../ShareProjectPopover.svelte | 9 +- .../chat/core/conversation-manager.ts | 23 +++ .../src/features/chat/core/conversation.ts | 156 ++++++++++++++++-- .../chat/layouts/fullpage/FullPageChat.svelte | 37 +++++ .../chat/layouts/sidebar/SidebarHeader.svelte | 18 ++ .../chat/share/ShareChatPopover.svelte | 109 ++++++++++++ web-common/src/features/chat/share/index.ts | 1 + 8 files changed, 342 insertions(+), 22 deletions(-) create mode 100644 web-common/src/features/chat/share/ShareChatPopover.svelte create mode 100644 web-common/src/features/chat/share/index.ts 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"; From 10f9e105884c9424cab291b52ca40e6828bbbfea Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 21 Jan 2026 09:28:28 -0500 Subject: [PATCH 2/6] Fix "untitled conversation" in popover --- .../features/chat/layouts/fullpage/FullPageChat.svelte | 1 - .../features/chat/layouts/sidebar/SidebarHeader.svelte | 4 +--- .../src/features/chat/share/ShareChatPopover.svelte | 10 +--------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte index e1f8445f847..0928fc49ab6 100644 --- a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte +++ b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte @@ -77,7 +77,6 @@
- {#if hasExistingConversation && organization && project} + {#if currentConversationDto?.id && organization && project} @@ -83,9 +77,7 @@
-

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

+

Share conversation

Share this conversation with other project members. They can view and continue the conversation. From 14a53b971484ea86762eaae6667ed5e8516e6016 Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 21 Jan 2026 10:17:02 -0500 Subject: [PATCH 3/6] Disable "Share" button when N/A, don't hide it --- .../chat/layouts/fullpage/FullPageChat.svelte | 21 +++++++++--------- .../chat/layouts/sidebar/SidebarHeader.svelte | 16 +++++++------- .../chat/share/ShareChatPopover.svelte | 22 ++++++++++++------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte index 0928fc49ab6..eb12f63076b 100644 --- a/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte +++ b/web-common/src/features/chat/layouts/fullpage/FullPageChat.svelte @@ -28,7 +28,6 @@ $: currentConversationStore = conversationManager.getCurrentConversation(); $: getConversationQuery = $currentConversationStore?.getConversationQuery(); $: currentConversation = $getConversationQuery?.data?.conversation ?? null; - $: hasExistingConversation = !!currentConversation?.id; let chatInputComponent: ChatInput; @@ -73,16 +72,16 @@

- {#if hasExistingConversation && currentConversation?.id && organization && project} -
- -
- {/if} +
+ +
- {#if currentConversationDto?.id && organization && project} - - {/if} + - + - Share conversation + + {disabled ? disabledTooltip : "Share conversation"} + From 7ab2e7b30aaded788e8c673115fe1c8c04ae502f Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 21 Jan 2026 10:22:44 -0500 Subject: [PATCH 4/6] Make all icon buttons consistent w/ tooltips --- .../sidebar/ConversationHistoryMenu.svelte | 25 +++++++++++-------- .../chat/layouts/sidebar/SidebarHeader.svelte | 2 ++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte b/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte index 43c434223ef..b004892ad65 100644 --- a/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte +++ b/web-common/src/features/chat/layouts/sidebar/ConversationHistoryMenu.svelte @@ -1,6 +1,7 @@ - - + + History + + New conversation + Close
From 859c5284ac4f09933b2388d6993148f82483a6c4 Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 21 Jan 2026 14:30:51 -0500 Subject: [PATCH 5/6] Self-review --- .../src/components/button/IconButton.svelte | 1 + .../src/features/chat/core/conversation.ts | 20 ++++--- .../chat/layouts/fullpage/FullPageChat.svelte | 54 +++++-------------- .../sidebar/ConversationHistoryMenu.svelte | 6 ++- .../chat/layouts/sidebar/SidebarHeader.svelte | 3 +- .../chat/share/ShareChatPopover.svelte | 18 +++---- web-common/src/features/chat/share/index.ts | 1 - 7 files changed, 35 insertions(+), 68 deletions(-) delete mode 100644 web-common/src/features/chat/share/index.ts diff --git a/web-common/src/components/button/IconButton.svelte b/web-common/src/components/button/IconButton.svelte index a2628b847ef..168bef28931 100644 --- a/web-common/src/components/button/IconButton.svelte +++ b/web-common/src/components/button/IconButton.svelte @@ -29,6 +29,7 @@
@@ -107,76 +106,49 @@