diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 35cad707..458306d3 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -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(null); + const [sharedSessionError, setSharedSessionError] = useState(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' && } + {view === 'sharedSession' && ( + 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); + } + } + }} + /> + )} {isGoosehintsModalOpen && ( diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index ef2e59a9..40fc1e65 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -378,6 +378,7 @@ export default function ChatView({
+ {messages.length === 0 ? ( setView('sessions')} - subtitle="View previous sessions and their contents" + subtitle="View and share previous sessions" icon={ +
+ - - {/* Content */} -
-
-
- {isLoading ? ( -
-
-
- ) : error ? ( -
-
- -
-

Error Loading Session Details

-

{error}

- -
- ) : 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 ( - -
- - {message.role === 'user' ? 'You' : 'Goose'} - - - {new Date(message.created * 1000).toLocaleTimeString()} - -
- -
- {/* Text content */} - {textContent && ( -
0 ? 'mb-4' : ''}`}> - -
- )} - - {/* Tool requests and responses */} - {toolRequests.length > 0 && ( -
- {toolRequests.map((toolRequest) => ( - - ))} -
- )} -
-
- ); - }) - .filter(Boolean) // Filter out null entries - ) : ( -
- -

No messages found

-

This session doesn't contain any messages

-
- )} -
-
+ + Resume Session +
-
+ + + + + {/* Share Link Modal */} + + + + Share Session + +
+
+
+ {shareLink} +
+ +
+

+ Share this link with others to give them access to this session. +
+ They will need to have Goose installed and session sharing configured. +

+
+ + + +
+
); }; diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 20f1a2db..cc955025 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -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 = ({ setView, onSelectSess
{isLoading ? (
- +
) : error ? (
diff --git a/ui/desktop/src/components/sessions/SessionViewComponents.tsx b/ui/desktop/src/components/sessions/SessionViewComponents.tsx new file mode 100644 index 00000000..a5892835 --- /dev/null +++ b/ui/desktop/src/components/sessions/SessionViewComponents.tsx @@ -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 = ({ onBack, children }) => { + return ( + + + {children} + + ); +}; + +/** + * 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 = ({ + messages, + isLoading, + error, + onRetry, +}) => { + return ( + +
+
+
+ {isLoading ? ( +
+
+
+ ) : error ? ( +
+
+ +
+

Error Loading Session Details

+

{error}

+ +
+ ) : 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 ( + +
+ + {message.role === 'user' ? 'You' : 'Goose'} + + + {new Date(message.created * 1000).toLocaleTimeString()} + +
+ +
+ {/* Text content */} + {textContent && ( +
0 ? 'mb-4' : ''}`}> + +
+ )} + + {/* Tool requests and responses */} + {toolRequests.length > 0 && ( +
+ {toolRequests.map((toolRequest) => ( + + ))} +
+ )} +
+
+ ); + }) + .filter(Boolean) // Filter out null entries + ) : ( +
+ +

No messages found

+

This session doesn't contain any messages

+
+ )} +
+
+
+
+ ); +}; diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index 3dac424a..9aed293d 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -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) => void; @@ -70,7 +76,7 @@ const SessionsView: React.FC = ({ 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 ? ( = ({ setView }) => { onRetry={handleRetryLoadSession} /> ) : ( - + <> + + ); }; diff --git a/ui/desktop/src/components/sessions/SharedSessionView.tsx b/ui/desktop/src/components/sessions/SharedSessionView.tsx new file mode 100644 index 00000000..96958568 --- /dev/null +++ b/ui/desktop/src/components/sessions/SharedSessionView.tsx @@ -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 = ({ + session, + isLoading, + error, + onBack, + onRetry, +}) => { + return ( +
+
+ + {/* Top Row - back, info (fixed) */} + + {/* Session info row */} +
+

+ {session ? session.description : 'Shared Session'} +

+ {session && ( +
+ + + {new Date(session.messages[0]?.created * 1000).toLocaleString()} + + + + {session.base_url} + +
+ )} +
+
+ + +
+ ); +}; + +export default SharedSessionView; diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 9b98e745..58c6d888 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -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({
+
+ +
diff --git a/ui/desktop/src/components/settings/session/SessionSharingSection.tsx b/ui/desktop/src/components/settings/session/SessionSharingSection.tsx new file mode 100644 index 00000000..0ee27680 --- /dev/null +++ b/ui/desktop/src/components/settings/session/SessionSharingSection.tsx @@ -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 user’s 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 user’s 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) => { + 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 ( + <> +
+

Session Sharing

+
+ +
+

+ 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. +

+ +
+ {/* Enable Session Sharing toggle */} +
+ + +
+ + {/* Base URL field (only visible if enabled) */} + {sessionSharingConfig.enabled && ( +
+
+ + {urlSaved && } +
+
+ +
+ {urlError &&

{urlError}

} +
+ )} +
+
+ + ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index f3efdfd6..8d2e57c0 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -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'); diff --git a/ui/desktop/src/sessionLinks.ts b/ui/desktop/src/sessionLinks.ts new file mode 100644 index 00000000..9ed3a760 --- /dev/null +++ b/ui/desktop/src/sessionLinks.ts @@ -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) => void, + baseUrl?: string +): Promise { + 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; + } +} diff --git a/ui/desktop/src/sharedSessions.ts b/ui/desktop/src/sharedSessions.ts new file mode 100644 index 00000000..877e4d1f --- /dev/null +++ b/ui/desktop/src/sharedSessions.ts @@ -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 { + 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 { + 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; + } +}