feat: Add frontend support for public conversation sharing (#12047)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Tim O'Farrell
2025-12-29 12:04:06 -07:00
committed by GitHub
parent 1480d4acb0
commit d628e1f20a
15 changed files with 503 additions and 2 deletions

View File

@@ -298,6 +298,23 @@ class V1ConversationService {
return data;
}
/**
* Update a V1 conversation's public flag
* @param conversationId The conversation ID
* @param isPublic Whether the conversation should be public
* @returns Updated conversation info
*/
static async updateConversationPublicFlag(
conversationId: string,
isPublic: boolean,
): Promise<V1AppConversation> {
const { data } = await openHands.patch<V1AppConversation>(
`/api/v1/app-conversations/${conversationId}`,
{ public: isPublic },
);
return data;
}
/**
* Read a file from a specific conversation's sandbox workspace
* @param conversationId The conversation ID

View File

@@ -98,6 +98,7 @@ export interface V1AppConversation {
execution_status: V1ConversationExecutionStatus | null;
conversation_url: string | null;
session_api_key: string | null;
public?: boolean;
}
export interface Skill {

View File

@@ -78,6 +78,7 @@ export interface Conversation {
pr_number?: number[] | null;
conversation_version?: "V0" | "V1";
sub_conversation_ids?: string[];
public?: boolean;
}
export interface ResultSet<T> {

View File

@@ -0,0 +1,60 @@
import { OpenHandsEvent } from "#/types/v1/core";
import { openHands } from "./open-hands-axios";
export interface SharedConversation {
id: string;
created_by_user_id: string | null;
sandbox_id: string;
selected_repository: string | null;
selected_branch: string | null;
git_provider: string | null;
title: string | null;
pr_number: number[];
llm_model: string | null;
metrics: unknown | null;
parent_conversation_id: string | null;
sub_conversation_ids: string[];
created_at: string;
updated_at: string;
}
export interface EventPage {
items: OpenHandsEvent[];
next_page_id: string | null;
}
export const sharedConversationService = {
/**
* Get a single shared conversation by ID
*/
async getSharedConversation(
conversationId: string,
): Promise<SharedConversation | null> {
const response = await openHands.get<(SharedConversation | null)[]>(
"/api/shared-conversations",
{ params: { ids: conversationId } },
);
return response.data[0] || null;
},
/**
* Get events for a shared conversation
*/
async getSharedConversationEvents(
conversationId: string,
limit: number = 100,
pageId?: string,
): Promise<EventPage> {
const response = await openHands.get<EventPage>(
"/api/shared-events/search",
{
params: {
conversation_id: conversationId,
limit,
...(pageId && { page_id: pageId }),
},
},
);
return response.data;
},
};

View File

@@ -7,6 +7,7 @@ import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { Divider } from "#/ui/divider";
import { I18nKey } from "#/i18n/declaration";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useConfig } from "#/hooks/query/use-config";
import EditIcon from "#/icons/u-edit.svg?react";
import RobotIcon from "#/icons/u-robot.svg?react";
@@ -16,6 +17,7 @@ import DownloadIcon from "#/icons/u-download.svg?react";
import CreditCardIcon from "#/icons/u-credit-card.svg?react";
import CloseIcon from "#/icons/u-close.svg?react";
import DeleteIcon from "#/icons/u-delete.svg?react";
import LinkIcon from "#/icons/link-external.svg?react";
import { ConversationNameContextMenuIconText } from "./conversation-name-context-menu-icon-text";
import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants";
@@ -34,7 +36,9 @@ interface ConversationNameContextMenuProps {
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onTogglePublic?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onCopyShareLink?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@@ -48,7 +52,9 @@ export function ConversationNameContextMenu({
onShowSkills,
onExportConversation,
onDownloadViaVSCode,
onTogglePublic,
onDownloadConversation,
onCopyShareLink,
position = "bottom",
}: ConversationNameContextMenuProps) {
const { width } = useWindowSize();
@@ -56,10 +62,16 @@ export function ConversationNameContextMenu({
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { data: conversation } = useActiveConversation();
const { data: config } = useConfig();
// This is a temporary measure and may be re-enabled in the future
const isV1Conversation = conversation?.conversation_version === "V1";
// Check if we should show the public sharing option
// Only show for V1 conversations in SAAS mode
const shouldShowPublicSharing =
isV1Conversation && config?.APP_MODE === "saas" && onTogglePublic;
const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
const hasExport = Boolean(onExportConversation);
const hasTools = Boolean(onShowAgentTools || onShowSkills);
@@ -182,6 +194,36 @@ export function ConversationNameContextMenu({
</ContextMenuListItem>
)}
{shouldShowPublicSharing && (
<ContextMenuListItem
testId="share-publicly-button"
onClick={onTogglePublic}
className={contextMenuListItemClassName}
>
<div className="flex items-center gap-2 justify-between w-full">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={conversation?.public || false}
className="w-4 h-4 ml-2"
/>
<span>{t(I18nKey.CONVERSATION$SHARE_PUBLICLY)}</span>
</div>
{conversation?.public && onCopyShareLink && (
<button
type="button"
data-testid="copy-share-link-button"
onClick={onCopyShareLink}
className="p-1 hover:bg-[#717888] rounded"
title={t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)}
>
<LinkIcon width={16} height={16} />
</button>
)}
</div>
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem
testId="stop-button"

View File

@@ -6,6 +6,7 @@ import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation"
import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { ENABLE_PUBLIC_CONVERSATION_SHARING } from "#/utils/feature-flags";
import { EllipsisButton } from "../conversation-panel/ellipsis-button";
import { ConversationNameContextMenu } from "./conversation-name-context-menu";
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
@@ -35,6 +36,8 @@ export function ConversationName() {
handleShowAgentTools,
handleShowSkills,
handleExportConversation,
handleTogglePublic,
handleCopyShareLink,
handleConfirmDelete,
handleConfirmStop,
metricsModalVisible,
@@ -179,6 +182,16 @@ export function ConversationName() {
onDownloadViaVSCode={
shouldShowDownload ? handleDownloadViaVSCode : undefined
}
onTogglePublic={
ENABLE_PUBLIC_CONVERSATION_SHARING()
? handleTogglePublic
: undefined
}
onCopyShareLink={
ENABLE_PUBLIC_CONVERSATION_SHARING()
? handleCopyShareLink
: undefined
}
onDownloadConversation={
shouldShowDownloadConversation
? handleDownloadConversation

View File

@@ -0,0 +1,70 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { I18nKey } from "#/i18n/declaration";
import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
export const useUpdateConversationPublicFlag = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (variables: { conversationId: string; isPublic: boolean }) =>
V1ConversationService.updateConversationPublicFlag(
variables.conversationId,
variables.isPublic,
),
onMutate: async (variables) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: ["user", "conversation", variables.conversationId],
});
// Snapshot the previous value
const previousConversation = queryClient.getQueryData([
"user",
"conversation",
variables.conversationId,
]);
// Optimistically update the conversation
queryClient.setQueryData(
["user", "conversation", variables.conversationId],
(old: unknown) =>
old && typeof old === "object"
? { ...old, public: variables.isPublic }
: old,
);
return { previousConversation };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousConversation) {
queryClient.setQueryData(
["user", "conversation", variables.conversationId],
context.previousConversation,
);
}
displayErrorToast(
t(I18nKey.CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING),
);
},
onSuccess: () => {
displaySuccessToast(t(I18nKey.CONVERSATION$PUBLIC_SHARING_UPDATED));
},
onSettled: (data, error, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({
queryKey: ["user", "conversation", variables.conversationId],
});
// Also invalidate the conversations list to update any cached data
queryClient.invalidateQueries({
queryKey: ["user", "conversations"],
});
},
});
};

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { sharedConversationService } from "#/api/shared-conversation-service.api";
export const useSharedConversationEvents = (conversationId?: string) =>
useQuery({
queryKey: ["shared-conversation-events", conversationId],
queryFn: () => {
if (!conversationId) {
throw new Error("Conversation ID is required");
}
return sharedConversationService.getSharedConversationEvents(
conversationId,
);
},
enabled: !!conversationId,
retry: false, // Don't retry for shared conversations
});

View File

@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { sharedConversationService } from "#/api/shared-conversation-service.api";
export const useSharedConversation = (conversationId?: string) =>
useQuery({
queryKey: ["shared-conversation", conversationId],
queryFn: () => {
if (!conversationId) {
throw new Error("Conversation ID is required");
}
return sharedConversationService.getSharedConversation(conversationId);
},
enabled: !!conversationId,
retry: false, // Don't retry for shared conversations
});

View File

@@ -10,8 +10,12 @@ import ConversationService from "#/api/conversation-service/conversation-service
import { useDeleteConversation } from "./mutation/use-delete-conversation";
import { useUnifiedPauseConversationSandbox } from "./mutation/use-unified-stop-conversation";
import { useGetTrajectory } from "./mutation/use-get-trajectory";
import { useUpdateConversationPublicFlag } from "./mutation/use-update-conversation-public-flag";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { useEventStore } from "#/stores/use-event-store";
import { isV0Event } from "#/types/v1/type-guards";
@@ -36,10 +40,11 @@ export function useConversationNameContextMenu({
const { conversationId: currentConversationId } = useParams();
const navigate = useNavigate();
const events = useEventStore((state) => state.events);
const { data: conversation } = useActiveConversation();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
const { mutate: getTrajectory } = useGetTrajectory();
const { mutate: updatePublicFlag } = useUpdateConversationPublicFlag();
const { data: conversation } = useActiveConversation();
const metrics = useMetricsStore();
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
@@ -181,6 +186,35 @@ export function useConversationNameContextMenu({
onContextMenuToggle?.(false);
};
const handleTogglePublic = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId && conversation) {
// Toggle the current public state
const newPublicState = !conversation.public;
updatePublicFlag({
conversationId,
isPublic: newPublicState,
});
}
onContextMenuToggle?.(false);
};
const handleCopyShareLink = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId) {
const shareUrl = `${window.location.origin}/shared/conversations/${conversationId}`;
navigator.clipboard.writeText(shareUrl);
displaySuccessToast(t(I18nKey.CONVERSATION$LINK_COPIED));
}
onContextMenuToggle?.(false);
};
return {
// Handlers
handleDelete,
@@ -192,6 +226,8 @@ export function useConversationNameContextMenu({
handleDisplayCost,
handleShowAgentTools,
handleShowSkills,
handleTogglePublic,
handleCopyShareLink,
handleConfirmDelete,
handleConfirmStop,

View File

@@ -965,4 +965,11 @@ export enum I18nKey {
OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY",
CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE",
CONVERSATION$SHARE_PUBLICLY = "CONVERSATION$SHARE_PUBLICLY",
CONVERSATION$PUBLIC_SHARING_UPDATED = "CONVERSATION$PUBLIC_SHARING_UPDATED",
CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING = "CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING",
CONVERSATION$NOT_FOUND = "CONVERSATION$NOT_FOUND",
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED",
}

View File

@@ -15438,5 +15438,117 @@
"es": "Habilidades disponibles",
"tr": "Kullanılabilir yetenekler",
"uk": "Доступні навички"
},
"CONVERSATION$SHARE_PUBLICLY": {
"en": "Public Share",
"ja": "公開で共有",
"zh-CN": "公开分享",
"zh-TW": "公開分享",
"ko-KR": "공개적으로 공유",
"no": "Del offentlig",
"it": "Condividi pubblicamente",
"pt": "Compartilhar publicamente",
"es": "Compartir públicamente",
"ar": "مشاركة عامة",
"fr": "Partager publiquement",
"tr": "Herkese açık paylaş",
"de": "Öffentlich teilen",
"uk": "Поділитися публічно"
},
"CONVERSATION$PUBLIC_SHARING_UPDATED": {
"en": "Public sharing updated",
"ja": "公開共有が更新されました",
"zh-CN": "公开分享已更新",
"zh-TW": "公開分享已更新",
"ko-KR": "공개 공유가 업데이트되었습니다",
"no": "Offentlig deling oppdatert",
"it": "Condivisione pubblica aggiornata",
"pt": "Compartilhamento público atualizado",
"es": "Compartir público actualizado",
"ar": "تم تحديث المشاركة العامة",
"fr": "Partage public mis à jour",
"tr": "Herkese açık paylaşım güncellendi",
"de": "Öffentliche Freigabe aktualisiert",
"uk": "Публічний доступ оновлено"
},
"CONVERSATION$FAILED_TO_UPDATE_PUBLIC_SHARING": {
"en": "Failed to update public sharing",
"ja": "公開共有の更新に失敗しました",
"zh-CN": "更新公开分享失败",
"zh-TW": "更新公開分享失敗",
"ko-KR": "공개 공유 업데이트에 실패했습니다",
"no": "Kunne ikke oppdatere offentlig deling",
"it": "Impossibile aggiornare la condivisione pubblica",
"pt": "Falha ao atualizar compartilhamento público",
"es": "Error al actualizar compartir público",
"ar": "فشل في تحديث المشاركة العامة",
"fr": "Échec de la mise à jour du partage public",
"tr": "Herkese açık paylaşım güncellenemedi",
"de": "Fehler beim Aktualisieren der öffentlichen Freigabe",
"uk": "Не вдалося оновити публічний доступ"
},
"CONVERSATION$NOT_FOUND": {
"en": "Conversation not found",
"ja": "会話が見つかりません",
"zh-CN": "未找到对话",
"zh-TW": "找不到對話",
"ko-KR": "대화를 찾을 수 없습니다",
"no": "Samtale ikke funnet",
"it": "Conversazione non trovata",
"pt": "Conversa não encontrada",
"es": "Conversación no encontrada",
"ar": "المحادثة غير موجودة",
"fr": "Conversation introuvable",
"tr": "Konuşma bulunamadı",
"de": "Unterhaltung nicht gefunden",
"uk": "Розмову не знайдено"
},
"CONVERSATION$NO_HISTORY_AVAILABLE": {
"en": "No conversation history available",
"ja": "会話履歴がありません",
"zh-CN": "没有可用的对话历史",
"zh-TW": "沒有可用的對話歷史",
"ko-KR": "사용 가능한 대화 기록이 없습니다",
"no": "Ingen samtalehistorikk tilgjengelig",
"it": "Nessuna cronologia conversazione disponibile",
"pt": "Nenhum histórico de conversa disponível",
"es": "No hay historial de conversación disponible",
"ar": "لا يوجد تاريخ محادثة متاح",
"fr": "Aucun historique de conversation disponible",
"tr": "Kullanılabilir konuşma geçmişi yok",
"de": "Keine Unterhaltungshistorie verfügbar",
"uk": "Історія розмов недоступна"
},
"CONVERSATION$SHARED_CONVERSATION": {
"en": "Shared Conversation",
"ja": "公開会話",
"zh-CN": "公开对话",
"zh-TW": "公開對話",
"ko-KR": "공개 대화",
"no": "Offentlig samtale",
"it": "Conversazione pubblica",
"pt": "Conversa pública",
"es": "Conversación pública",
"ar": "محادثة عامة",
"fr": "Conversation publique",
"tr": "Herkese açık konuşma",
"de": "Öffentliche Unterhaltung",
"uk": "Публічна розмова"
},
"CONVERSATION$LINK_COPIED": {
"en": "Link copied to clipboard",
"ja": "リンクをクリップボードにコピーしました",
"zh-CN": "链接已复制到剪贴板",
"zh-TW": "連結已複製到剪貼簿",
"ko-KR": "링크가 클립보드에 복사되었습니다",
"no": "Lenke kopiert til utklippstavle",
"it": "Link copiato negli appunti",
"pt": "Link copiado para a área de transferência",
"es": "Enlace copiado al portapapeles",
"ar": "تم نسخ الرابط إلى الحافظة",
"fr": "Lien copié dans le presse-papiers",
"tr": "Bağlantı panoya kopyalandı",
"de": "Link in die Zwischenablage kopiert",
"uk": "Посилання скопійовано в буфер обміну"
}
}

View File

@@ -23,4 +23,9 @@ export default [
route("microagent-management", "routes/microagent-management.tsx"),
route("oauth/device/verify", "routes/device-verify.tsx"),
]),
// Shared routes that don't require authentication
route(
"shared/conversations/:conversationId",
"routes/shared-conversation.tsx",
),
] satisfies RouteConfig;

View File

@@ -0,0 +1,103 @@
import React from "react";
import { Link, useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useSharedConversation } from "#/hooks/query/use-shared-conversation";
import { useSharedConversationEvents } from "#/hooks/query/use-shared-conversation-events";
import { Messages as V1Messages } from "#/components/v1/chat";
import { shouldRenderEvent } from "#/components/v1/chat/event-content-helpers/should-render-event";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
export default function SharedConversation() {
const { t } = useTranslation();
const { conversationId } = useParams<{ conversationId: string }>();
const {
data: conversation,
isLoading: isLoadingConversation,
error: conversationError,
} = useSharedConversation(conversationId);
const {
data: eventsData,
isLoading: isLoadingEvents,
error: eventsError,
} = useSharedConversationEvents(conversationId);
const isLoading = isLoadingConversation || isLoadingEvents;
const error = conversationError || eventsError;
// Transform shared events to V1 format
const v1Events = eventsData?.items || [];
// Filter events that should be rendered
const renderableEvents = React.useMemo(
() => v1Events.filter(shouldRenderEvent),
[v1Events],
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-neutral-900">
<LoadingSpinner size="large" />
</div>
);
}
if (error || !conversation) {
return (
<div className="flex items-center justify-center h-screen bg-neutral-900">
<div className="text-white">{t(I18nKey.CONVERSATION$NOT_FOUND)}</div>
</div>
);
}
return (
<div className="h-screen bg-neutral-900 text-white flex flex-col">
{/* Header with logo, conversation title and branch info */}
<div className="border-b border-neutral-700 p-4 flex-shrink-0">
<div className="max-w-4xl mx-auto flex items-start gap-4">
<Link
to="/"
className="flex-shrink-0"
aria-label={t(I18nKey.BRANDING$OPENHANDS_LOGO)}
>
<OpenHandsLogo width={46} height={30} />
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold mb-2">
{conversation?.title ||
t(I18nKey.CONVERSATION$SHARED_CONVERSATION)}
</h1>
{conversation?.selected_branch && (
<div className="text-sm text-neutral-400">
{t(I18nKey.CONVERSATION$BRANCH)}: {conversation.selected_branch}
</div>
)}
{conversation?.selected_repository && (
<div className="text-sm text-neutral-400">
{t(I18nKey.CONVERSATION$REPOSITORY)}:{" "}
{conversation.selected_repository}
</div>
)}
</div>
</div>
</div>
{/* Chat panel - read-only */}
<div className="flex-1 overflow-y-auto custom-scrollbar-always px-4 pt-4 gap-2">
<div className="max-w-4xl mx-auto p-4 border border-neutral-700 rounded">
{renderableEvents.length > 0 ? (
<V1Messages messages={renderableEvents} allEvents={v1Events} />
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center text-neutral-400 py-8">
{t(I18nKey.CONVERSATION$NO_HISTORY_AVAILABLE)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -18,3 +18,5 @@ export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
export const USE_PLANNING_AGENT = () => loadFeatureFlag("USE_PLANNING_AGENT");
export const ENABLE_PUBLIC_CONVERSATION_SHARING = () =>
loadFeatureFlag("PUBLIC_CONVERSATION_SHARING");