diff --git a/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts
new file mode 100644
index 000000000..a0343fb9d
--- /dev/null
+++ b/surfsense_web/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms.ts
@@ -0,0 +1,17 @@
+import { atomWithMutation } from "jotai-tanstack-query";
+import { toast } from "sonner";
+import type { PublicChatSnapshotDeleteRequest } from "@/contracts/types/chat-threads.types";
+import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
+
+export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({
+ mutationFn: async (request: PublicChatSnapshotDeleteRequest) => {
+ return chatThreadsApiService.deletePublicChatSnapshot(request);
+ },
+ onSuccess: () => {
+ toast.success("Public link deleted");
+ },
+ onError: (error: Error) => {
+ console.error("Failed to delete public chat link:", error);
+ toast.error("Failed to delete public link");
+ },
+}));
diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx
new file mode 100644
index 000000000..696d32466
--- /dev/null
+++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshot-row.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { Copy, MessageSquare, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
+
+interface PublicChatSnapshotRowProps {
+ snapshot: PublicChatSnapshotDetail;
+ canDelete: boolean;
+ onCopy: (snapshot: PublicChatSnapshotDetail) => void;
+ onDelete: (snapshot: PublicChatSnapshotDetail) => void;
+ isDeleting?: boolean;
+}
+
+export function PublicChatSnapshotRow({
+ snapshot,
+ canDelete,
+ onCopy,
+ onDelete,
+ isDeleting = false,
+}: PublicChatSnapshotRowProps) {
+ const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+
+ return (
+
+
+
+ {snapshot.thread_title}
+
+
+ {formattedDate}
+
+
+ {snapshot.message_count}
+
+
+
+
+ onCopy(snapshot)}
+ className="h-8 px-2"
+ title="Copy link"
+ >
+
+
+ {canDelete && (
+ onDelete(snapshot)}
+ disabled={isDeleting}
+ className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
+ title="Delete link"
+ >
+
+
+ )}
+
+
+ );
+}
diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx
new file mode 100644
index 000000000..4bb295217
--- /dev/null
+++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { Link2Off } from "lucide-react";
+
+interface PublicChatSnapshotsEmptyStateProps {
+ title?: string;
+ description?: string;
+}
+
+export function PublicChatSnapshotsEmptyState({
+ title = "No public chat links",
+ description = "When you create public links to share chats, they will appear here.",
+}: PublicChatSnapshotsEmptyStateProps) {
+ return (
+
+
+
+
+
{title}
+
{description}
+
+ );
+}
diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx
new file mode 100644
index 000000000..38c435059
--- /dev/null
+++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-list.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
+import { PublicChatSnapshotRow } from "./public-chat-snapshot-row";
+import { PublicChatSnapshotsEmptyState } from "./public-chat-snapshots-empty-state";
+
+interface PublicChatSnapshotsListProps {
+ snapshots: PublicChatSnapshotDetail[];
+ canDelete: boolean;
+ onCopy: (snapshot: PublicChatSnapshotDetail) => void;
+ onDelete: (snapshot: PublicChatSnapshotDetail) => void;
+ deletingId?: number;
+}
+
+export function PublicChatSnapshotsList({
+ snapshots,
+ canDelete,
+ onCopy,
+ onDelete,
+ deletingId,
+}: PublicChatSnapshotsListProps) {
+ if (snapshots.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {snapshots.map((snapshot) => (
+
+ ))}
+
+ );
+}
diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx
new file mode 100644
index 000000000..a24efcd77
--- /dev/null
+++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-manager.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useAtomValue } from "jotai";
+import { Globe, Info } from "lucide-react";
+import { useCallback, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
+import { publicChatSnapshotsAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms";
+import { myAccessAtom } from "@/atoms/members/members-query.atoms";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
+import { PublicChatSnapshotsList } from "./public-chat-snapshots-list";
+
+interface PublicChatSnapshotsManagerProps {
+ searchSpaceId: number;
+}
+
+export function PublicChatSnapshotsManager({
+ searchSpaceId: _searchSpaceId,
+}: PublicChatSnapshotsManagerProps) {
+ const [deletingId, setDeletingId] = useState();
+
+ // Data fetching
+ const { data: snapshotsData, isLoading, refetch } = useAtomValue(publicChatSnapshotsAtom);
+
+ // Permissions
+ const { data: access } = useAtomValue(myAccessAtom);
+ const canView = useMemo(() => {
+ if (!access) return false;
+ if (access.is_owner) return true;
+ return access.permissions?.includes("public_sharing:view") ?? false;
+ }, [access]);
+
+ const canDelete = useMemo(() => {
+ if (!access) return false;
+ if (access.is_owner) return true;
+ return access.permissions?.includes("public_sharing:delete") ?? false;
+ }, [access]);
+
+ // Mutations
+ const { mutateAsync: deleteSnapshot } = useAtomValue(deletePublicChatSnapshotMutationAtom);
+
+ // Handlers
+ const handleCopy = useCallback((snapshot: PublicChatSnapshotDetail) => {
+ const publicUrl = `${window.location.origin}/public/${snapshot.share_token}`;
+ navigator.clipboard.writeText(publicUrl);
+ toast.success("Link copied to clipboard");
+ }, []);
+
+ const handleDelete = useCallback(
+ async (snapshot: PublicChatSnapshotDetail) => {
+ try {
+ setDeletingId(snapshot.id);
+ await deleteSnapshot({
+ thread_id: snapshot.thread_id,
+ snapshot_id: snapshot.id,
+ });
+ await refetch();
+ } catch (error) {
+ console.error("Failed to delete snapshot:", error);
+ } finally {
+ setDeletingId(undefined);
+ }
+ },
+ [deleteSnapshot, refetch]
+ );
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Permission denied
+ if (!canView) {
+ return (
+
+
+
+ You don't have permission to view public chat links in this search space.
+
+
+ );
+ }
+
+ const snapshots = snapshotsData?.snapshots ?? [];
+
+ return (
+
+
+
+
+ Public chat links allow anyone with the URL to view a snapshot of a chat. These links do
+ not update when the original chat changes.
+
+
+
+
+
+
+
+ Public Chat Links
+
+
+ Manage public links to chats in this search space.
+
+
+
+
+
+
+
+ );
+}