mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
60
frontend/src/api/shared-conversation-service.api.ts
Normal file
60
frontend/src/api/shared-conversation-service.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
17
frontend/src/hooks/query/use-shared-conversation-events.ts
Normal file
17
frontend/src/hooks/query/use-shared-conversation-events.ts
Normal 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
|
||||
});
|
||||
15
frontend/src/hooks/query/use-shared-conversation.ts
Normal file
15
frontend/src/hooks/query/use-shared-conversation.ts
Normal 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
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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": "Посилання скопійовано в буфер обміну"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
103
frontend/src/routes/shared-conversation.tsx
Normal file
103
frontend/src/routes/shared-conversation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user