fix(layout): unified right sidebar with chat mode and collapsing activity rail

This commit is contained in:
zenchantlive 2026-02-16 23:50:20 -08:00
parent 24c904554b
commit c4622ea0b6
6 changed files with 112 additions and 59 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -6,8 +6,8 @@ import type { ActivityEvent } from '../../lib/activity';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
type AgentStatus = 'active' | 'stale' | 'stuck' | 'dead';
@ -20,6 +20,7 @@ interface AgentRosterEntry {
interface ActivityPanelProps {
issues: BeadIssue[];
collapsed?: boolean;
}
const AGENT_LABEL = 'gt:agent';
@ -99,17 +100,6 @@ function formatRelativeTime(timestamp: string): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// Get status badge variant
function getStatusVariant(status: AgentStatus): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (status) {
case 'active': return 'default';
case 'stale': return 'secondary';
case 'stuck': return 'outline';
case 'dead': return 'destructive';
default: return 'secondary';
}
}
// Get event kind icon/color
function getEventKindInfo(kind: string): { label: string; color: string } {
const events: Record<string, { label: string; color: string }> = {
@ -131,7 +121,7 @@ function getInitials(name: string): string {
return name.split(/[-_\s]/).map(p => p[0]).join('').toUpperCase().slice(0, 2);
}
export function ActivityPanel({ issues }: ActivityPanelProps) {
export function ActivityPanel({ issues, collapsed = false }: ActivityPanelProps) {
const [activities, setActivities] = useState<ActivityEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -182,6 +172,44 @@ export function ActivityPanel({ issues }: ActivityPanelProps) {
const activeAgents = agentRoster.filter(a => a.status === 'active').length;
const staleAgents = agentRoster.filter(a => a.status === 'stale').length;
if (collapsed) {
return (
<div className="flex flex-col items-center gap-4 py-4 h-full bg-black/20">
{/* Collapsed Agent Icons */}
<div className="flex flex-col gap-2">
{agentRoster.slice(0, 5).map(agent => (
<div key={agent.beadId} className="relative group cursor-help" title={`${agent.name} (${agent.status})`}>
<Avatar className="h-8 w-8 ring-1 ring-white/10 hover:ring-white/30 transition-all">
<AvatarFallback className="text-[10px] bg-white/5">
{getInitials(agent.name)}
</AvatarFallback>
</Avatar>
<div className={cn(
"absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-[#1e1e1e]",
agent.status === 'active' ? 'bg-emerald-500' :
agent.status === 'stale' ? 'bg-amber-500' : 'bg-rose-500'
)} />
</div>
))}
</div>
{/* Divider */}
<div className="w-4 h-[1px] bg-white/10" />
{/* Mini Activity Dots (Just visual pulse) */}
<div className="flex flex-col gap-1">
{/* Just show a few recent activity dots as a visual "heartbeat" */}
{activities.slice(0, 5).map((act) => (
<div key={act.id} className={cn(
"w-1.5 h-1.5 rounded-full opacity-50",
act.kind === 'created' ? 'bg-emerald-500' : 'bg-cyan-500'
)} />
))}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* AGENT ROSTER SECTION */}
@ -294,4 +322,4 @@ export function ActivityPanel({ issues }: ActivityPanelProps) {
</div>
</div>
);
}
}

View file

@ -7,30 +7,49 @@ import { useUrlState } from '../../hooks/use-url-state';
export interface RightPanelProps {
children?: ReactNode;
rail?: ReactNode;
isOpen?: boolean;
}
export function RightPanel({ children, isOpen: externalIsOpen }: RightPanelProps) {
export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPanelProps) {
const { isMobile, isDesktop } = useResponsive();
const { panel, togglePanel } = useUrlState();
const isOpen = externalIsOpen ?? (panel === 'open');
// Calculate width based on content (Standard 17rem vs Chat Mode ~26rem)
// If rail is present, we are in "Chat Mode" (Wide Panel + Rail)
// If no rail, we are in "Activity Mode" (Standard Panel)
const panelWidth = isOpen ? (rail ? '26rem' : '17rem') : '0';
if (isDesktop) {
return (
<div
className="overflow-y-auto transition-all duration-300"
className="overflow-hidden transition-all duration-300 flex"
style={{
width: isOpen ? '17rem' : '0',
width: panelWidth,
backgroundColor: 'var(--color-bg-card)',
borderLeft: isOpen ? '1px solid rgba(255, 255, 255, 0.1)' : 'none',
}}
data-testid="right-panel-desktop"
>
{isOpen && (
<div className="p-4" style={{ color: 'var(--color-text-secondary)' }}>
{children || <span>Right Panel</span>}
</div>
<>
{/* Main Content (Chat or Activity) */}
<div className="flex-1 min-w-0 h-full overflow-hidden flex flex-col">
<div className="flex-1 overflow-y-auto custom-scrollbar p-0">
{/* Remove default padding to allow edge-to-edge chat */}
{children || <span>Right Panel</span>}
</div>
</div>
{/* Side Rail (Mini Activity - Only if provided) */}
{rail && (
<div className="w-12 h-full flex-shrink-0 border-l border-white/10 bg-black/20">
{rail}
</div>
)}
</>
)}
</div>
);
@ -109,4 +128,4 @@ export function RightPanel({ children, isOpen: externalIsOpen }: RightPanelProps
);
}
export default RightPanel;
export default RightPanel;

View file

@ -10,6 +10,7 @@ interface ThreadDrawerProps {
title: string;
id: string;
items?: ThreadItem[];
embedded?: boolean; // New prop for embedded mode
}
// Sample data for demo
@ -37,23 +38,24 @@ const SAMPLE_ITEMS: ThreadItem[] = [
},
];
export function ThreadDrawer({ isOpen, onClose, title, id, items = SAMPLE_ITEMS }: ThreadDrawerProps) {
export function ThreadDrawer({ isOpen, onClose, title, id, items = SAMPLE_ITEMS, embedded = false }: ThreadDrawerProps) {
const [comment, setComment] = useState('');
if (!isOpen) return null;
return (
<div
className="h-full w-[24rem] overflow-hidden flex flex-col"
className="h-full flex flex-col"
style={{
width: embedded ? '100%' : '24rem', // Full width when embedded
backgroundColor: 'var(--color-bg-card)',
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.3)',
borderLeft: embedded ? 'none' : '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: embedded ? 'none' : '-4px 0 20px rgba(0, 0, 0, 0.3)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between p-4 border-b"
className="flex items-center justify-between p-4 border-b flex-shrink-0"
style={{ borderColor: 'rgba(255, 255, 255, 0.1)' }}
>
<div className="flex-1 min-w-0 mr-4">
@ -78,13 +80,13 @@ export function ThreadDrawer({ isOpen, onClose, title, id, items = SAMPLE_ITEMS
</div>
{/* Thread Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
<ThreadView items={items} />
</div>
{/* Compose */}
<div
className="p-4 border-t"
className="p-4 border-t flex-shrink-0"
style={{ borderColor: 'rgba(255, 255, 255, 0.1)' }}
>
<div className="flex gap-2">
@ -93,7 +95,7 @@ export function ThreadDrawer({ isOpen, onClose, title, id, items = SAMPLE_ITEMS
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Add a comment..."
className="flex-1 px-3 py-2 rounded-md text-sm"
className="flex-1 px-3 py-2 rounded-md text-sm outline-none focus:ring-1 focus:ring-teal-500/50 transition-all"
style={{
backgroundColor: 'var(--color-bg-input)',
color: 'var(--color-text-primary)',
@ -106,7 +108,7 @@ export function ThreadDrawer({ isOpen, onClose, title, id, items = SAMPLE_ITEMS
}}
/>
<button
className="p-2 rounded-md"
className="p-2 rounded-md hover:opacity-90 transition-opacity"
style={{
backgroundColor: 'var(--color-accent-green)',
color: '#fff',
@ -119,4 +121,4 @@ export function ThreadDrawer({ isOpen, onClose, title, id, items = SAMPLE_ITEMS
</div>
</div>
);
}
}

View file

@ -52,14 +52,32 @@ export function UnifiedShell({
setDrawer('closed');
};
// Thread drawer - shows when card selected
const isDrawerOpen = drawer === 'open' && (!!taskId || !!swarmId);
// Chat Mode Logic: If a card is selected (drawer='open'), we show Chat + Mini Activity Rail
const isChatOpen = drawer === 'open' && (!!taskId || !!swarmId);
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || '';
const drawerId = taskId || swarmId || '';
const renderRightPanel = () => {
return <ActivityPanel issues={issues} />;
};
// Right Panel Content Logic
// - Chat Mode: Main = Chat, Rail = Activity(Collapsed)
// - Default: Main = Activity, Rail = None
const rightPanelMain = isChatOpen ? (
<ThreadDrawer
isOpen={true} // Always "open" inside the panel
onClose={handleCloseDrawer}
title={drawerTitle}
id={drawerId}
embedded={true} // New prop to tell ThreadDrawer it's embedded, not an overlay
/>
) : (
<ActivityPanel issues={issues} />
);
const rightPanelRail = isChatOpen ? (
<ActivityPanel issues={issues} collapsed={true} />
) : undefined;
// Grid Layout: Expand Right Panel width when Chat is open
const rightPanelWidth = isChatOpen ? '26rem' : '17rem';
const renderMiddleContent = () => {
if (view === 'graph') {
@ -103,42 +121,28 @@ export function UnifiedShell({
{/* TOP BAR: 3rem fixed */}
<TopBar />
{/* MAIN AREA: CSS Grid [13rem | 1fr | 17rem] */}
{/* MAIN AREA: CSS Grid [13rem | 1fr | RightPanel] */}
<div
className="flex-1 grid overflow-hidden"
style={{ gridTemplateColumns: '13rem 1fr 17rem' }}
className="flex-1 grid overflow-hidden transition-all duration-300"
style={{ gridTemplateColumns: `13rem 1fr ${rightPanelWidth}` }}
data-testid="main-area"
>
{/* LEFT PANEL: 13rem channel tree */}
<LeftPanel issues={issues} />
{/* MIDDLE CONTENT: flex-1 - contains card grid AND thread drawer */}
<div className="relative overflow-hidden" data-testid="middle-content">
{/* MIDDLE CONTENT: flex-1 */}
<div className="relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
{renderMiddleContent()}
{/* THREAD DRAWER: Inside middle section, attached to right edge */}
{isDrawerOpen && (
<div className="absolute top-0 right-0 h-full z-50">
<ThreadDrawer
isOpen={isDrawerOpen}
onClose={handleCloseDrawer}
title={drawerTitle}
id={drawerId}
/>
</div>
)}
</div>
{/* RIGHT PANEL: 17rem - Always shows Activity (bb-ui2.29) */}
<RightPanel isOpen={panel === 'open'}>
{renderRightPanel()}
{/* RIGHT PANEL: Dynamic Content + Optional Rail */}
<RightPanel isOpen={panel === 'open'} rail={rightPanelRail}>
{rightPanelMain}
</RightPanel>
</div>
{/* MOBILE NAV: Bottom tab bar */}
<MobileNav />
</div>
);
}
}