diff --git a/packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts b/packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts new file mode 100644 index 00000000..bec7b538 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/ConsoleContext.test.ts @@ -0,0 +1,77 @@ +import { PassThrough } from 'node:stream'; +import React from 'react'; +import { render, Text } from 'ink'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AgentManager } from '@ai-devkit/agent-manager'; +import type { ConfigStore } from '@ai-devkit/channel-connector'; +import type { ChannelService } from '../../../services/channel/channel.service.js'; +import { + ConsoleProvider, + useConsoleAgentContext, +} from '../../../tui/console/state/ConsoleContext.js'; + +describe('ConsoleProvider subscriptions', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not rerender an agent-only consumer when only channel state changes', async () => { + vi.useFakeTimers(); + const manager = { + listAgents: vi.fn().mockResolvedValue([]), + } as unknown as AgentManager; + const channelService = { + getLiveBridges: vi.fn() + .mockResolvedValueOnce([]) + .mockResolvedValue([{ + channelName: 'telegram', + channelType: 'telegram', + agentName: 'agent-1', + bridgePid: 42, + }]), + } as unknown as ChannelService; + const configStore = { + getConfig: vi.fn().mockResolvedValue({ channels: {} }), + } as unknown as ConfigStore; + let agentConsumerRenders = 0; + let agentListLoading = true; + const AgentOnlyConsumer = () => { + const { agents, isLoading } = useConsoleAgentContext(); + agentConsumerRenders += 1; + agentListLoading = isLoading; + return React.createElement(Text, null, agents.length); + }; + const output = new PassThrough(); + const instance = render( + React.createElement( + ConsoleProvider, + { manager, inputFocused: false, channelService, configStore }, + React.createElement(AgentOnlyConsumer), + ), + { + stdout: output as unknown as NodeJS.WriteStream, + interactive: false, + patchConsole: false, + }, + ); + + await vi.waitFor(() => { + expect(manager.listAgents).toHaveBeenCalledOnce(); + expect(channelService.getLiveBridges).toHaveBeenCalledOnce(); + expect(configStore.getConfig).toHaveBeenCalledOnce(); + expect(agentListLoading).toBe(false); + }); + await vi.runAllTicks(); + const rendersAfterInitialLoad = agentConsumerRenders; + + await vi.advanceTimersByTimeAsync(3000); + await vi.waitFor(() => { + expect(channelService.getLiveBridges).toHaveBeenCalledTimes(2); + }); + await vi.runAllTicks(); + + expect(agentConsumerRenders).toBe(rendersAfterInitialLoad); + instance.unmount(); + await instance.waitUntilExit(); + }); +}); diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 46c7d1db..957fc95a 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -1,7 +1,11 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Box, useApp, useInput, type RenderOptions } from 'ink'; import type { AgentManager } from '@ai-devkit/agent-manager'; -import { ConsoleProvider, useConsoleContext } from './state/ConsoleContext.js'; +import { + ConsoleProvider, + useConsoleAgentContext, + useConsoleChannelContext, +} from './state/ConsoleContext.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useStartAgentPane } from './hooks/useStartAgentPane.js'; import { useRenameAgentPane } from './hooks/useRenameAgentPane.js'; @@ -110,11 +114,13 @@ const ConsoleAppShell: React.FC<{ lastUpdated, isLoading, refresh, + } = useConsoleAgentContext(); + const { channelStatuses, configuredChannels, refreshConfiguredChannels, refreshChannels, - } = useConsoleContext(); + } = useConsoleChannelContext(); const agentsRef = useRef(agents); agentsRef.current = agents; diff --git a/packages/cli/src/tui/console/HeaderBar.tsx b/packages/cli/src/tui/console/HeaderBar.tsx index a81ff6fb..cb998cf1 100644 --- a/packages/cli/src/tui/console/HeaderBar.tsx +++ b/packages/cli/src/tui/console/HeaderBar.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Box, Text } from 'ink'; -import { useConsoleContext } from './state/ConsoleContext.js'; +import { useConsoleAgentContext } from './state/ConsoleContext.js'; import { TUI_COLORS } from '../design-system/index.js'; const HeaderBarInner: React.FC = () => { - const { agents, isLoading } = useConsoleContext(); + const { agents, isLoading } = useConsoleAgentContext(); const totalLabel = isLoading && agents.length === 0 ? 'scanning…' : `${agents.length} agent${agents.length === 1 ? '' : 's'}`; return ( diff --git a/packages/cli/src/tui/console/PreviewSection.tsx b/packages/cli/src/tui/console/PreviewSection.tsx index c4dce264..425a66db 100644 --- a/packages/cli/src/tui/console/PreviewSection.tsx +++ b/packages/cli/src/tui/console/PreviewSection.tsx @@ -1,6 +1,9 @@ import React, { useMemo } from 'react'; import { PreviewPane } from './PreviewPane.js'; -import { useConsoleContext } from './state/ConsoleContext.js'; +import { + useConsoleAgentContext, + useConsoleChannelContext, +} from './state/ConsoleContext.js'; import { useAgentConversation } from './hooks/useAgentConversation.js'; import { Panel } from '../design-system/index.js'; import { getPreviewPanelTone } from './PreviewPane.js'; @@ -20,7 +23,8 @@ const PreviewSectionInner: React.FC = ({ scrollOffset = 0, onScrollOffsetClamp, }) => { - const { agents, manager, inputFocused, channelStatuses } = useConsoleContext(); + const { agents, manager, inputFocused } = useConsoleAgentContext(); + const { channelStatuses } = useConsoleChannelContext(); const selectedAgent = useMemo( () => agents.find(a => a.name === selectedName) ?? null, [agents, selectedName], diff --git a/packages/cli/src/tui/console/state/ConsoleContext.tsx b/packages/cli/src/tui/console/state/ConsoleContext.tsx index c826875f..350af853 100644 --- a/packages/cli/src/tui/console/state/ConsoleContext.tsx +++ b/packages/cli/src/tui/console/state/ConsoleContext.tsx @@ -12,19 +12,37 @@ import { export { buildAgentChannelStatuses, buildConfiguredChannels }; -interface ConsoleContextValue extends UseAgentListResult, UseChannelStateResult { +interface ConsoleAgentContextValue extends UseAgentListResult { manager: AgentManager; inputFocused: boolean; } -const ConsoleContext = createContext(null); +type ConsoleChannelContextValue = UseChannelStateResult; -export const useConsoleContext = (): ConsoleContextValue => { - const ctx = useContext(ConsoleContext); - if (!ctx) throw new Error('useConsoleContext must be used inside '); +const ConsoleAgentContext = createContext(null); +const ConsoleChannelContext = createContext(null); + +export const useConsoleAgentContext = (): ConsoleAgentContextValue => { + const ctx = useContext(ConsoleAgentContext); + if (!ctx) throw new Error('useConsoleAgentContext must be used inside '); + return ctx; +}; + +export const useConsoleChannelContext = (): ConsoleChannelContextValue => { + const ctx = useContext(ConsoleChannelContext); + if (!ctx) throw new Error('useConsoleChannelContext must be used inside '); return ctx; }; +export const useConsoleContext = (): ConsoleAgentContextValue & ConsoleChannelContextValue => { + const agentContext = useConsoleAgentContext(); + const channelContext = useConsoleChannelContext(); + return useMemo( + () => ({ ...agentContext, ...channelContext }), + [agentContext, channelContext], + ); +}; + interface ConsoleProviderProps { manager: AgentManager; inputFocused: boolean; @@ -45,14 +63,45 @@ export const ConsoleProvider: React.FC = ({ const list = useAgentList(manager, undefined, inputFocused); const channelState = useChannelState(channelService, configStore, undefined, inputFocused); - const value = useMemo( + const agentValue = useMemo( () => ({ - ...list, - ...channelState, + agents: list.agents, + error: list.error, + lastUpdated: list.lastUpdated, + isLoading: list.isLoading, + refresh: list.refresh, manager, inputFocused, }), - [list, channelState, manager, inputFocused], + [ + list.agents, + list.error, + list.lastUpdated, + list.isLoading, + list.refresh, + manager, + inputFocused, + ], + ); + const channelValue = useMemo( + () => ({ + channelStatuses: channelState.channelStatuses, + refreshChannels: channelState.refreshChannels, + configuredChannels: channelState.configuredChannels, + refreshConfiguredChannels: channelState.refreshConfiguredChannels, + }), + [ + channelState.channelStatuses, + channelState.refreshChannels, + channelState.configuredChannels, + channelState.refreshConfiguredChannels, + ], + ); + return ( + + + {children} + + ); - return {children}; };