mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -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;
|
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
|
* Read a file from a specific conversation's sandbox workspace
|
||||||
* @param conversationId The conversation ID
|
* @param conversationId The conversation ID
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface V1AppConversation {
|
|||||||
execution_status: V1ConversationExecutionStatus | null;
|
execution_status: V1ConversationExecutionStatus | null;
|
||||||
conversation_url: string | null;
|
conversation_url: string | null;
|
||||||
session_api_key: string | null;
|
session_api_key: string | null;
|
||||||
|
public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export interface Conversation {
|
|||||||
pr_number?: number[] | null;
|
pr_number?: number[] | null;
|
||||||
conversation_version?: "V0" | "V1";
|
conversation_version?: "V0" | "V1";
|
||||||
sub_conversation_ids?: string[];
|
sub_conversation_ids?: string[];
|
||||||
|
public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResultSet<T> {
|
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 { Divider } from "#/ui/divider";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||||
|
import { useConfig } from "#/hooks/query/use-config";
|
||||||
|
|
||||||
import EditIcon from "#/icons/u-edit.svg?react";
|
import EditIcon from "#/icons/u-edit.svg?react";
|
||||||
import RobotIcon from "#/icons/u-robot.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 CreditCardIcon from "#/icons/u-credit-card.svg?react";
|
||||||
import CloseIcon from "#/icons/u-close.svg?react";
|
import CloseIcon from "#/icons/u-close.svg?react";
|
||||||
import DeleteIcon from "#/icons/u-delete.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 { ConversationNameContextMenuIconText } from "./conversation-name-context-menu-icon-text";
|
||||||
import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants";
|
import { CONTEXT_MENU_ICON_TEXT_CLASSNAME } from "#/utils/constants";
|
||||||
|
|
||||||
@@ -34,7 +36,9 @@ interface ConversationNameContextMenuProps {
|
|||||||
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
onTogglePublic?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onDownloadConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
onCopyShareLink?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
position?: "top" | "bottom";
|
position?: "top" | "bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +52,9 @@ export function ConversationNameContextMenu({
|
|||||||
onShowSkills,
|
onShowSkills,
|
||||||
onExportConversation,
|
onExportConversation,
|
||||||
onDownloadViaVSCode,
|
onDownloadViaVSCode,
|
||||||
|
onTogglePublic,
|
||||||
onDownloadConversation,
|
onDownloadConversation,
|
||||||
|
onCopyShareLink,
|
||||||
position = "bottom",
|
position = "bottom",
|
||||||
}: ConversationNameContextMenuProps) {
|
}: ConversationNameContextMenuProps) {
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
@@ -56,10 +62,16 @@ export function ConversationNameContextMenu({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||||
const { data: conversation } = useActiveConversation();
|
const { data: conversation } = useActiveConversation();
|
||||||
|
const { data: config } = useConfig();
|
||||||
|
|
||||||
// This is a temporary measure and may be re-enabled in the future
|
// This is a temporary measure and may be re-enabled in the future
|
||||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
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 hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
|
||||||
const hasExport = Boolean(onExportConversation);
|
const hasExport = Boolean(onExportConversation);
|
||||||
const hasTools = Boolean(onShowAgentTools || onShowSkills);
|
const hasTools = Boolean(onShowAgentTools || onShowSkills);
|
||||||
@@ -182,6 +194,36 @@ export function ConversationNameContextMenu({
|
|||||||
</ContextMenuListItem>
|
</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 && (
|
{onStop && (
|
||||||
<ContextMenuListItem
|
<ContextMenuListItem
|
||||||
testId="stop-button"
|
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 { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu";
|
||||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||||
import { I18nKey } from "#/i18n/declaration";
|
import { I18nKey } from "#/i18n/declaration";
|
||||||
|
import { ENABLE_PUBLIC_CONVERSATION_SHARING } from "#/utils/feature-flags";
|
||||||
import { EllipsisButton } from "../conversation-panel/ellipsis-button";
|
import { EllipsisButton } from "../conversation-panel/ellipsis-button";
|
||||||
import { ConversationNameContextMenu } from "./conversation-name-context-menu";
|
import { ConversationNameContextMenu } from "./conversation-name-context-menu";
|
||||||
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
|
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
|
||||||
@@ -35,6 +36,8 @@ export function ConversationName() {
|
|||||||
handleShowAgentTools,
|
handleShowAgentTools,
|
||||||
handleShowSkills,
|
handleShowSkills,
|
||||||
handleExportConversation,
|
handleExportConversation,
|
||||||
|
handleTogglePublic,
|
||||||
|
handleCopyShareLink,
|
||||||
handleConfirmDelete,
|
handleConfirmDelete,
|
||||||
handleConfirmStop,
|
handleConfirmStop,
|
||||||
metricsModalVisible,
|
metricsModalVisible,
|
||||||
@@ -179,6 +182,16 @@ export function ConversationName() {
|
|||||||
onDownloadViaVSCode={
|
onDownloadViaVSCode={
|
||||||
shouldShowDownload ? handleDownloadViaVSCode : undefined
|
shouldShowDownload ? handleDownloadViaVSCode : undefined
|
||||||
}
|
}
|
||||||
|
onTogglePublic={
|
||||||
|
ENABLE_PUBLIC_CONVERSATION_SHARING()
|
||||||
|
? handleTogglePublic
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onCopyShareLink={
|
||||||
|
ENABLE_PUBLIC_CONVERSATION_SHARING()
|
||||||
|
? handleCopyShareLink
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDownloadConversation={
|
onDownloadConversation={
|
||||||
shouldShowDownloadConversation
|
shouldShowDownloadConversation
|
||||||
? handleDownloadConversation
|
? 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 { useDeleteConversation } from "./mutation/use-delete-conversation";
|
||||||
import { useUnifiedPauseConversationSandbox } from "./mutation/use-unified-stop-conversation";
|
import { useUnifiedPauseConversationSandbox } from "./mutation/use-unified-stop-conversation";
|
||||||
import { useGetTrajectory } from "./mutation/use-get-trajectory";
|
import { useGetTrajectory } from "./mutation/use-get-trajectory";
|
||||||
|
import { useUpdateConversationPublicFlag } from "./mutation/use-update-conversation-public-flag";
|
||||||
import { downloadTrajectory } from "#/utils/download-trajectory";
|
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 { I18nKey } from "#/i18n/declaration";
|
||||||
import { useEventStore } from "#/stores/use-event-store";
|
import { useEventStore } from "#/stores/use-event-store";
|
||||||
import { isV0Event } from "#/types/v1/type-guards";
|
import { isV0Event } from "#/types/v1/type-guards";
|
||||||
@@ -36,10 +40,11 @@ export function useConversationNameContextMenu({
|
|||||||
const { conversationId: currentConversationId } = useParams();
|
const { conversationId: currentConversationId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const events = useEventStore((state) => state.events);
|
const events = useEventStore((state) => state.events);
|
||||||
const { data: conversation } = useActiveConversation();
|
|
||||||
const { mutate: deleteConversation } = useDeleteConversation();
|
const { mutate: deleteConversation } = useDeleteConversation();
|
||||||
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
|
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
|
||||||
const { mutate: getTrajectory } = useGetTrajectory();
|
const { mutate: getTrajectory } = useGetTrajectory();
|
||||||
|
const { mutate: updatePublicFlag } = useUpdateConversationPublicFlag();
|
||||||
|
const { data: conversation } = useActiveConversation();
|
||||||
const metrics = useMetricsStore();
|
const metrics = useMetricsStore();
|
||||||
|
|
||||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||||
@@ -181,6 +186,35 @@ export function useConversationNameContextMenu({
|
|||||||
onContextMenuToggle?.(false);
|
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 {
|
return {
|
||||||
// Handlers
|
// Handlers
|
||||||
handleDelete,
|
handleDelete,
|
||||||
@@ -192,6 +226,8 @@ export function useConversationNameContextMenu({
|
|||||||
handleDisplayCost,
|
handleDisplayCost,
|
||||||
handleShowAgentTools,
|
handleShowAgentTools,
|
||||||
handleShowSkills,
|
handleShowSkills,
|
||||||
|
handleTogglePublic,
|
||||||
|
handleCopyShareLink,
|
||||||
handleConfirmDelete,
|
handleConfirmDelete,
|
||||||
handleConfirmStop,
|
handleConfirmStop,
|
||||||
|
|
||||||
|
|||||||
@@ -965,4 +965,11 @@ export enum I18nKey {
|
|||||||
OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY",
|
OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY",
|
||||||
CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
|
CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
|
||||||
SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE",
|
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",
|
"es": "Habilidades disponibles",
|
||||||
"tr": "Kullanılabilir yetenekler",
|
"tr": "Kullanılabilir yetenekler",
|
||||||
"uk": "Доступні навички"
|
"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("microagent-management", "routes/microagent-management.tsx"),
|
||||||
route("oauth/device/verify", "routes/device-verify.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;
|
] 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 = () =>
|
export const ENABLE_TRAJECTORY_REPLAY = () =>
|
||||||
loadFeatureFlag("TRAJECTORY_REPLAY");
|
loadFeatureFlag("TRAJECTORY_REPLAY");
|
||||||
export const USE_PLANNING_AGENT = () => loadFeatureFlag("USE_PLANNING_AGENT");
|
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