From 655840de14db0b85caf4367e87d88e5b5425743f Mon Sep 17 00:00:00 2001 From: Vukasin Date: Sat, 3 May 2025 23:03:16 +0200 Subject: [PATCH 1/3] feat: init share modal --- .../src/components/sidebar/nav-agents.tsx | 69 +++-- .../src/components/sidebar/share-modal.tsx | 228 +++++++++++++++ .../components/thread/thread-site-header.tsx | 270 +++++++++--------- frontend/src/lib/api.ts | 1 + 4 files changed, 407 insertions(+), 161 deletions(-) create mode 100644 frontend/src/components/sidebar/share-modal.tsx diff --git a/frontend/src/components/sidebar/nav-agents.tsx b/frontend/src/components/sidebar/nav-agents.tsx index 290a189e..d5e83f3b 100644 --- a/frontend/src/components/sidebar/nav-agents.tsx +++ b/frontend/src/components/sidebar/nav-agents.tsx @@ -9,6 +9,7 @@ import { Plus, MessagesSquare, Loader2, + Share2 } from "lucide-react" import { toast } from "sonner" import { usePathname, useRouter } from "next/navigation" @@ -36,6 +37,7 @@ import { } from "@/components/ui/tooltip" import { getProjects, getThreads, Project } from "@/lib/api" import Link from "next/link" +import { ShareModal } from "./share-modal" // Thread with associated project info for display in sidebar type ThreadWithProject = { @@ -51,6 +53,8 @@ export function NavAgents() { const [threads, setThreads] = useState([]) const [isLoading, setIsLoading] = useState(true) const [loadingThreadId, setLoadingThreadId] = useState(null) + const [showShareModal, setShowShareModal] = useState(false) + const [selectedThreadId, setSelectedThreadId] = useState(null) const pathname = usePathname() const router = useRouter() @@ -67,44 +71,44 @@ export function NavAgents() { if (showLoading) { setIsLoading(true) } - + // Get all projects 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) { setThreads([]) return } - + // Create a map of projects by ID for faster lookups const projectsById = new Map(); projects.forEach(project => { projectsById.set(project.id, project); }); - + // Get all threads at once - const allThreads = await getThreads() + 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[] = []; - + for (const thread of allThreads) { const projectId = thread.project_id; // Skip threads without a project ID if (!projectId) continue; - + // Get the associated project const project = projectsById.get(projectId); if (!project) { console.log(`❌ Thread ${thread.thread_id} has project_id=${projectId} but no matching project found`); continue; } - + console.log(`✅ Thread ${thread.thread_id} matched with project "${project.name}" (${projectId})`); - + // Add to our list threadsWithProjects.push({ threadId: thread.thread_id, @@ -114,7 +118,7 @@ export function NavAgents() { updatedAt: thread.updated_at || project.updated_at || new Date().toISOString() }); } - + // Set threads, ensuring consistent sort order setThreads(sortThreads(threadsWithProjects)) } catch (err) { @@ -139,22 +143,22 @@ export function NavAgents() { const customEvent = event as CustomEvent; if (customEvent.detail) { const { projectId, updatedData } = customEvent.detail; - + // Update just the name for the threads with the matching project ID setThreads(prevThreads => { - const updatedThreads = prevThreads.map(thread => - thread.projectId === projectId - ? { - ...thread, - projectName: updatedData.name, - } + const updatedThreads = prevThreads.map(thread => + thread.projectId === projectId + ? { + ...thread, + projectName: updatedData.name, + } : thread ); - + // Return the threads without re-sorting immediately return updatedThreads; }); - + // Silently refresh in background to fetch updated timestamp and re-sort setTimeout(() => loadThreadsWithProjects(false), 1000); } @@ -162,7 +166,7 @@ export function NavAgents() { // Add event listener window.addEventListener('project-updated', handleProjectUpdate as EventListener); - + // Cleanup return () => { window.removeEventListener('project-updated', handleProjectUpdate as EventListener); @@ -188,8 +192,8 @@ export function NavAgents() { {state !== "collapsed" ? ( - @@ -217,10 +221,10 @@ export function NavAgents() { )} - + {isLoading ? ( // Show skeleton loaders while loading - Array.from({length: 3}).map((_, index) => ( + Array.from({ length: 3 }).map((_, index) => (
@@ -235,7 +239,7 @@ export function NavAgents() { // Check if this thread is currently active const isActive = pathname?.includes(thread.threadId) || false; const isThreadLoading = loadingThreadId === thread.threadId; - + return ( {state === "collapsed" ? ( @@ -280,11 +284,11 @@ export function NavAgents() { align={isMobile ? "end" : "start"} > { - navigator.clipboard.writeText(window.location.origin + thread.url) - toast.success("Link copied to clipboard") + setSelectedThreadId(thread?.threadId) + setShowShareModal(true) }}> - - Copy Link + + Share @@ -314,6 +318,11 @@ export function NavAgents() { )} + setShowShareModal(false)} + threadId={selectedThreadId} + /> ) } diff --git a/frontend/src/components/sidebar/share-modal.tsx b/frontend/src/components/sidebar/share-modal.tsx new file mode 100644 index 00000000..f3d5b4de --- /dev/null +++ b/frontend/src/components/sidebar/share-modal.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { X, Copy, Share2, Link, Link2Off, Check } from "lucide-react"; +import { toast } from "sonner"; +import { getThread, toggleThreadPublicStatus } from "@/lib/api"; + +interface SocialShareOption { + name: string; + icon: JSX.Element; + onClick: () => void; +} + +interface ShareModalProps { + isOpen: boolean; + onClose: () => void; + threadId?: string; +} + +export function ShareModal({ isOpen, onClose, threadId }: 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 toggleThreadPublicStatus(threadId, 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 toggleThreadPublicStatus(threadId, 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 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 7af9bed5..3e30e854 100644 --- a/frontend/src/components/thread/thread-site-header.tsx +++ b/frontend/src/components/thread/thread-site-header.tsx @@ -1,7 +1,7 @@ "use client" import { Button } from "@/components/ui/button" -import { FolderOpen, Link, PanelRightOpen, Check, X, Menu } from "lucide-react" +import { FolderOpen, Link, PanelRightOpen, Check, X, Menu, Share2 } from "lucide-react" import { usePathname } from "next/navigation" import { toast } from "sonner" import { @@ -17,6 +17,7 @@ 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 @@ -28,11 +29,11 @@ interface ThreadSiteHeaderProps { isMobileView?: boolean } -export function SiteHeader({ - threadId, +export function SiteHeader({ + threadId, projectId, - projectName, - onViewFiles, + projectName, + onViewFiles, onToggleSidePanel, onProjectRenamed, isMobileView @@ -41,13 +42,13 @@ export function SiteHeader({ const [isEditing, setIsEditing] = useState(false) const [editName, setEditName] = useState(projectName) const inputRef = useRef(null) + const [showShareModal, setShowShareModal] = useState(false) + const isMobile = useIsMobile() || isMobileView const { setOpenMobile } = useSidebar() - - const copyCurrentUrl = () => { - const url = window.location.origin + pathname - navigator.clipboard.writeText(url) - toast.success("URL copied to clipboard") + + const openShareModal = () => { + setShowShareModal(true) } const startEditing = () => { @@ -70,7 +71,7 @@ export function SiteHeader({ setIsEditing(false) return } - + if (editName !== projectName) { try { if (!projectId) { @@ -79,7 +80,7 @@ export function SiteHeader({ setIsEditing(false) return } - + const updatedProject = await updateProject(projectId, { name: editName }) if (updatedProject) { onProjectRenamed?.(editName) @@ -94,7 +95,7 @@ export function SiteHeader({ setEditName(projectName) } } - + setIsEditing(false) } @@ -107,129 +108,136 @@ 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} + /> + ) } \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5e9acca3..bf07d5ed 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -212,6 +212,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: "", pass: "", vnc_preview: "", sandbox_url: "" } }; From 8b6d9c0903589367bb6635391265e8f6c1548df7 Mon Sep 17 00:00:00 2001 From: Vukasin Date: Sun, 4 May 2025 15:49:26 +0200 Subject: [PATCH 2/3] fix: policy and share page api error --- ...0250504123828_fix_thread_select_policy.sql | 16 + frontend/src/app/share/[threadId]/page.tsx | 468 +++++++++--------- .../src/components/sidebar/nav-agents.tsx | 9 +- .../src/components/sidebar/share-modal.tsx | 16 +- .../components/thread/thread-site-header.tsx | 1 + frontend/src/lib/api.ts | 4 - 6 files changed, 261 insertions(+), 253 deletions(-) create mode 100644 backend/supabase/migrations/20250504123828_fix_thread_select_policy.sql 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 7c324688..a3664362 100644 --- a/frontend/src/app/share/[threadId]/page.tsx +++ b/frontend/src/app/share/[threadId]/page.tsx @@ -99,10 +99,10 @@ function renderMarkdownContent(content: string, handleToolClick: (assistantMessa if (toolName === 'ask') { // Extract attachments from the XML attributes const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i); - const attachments = attachmentsMatch + const attachments = attachmentsMatch ? attachmentsMatch[1].split(',').map(a => a.trim()) : []; - + // Extract content from the ask tag const contentMatch = rawXml.match(/]*>([\s\S]*?)<\/ask>/i); const askContent = contentMatch ? contentMatch[1] : ''; @@ -111,7 +111,7 @@ function renderMarkdownContent(content: string, handleToolClick: (assistantMessa contentParts.push(
{askContent} - + {attachments.length > 0 && (
Attachments:
@@ -122,13 +122,13 @@ function renderMarkdownContent(content: string, handleToolClick: (assistantMessa const isPdf = extension === 'pdf'; const isMd = extension === 'md'; 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 = ; - + return (
); }; - + return WrappedPanel; }, []); @@ -1313,7 +1300,7 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Skeleton Chat Messages */}
@@ -1326,7 +1313,7 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Assistant response with tool usage */}
@@ -1339,12 +1326,12 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Tool call button skeleton */}
- +
@@ -1357,7 +1344,7 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Skeleton Side Panel (closed state) */}
@@ -1406,7 +1393,7 @@ export default function ThreadPage({ params }: { params: Promise }
- Kortix + Kortix
{projectName || 'Shared Conversation'}
@@ -1428,8 +1415,8 @@ export default function ThreadPage({ params }: { params: Promise } className="h-8 w-8" aria-label={isPlaying ? "Pause Replay" : "Play Replay"} > - {isPlaying ? - : + {isPlaying ? + : } @@ -1454,7 +1441,7 @@ export default function ThreadPage({ params }: { params: Promise }
-
}
{/* Gradient overlay */}
- +
@@ -1473,13 +1460,13 @@ export default function ThreadPage({ params }: { params: Promise }

This is a shared view-only agent run. Click play to replay the entire conversation with realistic timing.

-
@@ -1526,7 +1513,7 @@ export default function ThreadPage({ params }: { params: Promise } if (currentGroup) { groupedMessages.push(currentGroup); } - + return groupedMessages.map((group, groupIndex) => { if (group.type === 'user') { const message = group.messages[0]; @@ -1538,7 +1525,7 @@ export default function ThreadPage({ params }: { params: Promise } return message.content; } })(); - + return (
@@ -1551,7 +1538,7 @@ export default function ThreadPage({ params }: { params: Promise }
- Suna + Suna
@@ -1568,7 +1555,7 @@ export default function ThreadPage({ params }: { params: Promise } toolResultsMap.get(assistantId)?.push(msg); } }); - + const renderedToolResultIds = new Set(); const elements: React.ReactNode[] = []; @@ -1580,8 +1567,8 @@ export default function ThreadPage({ params }: { params: Promise } if (!parsedContent.content) return; const renderedContent = renderMarkdownContent( - parsedContent.content, - handleToolClick, + parsedContent.content, + handleToolClick, message.message_id, handleOpenFileViewer ); @@ -1600,47 +1587,47 @@ export default function ThreadPage({ params }: { params: Promise } })()} {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; - } - } + 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 && ( +
+ +
+ )} + + ); })()}
)} @@ -1654,13 +1641,13 @@ export default function ThreadPage({ params }: { params: Promise } return null; }); })()} - + {/* Show tool call animation if active */} {currentToolCall && (
- Suna + Suna
@@ -1673,13 +1660,13 @@ export default function ThreadPage({ params }: { params: Promise }
)} - + {/* Show streaming indicator if no messages yet */} {visibleMessages.length === 0 && isStreamingText && (
- Suna + Suna
@@ -1698,14 +1685,13 @@ export default function ThreadPage({ params }: { params: Promise }
- + {/* Floating playback controls - moved to be centered in the chat area when side panel is open */} {messages.length > 0 && ( -
+ }`}>
- +
{Math.min(currentMessageIndex + (isStreamingText ? 0 : 1), messages.length)}/{messages.length}
- + - +