mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Add interactive dropdown selectors to SpaceBreadcrumb component
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChevronRight, Hash } from 'lucide-react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ChevronRight, Hash, ChevronDown, Layers } from 'lucide-react';
|
||||
import { matrixService } from '../services/MatrixService';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface SpaceBreadcrumbProps {
|
||||
roomId: string;
|
||||
className?: string;
|
||||
onSpaceClick?: (spaceId: string) => void;
|
||||
onRoomClick?: (roomId: string) => void;
|
||||
}
|
||||
|
||||
interface BreadcrumbData {
|
||||
@@ -15,14 +17,27 @@ interface BreadcrumbData {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export const SpaceBreadcrumb: React.FC<SpaceBreadcrumbProps> = ({ roomId, className = '' }) => {
|
||||
export const SpaceBreadcrumb: React.FC<SpaceBreadcrumbProps> = ({
|
||||
roomId,
|
||||
className = '',
|
||||
onSpaceClick,
|
||||
onRoomClick
|
||||
}) => {
|
||||
const [breadcrumb, setBreadcrumb] = useState<BreadcrumbData | null>(null);
|
||||
const [showSpaceDropdown, setShowSpaceDropdown] = useState(false);
|
||||
const [showRoomDropdown, setShowRoomDropdown] = useState(false);
|
||||
const [allSpaces, setAllSpaces] = useState<Array<{ roomId: string; name: string }>>([]);
|
||||
const [spaceRooms, setSpaceRooms] = useState<Array<{ roomId: string; name: string }>>([]);
|
||||
|
||||
const spaceDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const roomDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBreadcrumb = () => {
|
||||
try {
|
||||
// Get all Spaces
|
||||
const spaces = matrixService.getSpaces();
|
||||
setAllSpaces(spaces.map(s => ({ roomId: s.roomId, name: s.name || 'Unnamed Space' })));
|
||||
|
||||
// Find which Space contains this room
|
||||
for (const space of spaces) {
|
||||
@@ -37,6 +52,12 @@ export const SpaceBreadcrumb: React.FC<SpaceBreadcrumbProps> = ({ roomId, classN
|
||||
roomName: childRoom.name || 'Unnamed Room',
|
||||
roomId: roomId,
|
||||
});
|
||||
|
||||
// Load all rooms in this Space
|
||||
setSpaceRooms(children.map(c => ({
|
||||
roomId: c.roomId,
|
||||
name: c.name || 'Unnamed Room'
|
||||
})));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -67,6 +88,31 @@ export const SpaceBreadcrumb: React.FC<SpaceBreadcrumbProps> = ({ roomId, classN
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (spaceDropdownRef.current && !spaceDropdownRef.current.contains(event.target as Node)) {
|
||||
setShowSpaceDropdown(false);
|
||||
}
|
||||
if (roomDropdownRef.current && !roomDropdownRef.current.contains(event.target as Node)) {
|
||||
setShowRoomDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSpaceSelect = useCallback((spaceId: string) => {
|
||||
setShowSpaceDropdown(false);
|
||||
onSpaceClick?.(spaceId);
|
||||
}, [onSpaceClick]);
|
||||
|
||||
const handleRoomSelect = useCallback((selectedRoomId: string) => {
|
||||
setShowRoomDropdown(false);
|
||||
onRoomClick?.(selectedRoomId);
|
||||
}, [onRoomClick]);
|
||||
|
||||
// Don't render if room is not in a Space
|
||||
if (!breadcrumb) {
|
||||
return null;
|
||||
@@ -79,24 +125,100 @@ export const SpaceBreadcrumb: React.FC<SpaceBreadcrumbProps> = ({ roomId, classN
|
||||
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>
|
||||
{/* Space Selector */}
|
||||
<div className="relative" ref={spaceDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowSpaceDropdown(!showSpaceDropdown)}
|
||||
className="flex items-center gap-2 text-text-muted hover:text-text-default transition-colors rounded px-2 py-1 hover:bg-background-hover"
|
||||
aria-label="Select space"
|
||||
aria-expanded={showSpaceDropdown}
|
||||
>
|
||||
<div className="w-5 h-5 bg-background-accent/20 rounded flex items-center justify-center">
|
||||
<Layers className="w-3 h-3 text-text-on-accent" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{breadcrumb.spaceName}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSpaceDropdown && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-background-elevated border border-border-default rounded-lg shadow-lg overflow-hidden z-50"
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{allSpaces.map((space) => (
|
||||
<button
|
||||
key={space.roomId}
|
||||
onClick={() => handleSpaceSelect(space.roomId)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-background-hover transition-colors ${
|
||||
space.roomId === breadcrumb.spaceId ? 'bg-background-accent/10 text-text-on-accent' : 'text-text-default'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{space.name}</span>
|
||||
{space.roomId === breadcrumb.spaceId && (
|
||||
<span className="ml-auto text-xs text-text-muted">Current</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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>
|
||||
{/* Room Selector */}
|
||||
<div className="relative" ref={roomDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowRoomDropdown(!showRoomDropdown)}
|
||||
className="flex items-center gap-2 text-text-default hover:text-text-emphasis transition-colors rounded px-2 py-1 hover:bg-background-hover"
|
||||
aria-label="Select room"
|
||||
aria-expanded={showRoomDropdown}
|
||||
>
|
||||
<Hash className="w-4 h-4" />
|
||||
<span className="text-sm">{breadcrumb.roomName}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showRoomDropdown && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 mt-1 w-64 bg-background-elevated border border-border-default rounded-lg shadow-lg overflow-hidden z-50"
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{spaceRooms.map((room) => (
|
||||
<button
|
||||
key={room.roomId}
|
||||
onClick={() => handleRoomSelect(room.roomId)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-background-hover transition-colors ${
|
||||
room.roomId === breadcrumb.roomId ? 'bg-background-accent/10 text-text-on-accent' : 'text-text-default'
|
||||
}`}
|
||||
>
|
||||
<Hash className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{room.name}</span>
|
||||
{room.roomId === breadcrumb.roomId && (
|
||||
<span className="ml-auto text-xs text-text-muted">Current</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Optional: Add a badge to indicate it's a Space room */}
|
||||
{/* Space Room Badge */}
|
||||
<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
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTabContext } from '../contexts/TabContext';
|
||||
import { ResizableSplitter } from './Layout/ResizableSplitter';
|
||||
import { TaskExecutionProvider } from '../contexts/TaskExecutionContext';
|
||||
import { SpaceBreadcrumb } from './SpaceBreadcrumb';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface TabbedChatContainerProps {
|
||||
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
|
||||
@@ -23,6 +24,7 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
|
||||
initialMessage,
|
||||
sidebarCollapsed = false
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
tabStates,
|
||||
activeTabId,
|
||||
@@ -35,11 +37,26 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
|
||||
syncTabTitleWithBackend,
|
||||
updateTabTitleFromMessage,
|
||||
updateSessionId,
|
||||
openMatrixChat,
|
||||
// Sidecar functions
|
||||
hideSidecarView,
|
||||
getSidecarState
|
||||
} = useTabContext();
|
||||
|
||||
// Handle room switching from breadcrumb
|
||||
const handleRoomSwitch = useCallback((roomId: string) => {
|
||||
console.log('🔄 Switching to room:', roomId);
|
||||
openMatrixChat(roomId);
|
||||
}, [openMatrixChat]);
|
||||
|
||||
// Handle space navigation from breadcrumb
|
||||
const handleSpaceNavigation = useCallback((spaceId: string) => {
|
||||
console.log('🏠 Navigating to space:', spaceId);
|
||||
// TODO: Navigate to space view (e.g., SpaceRoomsView or ChannelsView)
|
||||
// For now, we could navigate to the first room in the space
|
||||
// navigate(`/channels/${spaceId}`);
|
||||
}, [navigate]);
|
||||
|
||||
const handleMessageSubmitWrapper = useCallback(async (message: string, tabId: string) => {
|
||||
// Find the tab state to check if this is the first message
|
||||
const tabState = tabStates.find(ts => ts.tab.id === tabId);
|
||||
@@ -204,7 +221,11 @@ export const TabbedChatContainer: React.FC<TabbedChatContainerProps> = ({
|
||||
{/* 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} />
|
||||
<SpaceBreadcrumb
|
||||
roomId={activeTabState.tab.matrixRoomId}
|
||||
onRoomClick={handleRoomSwitch}
|
||||
onSpaceClick={handleSpaceNavigation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user