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
View File
@@ -378,6 +378,7 @@ export default function ChatView({
<div className="relative flex items-center h-[36px] w-full">
<MoreMenuLayout setView={setView} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
</div>
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
{messages.length === 0 ? (
<Splash
@@ -218,7 +218,7 @@ export default function MoreMenu({
<MenuButton
onClick={() => setView('sessions')}
subtitle="View previous sessions and their contents"
subtitle="View and share previous sessions"
icon={<Time className="w-4 h-4" />}
>
Session history
@@ -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;
@@ -15,8 +15,10 @@ import { RecentModelsRadio } from './models/RecentModels';
import { ExtensionItem } from './extensions/ExtensionItem';
import type { View } from '../../App';
import { ModeSelection } from './basic/ModeSelection';
import SessionSharingSection from './session/SessionSharingSection';
import { toastSuccess } from '../../toasts';
const EXTENSIONS_DESCRIPTION =
'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.';
@@ -260,6 +262,9 @@ export default function SettingsView({
<ModeSelection />
</div>
</section>
<section id="session-sharing">
<SessionSharingSection />
</section>
</div>
</div>
</div>
@@ -0,0 +1,131 @@
import React, { useState, useEffect } from 'react';
import { Input } from '../../ui/input';
import { Check } from 'lucide-react';
export default function SessionSharingSection() {
const [sessionSharingConfig, setSessionSharingConfig] = useState({
enabled: false,
baseUrl: '',
});
const [urlError, setUrlError] = useState('');
// Show a checkmark temporarily when the users input is valid
const [urlSaved, setUrlSaved] = useState(false);
// Load session sharing config from localStorage
useEffect(() => {
const savedSessionConfig = localStorage.getItem('session_sharing_config');
if (savedSessionConfig) {
try {
const config = JSON.parse(savedSessionConfig);
setSessionSharingConfig(config);
} catch (error) {
console.error('Error parsing session sharing config:', error);
}
}
}, []);
// Helper to check if the users input is a valid URL
function isValidUrl(value: string): boolean {
if (!value) return false;
try {
new URL(value);
return true;
} catch {
return false;
}
}
// Handle toggling "Enable Session Sharing"
const handleEnableToggle = () => {
setSessionSharingConfig((prev) => {
const updated = { ...prev, enabled: !prev.enabled };
localStorage.setItem('session_sharing_config', JSON.stringify(updated));
return updated;
});
};
// Handle changes to the base URL field
const handleBaseUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newBaseUrl = e.target.value;
setSessionSharingConfig((prev) => ({
...prev,
baseUrl: newBaseUrl,
}));
if (isValidUrl(newBaseUrl)) {
setUrlError('');
const updated = { ...sessionSharingConfig, baseUrl: newBaseUrl };
localStorage.setItem('session_sharing_config', JSON.stringify(updated));
// Show the checkmark temporarily
setUrlSaved(true);
setTimeout(() => {
setUrlSaved(false);
}, 2000);
} else {
setUrlError('Invalid URL format. Please enter a valid URL (e.g. https://example.com/api).');
}
};
return (
<>
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-semibold text-textStandard">Session Sharing</h2>
</div>
<div className="px-8">
<p className="text-sm text-textStandard mb-4">
You can enable session sharing to share your sessions with others. You'll then need to
enter the base URL for the session sharing API endpoint. Anyone with access to the same
API and sharing session enabled will be able to see your sessions.
</p>
<div className="space-y-4">
{/* Enable Session Sharing toggle */}
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-textStandard cursor-pointer">
Enable Session Sharing
</label>
<button
onClick={handleEnableToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full ${
sessionSharingConfig.enabled ? 'bg-indigo-500' : 'bg-bgProminent'
} transition-colors duration-200 ease-in-out focus:outline-none`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ${
sessionSharingConfig.enabled ? 'translate-x-[22px]' : 'translate-x-[2px]'
} transition-transform duration-200 ease-in-out`}
/>
</button>
</div>
{/* Base URL field (only visible if enabled) */}
{sessionSharingConfig.enabled && (
<div className="space-y-2 relative">
<div className="flex items-center space-x-2">
<label
htmlFor="session-sharing-url"
className="text-sm font-medium text-textStandard"
>
Base URL
</label>
{urlSaved && <Check className="w-5 h-5 text-green-500" />}
</div>
<div className="flex items-center">
<Input
id="session-sharing-url"
type="url"
placeholder="https://example.com/api"
value={sessionSharingConfig.baseUrl}
onChange={handleBaseUrlChange}
/>
</div>
{urlError && <p className="text-red-500 text-sm">{urlError}</p>}
</div>
)}
</div>
</div>
</>
);
}