Skip to content
Merged
18 changes: 18 additions & 0 deletions ui/src/components/monitor/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
//
// SPDX-License-Identifier: MPL-2.0

import React from 'react';

import {
ConnectionStatusContainer,
ConnectionStatusDot,
} from '@/components/monitor/MonitorView.styles';

// Memoized ConnectionStatus component
export const ConnectionStatus = React.memo(({ connected }: { connected: boolean }) => (
<ConnectionStatusContainer connected={connected}>
<ConnectionStatusDot connected={connected} />
{connected ? 'Connected' : 'Disconnected'}
</ConnectionStatusContainer>
));
165 changes: 165 additions & 0 deletions ui/src/components/monitor/LeftPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
//
// SPDX-License-Identifier: MPL-2.0

/**
* Left sidebar panel for the Monitor View.
*
* Contains the session list (with search) and, in staging mode,
* a "Nodes Library" tab for drag-and-drop node insertion.
*/

import React, { useState, useEffect } from 'react';

import {
LeftPanelAside,
SessionsContainer,
SessionSearchInput,
SearchWrapper,
SessionListWrapper,
LoadingText,
SessionList,
EmptyStateText,
NodesLibraryContainer,
} from '@/components/monitor/MonitorView.styles';
import { SessionItem } from '@/components/monitor/SessionItem';
import NodePalette from '@/components/NodePalette';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@/components/ui/Tabs';
import type { NodeDefinition } from '@/types/types';

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface LeftPanelProps {
isLoadingSessions: boolean;
sessions: { id: string; name: string | null; created_at: string }[];
selectedSessionId: string | null;
onSessionClick: (id: string) => void;
onSessionDelete: (id: string) => void;
editMode: boolean;
nodeDefinitions: NodeDefinition[];
onDragStart: (event: React.DragEvent, nodeType: string) => void;
pluginKinds: Set<string>;
pluginTypes: Map<string, 'wasm' | 'native'>;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export const LeftPanel = React.memo(
({
isLoadingSessions,
sessions,
selectedSessionId,
onSessionClick,
onSessionDelete,
editMode,
nodeDefinitions,
onDragStart,
pluginKinds,
pluginTypes,
}: LeftPanelProps) => {
const [activeTab, setActiveTab] = useState<'sessions' | 'add'>('sessions');
const [searchQuery, setSearchQuery] = useState('');

useEffect(() => {
if (!editMode && activeTab === 'add') {
setActiveTab('sessions');
}
}, [editMode, activeTab]);

const filteredSessions = React.useMemo(() => {
if (!searchQuery.trim()) {
return sessions;
}
const query = searchQuery.toLowerCase();
return sessions.filter(
(session) =>
session.id.toLowerCase().includes(query) ||
(session.name && session.name.toLowerCase().includes(query))
);
}, [sessions, searchQuery]);

return (
<LeftPanelAside>
<TabsRoot
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'sessions' | 'add')}
>
<TabsList>
<TabsTrigger value="sessions">Sessions</TabsTrigger>
{editMode && (
<TabsTrigger value="add" disabled={!selectedSessionId}>
Nodes Library
</TabsTrigger>
)}
</TabsList>

<TabsContent value="sessions">
<SessionsContainer data-testid="sessions-list">
{isLoadingSessions ? (
<LoadingText>Loading sessions...</LoadingText>
) : sessions.length === 0 ? (
<EmptyStateText>No active sessions</EmptyStateText>
) : (
<>
{sessions.length >= 5 && (
<SearchWrapper>
<SessionSearchInput
type="text"
placeholder="Search sessions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</SearchWrapper>
)}
Comment on lines +108 to +117
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale search filter hides sessions when session count drops below threshold

The search input is only rendered when sessions.length >= 5 (line 108), but searchQuery state persists even after the input is hidden. If the session count drops from ≥5 to <5 while a non-empty search query is active, the filteredSessions memo at lines 73-83 continues to filter by the stale query. This causes the session list to show "No matching sessions" with no visible search input to clear the filter, making existing sessions invisible to the user.

Suggested change
{sessions.length >= 5 && (
<SearchWrapper>
<SessionSearchInput
type="text"
placeholder="Search sessions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</SearchWrapper>
)}
{sessions.length >= 5 ? (
<SearchWrapper>
<SessionSearchInput
type="text"
placeholder="Search sessions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</SearchWrapper>
) : (
searchQuery && setSearchQuery('') || null
)}
Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

<SessionListWrapper>
{filteredSessions.length === 0 ? (
<EmptyStateText>No matching sessions</EmptyStateText>
) : (
<SessionList>
{filteredSessions.map((session) => (
<li key={session.id}>
<SessionItem
session={session}
isActive={selectedSessionId === session.id}
onClick={onSessionClick}
onDelete={onSessionDelete}
/>
</li>
))}
</SessionList>
)}
</SessionListWrapper>
</>
)}
</SessionsContainer>
</TabsContent>

<TabsContent value="add">
{editMode && (
<NodesLibraryContainer>
{selectedSessionId ? (
nodeDefinitions.length === 0 ? (
<EmptyStateText>Loading node definitions…</EmptyStateText>
) : (
<NodePalette
nodeDefinitions={nodeDefinitions}
onDragStart={onDragStart}
pluginKinds={pluginKinds}
pluginTypes={pluginTypes}
/>
)
) : (
<EmptyStateText>Select a session to add nodes</EmptyStateText>
)}
</NodesLibraryContainer>
)}
</TabsContent>
</TabsRoot>
</LeftPanelAside>
);
}
);
47 changes: 47 additions & 0 deletions ui/src/components/monitor/Legend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
//
// SPDX-License-Identifier: MPL-2.0

/**
* Node-state legend overlay for the Monitor View canvas.
* Memoized to prevent re-renders during node drag.
*/

import React from 'react';

import {
LegendContainer,
LegendTitle,
LegendItem,
LegendDot,
} from '@/components/monitor/MonitorView.styles';

export const Legend = React.memo(() => (
<LegendContainer>
<LegendTitle>Node States</LegendTitle>
<LegendItem>
<LegendDot color="var(--sk-status-initializing)" />
<span>Initializing</span>
</LegendItem>
<LegendItem>
<LegendDot color="var(--sk-status-running)" />
<span>Running</span>
</LegendItem>
<LegendItem>
<LegendDot color="var(--sk-status-recovering)" />
<span>Recovering</span>
</LegendItem>
<LegendItem>
<LegendDot color="var(--sk-status-degraded)" />
<span>Degraded</span>
</LegendItem>
<LegendItem>
<LegendDot color="var(--sk-status-failed)" />
<span>Failed</span>
</LegendItem>
<LegendItem>
<LegendDot color="var(--sk-status-stopped)" />
<span>Stopped</span>
</LegendItem>
</LegendContainer>
));
Loading