mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-23 00:54:22 +01:00
feat: share sessions in the UI (#1727)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { addExtensionFromDeepLink } from './extensions';
|
import { addExtensionFromDeepLink } from './extensions';
|
||||||
|
import { openSharedSessionFromDeepLink } from './sessionLinks';
|
||||||
import { getStoredModel } from './utils/providerUtils';
|
import { getStoredModel } from './utils/providerUtils';
|
||||||
import { getStoredProvider, initializeSystem } from './utils/providerUtils';
|
import { getStoredProvider, initializeSystem } from './utils/providerUtils';
|
||||||
import { useModel } from './components/settings/models/ModelContext';
|
import { useModel } from './components/settings/models/ModelContext';
|
||||||
@@ -12,7 +13,8 @@ import { ToastContainer } from 'react-toastify';
|
|||||||
import { toastService } from './toasts';
|
import { toastService } from './toasts';
|
||||||
import { extractExtensionName } from './components/settings/extensions/utils';
|
import { extractExtensionName } from './components/settings/extensions/utils';
|
||||||
import { GoosehintsModal } from './components/GoosehintsModal';
|
import { GoosehintsModal } from './components/GoosehintsModal';
|
||||||
import { SessionDetails, fetchSessionDetails } from './sessions';
|
import { SessionDetails } from './sessions';
|
||||||
|
import { SharedSessionDetails } from './sharedSessions';
|
||||||
|
|
||||||
import WelcomeView from './components/WelcomeView';
|
import WelcomeView from './components/WelcomeView';
|
||||||
import ChatView from './components/ChatView';
|
import ChatView from './components/ChatView';
|
||||||
@@ -21,6 +23,7 @@ import SettingsViewV2 from './components/settings_v2/SettingsView';
|
|||||||
import MoreModelsView from './components/settings/models/MoreModelsView';
|
import MoreModelsView from './components/settings/models/MoreModelsView';
|
||||||
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
|
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
|
||||||
import SessionsView from './components/sessions/SessionsView';
|
import SessionsView from './components/sessions/SessionsView';
|
||||||
|
import SharedSessionView from './components/sessions/SharedSessionView';
|
||||||
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
|
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
|
||||||
import { useChat } from './hooks/useChat';
|
import { useChat } from './hooks/useChat';
|
||||||
|
|
||||||
@@ -44,7 +47,8 @@ export type View =
|
|||||||
| 'configPage'
|
| 'configPage'
|
||||||
| 'ConfigureProviders'
|
| 'ConfigureProviders'
|
||||||
| 'settingsV2'
|
| 'settingsV2'
|
||||||
| 'sessions';
|
| 'sessions'
|
||||||
|
| 'sharedSession';
|
||||||
|
|
||||||
export type ViewConfig = {
|
export type ViewConfig = {
|
||||||
view: View;
|
view: View;
|
||||||
@@ -68,6 +72,116 @@ export default function App() {
|
|||||||
const { getExtensions, addExtension, read } = useConfig();
|
const { getExtensions, addExtension, read } = useConfig();
|
||||||
const initAttemptedRef = useRef(false);
|
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
|
// Utility function to extract the command from the link
|
||||||
function extractCommand(link: string): string {
|
function extractCommand(link: string): string {
|
||||||
const url = new URL(link);
|
const url = new URL(link);
|
||||||
@@ -210,6 +324,9 @@ export default function App() {
|
|||||||
|
|
||||||
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
|
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
|
||||||
const [isLoadingSession, setIsLoadingSession] = 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 });
|
const { chat, setChat } = useChat({ setView, setIsLoadingSession });
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// Keyboard shortcut handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Setting up keyboard shortcuts');
|
console.log('Setting up keyboard shortcuts');
|
||||||
@@ -505,6 +647,30 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'sessions' && <SessionsView setView={setView} />}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
{isGoosehintsModalOpen && (
|
{isGoosehintsModalOpen && (
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ export default function ChatView({
|
|||||||
<div className="relative flex items-center h-[36px] w-full">
|
<div className="relative flex items-center h-[36px] w-full">
|
||||||
<MoreMenuLayout setView={setView} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
|
<MoreMenuLayout setView={setView} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
|
<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 ? (
|
{messages.length === 0 ? (
|
||||||
<Splash
|
<Splash
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export default function MoreMenu({
|
|||||||
|
|
||||||
<MenuButton
|
<MenuButton
|
||||||
onClick={() => setView('sessions')}
|
onClick={() => setView('sessions')}
|
||||||
subtitle="View previous sessions and their contents"
|
subtitle="View and share previous sessions"
|
||||||
icon={<Time className="w-4 h-4" />}
|
icon={<Time className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
Session history
|
Session history
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Clock, MessageSquare, Folder, AlertCircle } from 'lucide-react';
|
import { Clock, MessageSquare, Folder, Share, Copy, Check, LoaderCircle } from 'lucide-react';
|
||||||
import { type SessionDetails } from '../../sessions';
|
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 { Button } from '../ui/button';
|
||||||
import BackButton from '../ui/BackButton';
|
import { toast } from 'react-toastify';
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
|
||||||
import MarkdownContent from '../MarkdownContent';
|
|
||||||
import ToolCallWithResponse from '../ToolCallWithResponse';
|
|
||||||
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message';
|
|
||||||
|
|
||||||
interface SessionHistoryViewProps {
|
interface SessionHistoryViewProps {
|
||||||
session: SessionDetails;
|
session: SessionDetails;
|
||||||
@@ -18,31 +16,6 @@ interface SessionHistoryViewProps {
|
|||||||
onRetry: () => void;
|
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> = ({
|
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||||
session,
|
session,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -51,14 +24,85 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
|||||||
onResume,
|
onResume,
|
||||||
onRetry,
|
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 (
|
return (
|
||||||
<div className="h-screen w-full">
|
<div className="h-screen w-full">
|
||||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
|
||||||
|
|
||||||
{/* Top Row - back, info, reopen thread (fixed) */}
|
{/* Top Row - back, info, reopen thread (fixed) */}
|
||||||
<Card className="px-8 pt-6 pb-4 bg-bgSecondary flex items-center">
|
<SessionHeaderCard onBack={onBack}>
|
||||||
<BackButton showText={false} onClick={onBack} className="text-textStandard" />
|
|
||||||
|
|
||||||
{/* Session info row */}
|
{/* Session info row */}
|
||||||
<div className="ml-8">
|
<div className="ml-8">
|
||||||
<h1 className="text-lg font-bold text-textStandard">
|
<h1 className="text-lg font-bold text-textStandard">
|
||||||
@@ -85,119 +129,83 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<div className="ml-auto flex items-center space-x-4">
|
||||||
onClick={onResume}
|
<button
|
||||||
className="ml-auto text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
|
onClick={handleShare}
|
||||||
>
|
disabled={!canShare || isSharing}
|
||||||
Resume Session
|
className={`flex items-center text-textStandard px-3 py-1 border rounded-md ${
|
||||||
</span>
|
canShare
|
||||||
</Card>
|
? '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">
|
<span
|
||||||
{/* Content */}
|
onClick={onResume}
|
||||||
<div className="p-4">
|
className="text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
|
||||||
<div className="flex flex-col space-y-4">
|
>
|
||||||
<div className="space-y-4 mb-6">
|
Resume Session
|
||||||
{isLoading ? (
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ViewConfig } from '../../App';
|
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 { fetchSessions, type Session } from '../../sessions';
|
||||||
import { Card } from '../ui/card';
|
import { Card } from '../ui/card';
|
||||||
import { Button } from '../ui/button';
|
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">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center items-center h-full">
|
<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>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
|
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
|
||||||
|
|||||||
185
ui/desktop/src/components/sessions/SessionViewComponents.tsx
Normal file
185
ui/desktop/src/components/sessions/SessionViewComponents.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ViewConfig } from '../../App';
|
import { ViewConfig } from '../../App';
|
||||||
import { fetchSessionDetails, type SessionDetails } from '../../sessions';
|
import { fetchSessionDetails, type SessionDetails } from '../../sessions';
|
||||||
|
import { fetchSharedSessionDetails } from '../../sharedSessions';
|
||||||
import SessionListView from './SessionListView';
|
import SessionListView from './SessionListView';
|
||||||
import SessionHistoryView from './SessionHistoryView';
|
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 {
|
interface SessionsViewProps {
|
||||||
setView: (view: ViewConfig['view'], viewOptions?: Record<any, any>) => void;
|
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
|
// 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 ? (
|
return selectedSession ? (
|
||||||
<SessionHistoryView
|
<SessionHistoryView
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
@@ -81,7 +87,9 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
|
|||||||
onRetry={handleRetryLoadSession}
|
onRetry={handleRetryLoadSession}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SessionListView setView={setView} onSelectSession={handleSelectSession} />
|
<>
|
||||||
|
<SessionListView setView={setView} onSelectSession={handleSelectSession} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
57
ui/desktop/src/components/sessions/SharedSessionView.tsx
Normal file
57
ui/desktop/src/components/sessions/SharedSessionView.tsx
Normal 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;
|
||||||
@@ -15,8 +15,10 @@ import { RecentModelsRadio } from './models/RecentModels';
|
|||||||
import { ExtensionItem } from './extensions/ExtensionItem';
|
import { ExtensionItem } from './extensions/ExtensionItem';
|
||||||
import type { View } from '../../App';
|
import type { View } from '../../App';
|
||||||
import { ModeSelection } from './basic/ModeSelection';
|
import { ModeSelection } from './basic/ModeSelection';
|
||||||
|
import SessionSharingSection from './session/SessionSharingSection';
|
||||||
import { toastSuccess } from '../../toasts';
|
import { toastSuccess } from '../../toasts';
|
||||||
|
|
||||||
|
|
||||||
const EXTENSIONS_DESCRIPTION =
|
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.';
|
'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 />
|
<ModeSelection />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="session-sharing">
|
||||||
|
<SessionSharingSection />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 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<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
|
session,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
dialog,
|
dialog,
|
||||||
globalShortcut,
|
globalShortcut,
|
||||||
@@ -81,6 +82,8 @@ app.on('open-url', async (event, url) => {
|
|||||||
// Handle extension install links
|
// Handle extension install links
|
||||||
if (parsedUrl.hostname === 'extension') {
|
if (parsedUrl.hostname === 'extension') {
|
||||||
firstOpenWindow.webContents.send('add-extension', pendingDeepLink);
|
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);
|
console.log('Processing pending deep link:', pendingDeepLink);
|
||||||
const parsedUrl = new URL(pendingDeepLink);
|
const parsedUrl = new URL(pendingDeepLink);
|
||||||
|
|
||||||
|
// Handle different deep link types
|
||||||
if (parsedUrl.hostname === 'extension') {
|
if (parsedUrl.hostname === 'extension') {
|
||||||
console.log('Sending add-extension event');
|
console.log('Sending add-extension event');
|
||||||
firstOpenWindow.webContents.send('add-extension', pendingDeepLink);
|
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;
|
pendingDeepLink = null;
|
||||||
} else {
|
} else {
|
||||||
console.log('No pending deep link to process');
|
console.log('No pending deep link to process');
|
||||||
@@ -423,6 +430,11 @@ ipcMain.handle('get-binary-path', (event, binaryName) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
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
|
// Test error feature - only enabled with GOOSE_TEST_ERROR=true
|
||||||
if (process.env.GOOSE_TEST_ERROR === 'true') {
|
if (process.env.GOOSE_TEST_ERROR === 'true') {
|
||||||
console.log('Test error feature enabled, will throw error in 5 seconds');
|
console.log('Test error feature enabled, will throw error in 5 seconds');
|
||||||
|
|||||||
78
ui/desktop/src/sessionLinks.ts
Normal file
78
ui/desktop/src/sessionLinks.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
ui/desktop/src/sharedSessions.ts
Normal file
89
ui/desktop/src/sharedSessions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user