Implement Matrix channel creation functionality (#5962)

This commit is contained in:
Spence
2025-12-03 14:08:18 -05:00
committed by GitHub
parent 4e302227b8
commit 493aeeee4e
20 changed files with 3306 additions and 602 deletions
+1195 -322
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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);
+20 -10
View File
@@ -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
+7
View File
@@ -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);
+3
View File
@@ -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
+11
View File
@@ -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,
+8 -1
View File
@@ -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
+30 -7
View File
@@ -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'
});
+107 -236
View File
@@ -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,
};
}
+125
View File
@@ -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();
+367 -6
View File
@@ -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
View File
@@ -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');
}
+4
View File
@@ -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
}
+122
View File
@@ -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>
* );
* };
* ```
*/