mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Implement Matrix channel creation functionality (#5962)
This commit is contained in:
+1195
-322
File diff suppressed because it is too large
Load Diff
@@ -387,6 +387,14 @@ function BaseChatContent({
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
const combinedTextFromInput = customEvent.detail?.value || '';
|
||||
|
||||
// DIAGNOSTIC: Log chat info when submitting message
|
||||
console.log('[Matrix Message Send - BaseChat] handleSubmit called:', {
|
||||
chatId: chat.id,
|
||||
chatTitle: chat.title,
|
||||
messagePreview: combinedTextFromInput.substring(0, 50) + '...',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Mark that user has started using the recipe when they submit a message
|
||||
if (recipeConfig && combinedTextFromInput.trim()) {
|
||||
setHasStartedUsingRecipe(true);
|
||||
|
||||
@@ -114,6 +114,7 @@ function BaseChatContent({
|
||||
initialMessage,
|
||||
onSessionIdChange,
|
||||
isMatrixTab: !!matrixRoomId, // Pass Matrix tab flag based on whether we have a matrixRoomId
|
||||
matrixRoomId, // Pass Matrix room ID for message routing
|
||||
tabId, // Pass tabId for sidecar filtering
|
||||
});
|
||||
|
||||
@@ -144,20 +145,26 @@ function BaseChatContent({
|
||||
sender: message.sender?.displayName || message.sender?.userId || 'unknown'
|
||||
});
|
||||
|
||||
// FIXED: Make Matrix message events SESSION-SPECIFIC to prevent cross-tab contamination
|
||||
// Include sessionId in the event detail so only the correct useChatStream instance processes it
|
||||
// FIXED: For Matrix tabs, use matrixRoomId as the routing key instead of sessionId
|
||||
// This ensures messages are routed correctly even if backend session ID changes
|
||||
const routingKey = matrixRoomId || sessionId;
|
||||
|
||||
const messageEvent = new CustomEvent('matrix-message-received', {
|
||||
detail: {
|
||||
message,
|
||||
targetSessionId: sessionId, // CRITICAL: Only this session should process this message
|
||||
targetSessionId: sessionId, // Backend session ID (for logging)
|
||||
targetRoomId: matrixRoomId, // Matrix room ID (for routing)
|
||||
routingKey: routingKey, // The actual key to match on
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(messageEvent);
|
||||
|
||||
console.log('📥 BaseChat2 dispatched SESSION-SPECIFIC matrix-message-received event:', {
|
||||
console.log('📥 BaseChat2 dispatched matrix-message-received event:', {
|
||||
messageId: message.id,
|
||||
targetSessionId: sessionId.substring(0, 8),
|
||||
targetSessionId: sessionId?.substring(0, 8),
|
||||
targetRoomId: matrixRoomId?.substring(0, 20),
|
||||
routingKey: routingKey?.substring(0, 20),
|
||||
sender: message.sender?.displayName || message.sender?.userId || 'unknown'
|
||||
});
|
||||
}
|
||||
@@ -286,17 +293,20 @@ function BaseChatContent({
|
||||
const shouldShowPopularTopics = showPopularTopics &&
|
||||
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
|
||||
|
||||
// Debug logging for empty state
|
||||
console.log('BaseChat2 render state:', {
|
||||
sessionId: sessionId, // Show full session ID for debugging
|
||||
sessionIdShort: sessionId.slice(0, 8), // Also show truncated for readability
|
||||
// DIAGNOSTIC: Enhanced logging for Matrix debugging
|
||||
console.log('🔍 BaseChat2 render state:', {
|
||||
sessionId: sessionId?.slice(0, 8) + '...', // Show truncated for readability
|
||||
matrixRoomId: matrixRoomId?.substring(0, 20) + '...',
|
||||
isMatrixTab: !!matrixRoomId,
|
||||
messagesLength: messages.length,
|
||||
chatState,
|
||||
shouldShowPopularTopics,
|
||||
loadingChat,
|
||||
hasSession: !!session,
|
||||
sessionName: session?.name,
|
||||
sessionDescription: session?.description
|
||||
sessionDescription: session?.description,
|
||||
showParticipantsBar,
|
||||
tabId
|
||||
});
|
||||
|
||||
// Memoize the chat object to prevent infinite re-renders
|
||||
|
||||
@@ -106,6 +106,7 @@ interface ChatInputProps {
|
||||
append?: (message: Message) => void;
|
||||
isExtensionsLoading?: boolean;
|
||||
gooseEnabled?: boolean;
|
||||
matrixRoomId?: string | null; // Matrix room ID for this specific chat tab
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
@@ -134,7 +135,13 @@ export default function ChatInput({
|
||||
append,
|
||||
isExtensionsLoading = false,
|
||||
gooseEnabled = true,
|
||||
matrixRoomId,
|
||||
}: ChatInputProps) {
|
||||
// DIAGNOSTIC: Log the matrixRoomId prop when ChatInput renders
|
||||
console.log('[ChatInput] Received matrixRoomId prop:', {
|
||||
matrixRoomId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
// Track the available width for responsive layout
|
||||
const [availableWidth, setAvailableWidth] = useState(window.innerWidth);
|
||||
const chatInputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronRight, Hash } from 'lucide-react';
|
||||
import { matrixService } from '../services/MatrixService';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface SpaceBreadcrumbProps {
|
||||
roomId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbData {
|
||||
spaceName: string;
|
||||
spaceId: string;
|
||||
roomName: string;
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export const SpaceBreadcrumb: React.FC<SpaceBreadcrumbProps> = ({ roomId, className = '' }) => {
|
||||
const [breadcrumb, setBreadcrumb] = useState<BreadcrumbData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBreadcrumb = () => {
|
||||
try {
|
||||
// Get all Spaces
|
||||
const spaces = matrixService.getSpaces();
|
||||
|
||||
// Find which Space contains this room
|
||||
for (const space of spaces) {
|
||||
const children = matrixService.getSpaceChildren(space.roomId);
|
||||
const childRoom = children.find(child => child.roomId === roomId);
|
||||
|
||||
if (childRoom) {
|
||||
// Found the parent Space!
|
||||
setBreadcrumb({
|
||||
spaceName: space.name || 'Unnamed Space',
|
||||
spaceId: space.roomId,
|
||||
roomName: childRoom.name || 'Unnamed Room',
|
||||
roomId: roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Room is not in any Space
|
||||
setBreadcrumb(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Space breadcrumb:', error);
|
||||
setBreadcrumb(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadBreadcrumb();
|
||||
|
||||
// Listen for Space changes
|
||||
const handleSpaceUpdate = () => {
|
||||
loadBreadcrumb();
|
||||
};
|
||||
|
||||
matrixService.on('spaceChildAdded', handleSpaceUpdate);
|
||||
matrixService.on('spaceChildRemoved', handleSpaceUpdate);
|
||||
matrixService.on('ready', handleSpaceUpdate);
|
||||
|
||||
return () => {
|
||||
matrixService.off('spaceChildAdded', handleSpaceUpdate);
|
||||
matrixService.off('spaceChildRemoved', handleSpaceUpdate);
|
||||
matrixService.off('ready', handleSpaceUpdate);
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
// Don't render if room is not in a Space
|
||||
if (!breadcrumb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`flex items-center gap-2 px-4 py-2 bg-background-muted border-b border-border-default ${className}`}
|
||||
>
|
||||
{/* Space Icon */}
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<div className="w-5 h-5 bg-background-accent/20 rounded flex items-center justify-center">
|
||||
<Hash className="w-3 h-3 text-text-on-accent" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{breadcrumb.spaceName}</span>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<ChevronRight className="w-4 h-4 text-text-muted" />
|
||||
|
||||
{/* Room Name */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="w-4 h-4 text-text-muted" />
|
||||
<span className="text-sm text-text-default">{breadcrumb.roomName}</span>
|
||||
</div>
|
||||
|
||||
{/* Optional: Add a badge to indicate it's a Space room */}
|
||||
<div className="ml-auto">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
Space Room
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import MultiPanelTabSidecar from './MultiPanelTabSidecar';
|
||||
import { useTabContext } from '../contexts/TabContext';
|
||||
import { ResizableSplitter } from './Layout/ResizableSplitter';
|
||||
import { TaskExecutionProvider } from '../contexts/TaskExecutionContext';
|
||||
import { SpaceBreadcrumb } from './SpaceBreadcrumb';
|
||||
|
||||
interface TabbedChatContainerProps {
|
||||
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
|
||||
@@ -200,6 +201,13 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Space Breadcrumb - Show for Matrix tabs with room IDs */}
|
||||
{activeTabState && activeTabState.tab.type === 'matrix' && activeTabState.tab.matrixRoomId && (
|
||||
<div className="flex-shrink-0">
|
||||
<SpaceBreadcrumb roomId={activeTabState.tab.matrixRoomId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area - Chat and Sidecar */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden rounded-t-lg bg-background-default">
|
||||
{/* Render all tabs but only show the active one - this prevents unmounting */}
|
||||
|
||||
@@ -19,6 +19,8 @@ import MatrixAuth from '../peers/MatrixAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTabContext } from '../../contexts/TabContext';
|
||||
import { matrixService } from '../../services/MatrixService';
|
||||
import { sessionMappingService } from '../../services/SessionMappingService';
|
||||
import SpaceRoomsView from './SpaceRoomsView';
|
||||
|
||||
interface Channel {
|
||||
roomId: string;
|
||||
@@ -547,6 +549,7 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
const [showMatrixAuth, setShowMatrixAuth] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
const [selectedSpace, setSelectedSpace] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
// Load favorites from localStorage on mount
|
||||
useEffect(() => {
|
||||
@@ -606,9 +609,11 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
return mxcUrl;
|
||||
};
|
||||
|
||||
// Filter channels (non-DM rooms) from Matrix rooms and add favorite status
|
||||
const channels: Channel[] = rooms
|
||||
.filter(room => !room.isDirectMessage)
|
||||
// Get Matrix Spaces from context
|
||||
const { spaces } = useMatrix();
|
||||
|
||||
// Map Spaces to channels and add favorite status
|
||||
const channels: Channel[] = spaces
|
||||
.map(room => ({
|
||||
roomId: room.roomId,
|
||||
name: room.name || 'Unnamed Channel',
|
||||
@@ -638,23 +643,33 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
|
||||
const handleOpenChannel = async (channel: Channel) => {
|
||||
try {
|
||||
console.log('📱 Opening channel:', channel);
|
||||
console.log('📦 Opening Space:', channel);
|
||||
|
||||
// Open a new tab/chat session with Matrix room parameters
|
||||
// Pass the channel name so it appears in the tab title
|
||||
openMatrixChat(channel.roomId, currentUser?.userId || '', channel.name);
|
||||
|
||||
// Navigate to the pair view where the tabs are displayed
|
||||
navigate('/pair');
|
||||
// Set the selected space to show its rooms
|
||||
setSelectedSpace({ id: channel.roomId, name: channel.name });
|
||||
} catch (error) {
|
||||
console.error('Failed to open channel:', error);
|
||||
console.error('Failed to open Space:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChannel = async (name: string, topic: string, isPublic: boolean) => {
|
||||
// TODO: Implement channel creation via Matrix service
|
||||
console.log('Creating channel:', { name, topic, isPublic });
|
||||
alert('Channel creation not yet implemented');
|
||||
try {
|
||||
console.log('📦 Creating Matrix Space:', { name, topic, isPublic });
|
||||
|
||||
// Use the new createSpace method from MatrixService
|
||||
const spaceId = await matrixService.createSpace(name, topic, isPublic);
|
||||
|
||||
console.log('✅ Space created successfully:', spaceId);
|
||||
|
||||
// Create session mapping for the new space
|
||||
const participants = [currentUser?.userId || ''];
|
||||
await sessionMappingService.createMappingWithBackendSession(spaceId, participants, name);
|
||||
|
||||
console.log('✅ Space creation complete');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create Space:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditChannel = (channel: Channel) => {
|
||||
@@ -734,6 +749,17 @@ const ChannelsView: React.FC<ChannelsViewProps> = ({ onClose }) => {
|
||||
return <MatrixAuth onClose={() => setShowMatrixAuth(false)} />;
|
||||
}
|
||||
|
||||
// Show SpaceRoomsView if a space is selected
|
||||
if (selectedSpace) {
|
||||
return (
|
||||
<SpaceRoomsView
|
||||
spaceId={selectedSpace.id}
|
||||
spaceName={selectedSpace.name}
|
||||
onBack={() => setSelectedSpace(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-screen bg-background-muted">
|
||||
{/* Header */}
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Hash,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
Users,
|
||||
Lock,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { useMatrix } from '../../contexts/MatrixContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTabContext } from '../../contexts/TabContext';
|
||||
import { matrixService } from '../../services/MatrixService';
|
||||
import { sessionMappingService } from '../../services/SessionMappingService';
|
||||
import { matrixHistorySyncService } from '../../services/MatrixHistorySync';
|
||||
|
||||
interface SpaceRoomsViewProps {
|
||||
spaceId: string;
|
||||
spaceName: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface RoomInfo {
|
||||
roomId: string;
|
||||
name: string;
|
||||
topic?: string;
|
||||
memberCount: number;
|
||||
isPublic: boolean;
|
||||
avatarUrl?: string;
|
||||
suggested: boolean;
|
||||
}
|
||||
|
||||
const RoomCard: React.FC<{
|
||||
room: RoomInfo;
|
||||
onOpenRoom: (room: RoomInfo) => void;
|
||||
}> = ({ room, onOpenRoom }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => onOpenRoom(room)}
|
||||
className="
|
||||
relative cursor-pointer group
|
||||
bg-background-default
|
||||
transition-colors duration-200
|
||||
hover:bg-background-medium
|
||||
aspect-square
|
||||
flex flex-col
|
||||
rounded-2xl
|
||||
overflow-hidden
|
||||
p-6
|
||||
"
|
||||
>
|
||||
{/* Room Icon */}
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-background-accent rounded-full mb-4">
|
||||
<Hash className="w-6 h-6 text-text-on-accent" />
|
||||
</div>
|
||||
|
||||
{/* Privacy Badge */}
|
||||
<div className={`absolute top-4 right-4 p-1.5 rounded-full ${
|
||||
room.isPublic
|
||||
? 'bg-green-500/90 text-white'
|
||||
: 'bg-orange-500/90 text-white'
|
||||
}`}>
|
||||
{room.isPublic ? (
|
||||
<Globe className="w-3 h-3" />
|
||||
) : (
|
||||
<Lock className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room Info */}
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
<h3 className="text-lg font-medium text-text-default truncate mb-1">
|
||||
{room.name}
|
||||
</h3>
|
||||
{room.topic && (
|
||||
<p className="text-xs text-text-muted truncate mb-2">
|
||||
{room.topic}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<Users className="w-3 h-3" />
|
||||
<span>{room.memberCount} members</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyRoomTile: React.FC<{ onCreateRoom: () => void }> = ({ onCreateRoom }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onCreateRoom}
|
||||
className="
|
||||
relative cursor-pointer group
|
||||
bg-background-default
|
||||
px-6 py-6
|
||||
transition-all duration-200
|
||||
hover:bg-background-medium
|
||||
aspect-square
|
||||
flex flex-col items-center justify-center
|
||||
rounded-2xl
|
||||
"
|
||||
>
|
||||
{/* Plus icon - hidden by default, shown on hover */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileHover={{ opacity: 1, scale: 1 }}
|
||||
className="opacity-0 group-hover:opacity-100 transition-all duration-200"
|
||||
>
|
||||
<div className="w-12 h-12 bg-background-accent rounded-full flex items-center justify-center mb-3">
|
||||
<Plus className="w-6 h-6 text-text-on-accent" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-default text-center">
|
||||
Create Room
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Subtle hint when not hovering */}
|
||||
<motion.div
|
||||
className="opacity-100 group-hover:opacity-0 transition-all duration-200 absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full border-2 border-dashed border-text-muted/30 flex items-center justify-center">
|
||||
<div className="w-1 h-1 bg-text-muted/30 rounded-full" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateRoomModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, topic: string, isPublic: boolean) => Promise<void>;
|
||||
}> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await onCreate(name.trim(), topic.trim(), isPublic);
|
||||
onClose();
|
||||
setName('');
|
||||
setTopic('');
|
||||
setIsPublic(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create room:', error);
|
||||
alert('Failed to create room. Please try again.');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="bg-background-default rounded-2xl p-6 w-full max-w-md mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-text-default">Create Room</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-background-medium transition-colors"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Room Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-default mb-2">
|
||||
Room Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="general, announcements, etc."
|
||||
className="w-full px-4 py-3 rounded-lg border border-border-default bg-background-muted focus:outline-none focus:ring-2 focus:ring-background-accent"
|
||||
disabled={isCreating}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Room Topic */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-default mb-2">
|
||||
Topic (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="Describe what this room is about..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-lg border border-border-default bg-background-muted focus:outline-none focus:ring-2 focus:ring-background-accent resize-none"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Privacy Setting */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-default mb-3">
|
||||
Privacy
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-border-default cursor-pointer hover:bg-background-medium transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="privacy"
|
||||
checked={isPublic}
|
||||
onChange={() => setIsPublic(true)}
|
||||
className="w-4 h-4"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<Globe className="w-5 h-5 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-text-default">Public</p>
|
||||
<p className="text-xs text-text-muted">Anyone can discover and join</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-border-default cursor-pointer hover:bg-background-medium transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="privacy"
|
||||
checked={!isPublic}
|
||||
onChange={() => setIsPublic(false)}
|
||||
className="w-4 h-4"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<Lock className="w-5 h-5 text-orange-600" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-text-default">Private</p>
|
||||
<p className="text-xs text-text-muted">Only invited members can join</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isCreating}
|
||||
className="flex-1 px-4 py-3 rounded-lg border border-border-default text-text-default hover:bg-background-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || isCreating}
|
||||
className="flex-1 px-4 py-3 rounded-lg bg-background-accent text-text-on-accent hover:bg-background-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const SpaceRoomsView: React.FC<SpaceRoomsViewProps> = ({ spaceId, spaceName, onBack }) => {
|
||||
const { currentUser, rooms } = useMatrix();
|
||||
const { openMatrixChat } = useTabContext();
|
||||
const navigate = useNavigate();
|
||||
const [spaceRooms, setSpaceRooms] = useState<RoomInfo[]>([]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadSpaceRooms();
|
||||
}, [spaceId]);
|
||||
|
||||
const loadSpaceRooms = () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Get children of this Space
|
||||
const children = matrixService.getSpaceChildren(spaceId);
|
||||
|
||||
console.log('📦 Space children:', children);
|
||||
|
||||
// Map children to room info - get rooms directly from Matrix client
|
||||
const roomInfos: RoomInfo[] = children
|
||||
.map(child => {
|
||||
// Get the room directly from the Matrix client (bypasses filtering)
|
||||
const matrixRoom = matrixService.client?.getRoom(child.roomId);
|
||||
|
||||
if (!matrixRoom) {
|
||||
console.warn('Room not found in Matrix client:', child.roomId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get join rules to determine if public
|
||||
const joinRulesEvent = matrixRoom.currentState.getStateEvents('m.room.join_rules', '');
|
||||
const isPublic = joinRulesEvent?.getContent()?.join_rule === 'public';
|
||||
|
||||
return {
|
||||
roomId: child.roomId,
|
||||
name: child.name || matrixRoom.name || 'Unnamed Room',
|
||||
topic: matrixRoom.currentState.getStateEvents('m.room.topic', '')?.getContent()?.topic,
|
||||
memberCount: matrixRoom.getMembers().length,
|
||||
isPublic: isPublic,
|
||||
avatarUrl: matrixRoom.currentState.getStateEvents('m.room.avatar', '')?.getContent()?.url || null,
|
||||
suggested: child.suggested,
|
||||
};
|
||||
})
|
||||
.filter((room): room is RoomInfo => room !== null);
|
||||
|
||||
console.log('📦 Loaded', roomInfos.length, 'rooms in Space');
|
||||
setSpaceRooms(roomInfos);
|
||||
} catch (error) {
|
||||
console.error('Failed to load space rooms:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRoom = async (room: RoomInfo) => {
|
||||
console.log('📱 Opening room:', room);
|
||||
|
||||
try {
|
||||
// Check if session mapping exists
|
||||
const existingMapping = sessionMappingService.getMapping(room.roomId);
|
||||
|
||||
if (!existingMapping) {
|
||||
console.log('📋 No session mapping found, creating backend session with Matrix history for room:', room.roomId);
|
||||
|
||||
// Get room members directly from Matrix client
|
||||
const matrixRoom = matrixService.client?.getRoom(room.roomId);
|
||||
const participants = matrixRoom?.getMembers().map(m => m.userId) || [currentUser?.userId || ''];
|
||||
|
||||
// Create backend session mapping
|
||||
await sessionMappingService.createMappingWithBackendSession(room.roomId, participants, room.name);
|
||||
console.log('✅ Backend session mapping created for existing room');
|
||||
|
||||
// Load Matrix history to display in UI
|
||||
// Note: The backend session is created fresh, but the UI will load and display
|
||||
// all historical messages from Matrix using getRoomHistoryAsGooseMessages()
|
||||
// This ensures users see the complete conversation history with proper timestamps
|
||||
const roomHistory = await matrixService.getRoomHistoryAsGooseMessages(room.roomId, 100);
|
||||
console.log(`📜 Matrix room has ${roomHistory.length} historical messages that will be displayed in UI`);
|
||||
console.log('📜 History includes messages from all participants with original timestamps preserved');
|
||||
} else {
|
||||
console.log('📋 Session mapping already exists for room:', room.roomId);
|
||||
}
|
||||
|
||||
// Open the room in a chat tab
|
||||
openMatrixChat(room.roomId, currentUser?.userId || '', room.name);
|
||||
|
||||
// Navigate to the pair view
|
||||
navigate('/pair');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to open room:', error);
|
||||
// Still try to open the chat even if mapping creation fails
|
||||
openMatrixChat(room.roomId, currentUser?.userId || '', room.name);
|
||||
navigate('/pair');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRoom = async (name: string, topic: string, isPublic: boolean) => {
|
||||
try {
|
||||
console.log('📝 Creating room in Space:', { spaceId, name, topic, isPublic });
|
||||
|
||||
// Create a regular Matrix room
|
||||
const room = await matrixService.client!.createRoom({
|
||||
name: name,
|
||||
topic: topic,
|
||||
preset: isPublic ? 'public_chat' : 'private_chat',
|
||||
visibility: isPublic ? 'public' : 'private',
|
||||
initial_state: isPublic ? [
|
||||
{
|
||||
type: 'm.room.join_rules',
|
||||
content: {
|
||||
join_rule: 'public'
|
||||
}
|
||||
}
|
||||
] : undefined
|
||||
});
|
||||
|
||||
const roomId = room.room_id;
|
||||
console.log('✅ Room created:', roomId);
|
||||
|
||||
// Create session mapping for the new room with backend session
|
||||
const participants = [currentUser?.userId || ''];
|
||||
await sessionMappingService.createMappingWithBackendSession(roomId, participants, name);
|
||||
console.log('✅ Backend session mapping created for room');
|
||||
|
||||
// Add the room to this Space
|
||||
await matrixService.addRoomToSpace(spaceId, roomId, false);
|
||||
console.log('✅ Room added to Space');
|
||||
|
||||
// Clear Matrix rooms cache to refresh
|
||||
(matrixService as any).cachedRooms = null;
|
||||
|
||||
// Reload space rooms
|
||||
setTimeout(() => {
|
||||
loadSpaceRooms();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create room in Space:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-screen bg-background-muted">
|
||||
{/* Header */}
|
||||
<div className="pt-14 pb-4 px-4 mb-0.5 bg-background-default rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 rounded-lg hover:bg-background-medium transition-colors"
|
||||
title="Back to Channels"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="w-8 h-8 bg-background-accent rounded-full flex items-center justify-center">
|
||||
<Hash className="w-5 h-5 text-text-on-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-default">{spaceName}</h1>
|
||||
<p className="text-sm text-text-muted">
|
||||
{spaceRooms.length} {spaceRooms.length === 1 ? 'room' : 'rooms'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-background-accent text-text-on-accent hover:bg-background-accent/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">New Room</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-background-accent border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-default mb-2">Loading...</h3>
|
||||
<p className="text-text-muted">Loading rooms in this space...</p>
|
||||
</div>
|
||||
) : spaceRooms.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="w-12 h-12 text-text-muted mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-text-default mb-2">No Rooms Yet</h3>
|
||||
<p className="text-text-muted mb-6">
|
||||
Create your first room in this space to get started.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-6 py-3 rounded-lg bg-background-accent text-text-on-accent hover:bg-background-accent/80 transition-colors"
|
||||
>
|
||||
Create Room
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-0.5">
|
||||
{spaceRooms.map((room) => (
|
||||
<RoomCard
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
onOpenRoom={handleOpenRoom}
|
||||
/>
|
||||
))}
|
||||
{/* Empty tiles for creating new rooms */}
|
||||
{Array.from({ length: Math.max(0, 6 - spaceRooms.length) }).map((_, index) => (
|
||||
<EmptyRoomTile
|
||||
key={`empty-${index}`}
|
||||
onCreateRoom={() => setShowCreateModal(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Room Modal */}
|
||||
<AnimatePresence>
|
||||
{showCreateModal && (
|
||||
<CreateRoomModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreate={handleCreateRoom}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceRoomsView;
|
||||
@@ -70,7 +70,7 @@ const CollaborativeSession: React.FC<CollaborativeSessionProps> = ({
|
||||
friends,
|
||||
sendAIPrompt,
|
||||
sendAIResponse,
|
||||
inviteToRoom,
|
||||
inviteToRoomAndSpace,
|
||||
onAIMessage,
|
||||
isConnected
|
||||
} = useMatrix();
|
||||
@@ -166,12 +166,13 @@ const CollaborativeSession: React.FC<CollaborativeSessionProps> = ({
|
||||
}
|
||||
}, [roomId, sessionId, sessionData, sendAIResponse]);
|
||||
|
||||
// Invite friend to session
|
||||
// Invite friend to session (and parent Space if applicable)
|
||||
const handleInviteFriend = async (friendId: string) => {
|
||||
if (!roomId) return;
|
||||
|
||||
try {
|
||||
await inviteToRoom(roomId, friendId);
|
||||
// Use inviteToRoomAndSpace to automatically invite to parent Space if room is in a Space
|
||||
await inviteToRoomAndSpace(roomId, friendId);
|
||||
setShowInviteModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to invite friend to session:', error);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { useMatrix } from '../contexts/MatrixContext';
|
||||
import { Message } from '../types/message';
|
||||
import { sessionMappingService } from '../services/SessionMappingService';
|
||||
import { matrixHistorySyncService } from '../services/MatrixHistorySync';
|
||||
|
||||
export interface PairRouteState {
|
||||
resumeSessionId?: string;
|
||||
@@ -934,6 +935,8 @@ export default function Pair({
|
||||
const customChatInputProps = {
|
||||
// Pass initial message from Hub or recipe prompt
|
||||
initialValue,
|
||||
// Pass Matrix room ID if this is a Matrix chat
|
||||
matrixRoomId: chat.matrixRoomId || null,
|
||||
};
|
||||
|
||||
// Matrix collaboration should be invisible - just a regular chat with Matrix sync
|
||||
|
||||
@@ -10,6 +10,7 @@ interface MatrixContextType {
|
||||
// Data
|
||||
friends: MatrixUser[];
|
||||
rooms: MatrixRoom[];
|
||||
spaces: MatrixRoom[];
|
||||
gooseInstances: GooseInstance[];
|
||||
|
||||
// Actions
|
||||
@@ -21,6 +22,7 @@ interface MatrixContextType {
|
||||
createAISession: (name: string, inviteUserIds?: string[]) => Promise<string>;
|
||||
joinRoom: (roomId: string) => Promise<void>;
|
||||
inviteToRoom: (roomId: string, userId: string) => Promise<void>;
|
||||
inviteToRoomAndSpace: (roomId: string, userId: string) => Promise<void>;
|
||||
sendMessage: (roomId: string, message: string) => Promise<void>;
|
||||
sendAIPrompt: (roomId: string, prompt: string, sessionId: string, model?: string) => Promise<void>;
|
||||
setAvatar: (file: File) => Promise<string>;
|
||||
@@ -101,6 +103,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children, matrix
|
||||
const [currentUser, setCurrentUser] = useState<MatrixUser | null>(null);
|
||||
const [friends, setFriends] = useState<MatrixUser[]>([]);
|
||||
const [rooms, setRooms] = useState<MatrixRoom[]>([]);
|
||||
const [spaces, setSpaces] = useState<MatrixRoom[]>([]);
|
||||
const [gooseInstances, setGooseInstances] = useState<GooseInstance[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,6 +208,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children, matrix
|
||||
setCurrentUser(user);
|
||||
setFriends(matrixService.getFriends());
|
||||
setRooms(matrixService.getRooms());
|
||||
setSpaces(matrixService.getSpaces());
|
||||
setGooseInstances(matrixService.getGooseInstances());
|
||||
};
|
||||
|
||||
@@ -243,6 +247,11 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children, matrix
|
||||
// Data will be updated via the membershipChange event
|
||||
};
|
||||
|
||||
const inviteToRoomAndSpace = async (roomId: string, userId: string) => {
|
||||
await matrixService.inviteToRoomAndSpace(roomId, userId);
|
||||
// Data will be updated via the membershipChange event
|
||||
};
|
||||
|
||||
const sendMessage = async (roomId: string, message: string) => {
|
||||
await matrixService.sendMessage(roomId, message);
|
||||
};
|
||||
@@ -392,6 +401,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children, matrix
|
||||
currentUser,
|
||||
friends,
|
||||
rooms,
|
||||
spaces,
|
||||
gooseInstances,
|
||||
login,
|
||||
register,
|
||||
@@ -401,6 +411,7 @@ export const MatrixProvider: React.FC<MatrixProviderProps> = ({ children, matrix
|
||||
createAISession,
|
||||
joinRoom,
|
||||
inviteToRoom,
|
||||
inviteToRoomAndSpace,
|
||||
sendMessage,
|
||||
sendAIPrompt,
|
||||
setAvatar,
|
||||
|
||||
@@ -579,6 +579,13 @@ export const useChatEngine = ({
|
||||
const handleSubmit = useCallback(
|
||||
(combinedTextFromInput: string, onSummaryReset?: () => void) => {
|
||||
if (combinedTextFromInput.trim()) {
|
||||
// DIAGNOSTIC: Log session info when sending message
|
||||
console.log('[Matrix Message Send - useChatEngine] Sending message:', {
|
||||
chatId: chat.id,
|
||||
messagePreview: combinedTextFromInput.substring(0, 50) + '...',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
window.electron.startPowerSaveBlocker();
|
||||
} catch (error) {
|
||||
@@ -615,7 +622,7 @@ export const useChatEngine = ({
|
||||
stopPowerSaveBlocker();
|
||||
}
|
||||
},
|
||||
[append, onMessageSent, stopPowerSaveBlocker]
|
||||
[append, onMessageSent, stopPowerSaveBlocker, chat.id]
|
||||
);
|
||||
|
||||
// Handle stopping the message stream
|
||||
|
||||
@@ -116,6 +116,7 @@ interface UseChatStreamProps {
|
||||
initialMessage?: string;
|
||||
onSessionIdChange?: (newSessionId: string) => void;
|
||||
isMatrixTab?: boolean; // Flag to indicate if this is a Matrix tab that should listen for Matrix messages
|
||||
matrixRoomId?: string; // Matrix room ID for message routing (stable identifier for Matrix tabs)
|
||||
tabId?: string; // Tab ID to filter sidecars for context injection
|
||||
}
|
||||
|
||||
@@ -285,6 +286,7 @@ export function useChatStream({
|
||||
initialMessage,
|
||||
onSessionIdChange,
|
||||
isMatrixTab = false,
|
||||
matrixRoomId,
|
||||
tabId,
|
||||
}: UseChatStreamProps): UseChatStreamReturn {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
@@ -793,29 +795,50 @@ export function useChatStream({
|
||||
|
||||
// Listen for Matrix messages from BaseChat2's append function - ONLY FOR MATRIX TABS
|
||||
useEffect(() => {
|
||||
// DIAGNOSTIC: Enhanced logging for Matrix listener setup
|
||||
console.log('🔍 useChatStream Matrix listener setup check:', {
|
||||
isMatrixTab,
|
||||
sessionId: sessionId?.substring(0, 8) + '...',
|
||||
willSetupListener: isMatrixTab,
|
||||
currentMessagesCount: messagesRef.current.length
|
||||
});
|
||||
|
||||
// CRITICAL SECURITY: Only Matrix tabs should listen for Matrix messages
|
||||
// This prevents Matrix messages from appearing in non-Matrix tabs
|
||||
if (!isMatrixTab) {
|
||||
console.log('🚫 useChatStream: Not a Matrix tab, skipping Matrix message listener setup:', {
|
||||
sessionId: sessionId.substring(0, 8),
|
||||
sessionId: sessionId?.substring(0, 8) + '...',
|
||||
isMatrixTab
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ useChatStream: Setting up Matrix message listener for Matrix tab:', {
|
||||
sessionId: sessionId.substring(0, 8),
|
||||
sessionId: sessionId?.substring(0, 8) + '...',
|
||||
isMatrixTab
|
||||
});
|
||||
|
||||
const handleMatrixMessage = (event: CustomEvent) => {
|
||||
const { message, targetSessionId, timestamp } = event.detail;
|
||||
const { message, targetSessionId, targetRoomId, routingKey, timestamp } = event.detail;
|
||||
|
||||
// CRITICAL: Only process messages intended for THIS specific session
|
||||
if (targetSessionId !== sessionId) {
|
||||
console.log('🚫 useChatStream ignoring matrix message for different session:', {
|
||||
// CRITICAL: For Matrix tabs, match on matrixRoomId (stable identifier)
|
||||
// This ensures messages are routed correctly even if backend session ID changes
|
||||
const ourRoutingKey = matrixRoomId || sessionId;
|
||||
|
||||
console.log('🔍 useChatStream routing check:', {
|
||||
eventRoutingKey: routingKey?.substring(0, 20),
|
||||
ourRoutingKey: ourRoutingKey?.substring(0, 20),
|
||||
matrixRoomId: matrixRoomId?.substring(0, 20),
|
||||
sessionId: sessionId?.substring(0, 8),
|
||||
willAccept: routingKey === ourRoutingKey
|
||||
});
|
||||
|
||||
if (routingKey !== ourRoutingKey) {
|
||||
console.log('🚫 useChatStream ignoring matrix message for different routing key:', {
|
||||
eventRoutingKey: routingKey?.substring(0, 20),
|
||||
ourRoutingKey: ourRoutingKey?.substring(0, 20),
|
||||
eventTargetSessionId: targetSessionId?.substring(0, 8),
|
||||
thisSessionId: sessionId.substring(0, 8),
|
||||
thisSessionId: sessionId?.substring(0, 8),
|
||||
messageId: message.id,
|
||||
sender: message.sender?.displayName || message.sender?.userId || 'unknown'
|
||||
});
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { createUserMessage, hasCompletedToolCalls, Message, Role } from '../types/message';
|
||||
import { getSession, Session } from '../api';
|
||||
import { createUserMessage, hasCompletedToolCalls, Message } from '../types/message';
|
||||
import { getSessionHistory } from '../api';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { sessionMappingService } from '../services/SessionMappingService';
|
||||
|
||||
// Force rebuild timestamp: 2025-01-15T02:50:00Z - Fixed SSE Finish session mapping error
|
||||
|
||||
let messageIdCounter = 0;
|
||||
|
||||
@@ -18,6 +15,19 @@ const TextDecoder = globalThis.TextDecoder;
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface SessionMetadata {
|
||||
workingDir: string;
|
||||
description: string;
|
||||
scheduleId: string | null;
|
||||
messageCount: number;
|
||||
totalTokens: number | null;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
accumulatedTotalTokens: number | null;
|
||||
accumulatedInputTokens: number | null;
|
||||
accumulatedOutputTokens: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationEvent {
|
||||
type: 'Notification';
|
||||
request_id: string;
|
||||
@@ -97,12 +107,6 @@ export interface UseMessageStreamOptions {
|
||||
* Default is 1.
|
||||
*/
|
||||
maxSteps?: number;
|
||||
|
||||
/**
|
||||
* If true, disables all API calls and makes this hook read-only.
|
||||
* Useful for collaborative sessions where you only consume messages.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMessageStreamHelpers {
|
||||
@@ -161,8 +165,8 @@ export interface UseMessageStreamHelpers {
|
||||
/** Current model info from the backend */
|
||||
currentModelInfo: { model: string; mode: string } | null;
|
||||
|
||||
/** Session including token counts */
|
||||
session: Session | null;
|
||||
/** Session metadata including token counts */
|
||||
sessionMetadata: SessionMetadata | null;
|
||||
|
||||
/** Clear error state */
|
||||
setError: (error: Error | undefined) => void;
|
||||
@@ -182,7 +186,6 @@ export function useMessageStream({
|
||||
headers,
|
||||
body,
|
||||
maxSteps = 1,
|
||||
disabled = false,
|
||||
}: UseMessageStreamOptions = {}): UseMessageStreamHelpers {
|
||||
// Generate a unique id for the chat if not provided
|
||||
const hookId = useId();
|
||||
@@ -198,7 +201,7 @@ export function useMessageStream({
|
||||
const [currentModelInfo, setCurrentModelInfo] = useState<{ model: string; mode: string } | null>(
|
||||
null
|
||||
);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [sessionMetadata, setSessionMetadata] = useState<SessionMetadata | null>(null);
|
||||
|
||||
// expose a way to update the body so we can update the session id when CLE occurs
|
||||
const updateMessageStreamBody = useCallback((newBody: object) => {
|
||||
@@ -283,12 +286,19 @@ export function useMessageStream({
|
||||
mutateChatState(ChatState.Streaming);
|
||||
|
||||
// Create a new message object with the properties preserved or defaulted
|
||||
const newMessage: Message = {
|
||||
const newMessage = {
|
||||
...parsedEvent.message,
|
||||
id: parsedEvent.message.id || undefined,
|
||||
role: parsedEvent.message.role as Role,
|
||||
created: parsedEvent.message.created || Date.now(),
|
||||
content: parsedEvent.message.content || [],
|
||||
// Ensure the message has an ID - if not provided, generate one
|
||||
id: parsedEvent.message.id || generateMessageId(),
|
||||
// Only set to true if it's undefined (preserve false values)
|
||||
display:
|
||||
parsedEvent.message.display === undefined
|
||||
? true
|
||||
: parsedEvent.message.display,
|
||||
sendToLLM:
|
||||
parsedEvent.message.sendToLLM === undefined
|
||||
? true
|
||||
: parsedEvent.message.sendToLLM,
|
||||
};
|
||||
|
||||
// Update messages with the new message
|
||||
@@ -337,63 +347,78 @@ export function useMessageStream({
|
||||
}
|
||||
|
||||
case 'Error': {
|
||||
// Always throw the error so it gets caught and sets the error state
|
||||
// This ensures the retry UI appears for ALL errors
|
||||
// Check if this is a token limit error (more specific detection)
|
||||
const errorMessage = parsedEvent.error;
|
||||
const isTokenLimitError =
|
||||
errorMessage &&
|
||||
((errorMessage.toLowerCase().includes('token') &&
|
||||
errorMessage.toLowerCase().includes('limit')) ||
|
||||
(errorMessage.toLowerCase().includes('context') &&
|
||||
errorMessage.toLowerCase().includes('length') &&
|
||||
errorMessage.toLowerCase().includes('exceeded')));
|
||||
|
||||
// If this is a token limit error, create a contextLengthExceeded message instead of throwing
|
||||
if (isTokenLimitError) {
|
||||
const contextMessage: Message = {
|
||||
id: generateMessageId(),
|
||||
role: 'assistant',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
{
|
||||
type: 'contextLengthExceeded',
|
||||
msg: errorMessage,
|
||||
},
|
||||
],
|
||||
display: true,
|
||||
sendToLLM: false,
|
||||
};
|
||||
|
||||
currentMessages = [...currentMessages, contextMessage];
|
||||
mutate(currentMessages, false);
|
||||
|
||||
// Clear any existing error state since we handled this as a context message
|
||||
setError(undefined);
|
||||
break; // Don't throw error, just add the message
|
||||
}
|
||||
|
||||
throw new Error(parsedEvent.error);
|
||||
}
|
||||
|
||||
case 'Finish': {
|
||||
// Call onFinish with the last message if available
|
||||
if (onFinish && currentMessages.length > 0) {
|
||||
const lastMessage = currentMessages[currentMessages.length - 1];
|
||||
onFinish(lastMessage, parsedEvent.reason);
|
||||
}
|
||||
|
||||
const originalSessionId = (extraMetadataRef.current.body as Record<string, unknown>)
|
||||
// Fetch updated session metadata with token counts
|
||||
const sessionId = (extraMetadataRef.current.body as Record<string, unknown>)
|
||||
?.session_id as string;
|
||||
|
||||
if (originalSessionId) {
|
||||
// Check if we should make backend calls for this session
|
||||
if (!sessionMappingService.shouldMakeBackendCalls(originalSessionId)) {
|
||||
console.log('📋 Skipping session data fetch in SSE Finish - Matrix session without backend mapping:', originalSessionId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Use session mapping service to get the appropriate backend session ID
|
||||
const backendSessionId = sessionMappingService.getBackendSessionId(originalSessionId);
|
||||
|
||||
console.log('📋 SSE Finish - Session ID mapping:', {
|
||||
originalSessionId,
|
||||
backendSessionId,
|
||||
isMatrixSession: originalSessionId.startsWith('!'),
|
||||
shouldMakeBackendCalls: sessionMappingService.shouldMakeBackendCalls(originalSessionId),
|
||||
});
|
||||
|
||||
// Double-check that we have a valid backend session ID
|
||||
if (!backendSessionId) {
|
||||
console.log('📋 No backend session ID available for:', originalSessionId);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log('📋 Making getSession call with backend session ID:', backendSessionId);
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
const sessionResponse = await getSession({
|
||||
path: { session_id: backendSessionId },
|
||||
throwOnError: false, // Don't throw on error, handle gracefully
|
||||
const sessionResponse = await getSessionHistory({
|
||||
path: { session_id: sessionId },
|
||||
});
|
||||
|
||||
if (sessionResponse.data) {
|
||||
console.log('📋 Successfully fetched session data for:', backendSessionId);
|
||||
setSession(sessionResponse.data);
|
||||
} else if (sessionResponse.error) {
|
||||
console.log('📋 Session not found in backend for:', backendSessionId, '- this is expected for Matrix sessions without real backend sessions');
|
||||
// For Matrix sessions with generated IDs that don't exist in backend, this is normal
|
||||
// Don't treat this as an error
|
||||
if (sessionResponse.data?.metadata) {
|
||||
setSessionMetadata({
|
||||
workingDir: sessionResponse.data.metadata.working_dir,
|
||||
description: sessionResponse.data.metadata.description,
|
||||
scheduleId: sessionResponse.data.metadata.schedule_id || null,
|
||||
messageCount: sessionResponse.data.metadata.message_count,
|
||||
totalTokens: sessionResponse.data.metadata.total_tokens || null,
|
||||
inputTokens: sessionResponse.data.metadata.input_tokens || null,
|
||||
outputTokens: sessionResponse.data.metadata.output_tokens || null,
|
||||
accumulatedTotalTokens:
|
||||
sessionResponse.data.metadata.accumulated_total_tokens || null,
|
||||
accumulatedInputTokens:
|
||||
sessionResponse.data.metadata.accumulated_input_tokens || null,
|
||||
accumulatedOutputTokens:
|
||||
sessionResponse.data.metadata.accumulated_output_tokens || null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📋 Session fetch failed for:', backendSessionId, '- likely Matrix session with generated ID that doesn\'t exist in backend');
|
||||
// This is expected for Matrix sessions that have mappings with generated session IDs
|
||||
// that don't actually exist in the backend. Don't treat as an error.
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch session metadata:', error);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -401,13 +426,6 @@ export function useMessageStream({
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE event:', e);
|
||||
|
||||
// Check if this is a session-related error that we should ignore
|
||||
if (e instanceof Error && e.message.includes('Session not found')) {
|
||||
console.log('📋 Ignoring session not found error in SSE stream - likely Matrix session without backend mapping');
|
||||
continue; // Skip this event and continue processing
|
||||
}
|
||||
|
||||
if (onError && e instanceof Error) {
|
||||
onError(e);
|
||||
}
|
||||
@@ -438,144 +456,6 @@ export function useMessageStream({
|
||||
[mutate, mutateChatState, onFinish, onError, forceUpdate, setError]
|
||||
);
|
||||
|
||||
// Function to expand custom command pills in text
|
||||
const expandCustomCommandPills = useCallback((text: string): string => {
|
||||
// Find all action pills in the format [Action Label]
|
||||
const actionPillRegex = /\[([^\]]+)\]/g;
|
||||
let expandedText = text;
|
||||
|
||||
// Replace each action pill with its corresponding prompt
|
||||
expandedText = expandedText.replace(actionPillRegex, (match, label) => {
|
||||
// Check if it's a custom command by looking for a command with this label
|
||||
try {
|
||||
const stored = localStorage.getItem('goose-custom-commands');
|
||||
if (stored) {
|
||||
const commands = JSON.parse(stored);
|
||||
const customCommand = commands.find((cmd: any) => cmd.label === label);
|
||||
|
||||
if (customCommand) {
|
||||
// Increment usage count for the custom command
|
||||
try {
|
||||
const updatedCommands = commands.map((cmd: any) =>
|
||||
cmd.id === customCommand.id
|
||||
? { ...cmd, usageCount: (cmd.usageCount || 0) + 1 }
|
||||
: cmd
|
||||
);
|
||||
localStorage.setItem('goose-custom-commands', JSON.stringify(updatedCommands));
|
||||
} catch (error) {
|
||||
console.error('Error updating usage count:', error);
|
||||
}
|
||||
|
||||
// Return the expanded prompt
|
||||
return customCommand.prompt || match;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error expanding custom command:', error);
|
||||
}
|
||||
|
||||
// If not a custom command, return the original pill (built-in actions)
|
||||
return match;
|
||||
});
|
||||
|
||||
return expandedText;
|
||||
}, []);
|
||||
|
||||
// Function to expand action pills in messages before sending to API
|
||||
const expandActionPillsInMessages = useCallback((messages: Message[]): Message[] => {
|
||||
return messages.map(message => {
|
||||
if (message.role === 'user') {
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map(content => {
|
||||
if (content.type === 'text') {
|
||||
return {
|
||||
...content,
|
||||
text: expandCustomCommandPills(content.text)
|
||||
};
|
||||
}
|
||||
return content;
|
||||
})
|
||||
};
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}, [expandCustomCommandPills]);
|
||||
|
||||
// Function to inject unified sidecar context into the last user message before sending to API
|
||||
const injectSidecarContext = useCallback((messages: Message[]): Message[] => {
|
||||
if (messages.length === 0) return messages;
|
||||
|
||||
// Get unified sidecar context from global window object with validation
|
||||
const unifiedSidecarContext = (window as any).__unifiedSidecarContext;
|
||||
|
||||
// Enhanced validation and debugging
|
||||
if (!unifiedSidecarContext) {
|
||||
console.log('🔧 useMessageStream: No unified sidecar context found on window object');
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (!unifiedSidecarContext.getSidecarContext) {
|
||||
console.warn('🔧 useMessageStream: Unified sidecar context missing getSidecarContext method');
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Log context info for debugging
|
||||
console.log('🔧 useMessageStream: Found unified sidecar context:', {
|
||||
contextId: unifiedSidecarContext.contextId,
|
||||
version: unifiedSidecarContext.version,
|
||||
hasGetSidecarContext: !!unifiedSidecarContext.getSidecarContext,
|
||||
hasGetActiveSidecars: !!unifiedSidecarContext.getActiveSidecars
|
||||
});
|
||||
|
||||
try {
|
||||
// First check how many active sidecars we have
|
||||
const activeSidecars = unifiedSidecarContext.getActiveSidecars ? unifiedSidecarContext.getActiveSidecars() : [];
|
||||
console.log('🔧 useMessageStream: Active sidecars before context generation:', activeSidecars.length,
|
||||
activeSidecars.map(s => ({ id: s.id, type: s.type, title: s.title })));
|
||||
|
||||
const contextInfo = unifiedSidecarContext.getSidecarContext();
|
||||
console.log('🔧 useMessageStream: Generated context info length:', contextInfo.length, 'chars');
|
||||
|
||||
if (!contextInfo.trim()) {
|
||||
console.log('🔧 useMessageStream: Context info is empty, not injecting');
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Find the last user message
|
||||
const lastMessageIndex = messages.length - 1;
|
||||
const lastMessage = messages[lastMessageIndex];
|
||||
|
||||
if (lastMessage.role !== 'user') {
|
||||
console.log('🔧 useMessageStream: Last message is not from user, not injecting context');
|
||||
return messages;
|
||||
}
|
||||
|
||||
console.log('🔧 useMessageStream: Injecting context into user message');
|
||||
|
||||
// Clone the messages array and modify only the last user message
|
||||
const modifiedMessages = [...messages];
|
||||
modifiedMessages[lastMessageIndex] = {
|
||||
...lastMessage,
|
||||
content: lastMessage.content.map(content => {
|
||||
if (content.type === 'text') {
|
||||
return {
|
||||
...content,
|
||||
text: `${contextInfo}\n\n---\n\n${content.text}`
|
||||
};
|
||||
}
|
||||
return content;
|
||||
})
|
||||
};
|
||||
|
||||
console.log('🔧 useMessageStream: Successfully injected sidecar context');
|
||||
return modifiedMessages;
|
||||
} catch (error) {
|
||||
console.error('🔧 useMessageStream: Error injecting sidecar context:', error);
|
||||
return messages;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send a request to the server
|
||||
const sendRequest = useCallback(
|
||||
async (requestMessages: Message[]) => {
|
||||
@@ -587,11 +467,20 @@ export function useMessageStream({
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
// Expand action pills in messages before sending to API
|
||||
let expandedMessages = expandActionPillsInMessages(requestMessages);
|
||||
|
||||
// Inject unified sidecar context into the last user message before sending to API
|
||||
expandedMessages = injectSidecarContext(expandedMessages);
|
||||
// Filter out messages where sendToLLM is explicitly false
|
||||
const filteredMessages = requestMessages.filter((message) => message.sendToLLM !== false);
|
||||
|
||||
// DIAGNOSTIC: Log the session_id being sent to backend
|
||||
const requestBody = {
|
||||
messages: filteredMessages,
|
||||
...extraMetadataRef.current.body,
|
||||
};
|
||||
console.log('[Matrix Message Send - useMessageStream] Sending to backend:', {
|
||||
api,
|
||||
session_id: requestBody.session_id,
|
||||
messageCount: filteredMessages.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Send request to the server
|
||||
const response = await fetch(api, {
|
||||
@@ -601,10 +490,7 @@ export function useMessageStream({
|
||||
'X-Secret-Key': await window.electron.getSecretKey(),
|
||||
...extraMetadataRef.current.headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: expandedMessages,
|
||||
...extraMetadataRef.current.body,
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
@@ -668,8 +554,8 @@ export function useMessageStream({
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
[api, processMessageStream, mutateChatState, setError, onResponse, onError, maxSteps, expandActionPillsInMessages, injectSidecarContext]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[api, processMessageStream, mutateChatState, setError, onResponse, onError, maxSteps]
|
||||
);
|
||||
|
||||
// Append a new message and send request
|
||||
@@ -678,21 +564,6 @@ export function useMessageStream({
|
||||
// If a string is passed, convert it to a Message object
|
||||
const messageToAppend = typeof message === 'string' ? createUserMessage(message) : message;
|
||||
|
||||
// If disabled, just add the message without making API calls
|
||||
if (disabled) {
|
||||
console.log('🚫 useMessageStream is disabled, adding message without API call:', {
|
||||
messageId: messageToAppend.id,
|
||||
role: messageToAppend.role,
|
||||
contentPreview: Array.isArray(messageToAppend.content)
|
||||
? messageToAppend.content[0]?.text?.substring(0, 50) + '...'
|
||||
: 'N/A'
|
||||
});
|
||||
|
||||
const currentMessages = [...messagesRef.current, messageToAppend];
|
||||
mutate(currentMessages, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we were waiting for user input and user provides input, transition away from that state
|
||||
if (chatState === ChatState.WaitingForUserInput) {
|
||||
mutateChatState(ChatState.Thinking);
|
||||
@@ -702,7 +573,7 @@ export function useMessageStream({
|
||||
mutate(currentMessages, false);
|
||||
await sendRequest(currentMessages);
|
||||
},
|
||||
[mutate, sendRequest, chatState, mutateChatState, disabled]
|
||||
[mutate, sendRequest, chatState, mutateChatState]
|
||||
);
|
||||
|
||||
// Reload the last message
|
||||
@@ -838,7 +709,7 @@ export function useMessageStream({
|
||||
updateMessageStreamBody,
|
||||
notifications,
|
||||
currentModelInfo,
|
||||
session: session,
|
||||
sessionMetadata,
|
||||
setError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Matrix Room Interceptor
|
||||
*
|
||||
* This module intercepts Matrix room chat creation and ensures proper session IDs.
|
||||
* It works by monkey-patching the navigation and chat creation to use unique Matrix session IDs.
|
||||
*/
|
||||
|
||||
import { ChatType } from './types/chat';
|
||||
import { generateMatrixSessionId } from './sessions';
|
||||
|
||||
/**
|
||||
* Checks if a chat object looks like a Matrix room chat
|
||||
*/
|
||||
function looksLikeMatrixChat(chat: any): boolean {
|
||||
if (!chat || typeof chat !== 'object') return false;
|
||||
|
||||
// Check for Matrix room ID pattern (!xxx:server.com)
|
||||
const hasMatrixRoomId = chat.matrixRoomId && typeof chat.matrixRoomId === 'string' &&
|
||||
chat.matrixRoomId.startsWith('!') && chat.matrixRoomId.includes(':');
|
||||
|
||||
// Check for title that might indicate a Matrix room
|
||||
const hasRoomTitle = chat.title && typeof chat.title === 'string';
|
||||
|
||||
// Check if it's explicitly marked as a Matrix tab
|
||||
const isMarkedAsMatrix = chat.isMatrixTab === true;
|
||||
|
||||
return hasMatrixRoomId || isMarkedAsMatrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes a Matrix chat object to have the correct session ID
|
||||
*/
|
||||
function fixMatrixChat(chat: any): ChatType {
|
||||
if (!looksLikeMatrixChat(chat)) {
|
||||
return chat;
|
||||
}
|
||||
|
||||
// If it already has a proper Matrix session ID, don't change it
|
||||
if (chat.id && chat.id.includes('_matrix_')) {
|
||||
console.log('[Matrix Interceptor] Chat already has Matrix session ID:', chat.id);
|
||||
return chat;
|
||||
}
|
||||
|
||||
// Generate proper Matrix session ID
|
||||
const matrixRoomId = chat.matrixRoomId || `!unknown_${Date.now()}:matrix.org`;
|
||||
const newSessionId = generateMatrixSessionId(matrixRoomId);
|
||||
|
||||
console.log('[Matrix Interceptor] Fixing Matrix chat:', {
|
||||
oldSessionId: chat.id,
|
||||
newSessionId,
|
||||
matrixRoomId,
|
||||
title: chat.title
|
||||
});
|
||||
|
||||
return {
|
||||
...chat,
|
||||
id: newSessionId,
|
||||
matrixRoomId,
|
||||
isMatrixTab: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts window.history.pushState and replaceState to fix Matrix chats
|
||||
*/
|
||||
export function interceptMatrixRoomNavigation() {
|
||||
const originalPushState = window.history.pushState;
|
||||
const originalReplaceState = window.history.replaceState;
|
||||
|
||||
window.history.pushState = function(state: any, title: string, url?: string | URL | null) {
|
||||
if (state && state.chat && looksLikeMatrixChat(state.chat)) {
|
||||
console.log('[Matrix Interceptor] Intercepting pushState with Matrix chat');
|
||||
state = {
|
||||
...state,
|
||||
chat: fixMatrixChat(state.chat)
|
||||
};
|
||||
}
|
||||
return originalPushState.call(this, state, title, url);
|
||||
};
|
||||
|
||||
window.history.replaceState = function(state: any, title: string, url?: string | URL | null) {
|
||||
if (state && state.chat && looksLikeMatrixChat(state.chat)) {
|
||||
console.log('[Matrix Interceptor] Intercepting replaceState with Matrix chat');
|
||||
state = {
|
||||
...state,
|
||||
chat: fixMatrixChat(state.chat)
|
||||
};
|
||||
}
|
||||
return originalReplaceState.call(this, state, title, url);
|
||||
};
|
||||
|
||||
console.log('[Matrix Interceptor] Navigation interception installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts React Router navigate calls (if we can access them)
|
||||
*/
|
||||
export function interceptReactRouterNavigate() {
|
||||
// This will be called from App.tsx where we have access to navigate
|
||||
return (originalNavigate: any) => {
|
||||
return (to: any, options?: any) => {
|
||||
if (options && options.state && options.state.chat && looksLikeMatrixChat(options.state.chat)) {
|
||||
console.log('[Matrix Interceptor] Intercepting React Router navigate with Matrix chat');
|
||||
options = {
|
||||
...options,
|
||||
state: {
|
||||
...options.state,
|
||||
chat: fixMatrixChat(options.state.chat)
|
||||
}
|
||||
};
|
||||
}
|
||||
return originalNavigate(to, options);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Matrix room interceptor
|
||||
* Call this early in the app initialization
|
||||
*/
|
||||
export function initializeMatrixInterceptor() {
|
||||
console.log('[Matrix Interceptor] Initializing...');
|
||||
interceptMatrixRoomNavigation();
|
||||
console.log('[Matrix Interceptor] Ready to intercept Matrix room navigation');
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* MatrixHistorySync Service
|
||||
*
|
||||
* Ensures that Matrix room history and Goose backend session history are perfectly synchronized.
|
||||
* Matrix is the source of truth - Goose backend mirrors Matrix history exactly.
|
||||
*
|
||||
* Key Principles:
|
||||
* 1. Matrix room history is the canonical source of truth
|
||||
* 2. Goose backend session should mirror Matrix history exactly
|
||||
* 3. Messages are matched by timestamp and content
|
||||
* 4. No divergence allowed between Matrix and Goose
|
||||
* 5. Strict 1:1 mapping between Matrix room ID and Goose session ID
|
||||
*/
|
||||
|
||||
import { matrixService } from './MatrixService';
|
||||
import { sessionMappingService } from './SessionMappingService';
|
||||
import { getSession } from '../api';
|
||||
import type { Message } from '../api';
|
||||
|
||||
interface MatrixMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
sender: string;
|
||||
role: 'user' | 'assistant';
|
||||
metadata?: {
|
||||
senderInfo?: {
|
||||
userId: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
matrixEventId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GooseMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
created: number; // Unix timestamp in seconds
|
||||
sender?: {
|
||||
userId: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
matrixMessageCount: number;
|
||||
gooseMessageCount: number;
|
||||
addedToGoose: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
class MatrixHistorySyncService {
|
||||
private syncInProgress = new Map<string, boolean>();
|
||||
private lastSyncTime = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Sync Matrix room history to Goose backend session
|
||||
* This is the primary sync function - Matrix is the source of truth
|
||||
*/
|
||||
async syncRoomHistoryToBackend(
|
||||
matrixRoomId: string,
|
||||
gooseSessionId: string,
|
||||
options: {
|
||||
fullSync?: boolean; // If true, sync ALL history. If false, only sync new messages
|
||||
messageLimit?: number; // Max messages to fetch from Matrix
|
||||
} = {}
|
||||
): Promise<SyncResult> {
|
||||
const { fullSync = true, messageLimit = 1000 } = options;
|
||||
|
||||
// Prevent concurrent syncs for the same room
|
||||
if (this.syncInProgress.get(matrixRoomId)) {
|
||||
console.log('⏳ Sync already in progress for room:', matrixRoomId);
|
||||
return {
|
||||
success: false,
|
||||
matrixMessageCount: 0,
|
||||
gooseMessageCount: 0,
|
||||
addedToGoose: 0,
|
||||
errors: ['Sync already in progress']
|
||||
};
|
||||
}
|
||||
|
||||
this.syncInProgress.set(matrixRoomId, true);
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
console.log('🔄 Starting Matrix → Goose history sync:', {
|
||||
matrixRoomId: matrixRoomId.substring(0, 20) + '...',
|
||||
gooseSessionId,
|
||||
fullSync,
|
||||
messageLimit
|
||||
});
|
||||
|
||||
// Step 1: Fetch Matrix room history (source of truth)
|
||||
console.log('📜 Fetching Matrix room history...');
|
||||
const matrixMessages = await matrixService.getRoomHistoryAsGooseMessages(
|
||||
matrixRoomId,
|
||||
messageLimit
|
||||
);
|
||||
|
||||
console.log('📜 Fetched', matrixMessages.length, 'messages from Matrix');
|
||||
|
||||
if (matrixMessages.length === 0) {
|
||||
console.log('ℹ️ No messages in Matrix room, nothing to sync');
|
||||
return {
|
||||
success: true,
|
||||
matrixMessageCount: 0,
|
||||
gooseMessageCount: 0,
|
||||
addedToGoose: 0,
|
||||
errors: []
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Fetch Goose backend session history
|
||||
console.log('📋 Fetching Goose backend session history...');
|
||||
let gooseMessages: GooseMessage[] = [];
|
||||
|
||||
try {
|
||||
const sessionResponse = await getSession({
|
||||
path: { session_id: gooseSessionId }
|
||||
});
|
||||
|
||||
if (sessionResponse.data?.conversation) {
|
||||
gooseMessages = sessionResponse.data.conversation;
|
||||
console.log('📋 Fetched', gooseMessages.length, 'messages from Goose backend');
|
||||
} else {
|
||||
console.log('📋 No conversation history in Goose backend');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to fetch Goose backend history:', error);
|
||||
errors.push(`Failed to fetch Goose history: ${error}`);
|
||||
// Continue with sync - we'll just add all Matrix messages
|
||||
}
|
||||
|
||||
// Step 3: Compare and identify missing messages
|
||||
console.log('🔍 Comparing Matrix and Goose histories...');
|
||||
const missingInGoose = this.identifyMissingMessages(matrixMessages, gooseMessages);
|
||||
|
||||
console.log('🔍 Found', missingInGoose.length, 'messages in Matrix that are missing from Goose');
|
||||
|
||||
if (missingInGoose.length === 0) {
|
||||
console.log('✅ Goose backend is already in sync with Matrix');
|
||||
this.lastSyncTime.set(matrixRoomId, Date.now());
|
||||
return {
|
||||
success: true,
|
||||
matrixMessageCount: matrixMessages.length,
|
||||
gooseMessageCount: gooseMessages.length,
|
||||
addedToGoose: 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: Add missing messages to Goose backend
|
||||
console.log('📤 Adding', missingInGoose.length, 'missing messages to Goose backend...');
|
||||
const addedCount = await this.addMessagesToGooseBackend(
|
||||
gooseSessionId,
|
||||
missingInGoose,
|
||||
gooseMessages
|
||||
);
|
||||
|
||||
console.log('✅ Successfully synced', addedCount, 'messages to Goose backend');
|
||||
this.lastSyncTime.set(matrixRoomId, Date.now());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
matrixMessageCount: matrixMessages.length,
|
||||
gooseMessageCount: gooseMessages.length + addedCount,
|
||||
addedToGoose: addedCount,
|
||||
errors
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Matrix history sync failed:', error);
|
||||
errors.push(`Sync failed: ${error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
matrixMessageCount: 0,
|
||||
gooseMessageCount: 0,
|
||||
addedToGoose: 0,
|
||||
errors
|
||||
};
|
||||
} finally {
|
||||
this.syncInProgress.delete(matrixRoomId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify messages that exist in Matrix but not in Goose backend
|
||||
* Uses timestamp and content matching to identify missing messages
|
||||
*/
|
||||
private identifyMissingMessages(
|
||||
matrixMessages: MatrixMessage[],
|
||||
gooseMessages: GooseMessage[]
|
||||
): MatrixMessage[] {
|
||||
const missing: MatrixMessage[] = [];
|
||||
|
||||
// Create a map of Goose messages by timestamp for quick lookup
|
||||
const gooseMessageMap = new Map<number, GooseMessage[]>();
|
||||
gooseMessages.forEach(msg => {
|
||||
const timestamp = msg.created;
|
||||
if (!gooseMessageMap.has(timestamp)) {
|
||||
gooseMessageMap.set(timestamp, []);
|
||||
}
|
||||
gooseMessageMap.get(timestamp)!.push(msg);
|
||||
});
|
||||
|
||||
// Check each Matrix message
|
||||
for (const matrixMsg of matrixMessages) {
|
||||
const matrixTimestamp = Math.floor(matrixMsg.timestamp.getTime() / 1000);
|
||||
|
||||
// Look for matching message in Goose by timestamp (±5 seconds tolerance)
|
||||
let found = false;
|
||||
|
||||
for (let offset = -5; offset <= 5; offset++) {
|
||||
const checkTimestamp = matrixTimestamp + offset;
|
||||
const gooseMsgsAtTime = gooseMessageMap.get(checkTimestamp);
|
||||
|
||||
if (gooseMsgsAtTime) {
|
||||
// Check if any message at this timestamp matches content
|
||||
for (const gooseMsg of gooseMsgsAtTime) {
|
||||
const gooseContent = gooseMsg.content
|
||||
.map(c => c.type === 'text' ? c.text : '')
|
||||
.join('');
|
||||
|
||||
// Match by content similarity (exact match or very close)
|
||||
if (this.messagesMatch(matrixMsg.content, gooseContent)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
missing.push(matrixMsg);
|
||||
console.log('🔍 Missing in Goose:', {
|
||||
timestamp: matrixMsg.timestamp.toISOString(),
|
||||
sender: matrixMsg.sender,
|
||||
content: matrixMsg.content.substring(0, 50) + '...'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two message contents match (allowing for minor differences)
|
||||
*/
|
||||
private messagesMatch(content1: string, content2: string): boolean {
|
||||
// Normalize whitespace and compare
|
||||
const normalize = (s: string) => s.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
return normalize(content1) === normalize(content2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add missing messages to Goose backend session
|
||||
* Maintains chronological order based on Matrix timestamps
|
||||
*/
|
||||
private async addMessagesToGooseBackend(
|
||||
gooseSessionId: string,
|
||||
missingMessages: MatrixMessage[],
|
||||
existingGooseMessages: GooseMessage[]
|
||||
): Promise<number> {
|
||||
if (missingMessages.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// Sort missing messages by timestamp (oldest first)
|
||||
const sortedMissing = [...missingMessages].sort(
|
||||
(a, b) => a.timestamp.getTime() - b.timestamp.getTime()
|
||||
);
|
||||
|
||||
// Convert Matrix messages to Goose message format
|
||||
const gooseFormattedMessages: GooseMessage[] = sortedMissing.map(msg => ({
|
||||
id: `matrix_${msg.timestamp.getTime()}_${msg.id}`,
|
||||
role: msg.role,
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: msg.content
|
||||
}],
|
||||
created: Math.floor(msg.timestamp.getTime() / 1000),
|
||||
sender: msg.metadata?.senderInfo ? {
|
||||
userId: msg.metadata.senderInfo.userId,
|
||||
displayName: msg.metadata.senderInfo.displayName,
|
||||
avatarUrl: msg.metadata.senderInfo.avatarUrl
|
||||
} : undefined
|
||||
}));
|
||||
|
||||
// Merge with existing messages and sort by timestamp
|
||||
const allMessages = [...existingGooseMessages, ...gooseFormattedMessages]
|
||||
.sort((a, b) => a.created - b.created);
|
||||
|
||||
// Update the backend session with the complete, sorted history
|
||||
// Note: This requires a backend API endpoint to update session history
|
||||
// For now, we'll use the reply endpoint to add messages incrementally
|
||||
|
||||
console.log('📤 Sending', gooseFormattedMessages.length, 'messages to Goose backend');
|
||||
|
||||
// Import the API client
|
||||
const { client } = await import('../api/client.gen');
|
||||
|
||||
// Get the secret key for authentication
|
||||
const secretKey = await window.electron.getSecretKey();
|
||||
|
||||
// Send the complete conversation history to the backend
|
||||
// This will replace the existing conversation with the synced version
|
||||
const response = await client.post({
|
||||
url: `/sessions/${gooseSessionId}/sync-history`,
|
||||
body: {
|
||||
messages: allMessages
|
||||
},
|
||||
headers: {
|
||||
'X-Secret-Key': secretKey
|
||||
},
|
||||
throwOnError: false
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
console.warn('⚠️ Backend history sync endpoint not available, using fallback method');
|
||||
|
||||
// Fallback: Add messages one by one using the reply endpoint
|
||||
// This is less efficient but works with existing API
|
||||
for (const msg of gooseFormattedMessages) {
|
||||
try {
|
||||
await client.post({
|
||||
url: '/reply',
|
||||
body: {
|
||||
session_id: gooseSessionId,
|
||||
messages: [msg]
|
||||
},
|
||||
headers: {
|
||||
'X-Secret-Key': secretKey
|
||||
},
|
||||
throwOnError: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to add message to backend:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Successfully added', gooseFormattedMessages.length, 'messages to Goose backend');
|
||||
return gooseFormattedMessages.length;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to add messages to Goose backend:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last sync time for a room
|
||||
*/
|
||||
getLastSyncTime(matrixRoomId: string): number | undefined {
|
||||
return this.lastSyncTime.get(matrixRoomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sync is currently in progress for a room
|
||||
*/
|
||||
isSyncInProgress(matrixRoomId: string): boolean {
|
||||
return this.syncInProgress.get(matrixRoomId) || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that Matrix room and Goose session are properly mapped
|
||||
*/
|
||||
async validateMapping(matrixRoomId: string, gooseSessionId: string): Promise<boolean> {
|
||||
try {
|
||||
// Check forward mapping (Matrix → Goose)
|
||||
const mappedSessionId = sessionMappingService.getGooseSessionId(matrixRoomId);
|
||||
if (mappedSessionId !== gooseSessionId) {
|
||||
console.error('❌ Mapping validation failed: Matrix room maps to different session', {
|
||||
matrixRoomId: matrixRoomId.substring(0, 20) + '...',
|
||||
expectedSessionId: gooseSessionId,
|
||||
actualSessionId: mappedSessionId
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check reverse mapping (Goose → Matrix)
|
||||
const mappedRoomId = sessionMappingService.getMatrixRoomId(gooseSessionId);
|
||||
if (mappedRoomId !== matrixRoomId) {
|
||||
console.error('❌ Mapping validation failed: Goose session maps to different room', {
|
||||
gooseSessionId,
|
||||
expectedRoomId: matrixRoomId.substring(0, 20) + '...',
|
||||
actualRoomId: mappedRoomId?.substring(0, 20) + '...'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Mapping validation passed:', {
|
||||
matrixRoomId: matrixRoomId.substring(0, 20) + '...',
|
||||
gooseSessionId
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Mapping validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const matrixHistorySyncService = new MatrixHistorySyncService();
|
||||
@@ -935,6 +935,52 @@ export class MatrixService extends EventEmitter {
|
||||
await this.client.invite(roomId, formattedUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to a room and its parent Space (if the room is in a Space)
|
||||
*/
|
||||
async inviteToRoomAndSpace(roomId: string, userId: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
// Ensure userId starts with '@' as required by Matrix
|
||||
const formattedUserId = userId.startsWith('@') ? userId : `@${userId}`;
|
||||
|
||||
console.log('🔗 Inviting user to room and parent Space:', {
|
||||
roomId,
|
||||
userId: formattedUserId
|
||||
});
|
||||
|
||||
// First, invite to the room
|
||||
await this.client.invite(roomId, formattedUserId);
|
||||
console.log('✅ User invited to room:', roomId);
|
||||
|
||||
// Find the parent Space(s) that contain this room
|
||||
const spaces = this.getSpaces();
|
||||
const parentSpaces = spaces.filter(space => {
|
||||
const children = this.getSpaceChildren(space.roomId);
|
||||
return children.some(child => child.roomId === roomId);
|
||||
});
|
||||
|
||||
// Invite to all parent Spaces
|
||||
for (const space of parentSpaces) {
|
||||
try {
|
||||
console.log('🔗 Inviting user to parent Space:', space.roomId);
|
||||
await this.client.invite(space.roomId, formattedUserId);
|
||||
console.log('✅ User invited to Space:', space.name);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to invite user to Space:', space.roomId, error);
|
||||
// Continue with other spaces even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
if (parentSpaces.length === 0) {
|
||||
console.log('ℹ️ Room is not in any Space, only invited to room');
|
||||
} else {
|
||||
console.log(`✅ User invited to room and ${parentSpaces.length} parent Space(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a Matrix room invitation and update invite state
|
||||
*/
|
||||
@@ -1112,15 +1158,20 @@ export class MatrixService extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Check if a room is a true direct message room
|
||||
* True DMs have exactly 2 members. We'll be more flexible about room names
|
||||
* since some Matrix clients might set names for DMs.
|
||||
* True DMs have exactly 2 members AND no explicit room name set.
|
||||
* Channels with explicit names should not be considered DMs, even with 2 members.
|
||||
*/
|
||||
private isDirectMessageRoom(room: any): boolean {
|
||||
const memberCount = room.getMembers().length;
|
||||
const hasExplicitName = room.name && room.name.trim() !== '';
|
||||
|
||||
// Primary check: exactly 2 members (this is the most reliable indicator)
|
||||
const isTrueDM = memberCount === 2;
|
||||
// Check if room was created as a DM (has is_direct flag)
|
||||
const isDirect = room.getDMInviter() !== null;
|
||||
|
||||
// A room is a DM if:
|
||||
// 1. It has exactly 2 members, AND
|
||||
// 2. Either it has no explicit name OR it was explicitly created as a direct message
|
||||
const isTrueDM = memberCount === 2 && (!hasExplicitName || isDirect);
|
||||
|
||||
// Additional context for debugging
|
||||
const roomDisplayName = room.name || room.getDefaultRoomName?.() || '(auto-generated)';
|
||||
@@ -1131,6 +1182,7 @@ export class MatrixService extends EventEmitter {
|
||||
roomName: room.name || '(none)',
|
||||
roomDisplayName,
|
||||
hasExplicitName,
|
||||
isDirect,
|
||||
isTrueDM,
|
||||
members: room.getMembers().map((m: any) => m.userId)
|
||||
});
|
||||
@@ -1139,7 +1191,7 @@ export class MatrixService extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rooms the user is in
|
||||
* Get all rooms the user is in (excluding rooms that are children of Spaces)
|
||||
*/
|
||||
getRooms(): MatrixRoom[] {
|
||||
if (!this.client) return [];
|
||||
@@ -1152,7 +1204,45 @@ export class MatrixService extends EventEmitter {
|
||||
|
||||
console.log('getRooms - fetching fresh room data');
|
||||
|
||||
this.cachedRooms = this.client.getRooms().map(room => {
|
||||
// Get all Spaces to check which rooms are Space children
|
||||
const spaces = this.client.getRooms().filter(room => {
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '');
|
||||
const roomType = createEvent?.getContent()?.type;
|
||||
return roomType === 'm.space';
|
||||
});
|
||||
|
||||
// Collect all room IDs that are children of any Space
|
||||
const spaceChildRoomIds = new Set<string>();
|
||||
spaces.forEach(space => {
|
||||
const childEvents = space.currentState.getStateEvents('m.space.child');
|
||||
childEvents.forEach(event => {
|
||||
const content = event.getContent();
|
||||
if (content && Object.keys(content).length > 0) {
|
||||
spaceChildRoomIds.add(event.getStateKey());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('getRooms - found', spaceChildRoomIds.size, 'rooms that are Space children');
|
||||
|
||||
this.cachedRooms = this.client.getRooms()
|
||||
.filter(room => {
|
||||
// Exclude Spaces themselves
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '');
|
||||
const roomType = createEvent?.getContent()?.type;
|
||||
if (roomType === 'm.space') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude rooms that are children of Spaces
|
||||
if (spaceChildRoomIds.has(room.roomId)) {
|
||||
console.log('getRooms - filtering out Space child room:', room.roomId.substring(0, 20) + '...', room.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(room => {
|
||||
// Get room avatar from state events
|
||||
const avatarEvent = room.currentState.getStateEvents('m.room.avatar', '');
|
||||
const avatarUrl = avatarEvent?.getContent()?.url || null;
|
||||
@@ -2366,6 +2456,276 @@ export class MatrixService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MATRIX SPACES METHODS =====
|
||||
|
||||
/**
|
||||
* Create a new Matrix Space
|
||||
*/
|
||||
async createSpace(
|
||||
name: string,
|
||||
topic?: string,
|
||||
isPublic: boolean = false,
|
||||
inviteUserIds: string[] = []
|
||||
): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📦 Creating Matrix Space:', { name, topic, isPublic, inviteCount: inviteUserIds.length });
|
||||
|
||||
// Create a Space (which is a room with type='m.space')
|
||||
const space = await this.client.createRoom({
|
||||
name: name,
|
||||
topic: topic,
|
||||
preset: isPublic ? 'public_chat' : 'private_chat',
|
||||
visibility: isPublic ? 'public' : 'private',
|
||||
creation_content: {
|
||||
type: 'm.space', // This makes it a Space instead of a regular room
|
||||
},
|
||||
invite: inviteUserIds,
|
||||
initial_state: [
|
||||
// Set join rules for public spaces
|
||||
...(isPublic ? [{
|
||||
type: 'm.room.join_rules',
|
||||
content: {
|
||||
join_rule: 'public'
|
||||
}
|
||||
}] : []),
|
||||
],
|
||||
});
|
||||
|
||||
const spaceId = space.room_id;
|
||||
console.log('✅ Space created successfully:', spaceId);
|
||||
|
||||
// Clear rooms cache to refresh the space list
|
||||
this.cachedRooms = null;
|
||||
|
||||
// Wait a moment for Matrix to sync the new space
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Force a refresh by emitting the ready event
|
||||
this.emit('ready');
|
||||
|
||||
return spaceId;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create Space:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Spaces (rooms with type='m.space')
|
||||
*/
|
||||
getSpaces(): MatrixRoom[] {
|
||||
if (!this.client) return [];
|
||||
|
||||
console.log('getSpaces - fetching spaces');
|
||||
|
||||
const allRooms = this.client.getRooms();
|
||||
const spaces = allRooms
|
||||
.filter(room => {
|
||||
// Check if this room is a Space by looking at the creation content
|
||||
const createEvent = room.currentState.getStateEvents('m.room.create', '');
|
||||
const roomType = createEvent?.getContent()?.type;
|
||||
return roomType === 'm.space';
|
||||
})
|
||||
.map(room => {
|
||||
// Get room avatar from state events
|
||||
const avatarEvent = room.currentState.getStateEvents('m.room.avatar', '');
|
||||
const avatarUrl = avatarEvent?.getContent()?.url || null;
|
||||
|
||||
// Get join rules to determine if public
|
||||
const joinRulesEvent = room.currentState.getStateEvents('m.room.join_rules', '');
|
||||
const isPublic = joinRulesEvent?.getContent()?.join_rule === 'public';
|
||||
|
||||
if (avatarUrl) {
|
||||
console.log('🖼️ getSpaces: Found avatar for space', room.roomId.substring(0, 20) + '...', '→', avatarUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
name: room.name,
|
||||
topic: room.currentState.getStateEvents('m.room.topic', '')?.getContent()?.topic,
|
||||
avatarUrl: avatarUrl,
|
||||
isPublic: isPublic,
|
||||
members: room.getMembers().map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.name,
|
||||
avatarUrl: member.getMxcAvatarUrl() || null,
|
||||
presence: this.client?.getUser(member.userId)?.presence,
|
||||
})),
|
||||
isDirectMessage: false, // Spaces are never DMs
|
||||
lastActivity: new Date(room.getLastActiveTimestamp()),
|
||||
};
|
||||
});
|
||||
|
||||
console.log('getSpaces - found', spaces.length, 'spaces');
|
||||
return spaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a room to a Space
|
||||
*/
|
||||
async addRoomToSpace(spaceId: string, roomId: string, suggested: boolean = false, order?: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📦 Adding room to Space:', { spaceId: spaceId.substring(0, 20) + '...', roomId: roomId.substring(0, 20) + '...', suggested });
|
||||
|
||||
// Add the room as a child of the space using m.space.child state event
|
||||
await this.client.sendStateEvent(spaceId, 'm.space.child', {
|
||||
via: [this.client.getDomain() || 'matrix.org'], // Server to use for joining
|
||||
suggested: suggested,
|
||||
order: order,
|
||||
}, roomId); // The state_key is the room ID
|
||||
|
||||
console.log('✅ Room added to Space successfully');
|
||||
|
||||
// Clear rooms cache
|
||||
this.cachedRooms = null;
|
||||
|
||||
this.emit('spaceChildAdded', { spaceId, roomId });
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to add room to Space:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a room from a Space
|
||||
*/
|
||||
async removeRoomFromSpace(spaceId: string, roomId: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('📦 Removing room from Space:', { spaceId: spaceId.substring(0, 20) + '...', roomId: roomId.substring(0, 20) + '...' });
|
||||
|
||||
// Remove the m.space.child state event by sending an empty content
|
||||
await this.client.sendStateEvent(spaceId, 'm.space.child', {}, roomId);
|
||||
|
||||
console.log('✅ Room removed from Space successfully');
|
||||
|
||||
// Clear rooms cache
|
||||
this.cachedRooms = null;
|
||||
|
||||
this.emit('spaceChildRemoved', { spaceId, roomId });
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to remove room from Space:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rooms that are children of a Space
|
||||
*/
|
||||
getSpaceChildren(spaceId: string): Array<{
|
||||
roomId: string;
|
||||
name?: string;
|
||||
suggested: boolean;
|
||||
order?: string;
|
||||
}> {
|
||||
if (!this.client) return [];
|
||||
|
||||
try {
|
||||
const space = this.client.getRoom(spaceId);
|
||||
if (!space) {
|
||||
console.warn('Space not found:', spaceId);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all m.space.child state events
|
||||
const childEvents = space.currentState.getStateEvents('m.space.child');
|
||||
|
||||
const children = childEvents
|
||||
.filter(event => {
|
||||
// Filter out empty/deleted child relationships
|
||||
const content = event.getContent();
|
||||
return content && Object.keys(content).length > 0;
|
||||
})
|
||||
.map(event => {
|
||||
const childRoomId = event.getStateKey();
|
||||
const content = event.getContent();
|
||||
const childRoom = this.client?.getRoom(childRoomId);
|
||||
|
||||
return {
|
||||
roomId: childRoomId,
|
||||
name: childRoom?.name,
|
||||
suggested: content.suggested || false,
|
||||
order: content.order,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('📦 Found', children.length, 'children in Space:', spaceId.substring(0, 20) + '...');
|
||||
return children;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get Space children:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Space hierarchy (including nested Spaces)
|
||||
*/
|
||||
async getSpaceHierarchy(spaceId: string): Promise<any> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the Matrix client's getSpaceHierarchy method if available
|
||||
if (typeof (this.client as any).getSpaceHierarchy === 'function') {
|
||||
const hierarchy = await (this.client as any).getSpaceHierarchy(spaceId);
|
||||
return hierarchy;
|
||||
} else {
|
||||
// Fallback: manually build hierarchy from state events
|
||||
console.warn('getSpaceHierarchy not available in SDK, using fallback');
|
||||
const children = this.getSpaceChildren(spaceId);
|
||||
return {
|
||||
rooms: children.map(child => ({
|
||||
room_id: child.roomId,
|
||||
name: child.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get Space hierarchy:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Space name
|
||||
*/
|
||||
async setSpaceName(spaceId: string, name: string): Promise<void> {
|
||||
return this.setRoomName(spaceId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Space topic
|
||||
*/
|
||||
async setSpaceTopic(spaceId: string, topic: string): Promise<void> {
|
||||
return this.setRoomTopic(spaceId, topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload and set Space avatar
|
||||
*/
|
||||
async setSpaceAvatar(spaceId: string, file: File): Promise<string> {
|
||||
return this.setRoomAvatar(spaceId, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Space avatar
|
||||
*/
|
||||
async removeSpaceAvatar(spaceId: string): Promise<void> {
|
||||
return this.removeRoomAvatar(spaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug information about the current Matrix state
|
||||
*/
|
||||
@@ -2377,6 +2737,7 @@ export class MatrixService extends EventEmitter {
|
||||
currentUser: this.getCurrentUser(),
|
||||
friendsCount: this.getFriends().length,
|
||||
roomsCount: this.getRooms().length,
|
||||
spacesCount: this.getSpaces().length,
|
||||
gooseInstancesCount: this.getGooseInstances().length,
|
||||
homeserver: this.config.homeserverUrl,
|
||||
};
|
||||
|
||||
+193
-3
@@ -1,8 +1,198 @@
|
||||
import { Session } from './api';
|
||||
import { Message } from './types/message';
|
||||
import { getSessionHistory, listSessions, SessionInfo } from './api';
|
||||
import { convertApiMessageToFrontendMessage } from './components/context_management';
|
||||
import { getApiUrl } from './config';
|
||||
|
||||
export interface SessionMetadata {
|
||||
description: string;
|
||||
message_count: number;
|
||||
total_tokens: number | null;
|
||||
working_dir: string; // Required in type, but may be missing in old sessions
|
||||
// Add the accumulated token fields from the API
|
||||
accumulated_input_tokens?: number | null;
|
||||
accumulated_output_tokens?: number | null;
|
||||
accumulated_total_tokens?: number | null;
|
||||
}
|
||||
|
||||
// Helper function to ensure working directory is set
|
||||
export function ensureWorkingDir(metadata: Partial<SessionMetadata>): SessionMetadata {
|
||||
return {
|
||||
description: metadata.description || '',
|
||||
message_count: metadata.message_count || 0,
|
||||
total_tokens: metadata.total_tokens || null,
|
||||
working_dir: metadata.working_dir || process.env.HOME || '',
|
||||
accumulated_input_tokens: metadata.accumulated_input_tokens || null,
|
||||
accumulated_output_tokens: metadata.accumulated_output_tokens || null,
|
||||
accumulated_total_tokens: metadata.accumulated_total_tokens || null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
path: string;
|
||||
modified: string;
|
||||
metadata: SessionMetadata;
|
||||
}
|
||||
|
||||
export interface SessionsResponse {
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
export interface SessionDetails {
|
||||
session_id: string;
|
||||
metadata: SessionMetadata;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a session ID in the format yyyymmdd_hhmmss
|
||||
*/
|
||||
export function generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID for a Matrix room
|
||||
* Uses the Matrix room ID to ensure uniqueness and persistence
|
||||
* @param matrixRoomId The Matrix room ID (e.g., !roomId:server.com)
|
||||
* @returns A unique session ID for this Matrix room
|
||||
*/
|
||||
export function generateMatrixSessionId(matrixRoomId: string): string {
|
||||
// Create a hash-like suffix from the Matrix room ID for uniqueness
|
||||
// This ensures each Matrix room gets its own unique session
|
||||
const roomHash = matrixRoomId
|
||||
.split('')
|
||||
.reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
|
||||
.toString(36)
|
||||
.replace('-', 'n'); // Replace negative sign with 'n'
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
// Format: YYYYMMDD_matrix_<roomhash>
|
||||
// This ensures:
|
||||
// 1. Each Matrix room has a unique session ID
|
||||
// 2. The same room always gets the same session ID (within a day)
|
||||
// 3. It's clearly identifiable as a Matrix session
|
||||
return `${year}${month}${day}_matrix_${roomHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all available sessions from the API
|
||||
* @returns Promise with sessions data
|
||||
*/
|
||||
/**
|
||||
* Fetches all available sessions from the API
|
||||
* @returns Promise with an array of Session objects
|
||||
*/
|
||||
export async function fetchSessions(): Promise<Session[]> {
|
||||
try {
|
||||
const response = await listSessions<true>();
|
||||
|
||||
// Check if the response has the expected structure
|
||||
if (response && response.data && response.data.sessions) {
|
||||
// Since the API returns SessionInfo, we need to convert to Session
|
||||
const sessions = response.data.sessions
|
||||
.filter(
|
||||
(sessionInfo: SessionInfo) =>
|
||||
sessionInfo.metadata && sessionInfo.metadata.message_count > 0
|
||||
)
|
||||
.map(
|
||||
(sessionInfo: SessionInfo): Session => ({
|
||||
id: sessionInfo.id,
|
||||
path: sessionInfo.path,
|
||||
modified: sessionInfo.modified,
|
||||
metadata: ensureWorkingDir(sessionInfo.metadata),
|
||||
})
|
||||
);
|
||||
|
||||
// order sessions by 'modified' date descending
|
||||
sessions.sort(
|
||||
(a: Session, b: Session) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
|
||||
);
|
||||
|
||||
return sessions;
|
||||
} else {
|
||||
throw new Error('Unexpected response format from listSessions');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sessions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches details for a specific session
|
||||
* @param sessionId The ID of the session to fetch
|
||||
* @returns Promise with session details
|
||||
*/
|
||||
export async function fetchSessionDetails(sessionId: string): Promise<SessionDetails> {
|
||||
try {
|
||||
const response = await getSessionHistory<true>({
|
||||
path: { session_id: sessionId },
|
||||
});
|
||||
|
||||
// Convert the SessionHistoryResponse to a SessionDetails object
|
||||
return {
|
||||
session_id: response.data.sessionId,
|
||||
metadata: ensureWorkingDir(response.data.metadata),
|
||||
messages: response.data.messages.map((message) =>
|
||||
convertApiMessageToFrontendMessage(message, true, true)
|
||||
), // slight diffs between backend and frontend Message obj
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching session details for ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the metadata for a specific session
|
||||
* @param sessionId The ID of the session to update
|
||||
* @param description The new description (name) for the session
|
||||
* @returns Promise that resolves when the update is complete
|
||||
*/
|
||||
export async function updateSessionMetadata(sessionId: string, description: string): Promise<void> {
|
||||
try {
|
||||
const url = getApiUrl(`/sessions/${sessionId}/metadata`);
|
||||
const secretKey = await window.electron.getSecretKey();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': secretKey,
|
||||
},
|
||||
body: JSON.stringify({ description }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to update session metadata: ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating session metadata for ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a session in a new window
|
||||
* @param session The session to resume
|
||||
*/
|
||||
export function resumeSession(session: Session) {
|
||||
console.log('Launching session in new window:', session.description || session.id);
|
||||
const workingDir = session.working_dir;
|
||||
console.log('Launching session in new window:', session.metadata.description || session.id);
|
||||
const workingDir = session.metadata.working_dir;
|
||||
if (!workingDir) {
|
||||
throw new Error('Cannot resume session: working directory is missing in session');
|
||||
}
|
||||
|
||||
@@ -18,4 +18,8 @@ export interface ChatType {
|
||||
recipeParameters?: Record<string, string> | null; // Add recipe parameters to chat state
|
||||
matrixContext?: MatrixChatContext | null; // Add Matrix chat context
|
||||
aiEnabled?: boolean; // Whether AI responses are enabled for this chat (default: true for regular chats, false for Matrix DMs)
|
||||
// Matrix-specific properties for message routing
|
||||
matrixRoomId?: string | null; // The Matrix room ID (e.g., !roomId:server.com)
|
||||
matrixRecipientId?: string | null; // The recipient user ID for Matrix rooms
|
||||
isMatrixTab?: boolean; // Flag to identify Matrix tabs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Matrix Room Helper
|
||||
*
|
||||
* This utility provides functions for creating and managing Matrix room sessions.
|
||||
* Use these functions when opening Matrix rooms to ensure proper session management
|
||||
* and message routing.
|
||||
*/
|
||||
|
||||
import { ChatType } from '../types/chat';
|
||||
import { generateMatrixSessionId } from '../sessions';
|
||||
|
||||
export interface MatrixRoomInfo {
|
||||
roomId: string;
|
||||
name: string;
|
||||
recipientId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ChatType object for a Matrix room with proper session management
|
||||
*
|
||||
* This ensures:
|
||||
* 1. Each Matrix room gets a unique session ID
|
||||
* 2. The session ID is stable (same room = same session)
|
||||
* 3. Matrix metadata is properly attached
|
||||
* 4. Messages will be routed correctly
|
||||
*
|
||||
* @param room - The Matrix room information
|
||||
* @returns A ChatType object ready to be used for the Matrix room
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const room = {
|
||||
* roomId: '!KxKXXYDKFfbKQXgDXO:tchncs.de',
|
||||
* name: 'Room Alpha',
|
||||
* recipientId: '@user:tchncs.de'
|
||||
* };
|
||||
*
|
||||
* const chat = createMatrixRoomChat(room);
|
||||
* // Now use this chat object to open a tab or navigate to pair view
|
||||
* ```
|
||||
*/
|
||||
export function createMatrixRoomChat(room: MatrixRoomInfo): ChatType {
|
||||
// Generate unique session ID for this Matrix room
|
||||
const sessionId = generateMatrixSessionId(room.roomId);
|
||||
|
||||
console.log('[Matrix Room Helper] Creating chat for Matrix room:', {
|
||||
roomId: room.roomId,
|
||||
roomName: room.name,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
title: room.name,
|
||||
messages: [],
|
||||
messageHistoryIndex: 0,
|
||||
matrixRoomId: room.roomId,
|
||||
matrixRecipientId: room.recipientId || null,
|
||||
isMatrixTab: true,
|
||||
recipeConfig: null,
|
||||
recipeParameters: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a chat is a Matrix room chat
|
||||
*
|
||||
* @param chat - The chat object to check
|
||||
* @returns true if the chat is a Matrix room
|
||||
*/
|
||||
export function isMatrixRoom(chat: ChatType): boolean {
|
||||
return chat.isMatrixTab === true && !!chat.matrixRoomId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Matrix room ID from a chat object
|
||||
*
|
||||
* @param chat - The chat object
|
||||
* @returns The Matrix room ID or null if not a Matrix chat
|
||||
*/
|
||||
export function getMatrixRoomId(chat: ChatType): string | null {
|
||||
return chat.matrixRoomId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage in a component that opens Matrix rooms:
|
||||
*
|
||||
* ```typescript
|
||||
* import { createMatrixRoomChat } from '../utils/matrixRoomHelper';
|
||||
* import { useNavigate } from 'react-router-dom';
|
||||
*
|
||||
* const SpaceRoomsView = ({ spaceId }) => {
|
||||
* const navigate = useNavigate();
|
||||
*
|
||||
* const handleOpenRoom = (room) => {
|
||||
* // Create chat object with proper Matrix session
|
||||
* const chat = createMatrixRoomChat({
|
||||
* roomId: room.roomId,
|
||||
* name: room.name,
|
||||
* recipientId: room.recipientId
|
||||
* });
|
||||
*
|
||||
* // Navigate to pair view with the Matrix chat
|
||||
* navigate('/pair', { state: { chat } });
|
||||
*
|
||||
* // OR if using a tab system:
|
||||
* // addTab(chat);
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {rooms.map(room => (
|
||||
* <button key={room.roomId} onClick={() => handleOpenRoom(room)}>
|
||||
* {room.name}
|
||||
* </button>
|
||||
* ))}
|
||||
* </div>
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
Reference in New Issue
Block a user