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..ab96907 --- /dev/null +++ b/app/panel-demo/page.tsx @@ -0,0 +1,37 @@ +"use client" + +import React, { useEffect } from 'react'; +import { ChatWithPanel } from '@/components/panel/chat-with-panel'; +import { registerAllAgentComponents } from '@/agentic-ui/registry'; + +/** + * PanelDemoPage - Demo page for the AI-controlled panel with tabs and zones + */ +export default function PanelDemoPage() { + // Register all components when the page loads + useEffect(() => { + registerAllAgentComponents(); + console.log("Registered all agent components for panel demo"); + }, []); + + return ( +
+
+

AI-Controlled Panel with Tabs and Zones Demo

+

+ Try asking the AI to create tabs, zones, and components in 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..febdaca --- /dev/null +++ b/components/panel/chat-with-panel.tsx @@ -0,0 +1,674 @@ +"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 { state, 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(() => { + // Debug logs + console.log('Current panel state:', state); + console.log('Processing message:', input); + + // For demo purposes, check if the message contains panel-related keywords + const lowerInput = input.toLowerCase(); + + // Check for panel-related keywords or specific commands + if (lowerInput.includes('panel') || + lowerInput.includes('tab') || + lowerInput.includes('zone') || + lowerInput.includes('component') || + lowerInput.includes('create') || + lowerInput.includes('add') || + lowerInput.includes('dashboard')) { + // Simulate a panel action response + handlePanelActionDemo(input); + } 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(); + + // Extract tab name if present + let tabName = ''; + if (lowerInput.includes('named')) { + const parts = input.split(/named\s+/i); + if (parts.length > 1) { + tabName = parts[1].trim().split(/\s+/)[0]; + } + } else if (lowerInput.includes('dashboard')) { + tabName = 'Dashboard'; + } else if (lowerInput.includes('analytics')) { + tabName = 'Analytics'; + } else if (lowerInput.includes('reports')) { + tabName = 'Reports'; + } else if (lowerInput.includes('create tab') || lowerInput.includes('add tab')) { + tabName = `Tab ${Math.floor(Math.random() * 100)}`; + } + + // CREATE TAB: Create a new tab + if ((lowerInput.includes('create') || lowerInput.includes('add')) && + (lowerInput.includes('tab') || lowerInput.includes('dashboard') || + lowerInput.includes('analytics') || lowerInput.includes('reports')) && + !lowerInput.includes('component') && !lowerInput.includes('zone')) { + + const tabId = `tab-${Date.now()}`; + + // If no tab name was extracted, use a default + if (!tabName) { + tabName = `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: 'markdownrenderer', + props: { + content: `# ${tabName} Tab + +## This is a demo component in the ${tabName} tab + +This component was added to demonstrate the panel functionality. + +- The panel supports multiple tabs +- Each tab can have multiple zones +- Each zone can have multiple components +- Components can be added, removed, and updated by AI actions` + } + } + }; + + 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: Switch to a different tab + else if (lowerInput.includes('switch') || lowerInput.includes('change')) { + // Find the tab to switch to + let targetTabId = null; + let targetTabName = ''; + + // Check if a specific tab name is mentioned + for (const tab of state.tabs) { + if (lowerInput.includes(tab.title.toLowerCase())) { + targetTabId = tab.id; + targetTabName = tab.title; + break; + } + } + + // If no specific tab is found but there are tabs, switch to the first one + if (!targetTabId && state.tabs.length > 0) { + targetTabId = state.tabs[0].id; + targetTabName = state.tabs[0].title; + } + + if (targetTabId) { + const action: PanelActionType = { + action: 'switchTab', + tabId: targetTabId + }; + + executeAction(action); + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: `I've switched to the "${targetTabName}" tab.` + } + ]); + } else { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I couldn't find any tabs to switch to. Please create a tab first." + } + ]); + } + } + + // REMOVE TAB: Delete a tab + else if ((lowerInput.includes('remove') || lowerInput.includes('delete')) && + lowerInput.includes('tab')) { + // Find the tab to remove + let targetTabId = null; + let targetTabName = ''; + + // Check if a specific tab name is mentioned + for (const tab of state.tabs) { + if (lowerInput.includes(tab.title.toLowerCase())) { + targetTabId = tab.id; + targetTabName = tab.title; + break; + } + } + + // If no specific tab is found but there are tabs, remove the active tab + if (!targetTabId && state.tabs.length > 0) { + if (state.activeTabId) { + const activeTab = state.tabs.find(tab => tab.id === state.activeTabId); + if (activeTab) { + targetTabId = activeTab.id; + targetTabName = activeTab.title; + } + } + + // If still no target, use the first tab + if (!targetTabId) { + targetTabId = state.tabs[0].id; + targetTabName = state.tabs[0].title; + } + } + + if (targetTabId) { + const action: PanelActionType = { + action: 'removeTab', + tabId: targetTabId + }; + + executeAction(action); + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: `I've removed the "${targetTabName}" tab.` + } + ]); + } else { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I couldn't find any tabs to remove. Please create a tab first." + } + ]); + } + } + + // ADD COMPONENT: Add a component to a tab + else if ((lowerInput.includes('add') || lowerInput.includes('create')) && + lowerInput.includes('component') && !lowerInput.includes('zone')) { + // Find the tab to add the component to + let targetTabId = null; + let targetTabName = ''; + let targetZoneId = null; + + // Check if a specific tab name is mentioned + for (const tab of state.tabs) { + if (lowerInput.includes(tab.title.toLowerCase())) { + targetTabId = tab.id; + targetTabName = tab.title; + + // Get the first zone in the tab + if (tab.zones.length > 0) { + targetZoneId = tab.zones[0].id; + } + break; + } + } + + // If no specific tab is found but there are tabs, use the active tab + if (!targetTabId && state.tabs.length > 0) { + if (state.activeTabId) { + const activeTab = state.tabs.find(tab => tab.id === state.activeTabId); + if (activeTab) { + targetTabId = activeTab.id; + targetTabName = activeTab.title; + + // Get the first zone in the tab + if (activeTab.zones.length > 0) { + targetZoneId = activeTab.zones[0].id; + } + } + } + + // If still no target, use the first tab + if (!targetTabId) { + const firstTab = state.tabs[0]; + targetTabId = firstTab.id; + targetTabName = firstTab.title; + + // Get the first zone in the tab + if (firstTab.zones.length > 0) { + targetZoneId = firstTab.zones[0].id; + } + } + } + + // If we have a tab but no zone, create a zone + if (targetTabId && !targetZoneId) { + const zoneId = `zone-${Date.now()}`; + const zoneAction: PanelActionType = { + action: 'addZone', + tabId: targetTabId, + zone: { + id: zoneId, + components: [] + } + }; + + executeAction(zoneAction); + targetZoneId = zoneId; + } + + if (targetTabId && targetZoneId) { + // Determine component type based on input + let componentType = 'markdownrenderer'; + let componentProps: any = { + content: `# New Component + +This is a new component added to the ${targetTabName} tab.` + }; + + // Check for specific component types in the input + if (lowerInput.includes('chart')) { + componentType = 'chart'; + componentProps = { + title: 'Sample Chart', + type: 'bar', + data: { + labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'], + datasets: [ + { + label: 'Sales', + data: [65, 59, 80, 81, 56], + backgroundColor: 'rgba(54, 162, 235, 0.5)' + } + ] + } + }; + } else if (lowerInput.includes('table')) { + componentType = 'datatable'; + componentProps = { + title: 'Sample Table', + data: [ + { id: 1, name: 'John Doe', email: 'john@example.com' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' }, + { id: 3, name: 'Bob Johnson', email: 'bob@example.com' } + ], + columns: [ + { header: 'ID', accessorKey: 'id' }, + { header: 'Name', accessorKey: 'name' }, + { header: 'Email', accessorKey: 'email' } + ] + }; + } + + const componentAction: PanelActionType = { + action: 'addComponent', + tabId: targetTabId, + zoneId: targetZoneId, + component: { + id: `component-${Date.now()}`, + type: componentType, + props: componentProps + } + }; + + console.log('Adding component with action:', componentAction); + executeAction(componentAction); + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: `I've added a new ${componentType} component to the "${targetTabName}" tab.` + } + ]); + } else { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I couldn't find any tabs to add a component to. Please create a tab first." + } + ]); + } + } + + // ADD ZONE: Add a new zone to a tab + else if ((lowerInput.includes('add') || lowerInput.includes('create')) && + lowerInput.includes('zone')) { + // Find the tab to add the zone to + let targetTabId = null; + let targetTabName = ''; + + // Check if a specific tab name is mentioned + for (const tab of state.tabs) { + if (lowerInput.includes(tab.title.toLowerCase())) { + targetTabId = tab.id; + targetTabName = tab.title; + break; + } + } + + // If no specific tab is found but there are tabs, use the active tab + if (!targetTabId && state.tabs.length > 0) { + if (state.activeTabId) { + const activeTab = state.tabs.find(tab => tab.id === state.activeTabId); + if (activeTab) { + targetTabId = activeTab.id; + targetTabName = activeTab.title; + } + } + + // If still no target, use the first tab + if (!targetTabId) { + targetTabId = state.tabs[0].id; + targetTabName = state.tabs[0].title; + } + } + + if (targetTabId) { + const zoneAction: PanelActionType = { + action: 'addZone', + tabId: targetTabId, + zone: { + id: `zone-${Date.now()}`, + components: [] + } + }; + + console.log('Adding zone with action:', zoneAction); + executeAction(zoneAction); + + // Add a demo component to the zone + setTimeout(() => { + const zoneId = zoneAction.zone.id; + const componentAction: PanelActionType = { + action: 'addComponent', + tabId: targetTabId!, + zoneId: zoneId, + component: { + id: `component-${Date.now()}`, + type: 'markdownrenderer', + props: { + content: `# New Zone + +This is a new zone added to the ${targetTabName} tab.` + } + } + }; + + executeAction(componentAction); + }, 500); + + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: `I've added a new zone to the "${targetTabName}" tab with a sample component.` + } + ]); + } else { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I couldn't find any tabs to add a zone to. Please create a tab first." + } + ]); + } + } + + // RENAME TAB: Rename a tab + else if (lowerInput.includes('rename') && lowerInput.includes('tab')) { + // Find the tab to rename + let targetTabId = null; + let oldTabName = ''; + let newTabName = ''; + + // Extract new name if present + if (lowerInput.includes('to')) { + const parts = input.split(/\s+to\s+/i); + if (parts.length > 1) { + newTabName = parts[1].trim().split(/\s+/)[0]; + } + } + + if (!newTabName) { + newTabName = `Tab ${Math.floor(Math.random() * 100)}`; + } + + // Check if a specific tab name is mentioned + for (const tab of state.tabs) { + if (lowerInput.includes(tab.title.toLowerCase())) { + targetTabId = tab.id; + oldTabName = tab.title; + break; + } + } + + // If no specific tab is found but there are tabs, use the active tab + if (!targetTabId && state.tabs.length > 0) { + if (state.activeTabId) { + const activeTab = state.tabs.find(tab => tab.id === state.activeTabId); + if (activeTab) { + targetTabId = activeTab.id; + oldTabName = activeTab.title; + } + } + + // If still no target, use the first tab + if (!targetTabId) { + targetTabId = state.tabs[0].id; + oldTabName = state.tabs[0].title; + } + } + + if (targetTabId) { + const action: PanelActionType = { + action: 'renameTab', + tabId: targetTabId, + title: newTabName + }; + + executeAction(action); + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: `I've renamed the "${oldTabName}" tab to "${newTabName}".` + } + ]); + } else { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I couldn't find any tabs to rename. Please create a tab first." + } + ]); + } + } + + // UNDO: Undo the last action + else if (lowerInput.includes('undo')) { + const action: PanelActionType = { + action: 'undo' + }; + + executeAction(action); + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I've undone the last panel action." + } + ]); + } + + // REDO: Redo the last undone action + else if (lowerInput.includes('redo')) { + const action: PanelActionType = { + action: 'redo' + }; + + executeAction(action); + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: "I've redone the last undone panel action." + } + ]); + } + + // General panel info + else { + setMessages((prev) => [ + ...prev, + { + type: 'assistant', + content: `The panel with tabs and zones can be controlled by AI actions. Try these commands: + +- "Create a tab named Dashboard" +- "Add a component to the Dashboard tab" +- "Add a zone to the Dashboard tab" +- "Switch to the Dashboard tab" +- "Rename the Dashboard tab to Analytics" +- "Remove the Analytics tab" +- "Undo the last action" +- "Redo the last undone action"` + } + ]); + } + }; + + 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., `