diff --git a/surfsense_web/app/public/[token]/page.tsx b/surfsense_web/app/public/[token]/page.tsx
new file mode 100644
index 000000000..530664ac6
--- /dev/null
+++ b/surfsense_web/app/public/[token]/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import { PublicChatView } from "@/components/public-chat/public-chat-view";
+
+export default function PublicChatPage() {
+ const params = useParams();
+ const token = params.token as string;
+
+ return ;
+}
diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx
index 2a8820bd6..c83d3556a 100644
--- a/surfsense_web/components/homepage/navbar.tsx
+++ b/surfsense_web/components/homepage/navbar.tsx
@@ -1,5 +1,11 @@
"use client";
-import { IconBrandDiscord, IconBrandGithub, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react";
+import {
+ IconBrandDiscord,
+ IconBrandGithub,
+ IconBrandReddit,
+ IconMenu2,
+ IconX,
+} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx
new file mode 100644
index 000000000..06e3d9975
--- /dev/null
+++ b/surfsense_web/components/public-chat/public-chat-footer.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { Copy, Loader2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
+import { getBearerToken } from "@/lib/auth-utils";
+
+interface PublicChatFooterProps {
+ shareToken: string;
+}
+
+export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
+ const router = useRouter();
+ const [isCloning, setIsCloning] = useState(false);
+
+ const handleCopyAndContinue = async () => {
+ const token = getBearerToken();
+
+ if (!token) {
+ const returnUrl = encodeURIComponent(`/public/${shareToken}`);
+ router.push(`/login?returnUrl=${returnUrl}&action=clone`);
+ return;
+ }
+
+ setIsCloning(true);
+
+ try {
+ await publicChatApiService.clonePublicChat({
+ share_token: shareToken,
+ });
+
+ toast.success("Copying chat to your account...", {
+ description: "You'll be notified when it's ready.",
+ });
+
+ router.push("/dashboard");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to copy chat";
+ toast.error(message);
+ } finally {
+ setIsCloning(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/surfsense_web/components/public-chat/public-chat-header.tsx b/surfsense_web/components/public-chat/public-chat-header.tsx
new file mode 100644
index 000000000..6f6e40a52
--- /dev/null
+++ b/surfsense_web/components/public-chat/public-chat-header.tsx
@@ -0,0 +1,34 @@
+import { formatDistanceToNow } from "date-fns";
+import Image from "next/image";
+import Link from "next/link";
+
+interface PublicChatHeaderProps {
+ title: string;
+ createdAt: string;
+}
+
+export function PublicChatHeader({ title, createdAt }: PublicChatHeaderProps) {
+ const timeAgo = formatDistanceToNow(new Date(createdAt), { addSuffix: true });
+
+ return (
+
+ );
+}
diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx
new file mode 100644
index 000000000..1b7543712
--- /dev/null
+++ b/surfsense_web/components/public-chat/public-chat-view.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { AssistantRuntimeProvider } from "@assistant-ui/react";
+import { Loader2 } from "lucide-react";
+import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
+import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
+import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
+import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
+import { usePublicChat } from "@/hooks/use-public-chat";
+import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
+import { PublicChatFooter } from "./public-chat-footer";
+import { PublicChatHeader } from "./public-chat-header";
+import { PublicThread } from "./public-thread";
+
+interface PublicChatViewProps {
+ shareToken: string;
+}
+
+export function PublicChatView({ shareToken }: PublicChatViewProps) {
+ const { data, isLoading, error } = usePublicChat(shareToken);
+ const runtime = usePublicChatRuntime({ data });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !data) {
+ return (
+
+
Chat not found
+
+ This chat may have been removed or is no longer public.
+
+
+ );
+ }
+
+ return (
+
+ {/* Tool UIs for rendering tool results */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx
new file mode 100644
index 000000000..2fe1ecff6
--- /dev/null
+++ b/surfsense_web/components/public-chat/public-thread.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import {
+ ActionBarPrimitive,
+ AssistantIf,
+ MessagePrimitive,
+ ThreadPrimitive,
+ useAssistantState,
+} from "@assistant-ui/react";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import { type FC, type ReactNode, useState } from "react";
+import { MarkdownText } from "@/components/assistant-ui/markdown-text";
+import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
+import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
+import { cn } from "@/lib/utils";
+
+interface PublicThreadProps {
+ header?: ReactNode;
+ footer?: ReactNode;
+}
+
+/**
+ * Read-only thread component for public chat viewing.
+ * No composer, no edit capabilities - just message display.
+ */
+export const PublicThread: FC = ({ header, footer }) => {
+ return (
+
+
+ {header}
+
+
+
+ {/* Spacer to ensure footer doesn't overlap last message */}
+
+
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ );
+};
+
+/**
+ * User avatar component with fallback to initials
+ */
+interface AuthorMetadata {
+ displayName: string | null;
+ avatarUrl: string | null;
+}
+
+const UserAvatar: FC void }> = ({
+ displayName,
+ avatarUrl,
+ hasError,
+ onError,
+}) => {
+ const initials = displayName
+ ? displayName
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2)
+ : "U";
+
+ if (avatarUrl && !hasError) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {initials}
+
+ );
+};
+
+const PublicUserMessage: FC = () => {
+ const metadata = useAssistantState(({ message }) => message?.metadata);
+ const author = metadata?.custom?.author as AuthorMetadata | undefined;
+
+ return (
+
+
+
+ {author && (
+
+
+
+ )}
+
+
+ );
+};
+
+const UserAvatarWithState: FC = ({ displayName, avatarUrl }) => {
+ const [hasError, setHasError] = useState(false);
+ return (
+ setHasError(true)}
+ />
+ );
+};
+
+const PublicAssistantMessage: FC = () => {
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+const PublicAssistantActionBar: FC = () => {
+ return (
+
+
+
+ message.isCopied}>
+
+
+ !message.isCopied}>
+
+
+
+
+
+ );
+};
diff --git a/surfsense_web/hooks/use-public-chat-runtime.ts b/surfsense_web/hooks/use-public-chat-runtime.ts
new file mode 100644
index 000000000..cc7e95fdc
--- /dev/null
+++ b/surfsense_web/hooks/use-public-chat-runtime.ts
@@ -0,0 +1,53 @@
+"use client";
+
+import {
+ type AppendMessage,
+ type ThreadMessageLike,
+ useExternalStoreRuntime,
+} from "@assistant-ui/react";
+import { useCallback, useMemo } from "react";
+import type { GetPublicChatResponse, PublicChatMessage } from "@/contracts/types/public-chat.types";
+
+interface UsePublicChatRuntimeOptions {
+ data: GetPublicChatResponse | undefined;
+}
+
+/**
+ * Creates a read-only runtime for public chat viewing.
+ */
+export function usePublicChatRuntime({ data }: UsePublicChatRuntimeOptions) {
+ const messages = useMemo(() => data?.messages ?? [], [data?.messages]);
+
+ // No-op - public chat is read-only
+ const onNew = useCallback(async (_message: AppendMessage) => {}, []);
+
+ // Convert PublicChatMessage to ThreadMessageLike
+ const convertMessage = useCallback(
+ (msg: PublicChatMessage, idx: number): ThreadMessageLike => ({
+ id: `public-msg-${idx}`,
+ role: msg.role as "user" | "assistant",
+ content: msg.content as ThreadMessageLike["content"],
+ createdAt: new Date(msg.created_at),
+ metadata: msg.author
+ ? {
+ custom: {
+ author: {
+ displayName: msg.author.display_name,
+ avatarUrl: msg.author.avatar_url,
+ },
+ },
+ }
+ : undefined,
+ }),
+ []
+ );
+
+ const runtime = useExternalStoreRuntime({
+ isRunning: false,
+ messages,
+ onNew,
+ convertMessage,
+ });
+
+ return runtime;
+}
diff --git a/surfsense_web/hooks/use-public-chat.ts b/surfsense_web/hooks/use-public-chat.ts
new file mode 100644
index 000000000..83f34712e
--- /dev/null
+++ b/surfsense_web/hooks/use-public-chat.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import type { GetPublicChatResponse } from "@/contracts/types/public-chat.types";
+import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+export function usePublicChat(shareToken: string) {
+ return useQuery({
+ queryKey: cacheKeys.publicChat.byToken(shareToken),
+ queryFn: () => publicChatApiService.getPublicChat({ share_token: shareToken }),
+ enabled: !!shareToken,
+ staleTime: 30_000,
+ retry: false,
+ });
+}
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 72f2bbd54..19ddbce7b 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -75,4 +75,7 @@ export const cacheKeys = {
comments: {
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
},
+ publicChat: {
+ byToken: (shareToken: string) => ["public-chat", shareToken] as const,
+ },
};