feat: share sessions in the UI (#1727)

This commit is contained in:
Salman Mohammed
2025-03-27 11:41:55 -04:00
committed by GitHub
parent afef3b1af7
commit ca41c6ba53
13 changed files with 902 additions and 155 deletions
@@ -1,13 +1,11 @@
import React from 'react';
import { Clock, MessageSquare, Folder, AlertCircle } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Clock, MessageSquare, Folder, Share, Copy, Check, LoaderCircle } from 'lucide-react';
import { type SessionDetails } from '../../sessions';
import { Card } from '../ui/card';
import { SessionHeaderCard, SessionMessages } from './SessionViewComponents';
import { createSharedSession } from '../../sharedSessions';
import { Modal, ModalContent, ModalHeader, ModalTitle, ModalFooter } from '../ui/modal';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message';
import { toast } from 'react-toastify';
interface SessionHistoryViewProps {
session: SessionDetails;
@@ -18,31 +16,6 @@ interface SessionHistoryViewProps {
onRetry: () => void;
}
export const getToolResponsesMap = (
session: SessionDetails,
messageIndex: number,
toolRequests: ToolRequestMessageContent[]
) => {
const responseMap = new Map();
if (messageIndex >= 0) {
for (let i = messageIndex + 1; i < session.messages.length; i++) {
const responses = session.messages[i].content
.filter((c) => c.type === 'toolResponse')
.map((c) => c as ToolResponseMessageContent);
for (const response of responses) {
const matchingRequest = toolRequests.find((req) => req.id === response.id);
if (matchingRequest) {
responseMap.set(response.id, response);
}
}
}
}
return responseMap;
};
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
session,
isLoading,
@@ -51,14 +24,85 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
onResume,
onRetry,
}) => {
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [shareLink, setShareLink] = useState<string>('');
const [isSharing, setIsSharing] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [canShare, setCanShare] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
useEffect(() => {
const savedSessionConfig = localStorage.getItem('session_sharing_config');
if (savedSessionConfig) {
try {
const config = JSON.parse(savedSessionConfig);
// If config.enabled is true and config.baseUrl is non-empty, we can share
if (config.enabled && config.baseUrl) {
setCanShare(true);
}
} catch (error) {
console.error('Error parsing session sharing config:', error);
}
}
}, []);
const handleShare = async () => {
setIsSharing(true);
setShareError(null);
try {
// Get the session sharing configuration from localStorage
const savedSessionConfig = localStorage.getItem('session_sharing_config');
if (!savedSessionConfig) {
throw new Error('Session sharing is not configured. Please configure it in settings.');
}
const config = JSON.parse(savedSessionConfig);
if (!config.enabled || !config.baseUrl) {
throw new Error('Session sharing is not enabled or base URL is not configured.');
}
// Create a shared session
const shareToken = await createSharedSession(
config.baseUrl,
session.messages,
session.metadata.description || 'Shared Session'
);
// Create the shareable link
const shareableLink = `goose://sessions/${shareToken}`;
setShareLink(shareableLink);
setIsShareModalOpen(true);
} catch (error) {
console.error('Error sharing session:', error);
setShareError(error instanceof Error ? error.message : 'Unknown error occurred');
toast.error(
`Failed to share session: ${error instanceof Error ? error.message : 'Unknown error'}`
);
} finally {
setIsSharing(false);
}
};
const handleCopyLink = () => {
navigator.clipboard
.writeText(shareLink)
.then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
})
.catch((err) => {
console.error('Failed to copy link:', err);
toast.error('Failed to copy link to clipboard');
});
};
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
{/* Top Row - back, info, reopen thread (fixed) */}
<Card className="px-8 pt-6 pb-4 bg-bgSecondary flex items-center">
<BackButton showText={false} onClick={onBack} className="text-textStandard" />
<SessionHeaderCard onBack={onBack}>
{/* Session info row */}
<div className="ml-8">
<h1 className="text-lg font-bold text-textStandard">
@@ -85,119 +129,83 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</div>
</div>
<span
onClick={onResume}
className="ml-auto text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
>
Resume Session
</span>
</Card>
<div className="ml-auto flex items-center space-x-4">
<button
onClick={handleShare}
disabled={!canShare || isSharing}
className={`flex items-center text-textStandard px-3 py-1 border rounded-md ${
canShare
? 'border-primary hover:text-primary hover:font-bold hover:scale-105 transition-all duration-150'
: 'border-gray-300 cursor-not-allowed opacity-50'
}`}
>
{isSharing ? (
<>
<LoaderCircle className="w-5 h-5 animate-spin mr-2" />
<span>Sharing...</span>
</>
) : (
<>
<Share className="w-5 h-5" />
</>
)}
</button>
<ScrollArea className="h-[calc(100vh-120px)] w-full">
{/* Content */}
<div className="p-4">
<div className="flex flex-col space-y-4">
<div className="space-y-4 mb-6">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<div className="text-red-500 mb-4">
<AlertCircle size={32} />
</div>
<p className="text-md mb-2">Error Loading Session Details</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={onRetry} variant="default">
Try Again
</Button>
</div>
) : session?.messages?.length > 0 ? (
session.messages
.map((message, index) => {
// Extract text content from the message
const textContent = message.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n');
// Get tool requests from the message
const toolRequests = message.content
.filter((c) => c.type === 'toolRequest')
.map((c) => c as ToolRequestMessageContent);
// Get tool responses map using the helper function
const toolResponsesMap = getToolResponsesMap(session, index, toolRequests);
// Skip pure tool response messages for cleaner display
const isOnlyToolResponse =
message.content.length > 0 &&
message.content.every((c) => c.type === 'toolResponse');
if (message.role === 'user' && isOnlyToolResponse) {
return null;
}
return (
<Card
key={index}
className={`p-4 ${
message.role === 'user'
? 'bg-bgSecondary border border-borderSubtle'
: 'bg-bgSubtle'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-textStandard">
{message.role === 'user' ? 'You' : 'Goose'}
</span>
<span className="text-xs text-textSubtle">
{new Date(message.created * 1000).toLocaleTimeString()}
</span>
</div>
<div className="flex flex-col w-full">
{/* Text content */}
{textContent && (
<div className={`${toolRequests.length > 0 ? 'mb-4' : ''}`}>
<MarkdownContent content={textContent} />
</div>
)}
{/* Tool requests and responses */}
{toolRequests.length > 0 && (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 mt-1">
{toolRequests.map((toolRequest) => (
<ToolCallWithResponse
// In the session history page, if no tool response found for given request, it means the tool call
// is broken or cancelled.
isCancelledMessage={
toolResponsesMap.get(toolRequest.id) == undefined
}
key={toolRequest.id}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
/>
))}
</div>
)}
</div>
</Card>
);
})
.filter(Boolean) // Filter out null entries
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquare className="w-12 h-12 mb-4" />
<p className="text-lg mb-2">No messages found</p>
<p className="text-sm">This session doesn't contain any messages</p>
</div>
)}
</div>
</div>
<span
onClick={onResume}
className="text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
>
Resume Session
</span>
</div>
</ScrollArea>
</SessionHeaderCard>
<SessionMessages
messages={session.messages}
isLoading={isLoading}
error={error}
onRetry={onRetry}
/>
{/* Share Link Modal */}
<Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
<ModalContent className="sm:max-w-md dark:bg-black">
<ModalHeader>
<ModalTitle className="text-textStandard">Share Session</ModalTitle>
</ModalHeader>
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center gap-2">
<div className="flex-1 p-2 rounded-md overflow-x-auto">
<code className="text-sm text-textStandard">{shareLink}</code>
</div>
<Button
size="sm"
className="flex-shrink-0"
onClick={handleCopyLink}
disabled={isCopied}
>
{isCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
<span className="sr-only">Copy</span>
</Button>
</div>
<p className="text-sm text-textSubtle">
Share this link with others to give them access to this session.
<br />
They will need to have Goose installed and session sharing configured.
</p>
</div>
<ModalFooter className="sm:justify-start">
<Button
type="button"
variant="ghost"
onClick={() => setIsShareModalOpen(false)}
className="hover:text-textStandard border border-borderSubtle text-textStandard hover:bg-bgSubtle"
>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
};
@@ -1,6 +1,13 @@
import React, { useEffect, useState } from 'react';
import { ViewConfig } from '../../App';
import { MessageSquare, Loader, AlertCircle, Calendar, ChevronRight, Folder } from 'lucide-react';
import {
MessageSquare,
LoaderCircle,
AlertCircle,
Calendar,
ChevronRight,
Folder,
} from 'lucide-react';
import { fetchSessions, type Session } from '../../sessions';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
@@ -80,7 +87,7 @@ const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSess
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex justify-center items-center h-full">
<Loader className="h-8 w-8 animate-spin text-textPrimary" />
<LoaderCircle className="h-8 w-8 animate-spin text-textPrimary" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
@@ -0,0 +1,185 @@
import React from 'react';
import { MessageSquare, AlertCircle } from 'lucide-react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message';
import { type Message } from '../../types/message';
/**
* Get tool responses map from messages
*/
export const getToolResponsesMap = (
messages: Message[],
messageIndex: number,
toolRequests: ToolRequestMessageContent[]
) => {
const responseMap = new Map();
if (messageIndex >= 0) {
for (let i = messageIndex + 1; i < messages.length; i++) {
const responses = messages[i].content
.filter((c) => c.type === 'toolResponse')
.map((c) => c as ToolResponseMessageContent);
for (const response of responses) {
const matchingRequest = toolRequests.find((req) => req.id === response.id);
if (matchingRequest) {
responseMap.set(response.id, response);
}
}
}
}
return responseMap;
};
/**
* Props for the SessionHeaderCard component
*/
export interface SessionHeaderCardProps {
onBack: () => void;
children: React.ReactNode;
}
/**
* Common header card for session views
*/
export const SessionHeaderCard: React.FC<SessionHeaderCardProps> = ({ onBack, children }) => {
return (
<Card className="px-8 pt-6 pb-4 bg-bgSecondary flex items-center">
<BackButton showText={false} onClick={onBack} className="text-textStandard" />
{children}
</Card>
);
};
/**
* Props for the SessionMessages component
*/
export interface SessionMessagesProps {
messages: Message[];
isLoading: boolean;
error: string | null;
onRetry: () => void;
}
/**
* Common component for displaying session messages
*/
export const SessionMessages: React.FC<SessionMessagesProps> = ({
messages,
isLoading,
error,
onRetry,
}) => {
return (
<ScrollArea className="h-[calc(100vh-120px)] w-full">
<div className="p-4">
<div className="flex flex-col space-y-4">
<div className="space-y-4 mb-6">
{isLoading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<div className="text-red-500 mb-4">
<AlertCircle size={32} />
</div>
<p className="text-md mb-2">Error Loading Session Details</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={onRetry} variant="default">
Try Again
</Button>
</div>
) : messages?.length > 0 ? (
messages
.map((message, index) => {
// Extract text content from the message
const textContent = message.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n');
// Get tool requests from the message
const toolRequests = message.content
.filter((c) => c.type === 'toolRequest')
.map((c) => c as ToolRequestMessageContent);
// Get tool responses map using the helper function
const toolResponsesMap = getToolResponsesMap(messages, index, toolRequests);
// Skip pure tool response messages for cleaner display
const isOnlyToolResponse =
message.content.length > 0 &&
message.content.every((c) => c.type === 'toolResponse');
if (message.role === 'user' && isOnlyToolResponse) {
return null;
}
return (
<Card
key={index}
className={`p-4 ${
message.role === 'user'
? 'bg-bgSecondary border border-borderSubtle'
: 'bg-bgSubtle'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-textStandard">
{message.role === 'user' ? 'You' : 'Goose'}
</span>
<span className="text-xs text-textSubtle">
{new Date(message.created * 1000).toLocaleTimeString()}
</span>
</div>
<div className="flex flex-col w-full">
{/* Text content */}
{textContent && (
<div className={`${toolRequests.length > 0 ? 'mb-4' : ''}`}>
<MarkdownContent content={textContent} />
</div>
)}
{/* Tool requests and responses */}
{toolRequests.length > 0 && (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 mt-1">
{toolRequests.map((toolRequest) => (
<ToolCallWithResponse
// In the session history page, if no tool response found for given request, it means the tool call
// is broken or cancelled.
isCancelledMessage={
toolResponsesMap.get(toolRequest.id) == undefined
}
key={toolRequest.id}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
/>
))}
</div>
)}
</div>
</Card>
);
})
.filter(Boolean) // Filter out null entries
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquare className="w-12 h-12 mb-4" />
<p className="text-lg mb-2">No messages found</p>
<p className="text-sm">This session doesn't contain any messages</p>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
);
};
@@ -1,8 +1,14 @@
import React, { useState } from 'react';
import { ViewConfig } from '../../App';
import { fetchSessionDetails, type SessionDetails } from '../../sessions';
import { fetchSharedSessionDetails } from '../../sharedSessions';
import SessionListView from './SessionListView';
import SessionHistoryView from './SessionHistoryView';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
interface SessionsViewProps {
setView: (view: ViewConfig['view'], viewOptions?: Record<any, any>) => void;
@@ -70,7 +76,7 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
};
// If a session is selected, show the session history view
// Otherwise, show the sessions list view
// Otherwise, show the sessions list view with a button to test shared sessions
return selectedSession ? (
<SessionHistoryView
session={selectedSession}
@@ -81,7 +87,9 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
onRetry={handleRetryLoadSession}
/>
) : (
<SessionListView setView={setView} onSelectSession={handleSelectSession} />
<>
<SessionListView setView={setView} onSelectSession={handleSelectSession} />
</>
);
};
@@ -0,0 +1,57 @@
import React from 'react';
import { Clock, Globe } from 'lucide-react';
import { type SharedSessionDetails } from '../../sharedSessions';
import { SessionHeaderCard, SessionMessages } from './SessionViewComponents';
interface SharedSessionViewProps {
session: SharedSessionDetails | null;
isLoading: boolean;
error: string | null;
onBack: () => void;
onRetry: () => void;
}
const SharedSessionView: React.FC<SharedSessionViewProps> = ({
session,
isLoading,
error,
onBack,
onRetry,
}) => {
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
{/* Top Row - back, info (fixed) */}
<SessionHeaderCard onBack={onBack}>
{/* Session info row */}
<div className="ml-8">
<h1 className="text-lg font-bold text-textStandard">
{session ? session.description : 'Shared Session'}
</h1>
{session && (
<div className="flex items-center text-sm text-textSubtle mt-2 space-x-4">
<span className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{new Date(session.messages[0]?.created * 1000).toLocaleString()}
</span>
<span className="flex items-center">
<Globe className="w-4 h-4 mr-1" />
{session.base_url}
</span>
</div>
)}
</div>
</SessionHeaderCard>
<SessionMessages
messages={session?.messages || []}
isLoading={isLoading}
error={error}
onRetry={onRetry}
/>
</div>
);
};
export default SharedSessionView;