feat: update UI for snapshot-based public sharing

This commit is contained in:
CREDO23 2026-01-30 14:20:06 +02:00
parent 6aff69f4ec
commit 98991d2ed4
6 changed files with 39 additions and 155 deletions

View file

@ -42,7 +42,6 @@ import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-
import { Spinner } from "@/components/ui/spinner";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric";
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -142,8 +141,6 @@ export default function NewChatPage() {
const params = useParams();
const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true);
const [isCompletingClone, setIsCompletingClone] = useState(false);
const [cloneError, setCloneError] = useState(false);
const [threadId, setThreadId] = useState<number | null>(null);
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
@ -332,42 +329,6 @@ export default function NewChatPage() {
initializeThread();
}, [initializeThread]);
// Handle clone completion when thread has clone_pending flag
useEffect(() => {
if (!currentThread?.clone_pending || isCompletingClone || cloneError) return;
const completeClone = async () => {
setIsCompletingClone(true);
try {
await publicChatApiService.completeClone({ thread_id: currentThread.id });
// Re-initialize thread to fetch cloned content using existing logic
await initializeThread();
// Invalidate threads query to update sidebar
queryClient.invalidateQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
});
} catch (error) {
console.error("[NewChatPage] Failed to complete clone:", error);
toast.error("Failed to copy chat content. Please try again.");
setCloneError(true);
} finally {
setIsCompletingClone(false);
}
};
completeClone();
}, [
currentThread?.clone_pending,
currentThread?.id,
isCompletingClone,
cloneError,
initializeThread,
queryClient,
]);
// Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams();
const targetCommentIdParam = searchParams.get("commentId");
@ -394,8 +355,6 @@ export default function NewChatPage() {
visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null,
publicShareEnabled: currentThread?.public_share_enabled ?? false,
publicShareToken: currentThread?.public_share_token ?? null,
}));
}, [currentThread, setCurrentThreadState]);
@ -1420,16 +1379,6 @@ export default function NewChatPage() {
);
}
// Show loading state while completing clone
if (isCompletingClone) {
return (
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<Spinner size="lg" />
<div className="text-sm text-muted-foreground">Copying chat content...</div>
</div>
);
}
// Show error state only if we tried to load an existing thread but failed
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) {

View file

@ -1,28 +1,31 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
TogglePublicShareRequest,
TogglePublicShareResponse,
CreateSnapshotRequest,
CreateSnapshotResponse,
} from "@/contracts/types/chat-threads.types";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
export const togglePublicShareMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: TogglePublicShareRequest) => {
return chatThreadsApiService.togglePublicShare(request);
export const createSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: CreateSnapshotRequest) => {
return chatThreadsApiService.createSnapshot(request);
},
onSuccess: (response: TogglePublicShareResponse) => {
if (response.enabled && response.share_token) {
const publicUrl = `${window.location.origin}/public/${response.share_token}`;
navigator.clipboard.writeText(publicUrl);
toast.success("Public link copied to clipboard", {
description: "Anyone with this link can view the chat",
onSuccess: (response: CreateSnapshotResponse) => {
// Construct URL using frontend origin (backend returns its own URL which differs)
const publicUrl = `${window.location.origin}/public/${response.share_token}`;
navigator.clipboard.writeText(publicUrl);
if (response.is_new) {
toast.success("Public link created and copied to clipboard", {
description: "Anyone with this link can view a snapshot of this chat",
});
} else {
toast.success("Public sharing disabled");
toast.success("Public link copied to clipboard", {
description: "This snapshot already exists",
});
}
},
onError: (error: Error) => {
console.error("Failed to toggle public share:", error);
toast.error("Failed to update public sharing");
console.error("Failed to create snapshot:", error);
toast.error("Failed to create public link");
},
}));

View file

@ -19,8 +19,6 @@ interface CurrentThreadState {
addingCommentToMessageId: number | null;
/** Whether the right-side comments panel is collapsed (desktop only) */
commentsCollapsed: boolean;
publicShareEnabled: boolean;
publicShareToken: string | null;
}
const initialState: CurrentThreadState = {
@ -29,8 +27,6 @@ const initialState: CurrentThreadState = {
hasComments: false,
addingCommentToMessageId: null,
commentsCollapsed: false,
publicShareEnabled: false,
publicShareToken: null,
};
export const currentThreadAtom = atom<CurrentThreadState>(initialState);

View file

@ -2,10 +2,10 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Globe, Link2, User, Users } from "lucide-react";
import { Globe, User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -49,19 +49,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
// Public share mutation
const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue(
togglePublicShareMutationAtom
// Snapshot creation mutation
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
createSnapshotMutationAtom
);
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
const isPublicEnabled =
currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false;
const publicShareToken = currentThreadState.publicShareToken ?? null;
const handleVisibilityChange = useCallback(
async (newVisibility: ChatVisibility) => {
@ -96,45 +92,24 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
);
const handlePublicShareToggle = useCallback(async () => {
const handleCreatePublicLink = useCallback(async () => {
if (!thread) return;
try {
const response = await togglePublicShare({
thread_id: thread.id,
enabled: !isPublicEnabled,
});
// Update atom state with response
setCurrentThreadState((prev) => ({
...prev,
publicShareEnabled: response.enabled,
publicShareToken: response.share_token,
}));
await createSnapshot({ thread_id: thread.id });
setOpen(false);
} catch (error) {
console.error("Failed to toggle public share:", error);
console.error("Failed to create public link:", error);
}
}, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]);
const handleCopyPublicLink = useCallback(async () => {
if (!publicShareToken) return;
const publicUrl = `${window.location.origin}/public/${publicShareToken}`;
await navigator.clipboard.writeText(publicUrl);
toast.success("Public link copied to clipboard");
}, [publicShareToken]);
}, [thread, createSnapshot]);
// Don't show if no thread (new chat that hasn't been created yet)
if (!thread) {
return null;
}
const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users;
const buttonLabel = isPublicEnabled
? "Public"
: currentVisibility === "PRIVATE"
? "Private"
: "Shared";
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
return (
<Popover open={open} onOpenChange={setOpen}>
@ -211,67 +186,31 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{/* Divider */}
<div className="border-t border-border my-1" />
{/* Public Share Option */}
{/* Public Link Option */}
<button
type="button"
onClick={handlePublicShareToggle}
disabled={isTogglingPublic}
onClick={handleCreatePublicLink}
disabled={isCreatingSnapshot}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed",
isPublicEnabled && "bg-accent/80"
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div
className={cn(
"size-7 rounded-md shrink-0 grid place-items-center",
isPublicEnabled ? "bg-primary/10" : "bg-muted"
)}
>
<Globe
className={cn(
"size-4 block",
isPublicEnabled ? "text-primary" : "text-muted-foreground"
)}
/>
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
<Globe className="size-4 block text-muted-foreground" />
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn("text-sm font-medium", isPublicEnabled && "text-primary")}>
Public
<span className="text-sm font-medium">
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
</span>
{isPublicEnabled && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
ON
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Anyone with the link can read
Creates a shareable snapshot of this chat
</p>
</div>
{isPublicEnabled && publicShareToken && (
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleCopyPublicLink();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
handleCopyPublicLink();
}
}}
className="shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors cursor-pointer"
title="Copy public link"
>
<Link2 className="size-4 text-muted-foreground" />
</div>
)}
</button>
</div>
</PopoverContent>

View file

@ -26,7 +26,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
share_token: shareToken,
});
// Redirect to the new chat page (content will be loaded there)
// Redirect to the new chat page with cloned content
router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to copy chat";

View file

@ -24,9 +24,6 @@ export interface ThreadRecord {
created_at: string;
updated_at: string;
has_comments?: boolean;
public_share_enabled?: boolean;
public_share_token?: string | null;
clone_pending?: boolean;
}
export interface MessageRecord {