Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions src/frontend/src/chat/ShareThreadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useState } from 'react';
import { Share2, Copy, Check, X } from 'lucide-react';
import { Button } from '../components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../components/ui/dialog';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';

interface ShareThreadButtonProps {
threadId: string;
className?: string;
}

export function ShareThreadButton({ threadId, className }: ShareThreadButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [shareUrl, setShareUrl] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [error, setError] = useState<string>('');

const createShareLink = async () => {
setIsLoading(true);
setError('');

try {
const token = localStorage.getItem('token');
if (!token) {
setError('Authentication required');
return;
}

const response = await fetch(`/api/threads/${threadId}/share`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create share link');
}

const data = await response.json();
setShareUrl(data.url);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create share link');
} finally {
setIsLoading(false);
}
};

const revokeShareLink = async () => {
setIsLoading(true);
setError('');

try {
const token = localStorage.getItem('token');
if (!token) {
setError('Authentication required');
return;
}

const response = await fetch(`/api/threads/${threadId}/unshare`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to revoke share link');
}

setShareUrl('');
setIsCopied(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to revoke share link');
} finally {
setIsLoading(false);
}
};

const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
};

const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open && !shareUrl) {
createShareLink();
}
};

return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className={className}
disabled={isLoading}
>
<Share2 className="h-4 w-4 mr-1" />
Share
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Share Thread</DialogTitle>
<DialogDescription>
Anyone with this link will be able to view this conversation in read-only mode.
</DialogDescription>
</DialogHeader>

<div className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}

{shareUrl ? (
<div className="space-y-3">
<div>
<Label htmlFor="share-url" className="text-sm font-medium">
Share URL
</Label>
<div className="flex space-x-2 mt-1">
<Input
id="share-url"
readOnly
value={shareUrl}
className="flex-1"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={copyToClipboard}
disabled={!shareUrl}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>

<div className="flex justify-between">
<Button
type="button"
variant="destructive"
size="sm"
onClick={revokeShareLink}
disabled={isLoading}
>
<X className="h-4 w-4 mr-1" />
Revoke Link
</Button>

<div className="flex space-x-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsOpen(false)}
>
Close
</Button>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center py-8">
{isLoading ? (
<div className="text-sm text-gray-500">Creating share link...</div>
) : (
<Button onClick={createShareLink}>
Create Share Link
</Button>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
160 changes: 160 additions & 0 deletions src/frontend/src/pages/SharedThread.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Eye, AlertCircle } from 'lucide-react';
import { ChatMessages } from '../chat/ChatMessages';
import { Button } from '../components/ui/button';
Comment on lines +1 to +5

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SharedThread.tsx depends on react-router-dom (useParams), but the frontend package.json has no react-router-dom dependency and the app doesn’t appear to be using a router elsewhere. Either add routing support + dependency, or derive the token from window.location.pathname so this page works in the current SPA architecture.

Copilot uses AI. Check for mistakes.

interface SharedThreadData {
thread_id: string;
session: {
id: string;
title: string;
created_at: string;
};
messages: Array<{
id: string;
role: string;
content: string;
timestamp: string;
toolCalls?: any[];
}>;
read_only: boolean;
}

export function SharedThread() {
const { token } = useParams<{ token: string }>();
const [threadData, setThreadData] = useState<SharedThreadData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');

useEffect(() => {
const fetchSharedThread = async () => {
if (!token) {
setError('Invalid share link');
setIsLoading(false);
return;
}

try {
const response = await fetch(`/shared/${token}`, {
headers: {
'Accept': 'application/json',
},
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to load shared thread');
}

const data = await response.json();
setThreadData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load shared thread');
} finally {
setIsLoading(false);
}
};

fetchSharedThread();
}, [token]);

if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-sm text-gray-600">Loading shared conversation...</div>
</div>
</div>
);
}

if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="max-w-md w-full bg-white rounded-lg shadow-sm border border-gray-200 p-6 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-gray-900 mb-2">
Unable to Load Shared Thread
</h2>
<p className="text-sm text-gray-600 mb-4">{error}</p>
<Button
onClick={() => window.location.href = '/'}
variant="outline"
>
Go Home
</Button>
</div>
</div>
);
}

if (!threadData) {
return null;
}

return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Eye className="h-5 w-5 text-gray-500" />
<div>
<h1 className="text-lg font-semibold text-gray-900">
{threadData.session.title || 'Shared Conversation'}
</h1>
<p className="text-sm text-gray-500">
Read-only view • Created {new Date(threadData.session.created_at).toLocaleDateString()}
</p>
</div>
</div>

<Button
onClick={() => window.location.href = '/'}
variant="outline"
size="sm"
>
Start New Chat
</Button>
</div>
</div>
</header>

{/* Messages */}
<main className="max-w-4xl mx-auto px-4 py-6">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
{threadData.messages.length > 0 ? (
<div className="divide-y divide-gray-100">
<ChatMessages
messages={threadData.messages}
sessionId={threadData.thread_id}
isSharedView={true}

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatMessages currently requires currentResponse, thinkingSteps, toolCalls, and isStreaming props and does not define an isSharedView prop. This usage will fail TypeScript compilation. Pass the required props (e.g., empty defaults for read-only view) and/or update ChatMessagesProps to support a read-only/shared mode instead of passing an unknown prop.

Suggested change
isSharedView={true}
currentResponse=""
thinkingSteps={[]}
toolCalls={[]}
isStreaming={false}

Copilot uses AI. Check for mistakes.
/>
</div>
) : (
<div className="p-8 text-center">
<div className="text-gray-500">
<Eye className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm">This conversation is empty.</p>
</div>
</div>
)}
</div>

{/* Read-only notice */}
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<div className="flex items-center space-x-2 text-sm text-blue-800">
<Eye className="h-4 w-4" />
<span>
You're viewing a read-only shared conversation.
You can't reply or interact with this chat.
</span>
</div>
</div>
</main>
</div>
);
}
Loading
Loading