From 9689177a700af66859a2a51022e776e2ef427bad Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Tue, 1 Jul 2025 14:18:23 -0400 Subject: [PATCH] Support sharing links to individual messages (#177) --- .../app/(authenticated)/usage/Messages.tsx | 110 ++++++++++++++++-- .../task-sharing/SharedTaskView.tsx | 2 + .../components/task-sharing/TaskDetails.tsx | 10 +- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(authenticated)/usage/Messages.tsx b/apps/web/src/app/(authenticated)/usage/Messages.tsx index 6973036b33..d9f2b07025 100644 --- a/apps/web/src/app/(authenticated)/usage/Messages.tsx +++ b/apps/web/src/app/(authenticated)/usage/Messages.tsx @@ -1,5 +1,7 @@ -import { useMemo, useEffect } from 'react'; +import { useMemo, useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; +import { Link2 } from 'lucide-react'; +import { toast } from 'sonner'; import type { Message } from '@/actions/analytics'; import { cn } from '@/lib/utils'; @@ -14,6 +16,8 @@ const PlainTextLink = ({ children }: { children?: React.ReactNode }) => { type MessagesProps = { messages: Message[]; + enableMessageLinks?: boolean; + shareToken?: string; }; type SuggestionItem = string | { answer: string }; @@ -40,7 +44,13 @@ const parseQuestionData = (text: string): QuestionData | null => { return null; }; -export const Messages = ({ messages }: MessagesProps) => { +export const Messages = ({ + messages, + enableMessageLinks = false, +}: MessagesProps) => { + const [hoveredMessageId, setHoveredMessageId] = useState(null); + const [clickedMessageId, setClickedMessageId] = useState(null); + const { containerRef, scrollToBottom, autoScrollToBottom, userHasScrolled } = useAutoScroll({ enabled: true, @@ -69,6 +79,59 @@ export const Messages = ({ messages }: MessagesProps) => { ); }, [messages]); + // Handle anchor link clicks + const handleAnchorClick = (messageId: string) => { + // Add click animation + setClickedMessageId(messageId); + setTimeout(() => setClickedMessageId(null), 200); + + const url = new URL(window.location.href); + url.hash = `#${messageId}`; + + // Update URL without reload + window.history.replaceState(null, '', url.toString()); + + // Copy to clipboard with enhanced feedback + navigator.clipboard + .writeText(url.toString()) + .then(() => { + toast.success('Message link copied to clipboard!', { + description: 'Share this link to highlight this specific message', + duration: 3000, + }); + }) + .catch(() => { + toast.error('Failed to copy link', { + description: 'Please try again or copy the URL manually', + duration: 4000, + }); + }); + }; + + // Handle URL hash on mount and highlight message + useEffect(() => { + if (enableMessageLinks && window.location.hash) { + const messageId = window.location.hash.substring(1); + const element = document.getElementById(messageId); + if (element) { + // Small delay to ensure the component is fully rendered + // Add simple highlight effect immediately + element.style.transition = 'background-color 0.3s ease-in-out'; + element.style.backgroundColor = 'rgba(59, 130, 246, 0.1)'; + + // Fade out the highlight after 2 seconds + setTimeout(() => { + element.style.backgroundColor = ''; + }, 2000); + + // Delay scroll to allow things to load + setTimeout(() => { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 1000); + } + } + }, [enableMessageLinks]); + // Auto-scroll when new messages arrive or content changes (only if user is at bottom) useEffect(() => { autoScrollToBottom(); @@ -92,13 +155,23 @@ export const Messages = ({ messages }: MessagesProps) => { const questionData = isQuestion && message.text ? parseQuestionData(message.text) : null; + const messageId = `message-${message.id}`; + return (
+ enableMessageLinks && setHoveredMessageId(messageId) + } + onMouseLeave={() => + enableMessageLinks && setHoveredMessageId(null) + } >
@@ -106,11 +179,32 @@ export const Messages = ({ messages }: MessagesProps) => {
·
{message.timestamp}
- {message.mode && ( -
- {message.mode} -
- )} +
+ {/* Anchor Link Button */} + {enableMessageLinks && hoveredMessageId === messageId && ( + + )} + {message.mode && ( +
+ {message.mode} +
+ )} +
{isQuestion && questionData ? ( diff --git a/apps/web/src/components/task-sharing/SharedTaskView.tsx b/apps/web/src/components/task-sharing/SharedTaskView.tsx index 3f2db5ae98..4179755c34 100644 --- a/apps/web/src/components/task-sharing/SharedTaskView.tsx +++ b/apps/web/src/components/task-sharing/SharedTaskView.tsx @@ -51,6 +51,8 @@ export const SharedTaskView = ({ sharedBy={sharedBy} sharedAt={sharedAt} showSharedInfo={true} + enableMessageLinks={true} + shareToken={shareToken} /> ); }; diff --git a/apps/web/src/components/task-sharing/TaskDetails.tsx b/apps/web/src/components/task-sharing/TaskDetails.tsx index a646344051..e75adb21d6 100644 --- a/apps/web/src/components/task-sharing/TaskDetails.tsx +++ b/apps/web/src/components/task-sharing/TaskDetails.tsx @@ -22,6 +22,8 @@ type TaskDetailsProps = { sharedAt?: Date; showSharedInfo?: boolean; headerActions?: React.ReactNode; + enableMessageLinks?: boolean; + shareToken?: string; }; export const TaskDetails = ({ @@ -31,6 +33,8 @@ export const TaskDetails = ({ sharedAt, showSharedInfo = false, headerActions, + enableMessageLinks = false, + shareToken, }: TaskDetailsProps) => { const taskTitle = task.title || generateFallbackTitle(task); @@ -152,7 +156,11 @@ export const TaskDetails = ({ Conversation - + ) : (