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

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { addExtensionFromDeepLink } from './extensions';
import { openSharedSessionFromDeepLink } from './sessionLinks';
import { getStoredModel } from './utils/providerUtils';
import { getStoredProvider, initializeSystem } from './utils/providerUtils';
import { useModel } from './components/settings/models/ModelContext';
@@ -12,7 +13,8 @@ import { ToastContainer } from 'react-toastify';
import { toastService } from './toasts';
import { extractExtensionName } from './components/settings/extensions/utils';
import { GoosehintsModal } from './components/GoosehintsModal';
import { SessionDetails, fetchSessionDetails } from './sessions';
import { SessionDetails } from './sessions';
import { SharedSessionDetails } from './sharedSessions';
import WelcomeView from './components/WelcomeView';
import ChatView from './components/ChatView';
@@ -21,6 +23,7 @@ import SettingsViewV2 from './components/settings_v2/SettingsView';
import MoreModelsView from './components/settings/models/MoreModelsView';
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
import SessionsView from './components/sessions/SessionsView';
import SharedSessionView from './components/sessions/SharedSessionView';
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
import { useChat } from './hooks/useChat';
@@ -44,7 +47,8 @@ export type View =
| 'configPage'
| 'ConfigureProviders'
| 'settingsV2'
| 'sessions';
| 'sessions'
| 'sharedSession';
export type ViewConfig = {
view: View;
@@ -68,6 +72,116 @@ export default function App() {
const { getExtensions, addExtension, read } = useConfig();
const initAttemptedRef = useRef(false);
useEffect(() => {
// Skip if feature flag is not enabled
if (!process.env.ALPHA) {
return;
}
console.log('Alpha flow initializing...');
// First quickly check if we have model and provider to set chat view
const checkRequiredConfig = async () => {
try {
console.log('Reading GOOSE_PROVIDER and GOOSE_MODEL from config...');
const provider = (await read('GOOSE_PROVIDER', false)) as string;
const model = (await read('GOOSE_MODEL', false)) as string;
if (provider && model) {
// We have all needed configuration, set chat view immediately
console.log(`Found provider: ${provider}, model: ${model}, setting chat view`);
setView('chat');
// Initialize the system in background
initializeSystem(provider, model)
.then(() => console.log('System initialization successful'))
.catch((error) => {
console.error('Error initializing system:', error);
setFatalError(`System initialization error: ${error.message || 'Unknown error'}`);
setView('welcome');
});
} else {
// Missing configuration, show onboarding
console.log('Missing configuration, showing onboarding');
if (!provider) console.log('Missing provider');
if (!model) console.log('Missing model');
setView('welcome');
}
} catch (error) {
console.error('Error checking configuration:', error);
setFatalError(`Configuration check error: ${error.message || 'Unknown error'}`);
setView('welcome');
}
};
// Setup extensions in parallel
const setupExtensions = async () => {
// Set the ref immediately to prevent duplicate runs
initAttemptedRef.current = true;
let refreshedExtensions: FixedExtensionEntry[] = [];
try {
// Force refresh extensions from the backend to ensure we have the latest
console.log('Getting extensions from backend...');
refreshedExtensions = await getExtensions(true);
console.log(`Retrieved ${refreshedExtensions.length} extensions`);
} catch (error) {
console.log('Error getting extensions list');
return; // Exit early if we can't get the extensions list
}
// built-in extensions block -- just adds them to config if missing
try {
console.log('Setting up built-in extensions...');
if (refreshedExtensions.length === 0) {
// If we still have no extensions, this is truly a first-time setup
console.log('First-time setup: Adding all built-in extensions...');
await initializeBuiltInExtensions(addExtension);
console.log('Built-in extensions initialization complete');
// Refresh the extensions list after initialization
refreshedExtensions = await getExtensions(true);
} else {
// Extensions exist, check for any missing built-ins
console.log('Checking for missing built-in extensions...');
console.log('Current extensions:', refreshedExtensions);
await syncBuiltInExtensions(refreshedExtensions, addExtension);
console.log('Built-in extensions sync complete');
}
} catch (error) {
console.error('Error setting up extensions:', error);
// We don't set fatal error here since the app might still work without extensions
}
// now try to add to agent
console.log('Adding enabled extensions to agent...');
for (const extensionEntry of refreshedExtensions) {
if (extensionEntry.enabled) {
console.log(`Adding extension to agent: ${extensionEntry.name}`);
// need to convert to config because that's what the endpoint expects
const extensionConfig = extractExtensionConfig(extensionEntry);
// will handle toasts and also set failures to enabled = false
await addToAgentOnStartup({ addToConfig: addExtension, extensionConfig });
} else {
console.log(`Skipping disabled extension: ${extensionEntry.name}`);
}
}
console.log('Extensions setup complete');
};
// Execute the two flows in parallel for speed
checkRequiredConfig().catch((error) => {
console.error('Unhandled error in checkRequiredConfig:', error);
setFatalError(`Config check error: ${error.message || 'Unknown error'}`);
});
setupExtensions().catch((error) => {
console.error('Unhandled error in setupExtensions:', error);
// Not setting fatal error here since extensions are optional
});
}, []); // Empty dependency array since we're using initAttemptedRef
// Utility function to extract the command from the link
function extractCommand(link: string): string {
const url = new URL(link);
@@ -210,6 +324,9 @@ export default function App() {
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const [sharedSession, setSharedSession] = useState<SharedSessionDetails | null>(null);
const [sharedSessionError, setSharedSessionError] = useState<string | null>(null);
const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false);
const { chat, setChat } = useChat({ setView, setIsLoadingSession });
useEffect(() => {
@@ -222,6 +339,31 @@ export default function App() {
}
}, []);
// Handle shared session deep links
useEffect(() => {
const handleOpenSharedSession = async (_: any, link: string) => {
window.electron.logInfo(`Opening shared session from deep link ${link}`);
setIsLoadingSharedSession(true);
setSharedSessionError(null);
try {
await openSharedSessionFromDeepLink(link, setView);
// No need to handle errors here as openSharedSessionFromDeepLink now handles them internally
} catch (error) {
// This should not happen, but just in case
console.error('Unexpected error opening shared session:', error);
setView('sessions'); // Fallback to sessions view
} finally {
setIsLoadingSharedSession(false);
}
};
window.electron.on('open-shared-session', handleOpenSharedSession);
return () => {
window.electron.off('open-shared-session', handleOpenSharedSession);
};
}, []);
// Keyboard shortcut handler
useEffect(() => {
console.log('Setting up keyboard shortcuts');
@@ -505,6 +647,30 @@ export default function App() {
/>
)}
{view === 'sessions' && <SessionsView setView={setView} />}
{view === 'sharedSession' && (
<SharedSessionView
session={viewOptions.sessionDetails}
isLoading={isLoadingSharedSession}
error={viewOptions.error || sharedSessionError}
onBack={() => setView('sessions')}
onRetry={async () => {
if (viewOptions.shareToken && viewOptions.baseUrl) {
setIsLoadingSharedSession(true);
try {
await openSharedSessionFromDeepLink(
`goose://sessions/${viewOptions.shareToken}`,
setView,
viewOptions.baseUrl
);
} catch (error) {
console.error('Failed to retry loading shared session:', error);
} finally {
setIsLoadingSharedSession(false);
}
}
}}
/>
)}
</div>
</div>
{isGoosehintsModalOpen && (

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

View File

@@ -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

View File

@@ -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>
<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>
<span
onClick={onResume}
className="ml-auto text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
className="text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
>
Resume Session
</span>
</Card>
</div>
</SessionHeaderCard>
<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>
<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>
) : 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
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>
) : 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'
}`}
<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"
>
<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>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
};

View File

@@ -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">

View File

@@ -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>
);
};

View File

@@ -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} />
</>
);
};

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
</>
);
}

View File

@@ -1,5 +1,6 @@
import {
app,
session,
BrowserWindow,
dialog,
globalShortcut,
@@ -81,6 +82,8 @@ app.on('open-url', async (event, url) => {
// Handle extension install links
if (parsedUrl.hostname === 'extension') {
firstOpenWindow.webContents.send('add-extension', pendingDeepLink);
} else if (parsedUrl.hostname === 'sessions') {
firstOpenWindow.webContents.send('open-shared-session', pendingDeepLink);
}
});
@@ -363,11 +366,15 @@ ipcMain.on('react-ready', (event) => {
console.log('Processing pending deep link:', pendingDeepLink);
const parsedUrl = new URL(pendingDeepLink);
// Handle different deep link types
if (parsedUrl.hostname === 'extension') {
console.log('Sending add-extension event');
firstOpenWindow.webContents.send('add-extension', pendingDeepLink);
} else if (parsedUrl.hostname === 'sessions') {
console.log('Sending open-shared-session event');
firstOpenWindow.webContents.send('open-shared-session', pendingDeepLink);
}
// Bot URLs are now handled directly through botConfig in additionalArguments
// Bot URLs are handled directly through botConfig in additionalArguments
pendingDeepLink = null;
} else {
console.log('No pending deep link to process');
@@ -423,6 +430,11 @@ ipcMain.handle('get-binary-path', (event, binaryName) => {
});
app.whenReady().then(async () => {
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
details.requestHeaders['Origin'] = 'http://localhost:5173';
callback({ cancel: false, requestHeaders: details.requestHeaders });
});
// Test error feature - only enabled with GOOSE_TEST_ERROR=true
if (process.env.GOOSE_TEST_ERROR === 'true') {
console.log('Test error feature enabled, will throw error in 5 seconds');

View File

@@ -0,0 +1,78 @@
import { toast } from 'react-toastify';
import { fetchSharedSessionDetails, SharedSessionDetails } from './sharedSessions';
import { type View } from './App';
/**
* Handles opening a shared session from a deep link
* @param url The deep link URL (goose://sessions/:shareToken)
* @param setView Function to set the current view
* @param baseUrl Optional base URL for the session sharing API
* @returns Promise that resolves when the session is opened
*/
export async function openSharedSessionFromDeepLink(
url: string,
setView: (view: View, options?: Record<string, any>) => void,
baseUrl?: string
): Promise<SharedSessionDetails | null> {
try {
if (!url.startsWith('goose://sessions/')) {
throw new Error('Invalid URL: URL must use the goose://sessions/ scheme');
}
// Extract the share token from the URL
const shareToken = url.replace('goose://sessions/', '');
if (!shareToken || shareToken.trim() === '') {
throw new Error('Invalid URL: Missing share token');
}
// If no baseUrl is provided, check if there's one in localStorage
if (!baseUrl) {
const savedSessionConfig = localStorage.getItem('session_sharing_config');
if (savedSessionConfig) {
try {
const config = JSON.parse(savedSessionConfig);
if (config.enabled && config.baseUrl) {
baseUrl = config.baseUrl;
} else {
throw new Error(
'Session sharing is not enabled or base URL is not configured. Check the settings page.'
);
}
} catch (error) {
console.error('Error parsing session sharing config:', error);
throw new Error(
'Session sharing is not enabled or base URL is not configured. Check the settings page.'
);
}
} else {
throw new Error('Session sharing is not configured');
}
}
// Fetch the shared session details
const sessionDetails = await fetchSharedSessionDetails(baseUrl, shareToken);
// Navigate to the shared session view
setView('sharedSession', {
sessionDetails,
shareToken,
baseUrl,
});
return sessionDetails;
} catch (error) {
const errorMessage = `Failed to open shared session: ${error instanceof Error ? error.message : 'Unknown error'}`;
console.error(errorMessage);
// Navigate to the shared session view with the error instead of throwing
setView('sharedSession', {
sessionDetails: null,
error: error instanceof Error ? error.message : 'Unknown error',
shareToken: url.replace('goose://sessions/', ''),
baseUrl,
});
return null;
}
}

View File

@@ -0,0 +1,89 @@
import { Message, createUserMessage, createAssistantMessage } from './types/message';
export interface SharedSessionDetails {
share_token: string;
created_at: number;
base_url: string;
description: string;
messages: Message[];
}
/**
* Fetches details for a specific shared session
* @param baseUrl The base URL for session sharing API
* @param shareToken The share token of the session to fetch
* @returns Promise with shared session details
*/
export async function fetchSharedSessionDetails(
baseUrl: string,
shareToken: string
): Promise<SharedSessionDetails> {
try {
const response = await fetch(`${baseUrl}/sessions/share/${shareToken}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// Origin: 'http://localhost:5173', // required to bypass Cloudflare security filter
},
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch shared session: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (baseUrl != data.base_url) {
throw new Error(`Base URL mismatch for shared session: ${baseUrl} != ${data.base_url}`);
}
return {
share_token: data.share_token,
created_at: data.created_at,
base_url: data.base_url,
description: data.description || 'Shared Session',
messages: data.messages,
};
} catch (error) {
console.error('Error fetching shared session:', error);
throw error;
}
}
/**
* Creates a new shared session
* @param baseUrl The base URL for session sharing API
* @param messages The messages to include in the shared session
* @param description Optional description for the shared session
* @returns Promise with the share token
*/
export async function createSharedSession(
baseUrl: string,
messages: Message[],
description?: string
): Promise<string> {
try {
const response = await fetch(`${baseUrl}/sessions/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages,
description: description || 'Shared Session',
base_url: baseUrl,
}),
});
if (!response.ok) {
throw new Error(`Failed to create shared session: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.share_token;
} catch (error) {
console.error('Error creating shared session:', error);
throw error;
}
}