Skip to content
Merged
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 @@ -150,7 +150,7 @@ export function ConversationListPanel(props: ConversationListPanelProps) {
iconOnly
iconBefore={Share2}
disabled={disabled || editing}
title='Copy share link'
title='Copy conversation link'
aria-label='Share conversation'
onClick={() => onShare(conversation.id)}
/>
Expand Down
55 changes: 55 additions & 0 deletions packages/genui/a2ui-playground/src/hooks/useConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import {
createConversationMeta,
deleteConversation,
getActiveConversationId,
importConversation,
listConversations,
loadConversation,
previewTextFromSharedMessages,
renameConversation,
saveConversationMessages,
setActiveConversationId,
} from '../storage/conversationRepo.js';
import type { SharedConversationDoc } from '../storage/sharedConversation.js';
import type {
ConversationMeta,
DataModelSnapshot,
Expand Down Expand Up @@ -64,6 +67,7 @@ export interface UseConversationReturn {
isPersistent: boolean;
switchTo: (id: string) => Promise<void>;
createNew: () => Promise<string>;
importShared: (doc: SharedConversationDoc) => Promise<string>;
remove: (id: string) => Promise<void>;
rename: (id: string, title: string) => Promise<void>;
recordTurn: (input: RecordTurnInput) => Promise<void>;
Expand Down Expand Up @@ -450,6 +454,56 @@ export function useConversation(): UseConversationReturn {
return meta.id;
}, [syncHotState]);

const importShared = useCallback(
async (doc: SharedConversationDoc): Promise<string> => {
// In-memory fallback (IndexedDB unavailable): build hot state directly.
if (!persistentRef.current) {
const meta: ConversationMeta = {
...createConversationMeta(doc.title || 'Shared conversation'),
messageCount: doc.messages.length,
previewText: previewTextFromSharedMessages(doc.messages),
};
const hotState: ConversationHotState = {
messages: doc.messages.map((message) => ({
role: message.role,
content: message.content,
previewPayloadUrls: message.previewPayloadUrls,
previewMetrics: clonePreviewPerformanceMetrics(
message.previewMetrics,
),
})),
dataModel: cloneDataModel(doc.snapshot?.dataModel ?? {}),
surfaceIds: new Set(doc.snapshot?.surfaceIds ?? []),
previewMessages: [],
previewPayloadUrls: doc.snapshot?.previewPayloadUrls ?? null,
};
conversationHotStateMapRef.current.set(
meta.id,
cloneHotState(hotState),
);
setConversations((prev) => [meta, ...prev]);
activationTokenRef.current = Symbol(meta.id);
activeIdRef.current = meta.id;
setActiveId(meta.id);
syncHotState(
hotState.messages,
hotState.dataModel,
hotState.surfaceIds,
hotState.previewMessages,
hotState.previewPayloadUrls,
);
return meta.id;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const meta = await importConversation(doc);
await setActiveConversationId(meta.id);
await refreshConversations();
await activateRecord(meta.id);
return meta.id;
},
[activateRecord, refreshConversations, syncHotState],
);

const remove = useCallback(
async (id: string) => {
if (persistentRef.current) {
Expand Down Expand Up @@ -676,6 +730,7 @@ export function useConversation(): UseConversationReturn {
isPersistent,
switchTo,
createNew,
importShared,
remove,
rename,
recordTurn,
Expand Down
118 changes: 56 additions & 62 deletions packages/genui/a2ui-playground/src/pages/AIChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,22 @@ import { useConversation } from '../hooks/useConversation.js';
import type { ModelChatMessage } from '../hooks/useConversation.js';
import { useResizablePanels } from '../hooks/useResizablePanels.js';
import { loadConversation } from '../storage/conversationRepo.js';
import {
isSharedConversationDoc,
serializeConversation,
} from '../storage/sharedConversation.js';
import type { PreviewPerformanceMetrics } from '../storage/types.js';
import { copyToClipboard } from '../utils/clipboard.js';
import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js';
import type { Protocol } from '../utils/protocol.js';
import { isDevHost, publishA2UIPayload } from '../utils/publishPayload.js';
import { isDevHost } from '../utils/publishPayload.js';
import { buildRenderUrl } from '../utils/renderUrl.js';
import {
buildConversationShareUrl,
clearImportConversationParam,
publishConversation,
readImportConversationParam,
} from '../utils/shareConversation.js';

interface ChatMessage {
id?: string;
Expand Down Expand Up @@ -1042,6 +1052,7 @@ export function AIChatPage(
buildConversationContext,
conversations,
createNew,
importShared,
isPersistent,
isReady,
messages: persistedMessages,
Expand Down Expand Up @@ -1088,6 +1099,7 @@ export function AIChatPage(
const abortRef = useRef<AbortController | null>(null);
const actionAbortRef = useRef<AbortController | null>(null);
const hydratedActiveIdRef = useRef<string | null>(null);
const importHandledRef = useRef(false);
const latestPreviewMessagesRef = useRef<unknown[]>([]);
const generatedCharacterCountRef = useRef(0);
const latestPreviewPayloadUrlsRef = useRef<PreviewPayloadUrls | null>(null);
Expand Down Expand Up @@ -1517,6 +1529,38 @@ export function AIChatPage(
updatePreviewPayloadUrls,
]);

// Import-on-open: when the page is loaded with a shared-conversation link,
// fetch the document once, rehydrate it into a new local conversation, then
// strip the param so a reload does not re-import.
useEffect(() => {
if (!isReady || importHandledRef.current) return;
const importUrl = readImportConversationParam();
if (!importUrl) {
importHandledRef.current = true;
return;
}
importHandledRef.current = true;
void (async () => {
try {
const response = await window.fetch(importUrl);
if (!response.ok) {
throw new Error(
`Failed to load shared conversation: ${response.status}`,
);
}
const doc = (await response.json()) as unknown;
if (!isSharedConversationDoc(doc)) {
throw new Error('Invalid shared conversation document');
}
await importShared(doc);
} catch (err) {
console.warn('[a2ui] Failed to import shared conversation', err);
} finally {
clearImportConversationParam();
}
})();
}, [isReady, importShared]);

useEffect(() => {
return () => {
abortRef.current?.abort();
Expand Down Expand Up @@ -2102,77 +2146,27 @@ export function AIChatPage(
const shareConversation = useCallback(
async (id: string) => {
try {
let urls: PreviewPayloadUrls | null = null;
let fallbackMessages: unknown[] | null = null;

// Active conversation: the freshest URLs/messages live in refs.
if (id === activeId) {
urls = latestPreviewPayloadUrlsRef.current;
fallbackMessages = latestPreviewMessagesRef.current;
}

// Otherwise (or if the active one hasn't published yet) read the
// durable Supabase URLs persisted on the conversation snapshot.
if (!urls?.messagesUrl) {
const record = await loadConversation(id);
const snapshot = record?.snapshot;
const fromSnapshot = snapshot?.previewPayloadUrls;
let fromMessage: PreviewPayloadUrls | undefined;
if (record) {
for (let i = record.messages.length - 1; i >= 0; i--) {
const message = record.messages[i];
if (message?.previewPayloadUrls?.messagesUrl) {
fromMessage = message.previewPayloadUrls;
break;
}
}
}
urls = (fromSnapshot?.messagesUrl ? fromSnapshot : fromMessage)
?? urls;
if (
!urls?.messagesUrl
&& (!fallbackMessages || fallbackMessages.length === 0)
) {
const previewMessages = snapshot?.previewMessages;
if (Array.isArray(previewMessages) && previewMessages.length > 0) {
fallbackMessages = previewMessages;
} else if (record && record.messages.length > 0) {
// Older snapshots: rebuild A2UI preview from assistant history.
const rebuilt = buildPreviewMessagesFromHistory(record.messages);
if (rebuilt.length > 0) fallbackMessages = rebuilt;
}
}
}

// No durable URL yet: publish the raw A2UI messages to Supabase.
if (
!urls?.messagesUrl && fallbackMessages && fallbackMessages.length > 0
) {
urls = await publishA2UIPayload(fallbackMessages);
}

if (!urls?.messagesUrl) {
// Share the whole conversation: serialize the persisted record,
// upload it, and build a link that imports it into the recipient's
// playground (transcript + data model), not just the final UI.
const record = await loadConversation(id);
if (!record || record.messages.length === 0) {
showCopyToast(false);
return;
}

const link = buildRenderUrl(
{
protocol,
demoUrl: DEFAULT_A2UI_DEMO_URL,
messages: [],
messagesUrl: urls.messagesUrl,
actionMocksUrl: urls.actionMocksUrl,
theme,
},
const doc = serializeConversation(record, protocol.name);
const conversationUrl = await publishConversation(doc);
const link = buildConversationShareUrl(
conversationUrl,
baseUrl,
protocol.name,
);
showCopyToast(await copyToClipboard(link));
} catch {
showCopyToast(false);
}
},
[activeId, baseUrl, protocol, showCopyToast, theme],
[baseUrl, protocol, showCopyToast],
);

const handleShareConversation = useCallback((id: string) => {
Expand Down
50 changes: 50 additions & 0 deletions packages/genui/a2ui-playground/src/storage/conversationRepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { getDB } from './db.js';
import type { SharedConversationDoc } from './sharedConversation.js';
import type {
ConversationMeta,
DataModelSnapshot,
Expand Down Expand Up @@ -131,6 +132,55 @@ export async function saveConversationMessages(
await tx.done;
}

export function previewTextFromSharedMessages(
messages: SharedConversationDoc['messages'],
): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role === 'user') {
const compact = message.content.replace(/\s+/gu, ' ').trim();
return compact.length > 80 ? `${compact.slice(0, 80)}...` : compact;
}
}
return '';
}

/**
* Write a shared conversation document into a brand-new local conversation
* (fresh id, re-sequenced messages) and return its meta. The snapshot's
* `previewMessages` is left empty on purpose — the chat page rebuilds the
* preview from the assistant message history when the conversation activates.
*/
export async function importConversation(
doc: SharedConversationDoc,
): Promise<ConversationMeta> {
const now = Date.now();
const meta: ConversationMeta = {
...createConversationMeta(doc.title || 'Shared conversation'),
messageCount: doc.messages.length,
previewText: previewTextFromSharedMessages(doc.messages),
};
const messages: PersistedMessage[] = doc.messages.map((message, index) => ({
conversationId: meta.id,
seq: index,
role: message.role,
content: message.content,
previewPayloadUrls: message.previewPayloadUrls,
previewMetrics: message.previewMetrics,
createdAt: now + index,
}));
const snapshot: DataModelSnapshot = {
conversationId: meta.id,
dataModel: doc.snapshot?.dataModel ?? {},
surfaceIds: doc.snapshot?.surfaceIds ?? [],
previewMessages: [],
previewPayloadUrls: doc.snapshot?.previewPayloadUrls,
updatedAt: now,
};
await saveConversationMessages(meta, messages, snapshot);
return meta;
}

export async function renameConversation(
id: string,
title: string,
Expand Down
Loading
Loading