Support sharing links to individual messages (#177)

This commit is contained in:
Matt Rubens 2025-07-01 14:18:23 -04:00 committed by GitHub
parent 6c39ff9c2a
commit 9689177a70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 113 additions and 9 deletions

View file

@ -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<string | null>(null);
const [clickedMessageId, setClickedMessageId] = useState<string | null>(null);
const { containerRef, scrollToBottom, autoScrollToBottom, userHasScrolled } =
useAutoScroll<HTMLDivElement>({
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 (
<div
key={message.id}
id={messageId}
className={cn(
'flex flex-col gap-3 rounded-lg p-4',
'flex flex-col gap-3 rounded-lg p-4 relative transition-all duration-200',
message.role === 'user' ? 'bg-primary/10' : 'bg-secondary/10',
enableMessageLinks && 'hover:shadow-sm hover:bg-opacity-80',
)}
onMouseEnter={() =>
enableMessageLinks && setHoveredMessageId(messageId)
}
onMouseLeave={() =>
enableMessageLinks && setHoveredMessageId(null)
}
>
<div className="flex flex-row items-center justify-between gap-2 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-2">
@ -106,11 +179,32 @@ export const Messages = ({ messages }: MessagesProps) => {
<div>&middot;</div>
<div>{message.timestamp}</div>
</div>
{message.mode && (
<div className="px-2 py-1 bg-muted rounded text-xs font-medium">
{message.mode}
</div>
)}
<div className="flex items-center gap-2">
{/* Anchor Link Button */}
{enableMessageLinks && hoveredMessageId === messageId && (
<button
onClick={() => handleAnchorClick(messageId)}
className={cn(
'p-1 rounded hover:bg-muted transition-colors duration-200 cursor-pointer',
clickedMessageId === messageId && 'bg-primary/10',
)}
title="Copy link to this message"
type="button"
>
<Link2
className={cn(
'h-3 w-3 text-muted-foreground hover:text-primary transition-colors duration-200',
clickedMessageId === messageId && 'text-primary',
)}
/>
</button>
)}
{message.mode && (
<div className="px-2 py-1 bg-muted rounded text-xs font-medium">
{message.mode}
</div>
)}
</div>
</div>
{isQuestion && questionData ? (

View file

@ -51,6 +51,8 @@ export const SharedTaskView = ({
sharedBy={sharedBy}
sharedAt={sharedAt}
showSharedInfo={true}
enableMessageLinks={true}
shareToken={shareToken}
/>
);
};

View file

@ -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 = ({
<CardTitle>Conversation</CardTitle>
</CardHeader>
<CardContent>
<Messages messages={messages} />
<Messages
messages={messages}
enableMessageLinks={enableMessageLinks}
shareToken={shareToken}
/>
</CardContent>
</Card>
) : (