diff --git a/backend/supabase/migrations/20250504123828_fix_thread_select_policy.sql b/backend/supabase/migrations/20250504123828_fix_thread_select_policy.sql new file mode 100644 index 00000000..b15cb8bc --- /dev/null +++ b/backend/supabase/migrations/20250504123828_fix_thread_select_policy.sql @@ -0,0 +1,16 @@ +DROP POLICY IF EXISTS thread_select_policy ON threads; + +CREATE POLICY thread_select_policy ON threads +FOR SELECT +USING ( + is_public IS TRUE + OR basejump.has_role_on_account(account_id) = true + OR EXISTS ( + SELECT 1 FROM projects + WHERE projects.project_id = threads.project_id + AND ( + projects.is_public IS TRUE + OR basejump.has_role_on_account(projects.account_id) = true + ) + ) +); diff --git a/frontend/src/app/share/[threadId]/page.tsx b/frontend/src/app/share/[threadId]/page.tsx index cfcb1e0a..1b33e56d 100644 --- a/frontend/src/app/share/[threadId]/page.tsx +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -143,7 +143,7 @@ function renderMarkdownContent( // Extract attachments from the XML attributes const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i); const attachments = attachmentsMatch - ? attachmentsMatch[1].split(',').map((a) => a.trim()) + ? attachmentsMatch[1].split(',').map(a => a.trim()) : []; // Extract content from the ask tag @@ -153,9 +153,7 @@ function renderMarkdownContent( // Render tag content with attachment UI contentParts.push(
- - {askContent} - + {askContent} {attachments.length > 0 && (
@@ -175,28 +173,13 @@ function renderMarkdownContent( ].includes(extension || ''); const isPdf = extension === 'pdf'; const isMd = extension === 'md'; - const isCode = [ - 'js', - 'jsx', - 'ts', - 'tsx', - 'py', - 'html', - 'css', - 'json', - ].includes(extension || ''); + const isCode = ['js', 'jsx', 'ts', 'tsx', 'py', 'html', 'css', 'json'].includes(extension || ''); - let icon = ( - - ); - if (isImage) - icon = ; - if (isPdf) - icon = ; - if (isMd) - icon = ; - if (isCode) - icon = ; + let icon = ; + if (isImage) icon = ; + if (isPdf) icon = ; + if (isMd) icon = ; + if (isCode) icon = ; return ( @@ -1883,13 +1728,7 @@ export default function ThreadPage({ >
- Suna + Suna
@@ -1917,8 +1756,7 @@ export default function ThreadPage({ } }); - const renderedToolResultIds = - new Set(); + const renderedToolResultIds = new Set(); const elements: React.ReactNode[] = []; group.messages.forEach( @@ -1935,13 +1773,12 @@ export default function ThreadPage({ if (!parsedContent.content) return; - const renderedContent = - renderMarkdownContent( - parsedContent.content, - handleToolClick, - message.message_id, - handleOpenFileViewer, - ); + const renderedContent = renderMarkdownContent( + parsedContent.content, + handleToolClick, + message.message_id, + handleOpenFileViewer + ); elements.push(
- {(() => { - let detectedTag: string | null = null; - let tagStartIndex = -1; - if (streamingText) { - for (const tag of HIDE_STREAMING_XML_TAGS) { - const openingTagPattern = `<${tag}`; - const index = - streamingText.indexOf( - openingTagPattern, - ); - if (index !== -1) { - detectedTag = tag; - tagStartIndex = index; - break; - } + {groupIndex === groupedMessages.length - 1 && isStreamingText && ( +
+ {(() => { + let detectedTag: string | null = null; + let tagStartIndex = -1; + if (streamingText) { + for (const tag of HIDE_STREAMING_XML_TAGS) { + const openingTagPattern = `<${tag}`; + const index = streamingText.indexOf(openingTagPattern); + if (index !== -1) { + detectedTag = tag; + tagStartIndex = index; + break; } } + } - const textToRender = - streamingText || ''; - const textBeforeTag = detectedTag - ? textToRender.substring( - 0, - tagStartIndex, - ) - : textToRender; - const showCursor = - isStreamingText && !detectedTag; + const textToRender = streamingText || ''; + const textBeforeTag = detectedTag ? textToRender.substring(0, tagStartIndex) : textToRender; + const showCursor = isStreamingText && !detectedTag; - return ( - <> - {textBeforeTag && ( - - {textBeforeTag} - - )} - {showCursor && ( - - )} + return ( + <> + {textBeforeTag && ( + {textBeforeTag} + )} + {showCursor && ( + + )} - {detectedTag && ( -
- -
- )} - - ); - })()} -
- )} + {detectedTag && ( +
+ +
+ )} + + ); + })()} +
+ )}
@@ -2036,13 +1860,7 @@ export default function ThreadPage({
- Suna + Suna
@@ -2061,13 +1879,7 @@ export default function ThreadPage({
- Suna + Suna
@@ -2089,13 +1901,10 @@ export default function ThreadPage({ {/* Floating playback controls - moved to be centered in the chat area when side panel is open */} {messages.length > 0 && ( -
+
diff --git a/frontend/src/components/sidebar/nav-agents.tsx b/frontend/src/components/sidebar/nav-agents.tsx index 5625ece2..9d94be51 100644 --- a/frontend/src/components/sidebar/nav-agents.tsx +++ b/frontend/src/components/sidebar/nav-agents.tsx @@ -9,9 +9,10 @@ import { Plus, MessagesSquare, Loader2, -} from 'lucide-react'; -import { toast } from 'sonner'; -import { usePathname, useRouter } from 'next/navigation'; + Share2 +} from "lucide-react" +import { toast } from "sonner" +import { usePathname, useRouter } from "next/navigation" import { DropdownMenu, @@ -32,12 +33,13 @@ import { import { Tooltip, TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { getProjects, getThreads, Project, deleteThread } from '@/lib/api'; -import Link from 'next/link'; -import { DeleteConfirmationDialog } from '@/components/thread/DeleteConfirmationDialog'; -import { useDeleteOperation } from '@/contexts/DeleteOperationContext'; + TooltipTrigger +} from "@/components/ui/tooltip" +import { getProjects, getThreads, Project, deleteThread } from "@/lib/api" +import Link from "next/link" +import { ShareModal } from "./share-modal" +import { DeleteConfirmationDialog } from "@/components/thread/DeleteConfirmationDialog" +import { useDeleteOperation } from '@/contexts/DeleteOperationContext' // Thread with associated project info for display in sidebar type ThreadWithProject = { @@ -49,19 +51,18 @@ type ThreadWithProject = { }; export function NavAgents() { - const { isMobile, state } = useSidebar(); - const [threads, setThreads] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [loadingThreadId, setLoadingThreadId] = useState(null); - const pathname = usePathname(); - const router = useRouter(); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [threadToDelete, setThreadToDelete] = useState<{ - id: string; - name: string; - } | null>(null); - const [isDeleting, setIsDeleting] = useState(false); - const isNavigatingRef = useRef(false); + const { isMobile, state } = useSidebar() + const [threads, setThreads] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [loadingThreadId, setLoadingThreadId] = useState(null) + const [showShareModal, setShowShareModal] = useState(false) + const [selectedItem, setSelectedItem] = useState<{ threadId: string, projectId: string } | null>(null) + const pathname = usePathname() + const router = useRouter() + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [threadToDelete, setThreadToDelete] = useState<{ id: string; name: string } | null>(null) + const [isDeleting, setIsDeleting] = useState(false) + const isNavigatingRef = useRef(false) const { performDelete, isOperationInProgress } = useDeleteOperation(); const isPerformingActionRef = useRef(false); @@ -82,12 +83,8 @@ export function NavAgents() { } // Get all projects - const projects = (await getProjects()) as Project[]; - console.log( - 'Projects loaded:', - projects.length, - projects.map((p) => ({ id: p.id, name: p.name })), - ); + const projects = await getProjects() as Project[] + console.log("Projects loaded:", projects.length, projects.map(p => ({ id: p.id, name: p.name }))); // If no projects are found, the user might not be logged in if (projects.length === 0) { @@ -102,15 +99,8 @@ export function NavAgents() { }); // Get all threads at once - const allThreads = await getThreads(); - console.log( - 'Threads loaded:', - allThreads.length, - allThreads.map((t) => ({ - thread_id: t.thread_id, - project_id: t.project_id, - })), - ); + const allThreads = await getThreads() + console.log("Threads loaded:", allThreads.length, allThreads.map(t => ({ thread_id: t.thread_id, project_id: t.project_id }))); // Create display objects for threads with their project info const threadsWithProjects: ThreadWithProject[] = []; @@ -129,9 +119,7 @@ export function NavAgents() { continue; } - console.log( - `✅ Thread ${thread.thread_id} matched with project "${project.name}" (${projectId})`, - ); + console.log(`✅ Thread ${thread.thread_id} matched with project "${project.name}" (${projectId})`); // Add to our list threadsWithProjects.push({ @@ -170,14 +158,14 @@ export function NavAgents() { const { projectId, updatedData } = customEvent.detail; // Update just the name for the threads with the matching project ID - setThreads((prevThreads) => { - const updatedThreads = prevThreads.map((thread) => + setThreads(prevThreads => { + const updatedThreads = prevThreads.map(thread => thread.projectId === projectId ? { - ...thread, - projectName: updatedData.name, - } - : thread, + ...thread, + projectName: updatedData.name, + } + : thread ); // Return the threads without re-sorting immediately @@ -190,10 +178,7 @@ export function NavAgents() { }; // Add event listener - window.addEventListener( - 'project-updated', - handleProjectUpdate as EventListener, - ); + window.addEventListener('project-updated', handleProjectUpdate as EventListener); // Cleanup return () => { @@ -217,12 +202,12 @@ export function NavAgents() { isNavigatingRef.current = false; }; - window.addEventListener('popstate', handleNavigationComplete); + window.addEventListener("popstate", handleNavigationComplete); return () => { window.removeEventListener('popstate', handleNavigationComplete); // Ensure we clean up any leftover styles - document.body.style.pointerEvents = 'auto'; + document.body.style.pointerEvents = "auto"; }; }, []); @@ -233,15 +218,11 @@ export function NavAgents() { }, [pathname]); // Function to handle thread click with loading state - const handleThreadClick = ( - e: React.MouseEvent, - threadId: string, - url: string, - ) => { - e.preventDefault(); - setLoadingThreadId(threadId); - router.push(url); - }; + const handleThreadClick = (e: React.MouseEvent, threadId: string, url: string) => { + e.preventDefault() + setLoadingThreadId(threadId) + router.push(url) + } // Function to handle thread deletion const handleDeleteThread = async (threadId: string, threadName: string) => { @@ -279,7 +260,7 @@ export function NavAgents() { await deleteThread(threadId); // Update the thread list - setThreads((prev) => prev.filter((t) => t.threadId !== threadId)); + setThreads(prev => prev.filter(t => t.threadId !== threadId)); // Show success message toast.success('Conversation deleted successfully'); @@ -413,16 +394,12 @@ export function NavAgents() { side={isMobile ? 'bottom' : 'right'} align={isMobile ? 'end' : 'start'} > - { - navigator.clipboard.writeText( - window.location.origin + thread.url, - ); - toast.success('Link copied to clipboard'); - }} - > - - Copy Link + { + setSelectedItem({ threadId: thread?.threadId, projectId: thread?.projectId }) + setShowShareModal(true) + }}> + + Share Chat )} + setShowShareModal(false)} + threadId={selectedItem?.threadId} + projectId={selectedItem?.projectId} + /> {threadToDelete && ( void; +} + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + threadId?: string; + projectId?: string; +} + +export function ShareModal({ isOpen, onClose, threadId, projectId }: ShareModalProps) { + const [shareLink, setShareLink] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isChecking, setIsChecking] = useState(false); + const [isCopying, setIsCopying] = useState(false); + useEffect(() => { + if (isOpen && threadId) { + checkShareStatus(); + } + }, [isOpen, threadId]); + + const checkShareStatus = async () => { + if (!threadId) return; + + setIsChecking(true); + + try { + const threadData = await getThread(threadId); + + if (threadData?.is_public) { + const publicUrl = generateShareLink(); + setShareLink(publicUrl); + } else { + setShareLink(null); + } + } catch (error) { + console.error("Error checking share status:", error); + toast.error("Failed to check sharing status"); + setShareLink(null); + } finally { + setIsChecking(false); + } + }; + + const generateShareLink = () => { + if (!threadId) return ""; + return `${process.env.NEXT_PUBLIC_URL || window.location.origin}/share/${threadId}`; + }; + + const createShareLink = async () => { + if (!threadId) return; + + setIsLoading(true); + + try { + // Use the API to mark the thread as public + await updatePublicStatus(true); + const generatedLink = generateShareLink(); + setShareLink(generatedLink); + toast.success("Shareable link created successfully"); + } catch (error) { + console.error("Error creating share link:", error); + toast.error("Failed to create shareable link"); + } finally { + setIsLoading(false); + } + }; + + const removeShareLink = async () => { + if (!threadId) return; + + setIsLoading(true); + + try { + // Use the API to mark the thread as private + await updatePublicStatus(false); + setShareLink(null); + toast.success("Shareable link removed"); + } catch (error) { + console.error("Error removing share link:", error); + toast.error("Failed to remove shareable link"); + } finally { + setIsLoading(false); + } + }; + + const updatePublicStatus = async (isPublic: boolean) => { + console.log("Updating public status for thread:", threadId, "and project:", projectId, "to", isPublic); + if (!threadId) return; + await updateProject(projectId, { is_public: isPublic }); + await updateThread(threadId, { is_public: isPublic }); + }; + + const copyToClipboard = () => { + if (shareLink) { + setIsCopying(true); + navigator.clipboard.writeText(shareLink); + toast.success("Link copied to clipboard"); + setTimeout(() => { + setIsCopying(false); + }, 500); + } + }; + + const socialOptions: SocialShareOption[] = [ + { + name: "LinkedIn", + icon: + + , + onClick: () => { + if (shareLink) { + window.open(`https://www.linkedin.com/shareArticle?url=${encodeURIComponent(shareLink)}&text=Shared conversation`, '_blank'); + } + } + }, + { + name: "X", + icon: + + , + onClick: () => { + if (shareLink) { + window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareLink)}&text=Shared conversation`, '_blank'); + } + } + } + ]; + + return ( + + + + Share Chat + + + +
+ {isChecking ? ( +
+
+

Checking share status...

+
+ ) : shareLink ? ( + <> +

+ Your chat is now publicly accessible with the link below. Anyone with this link can view this conversation. +

+ +
+
+ +
+ +
+ +
+ {socialOptions.map((option, index) => ( + + ))} +
+ +
+ +
+ + ) : ( + <> +
+
+ +
+

Share this chat

+

+ Create a shareable link that allows others to view this conversation. +

+ +
+ + )} +
+
+
+ ); +} + diff --git a/frontend/src/components/thread/thread-site-header.tsx b/frontend/src/components/thread/thread-site-header.tsx index b7d73904..02a99e65 100644 --- a/frontend/src/components/thread/thread-site-header.tsx +++ b/frontend/src/components/thread/thread-site-header.tsx @@ -1,22 +1,23 @@ 'use client'; -import { Button } from '@/components/ui/button'; -import { FolderOpen, Link, PanelRightOpen, Check, X, Menu } from 'lucide-react'; -import { usePathname } from 'next/navigation'; -import { toast } from 'sonner'; +import { Button } from "@/components/ui/button" +import { FolderOpen, Link, PanelRightOpen, Check, X, Menu, Share2 } from "lucide-react" +import { usePathname } from "next/navigation" +import { toast } from "sonner" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from '@/components/ui/tooltip'; -import { useState, useRef, KeyboardEvent } from 'react'; -import { Input } from '@/components/ui/input'; -import { updateProject } from '@/lib/api'; -import { Skeleton } from '@/components/ui/skeleton'; -import { useIsMobile } from '@/hooks/use-mobile'; -import { cn } from '@/lib/utils'; -import { useSidebar } from '@/components/ui/sidebar'; +} from "@/components/ui/tooltip" +import { useState, useRef, KeyboardEvent } from "react" +import { Input } from "@/components/ui/input" +import { updateProject } from "@/lib/api" +import { Skeleton } from "@/components/ui/skeleton" +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { useSidebar } from "@/components/ui/sidebar" +import { ShareModal } from "@/components/sidebar/share-modal" interface ThreadSiteHeaderProps { threadId: string; @@ -37,18 +38,18 @@ export function SiteHeader({ onProjectRenamed, isMobileView, }: ThreadSiteHeaderProps) { - const pathname = usePathname(); - const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(projectName); - const inputRef = useRef(null); - const isMobile = useIsMobile() || isMobileView; - const { setOpenMobile } = useSidebar(); + const pathname = usePathname() + const [isEditing, setIsEditing] = useState(false) + const [editName, setEditName] = useState(projectName) + const inputRef = useRef(null) + const [showShareModal, setShowShareModal] = useState(false) - const copyCurrentUrl = () => { - const url = window.location.origin + pathname; - navigator.clipboard.writeText(url); - toast.success('URL copied to clipboard'); - }; + const isMobile = useIsMobile() || isMobileView + const { setOpenMobile } = useSidebar() + + const openShareModal = () => { + setShowShareModal(true) + } const startEditing = () => { setEditName(projectName); @@ -80,9 +81,7 @@ export function SiteHeader({ return; } - const updatedProject = await updateProject(projectId, { - name: editName, - }); + const updatedProject = await updateProject(projectId, { name: editName }) if (updatedProject) { onProjectRenamed?.(editName); toast.success('Project renamed successfully'); @@ -98,8 +97,8 @@ export function SiteHeader({ } } - setIsEditing(false); - }; + setIsEditing(false) + } const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { @@ -110,131 +109,137 @@ export function SiteHeader({ }; return ( -
- {isMobile && ( - - )} - -
- {isEditing ? ( -
- setEditName(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={saveNewName} - className="h-8 w-auto min-w-[180px] text-base font-medium" - maxLength={50} - /> - - -
- ) : !projectName || projectName === 'Project' ? ( - - ) : ( -
- {projectName} -
- )} -
- -
- {isMobile ? ( - // Mobile view - only show the side panel toggle + <> +
+ {isMobile && ( - ) : ( - // Desktop view - show all buttons with tooltips - - - - - - -

View Files in Task

-
-
- - - - - - -

Copy Link

-
-
- - - - - - -

Toggle Computer Preview (CMD+I)

-
-
-
)} -
-
- ); -} + +
+ {isEditing ? ( +
+ setEditName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={saveNewName} + className="h-8 w-auto min-w-[180px] text-base font-medium" + maxLength={50} + /> + + +
+ ) : !projectName || projectName === 'Project' ? ( + + ) : ( +
+ {projectName} +
+ )} +
+ +
+ {isMobile ? ( + // Mobile view - only show the side panel toggle + + ) : ( + // Desktop view - show all buttons with tooltips + + + + + + +

View Files in Task

+
+
+ + + + + + +

Share Chat

+
+
+ + + + + + +

Toggle Computer Preview (CMD+I)

+
+
+
+ )} +
+ + setShowShareModal(false)} + threadId={threadId} + projectId={projectId} + /> + + ) +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index db6cd8d4..18b2fa32 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -236,6 +236,7 @@ export const getProject = async (projectId: string): Promise => { name: data.name || '', description: data.description || '', account_id: data.account_id, + is_public: data.is_public || false, created_at: data.created_at, sandbox: data.sandbox || { id: '',