From a430a9dbf50d882c3944fdafaafaccbcf69e4ed3 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 28 Jul 2025 19:09:53 +0000 Subject: [PATCH 1/7] Implement AI-controlled panel with tabs and zones feature --- __tests__/panel-action-service.test.ts | 135 ++++++ __tests__/panel-state.test.tsx | 226 +++++++++ app/panel-demo/page.tsx | 30 ++ components/panel/chat-with-panel.tsx | 216 +++++++++ components/panel/panel-component-renderer.tsx | 51 ++ components/panel/panel-with-tabs-zones.tsx | 114 +++++ components/panel/panel-zone.tsx | 37 ++ .../guidelines/panel-tabs-zones-guidelines.md | 148 ++++++ docs/panel-with-tabs-zones.md | 243 ++++++++++ .../requirements/panel-zones-tabs_Version2.md | 141 ++++++ .../technical-panel-zones-tabs.md | 133 ++++++ hooks/use-panel-state.tsx | 450 ++++++++++++++++++ services/ai-service-panel-extension.ts | 90 ++++ services/panel-action-service.ts | 174 +++++++ types/panel.ts | 165 +++++++ 15 files changed, 2353 insertions(+) create mode 100644 __tests__/panel-action-service.test.ts create mode 100644 __tests__/panel-state.test.tsx create mode 100644 app/panel-demo/page.tsx create mode 100644 components/panel/chat-with-panel.tsx create mode 100644 components/panel/panel-component-renderer.tsx create mode 100644 components/panel/panel-with-tabs-zones.tsx create mode 100644 components/panel/panel-zone.tsx create mode 100644 docs/guidelines/panel-tabs-zones-guidelines.md create mode 100644 docs/panel-with-tabs-zones.md create mode 100644 docs/requirements/panel-zones-tabs_Version2.md create mode 100644 docs/requirements/technical-panel-zones-tabs.md create mode 100644 hooks/use-panel-state.tsx create mode 100644 services/ai-service-panel-extension.ts create mode 100644 services/panel-action-service.ts create mode 100644 types/panel.ts diff --git a/__tests__/panel-action-service.test.ts b/__tests__/panel-action-service.test.ts new file mode 100644 index 0000000..92729d6 --- /dev/null +++ b/__tests__/panel-action-service.test.ts @@ -0,0 +1,135 @@ +import { parsePanelActions } from '@/services/panel-action-service'; + +describe('Panel Action Service', () => { + test('should parse direct panel actions', () => { + const action = { + action: 'addTab', + tab: { + id: 'tab1', + title: 'Test Tab', + zones: [] + } + }; + + const result = parsePanelActions(action); + expect(result).toHaveLength(1); + expect(result?.[0]).toEqual(action); + }); + + test('should parse panel actions in tool/payload structure', () => { + const message = { + tool: 'panelAction', + payload: { + action: 'switchTab', + tabId: 'tab1' + } + }; + + const result = parsePanelActions(message); + expect(result).toHaveLength(1); + expect(result?.[0]).toEqual(message.payload); + }); + + test('should parse multiple panel actions in payload array', () => { + const message = { + tool: 'panelAction', + payload: [ + { + action: 'addTab', + tab: { + id: 'tab1', + title: 'Test Tab', + zones: [] + } + }, + { + action: 'addZone', + tabId: 'tab1', + zone: { + id: 'zone1', + components: [] + } + } + ] + }; + + const result = parsePanelActions(message); + expect(result).toHaveLength(2); + expect(result?.[0]).toEqual(message.payload[0]); + expect(result?.[1]).toEqual(message.payload[1]); + }); + + test('should parse panel actions in content structure', () => { + const message = { + content: { + tool: 'panelAction', + payload: { + action: 'removeComponent', + tabId: 'tab1', + zoneId: 'zone1', + componentId: 'comp1' + } + } + }; + + const result = parsePanelActions(message); + expect(result).toHaveLength(1); + expect(result?.[0]).toEqual(message.content.payload); + }); + + test('should parse panel actions from JSON string', () => { + const jsonString = JSON.stringify({ + action: 'updateComponent', + tabId: 'tab1', + zoneId: 'zone1', + componentId: 'comp1', + props: { title: 'Updated Title' } + }); + + const result = parsePanelActions(jsonString); + expect(result).toHaveLength(1); + expect(result?.[0].action).toBe('updateComponent'); + }); + + test('should return null for non-panel actions', () => { + const message = { + type: 'text', + content: 'This is a regular message' + }; + + const result = parsePanelActions(message); + expect(result).toBeNull(); + }); + + test('should validate required fields for different action types', () => { + // Valid action + const validAction = { + action: 'addComponent', + tabId: 'tab1', + zoneId: 'zone1', + component: { + id: 'comp1', + type: 'chart', + props: { data: [1, 2, 3] } + } + }; + + // Missing required field + const invalidAction = { + action: 'addComponent', + tabId: 'tab1', + // Missing zoneId + component: { + id: 'comp1', + type: 'chart', + props: { data: [1, 2, 3] } + } + }; + + const validResult = parsePanelActions(validAction); + expect(validResult).toHaveLength(1); + + const invalidResult = parsePanelActions(invalidAction); + expect(invalidResult).toBeNull(); + }); +}); \ No newline at end of file diff --git a/__tests__/panel-state.test.tsx b/__tests__/panel-state.test.tsx new file mode 100644 index 0000000..72c734e --- /dev/null +++ b/__tests__/panel-state.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PanelStateProvider, usePanelState, usePanelActions } from '@/hooks/use-panel-state'; +import { PanelTab, PanelZone, PanelComponent } from '@/types/panel'; + +// Test component to interact with panel state +function TestPanelComponent() { + const { state } = usePanelState(); + const { + addTab, + removeTab, + renameTab, + switchTab, + addZone, + removeZone, + addComponent, + removeComponent, + updateComponent, + undo, + redo + } = usePanelActions(); + + // Helper function to create a test tab + const createTestTab = () => { + const tab: PanelTab = { + id: 'test-tab', + title: 'Test Tab', + zones: [] + }; + addTab(tab); + }; + + // Helper function to create a test zone + const createTestZone = () => { + if (state.tabs.length === 0) { + createTestTab(); + } + + const zone: PanelZone = { + id: 'test-zone', + components: [] + }; + addZone('test-tab', zone); + }; + + // Helper function to create a test component + const createTestComponent = () => { + if (state.tabs.length === 0 || state.tabs[0].zones.length === 0) { + createTestZone(); + } + + const component: PanelComponent = { + id: 'test-component', + type: 'test', + props: { text: 'Test Component' } + }; + addComponent('test-tab', 'test-zone', component); + }; + + return ( +
+
+ {JSON.stringify(state)} +
+ + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +describe('Panel State Management', () => { + test('should add and remove tabs', () => { + render( + + + + ); + + // Initial state should have no tabs + expect(JSON.parse(screen.getByTestId('panel-state').textContent || '{}')).toHaveProperty('tabs', []); + + // Add a tab + fireEvent.click(screen.getByTestId('add-tab')); + const stateWithTab = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithTab.tabs).toHaveLength(1); + expect(stateWithTab.tabs[0].id).toBe('test-tab'); + expect(stateWithTab.tabs[0].title).toBe('Test Tab'); + + // Remove the tab + fireEvent.click(screen.getByTestId('remove-tab')); + expect(JSON.parse(screen.getByTestId('panel-state').textContent || '{}')).toHaveProperty('tabs', []); + }); + + test('should rename tabs', () => { + render( + + + + ); + + // Add a tab + fireEvent.click(screen.getByTestId('add-tab')); + + // Rename the tab + fireEvent.click(screen.getByTestId('rename-tab')); + const stateWithRenamedTab = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithRenamedTab.tabs[0].title).toBe('Renamed Tab'); + }); + + test('should add and remove zones', () => { + render( + + + + ); + + // Add a tab and zone + fireEvent.click(screen.getByTestId('add-zone')); + + // Check that the zone was added + const stateWithZone = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithZone.tabs[0].zones).toHaveLength(1); + expect(stateWithZone.tabs[0].zones[0].id).toBe('test-zone'); + + // Remove the zone + fireEvent.click(screen.getByTestId('remove-zone')); + const stateWithoutZone = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithoutZone.tabs[0].zones).toHaveLength(0); + }); + + test('should add, update, and remove components', () => { + render( + + + + ); + + // Add a tab, zone, and component + fireEvent.click(screen.getByTestId('add-component')); + + // Check that the component was added + const stateWithComponent = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithComponent.tabs[0].zones[0].components).toHaveLength(1); + expect(stateWithComponent.tabs[0].zones[0].components[0].id).toBe('test-component'); + expect(stateWithComponent.tabs[0].zones[0].components[0].props.text).toBe('Test Component'); + + // Update the component + fireEvent.click(screen.getByTestId('update-component')); + const stateWithUpdatedComponent = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithUpdatedComponent.tabs[0].zones[0].components[0].props.text).toBe('Updated Component'); + + // Remove the component + fireEvent.click(screen.getByTestId('remove-component')); + const stateWithoutComponent = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithoutComponent.tabs[0].zones[0].components).toHaveLength(0); + }); + + test('should support undo and redo', () => { + render( + + + + ); + + // Add a tab + fireEvent.click(screen.getByTestId('add-tab')); + const stateWithTab = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + + // Remove the tab + fireEvent.click(screen.getByTestId('remove-tab')); + const stateWithoutTab = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateWithoutTab.tabs).toHaveLength(0); + + // Undo the removal + fireEvent.click(screen.getByTestId('undo')); + const stateAfterUndo = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateAfterUndo.tabs).toHaveLength(1); + + // Redo the removal + fireEvent.click(screen.getByTestId('redo')); + const stateAfterRedo = JSON.parse(screen.getByTestId('panel-state').textContent || '{}'); + expect(stateAfterRedo.tabs).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/app/panel-demo/page.tsx b/app/panel-demo/page.tsx new file mode 100644 index 0000000..37123dc --- /dev/null +++ b/app/panel-demo/page.tsx @@ -0,0 +1,30 @@ +"use client" + +import React from 'react'; +import { ChatWithPanel } from '@/components/panel/chat-with-panel'; + +/** + * PanelDemoPage - Demo page for the AI-controlled panel with tabs and zones + */ +export default function PanelDemoPage() { + return ( +
+
+

AI-Controlled Panel with Tabs and Zones Demo

+

+ Try asking the AI to create tabs, zones, and components in the panel. +

+
+ +
+ +
+ +
+

+ Try commands like: "Create a tab named Dashboard" or "Add a component to the panel" +

+
+
+ ); +} \ No newline at end of file diff --git a/components/panel/chat-with-panel.tsx b/components/panel/chat-with-panel.tsx new file mode 100644 index 0000000..f32ebcd --- /dev/null +++ b/components/panel/chat-with-panel.tsx @@ -0,0 +1,216 @@ +"use client" + +import React, { useRef, useState, useEffect } from 'react'; +import { ChatTab } from '@/components/demo/chat-tab'; +import { PanelWithTabsZones } from './panel-with-tabs-zones'; +import { PanelStateProvider, usePanelState, usePanelActions } from '@/hooks/use-panel-state'; +import { parseAIResponseWithPanelSupport } from '@/services/ai-service-panel-extension'; +import { PanelActionType } from '@/types/panel'; +import { Button } from '@/components/ui/button'; +import { PanelLeft, PanelRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface Message { + type: 'user' | 'assistant' | 'component' | 'event'; + content: any; +} + +interface ChatWithPanelProps { + initialMessages?: Message[]; + className?: string; +} + +/** + * ChatWithPanelContent - Inner component with access to panel state + */ +function ChatWithPanelContent({ initialMessages = [], className }: ChatWithPanelProps) { + const [messages, setMessages] = useState(initialMessages); + const [input, setInput] = useState(''); + const [isPanelVisible, setIsPanelVisible] = useState(true); + const messagesEndRef = useRef(null); + const { executeAction } = usePanelState(); + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Handle sending a message + const handleSendMessage = async (e: React.FormEvent, modelKey: string) => { + e.preventDefault(); + if (!input.trim()) return; + + // Add user message + const userMessage: Message = { type: 'user', content: input }; + setMessages((prev) => [...prev, userMessage]); + setInput(''); + + // Simulate AI response (in a real app, this would call your API) + setTimeout(() => { + // For demo purposes, check if the message contains panel-related keywords + const lowerInput = input.toLowerCase(); + + if (lowerInput.includes('panel') || lowerInput.includes('tab') || lowerInput.includes('zone')) { + // Simulate a panel action response + handlePanelActionDemo(lowerInput); + } else { + // Regular text response + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I'm a simulated assistant. In a real implementation, I would connect to your backend API and could control the panel with tabs and zones." + } + ]); + } + }, 1000); + }; + + // Demo function to simulate panel actions based on user input + const handlePanelActionDemo = (input: string) => { + const lowerInput = input.toLowerCase(); + + // Create a demo tab + if (lowerInput.includes('create tab') || lowerInput.includes('add tab')) { + const tabId = `tab-${Date.now()}`; + const tabName = lowerInput.includes('named') + ? input.split('named')[1].trim().split(' ')[0] + : `Tab ${Math.floor(Math.random() * 100)}`; + + const action: PanelActionType = { + action: 'addTab', + tab: { + id: tabId, + title: tabName, + zones: [] + } + }; + + // Add a demo zone to the tab + setTimeout(() => { + const zoneAction: PanelActionType = { + action: 'addZone', + tabId, + zone: { + id: `zone-${Date.now()}`, + components: [] + } + }; + + executeAction(zoneAction); + + // Add a demo component to the zone + setTimeout(() => { + const componentAction: PanelActionType = { + action: 'addComponent', + tabId, + zoneId: zoneAction.zone.id, + component: { + id: `component-${Date.now()}`, + type: 'metricCard', + props: { + title: 'Demo Metric', + value: '42', + description: 'This is a demo component added to the panel', + trend: { + value: 12.5, + isPositive: true + }, + icon: 'trending-up' + } + } + }; + + executeAction(componentAction); + }, 500); + }, 500); + + // Execute the action and add a message + executeAction(action); + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: `I've created a new tab named "${tabName}" in the panel with a sample component.` + } + ]); + } + // Switch tab + else if (lowerInput.includes('switch tab') || lowerInput.includes('change tab')) { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I would switch to another tab if one was specified and existed." + } + ]); + } + // Add component + else if (lowerInput.includes('add component') || lowerInput.includes('create component')) { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I would add a component to a specified tab and zone if they existed." + } + ]); + } + // General panel info + else { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "The panel with tabs and zones can be controlled by AI actions. Try asking me to 'create a tab named Dashboard' to see a demo." + } + ]); + } + }; + + return ( +
+
+ +
+ +
+ +
+ +
+ {isPanelVisible && } +
+
+ ); +} + +/** + * ChatWithPanel - Main component that wraps the content with PanelStateProvider + */ +export function ChatWithPanel(props: ChatWithPanelProps) { + return ( + + + + ); +} \ No newline at end of file diff --git a/components/panel/panel-component-renderer.tsx b/components/panel/panel-component-renderer.tsx new file mode 100644 index 0000000..7e9a33b --- /dev/null +++ b/components/panel/panel-component-renderer.tsx @@ -0,0 +1,51 @@ +"use client" + +import React from 'react'; +import { AgentRenderer } from '@/components/agent-renderer'; +import { PanelComponent } from '@/types/panel'; +import { usePanelActions } from '@/hooks/use-panel-state'; +import { Button } from '@/components/ui/button'; +import { X } from 'lucide-react'; + +interface PanelComponentRendererProps { + tabId: string; + zoneId: string; + component: PanelComponent; +} + +/** + * PanelComponentRenderer - Renders a component within a zone + * Uses the AgentRenderer to render the actual component + */ +export function PanelComponentRenderer({ tabId, zoneId, component }: PanelComponentRendererProps) { + const { removeComponent } = usePanelActions(); + + const handleRemove = () => { + removeComponent(tabId, zoneId, component.id); + }; + + return ( +
+
+ +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/components/panel/panel-with-tabs-zones.tsx b/components/panel/panel-with-tabs-zones.tsx new file mode 100644 index 0000000..415bd92 --- /dev/null +++ b/components/panel/panel-with-tabs-zones.tsx @@ -0,0 +1,114 @@ +"use client" + +import React from 'react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ChevronLeft, ChevronRight, Undo, Redo } from 'lucide-react'; +import { usePanelState, usePanelActions } from '@/hooks/use-panel-state'; +import { PanelZoneComponent } from './panel-zone'; +import { cn } from '@/lib/utils'; + +interface PanelWithTabsZonesProps { + className?: string; +} + +/** + * PanelWithTabsZones - Main component for the AI-controlled panel with tabs and zones + */ +export function PanelWithTabsZones({ className }: PanelWithTabsZonesProps) { + const { state } = usePanelState(); + const { switchTab, undo, redo } = usePanelActions(); + + // If there are no tabs, show an empty state + if (state.tabs.length === 0) { + return ( + + + Panel + + +
+

No panel content available.

+

The AI can add content to this panel.

+
+
+
+ ); + } + + // Handle tab change + const handleTabChange = (tabId: string) => { + switchTab(tabId); + }; + + return ( + + + Panel +
+ + +
+
+ + + +
+ + {state.tabs.map((tab) => ( + + {tab.title} + + ))} + +
+ + {state.tabs.map((tab) => ( + + {tab.zones.length === 0 ? ( +
+

No zones in this tab.

+
+ ) : ( +
+ {tab.zones.map((zone) => ( + + ))} +
+ )} +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/panel/panel-zone.tsx b/components/panel/panel-zone.tsx new file mode 100644 index 0000000..0430343 --- /dev/null +++ b/components/panel/panel-zone.tsx @@ -0,0 +1,37 @@ +"use client" + +import React from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { PanelZone } from '@/types/panel'; +import { PanelComponentRenderer } from './panel-component-renderer'; + +interface PanelZoneComponentProps { + tabId: string; + zone: PanelZone; +} + +/** + * PanelZoneComponent - Renders a zone (row) within a tab + */ +export function PanelZoneComponent({ tabId, zone }: PanelZoneComponentProps) { + return ( + + + {zone.components.length === 0 ? ( +
+

Empty zone

+
+ ) : ( + zone.components.map((component) => ( + + )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/docs/guidelines/panel-tabs-zones-guidelines.md b/docs/guidelines/panel-tabs-zones-guidelines.md new file mode 100644 index 0000000..164095c --- /dev/null +++ b/docs/guidelines/panel-tabs-zones-guidelines.md @@ -0,0 +1,148 @@ +# Guidelines: Panel with Tabs & Zones Feature + +This document provides best practices and guidelines to follow during the development of the AI-controllable panel with tabs and zones in AgenticGenUI. + +--- + +## 1. Code Quality & Maintainability + +- **Type Safety:** + Use TypeScript for all new files and ensure strong typing across the data model (tabs, zones, components). + +- **Component Decomposition:** + Break UI into small, reusable components: + - Panel container + - Tabs bar + - Tab content + - Zone row + - Component renderer + +- **State Management:** + - Use React Context or a state management solution (Zustand, Redux, etc.) for global panel state. + - Keep state updates as pure functions (reducers) for easy testing and undo/redo functionality. + +- **Naming:** + - Use descriptive and consistent naming (e.g., `PanelTab`, `PanelZone`, `PanelComponent`). + - Avoid ambiguous abbreviations. + +- **Documentation:** + - Comment code where logic is non-obvious. + - Document public APIs and props for all components. + +--- + +## 2. Frontend Design & Responsiveness + +- **Layout:** + - Use Flexbox or CSS Grid for layout of tabs, zones, and components. + - Panel should be resizable/collapsible, especially on desktop. + - On mobile, panel should slide in/out or appear as a modal or drawer. + +- **Responsiveness:** + - Use media queries or utility CSS (e.g., Tailwind, if available) to adjust panel width, tab bar, and content for various screen sizes. + - Ensure the main chat area and panel can stack vertically on smaller devices. + - Tabs should become a dropdown or scrollable bar on mobile if there are too many to fit. + +- **Accessibility:** + - Use semantic HTML (e.g., `