From 1fc9b2bc29661771bc64f7848c7baec0ecdab0f3 Mon Sep 17 00:00:00 2001 From: Davit Date: Thu, 16 Apr 2026 23:16:29 +0400 Subject: [PATCH] client refactoring --- client/src/app/ThemedApp.tsx | 17 - client/src/app/main.tsx | 11 +- client/src/app/providers/AppProviders.tsx | 27 ++ client/src/app/providers/index.ts | 1 + client/src/app/store/hooks.ts | 20 - client/src/app/store/index.ts | 7 - client/src/app/store/store.ts | 6 +- client/src/entities/agent/index.ts | 1 + client/src/entities/channel/index.ts | 4 + .../channel/ui}/AuthRow.tsx | 2 +- .../channel/ui}/ChatRow.tsx | 11 +- .../channel/ui}/providerEmoji.ts | 0 client/src/entities/conversation/index.ts | 2 + .../conversation/ui}/ConversationItem.tsx | 7 +- client/src/entities/cron/index.ts | 2 + .../cron => entities/cron/ui}/CronRow.tsx | 11 +- client/src/entities/message/index.ts | 5 + .../message/ui}/FileAttachments.tsx | 4 +- .../message/ui}/MessageBubble.tsx | 6 +- .../message/ui}/ThinkingBlock.tsx | 2 +- client/src/entities/plugin/index.ts | 2 + .../plugin/ui}/PluginRow.tsx | 2 +- client/src/entities/skill/index.ts | 2 + .../skills => entities/skill/ui}/SkillRow.tsx | 2 +- client/src/entities/update/index.ts | 1 + client/src/entities/user/index.ts | 2 + .../users => entities/user/ui}/UserRow.tsx | 12 +- client/src/features/agent/create/index.ts | 2 + .../agent/create/ui}/CreateAgentForm.tsx | 23 +- .../features/agent/setup-terminal/index.ts | 1 + .../setup-terminal/ui}/TerminalPanel.tsx | 4 +- client/src/features/auth/index.ts | 4 + client/src/features/channel/add/index.ts | 1 + .../channel/add/ui}/AddChannelForm.tsx | 4 +- client/src/features/chat/useChat.ts | 297 --------------- client/src/features/cron/add/index.ts | 1 + .../cron/add/ui}/AddCronForm.tsx | 6 +- client/src/features/message/send/index.ts | 2 + .../message/send/model/useSendMessage.ts | 154 ++++++++ client/src/features/theme/ThemePicker.tsx | 3 +- client/src/features/theme/index.ts | 3 + client/src/features/update/install/index.ts | 1 + .../update/install/ui}/UpdateBanner.tsx | 4 +- client/src/features/user/edit/index.ts | 1 + .../user/edit/ui}/UserForm.tsx | 11 +- client/src/pages/agent/WorkspacePage.tsx | 86 +---- client/src/pages/agent/index.tsx | 43 +-- client/src/pages/channels/index.tsx | 105 +----- client/src/pages/cron/index.tsx | 77 +--- client/src/pages/login/index.tsx | 2 +- client/src/pages/plugins/index.tsx | 78 +--- client/src/pages/skills/index.tsx | 98 +---- client/src/pages/user/index.tsx | 130 +------ client/src/shared/api/index.ts | 1 + client/src/shared/hooks/index.ts | 1 + .../src/shared/hooks/useValidationErrors.ts | 15 + client/src/shared/ui/index.ts | 3 + client/src/widgets/channels/index.tsx | 105 ++++++ client/src/widgets/chat/index.ts | 8 + client/src/widgets/chat/model/types.ts | 23 ++ client/src/widgets/chat/model/useChat.ts | 187 +++++++++ client/src/widgets/chat/ui/Chat.tsx | 46 +++ .../src/widgets/chat/{ => ui}/ChatHeader.tsx | 2 +- .../src/widgets/chat/{ => ui}/ChatInput.tsx | 0 .../src/widgets/chat/{ => ui}/MessageList.tsx | 6 +- .../chat/{ => ui}/SessionSettingsBar.tsx | 5 +- .../src/widgets/chat/{ => ui}/SettingChip.tsx | 0 client/src/widgets/cron/index.tsx | 77 ++++ client/src/widgets/plugins/index.tsx | 79 ++++ client/src/widgets/sidebar/index.tsx | 356 +----------------- .../widgets/sidebar/{ => ui}/AgentSection.tsx | 8 +- client/src/widgets/sidebar/ui/AgentsPanel.tsx | 195 ++++++++++ .../src/widgets/sidebar/ui/SidebarHeader.tsx | 33 ++ client/src/widgets/sidebar/ui/SidebarMenu.tsx | 89 +++++ .../src/widgets/sidebar/ui/SidebarSearch.tsx | 37 ++ .../sidebar/{ => ui}/SyncProgressBar.tsx | 0 client/src/widgets/skills/index.tsx | 109 ++++++ client/src/widgets/users/index.tsx | 134 +++++++ client/src/widgets/workspace/index.ts | 2 + client/src/widgets/workspace/ui/Workspace.tsx | 85 +++++ .../workspace/ui}/WorkspaceFileTabs.tsx | 4 +- 81 files changed, 1555 insertions(+), 1365 deletions(-) delete mode 100644 client/src/app/ThemedApp.tsx create mode 100644 client/src/app/providers/AppProviders.tsx create mode 100644 client/src/app/providers/index.ts create mode 100644 client/src/entities/agent/index.ts create mode 100644 client/src/entities/channel/index.ts rename client/src/{widgets/channels => entities/channel/ui}/AuthRow.tsx (96%) rename client/src/{widgets/channels => entities/channel/ui}/ChatRow.tsx (92%) rename client/src/{widgets/channels => entities/channel/ui}/providerEmoji.ts (100%) create mode 100644 client/src/entities/conversation/index.ts rename client/src/{widgets/sidebar => entities/conversation/ui}/ConversationItem.tsx (96%) create mode 100644 client/src/entities/cron/index.ts rename client/src/{widgets/cron => entities/cron/ui}/CronRow.tsx (96%) create mode 100644 client/src/entities/message/index.ts rename client/src/{widgets/chat => entities/message/ui}/FileAttachments.tsx (95%) rename client/src/{widgets/chat => entities/message/ui}/MessageBubble.tsx (94%) rename client/src/{widgets/chat => entities/message/ui}/ThinkingBlock.tsx (96%) create mode 100644 client/src/entities/plugin/index.ts rename client/src/{widgets/plugins => entities/plugin/ui}/PluginRow.tsx (97%) create mode 100644 client/src/entities/skill/index.ts rename client/src/{widgets/skills => entities/skill/ui}/SkillRow.tsx (98%) create mode 100644 client/src/entities/update/index.ts create mode 100644 client/src/entities/user/index.ts rename client/src/{widgets/users => entities/user/ui}/UserRow.tsx (94%) create mode 100644 client/src/features/agent/create/index.ts rename client/src/{widgets/sidebar => features/agent/create/ui}/CreateAgentForm.tsx (92%) create mode 100644 client/src/features/agent/setup-terminal/index.ts rename client/src/{widgets/sidebar => features/agent/setup-terminal/ui}/TerminalPanel.tsx (99%) create mode 100644 client/src/features/auth/index.ts create mode 100644 client/src/features/channel/add/index.ts rename client/src/{widgets/channels => features/channel/add/ui}/AddChannelForm.tsx (97%) delete mode 100644 client/src/features/chat/useChat.ts create mode 100644 client/src/features/cron/add/index.ts rename client/src/{widgets/cron => features/cron/add/ui}/AddCronForm.tsx (97%) create mode 100644 client/src/features/message/send/index.ts create mode 100644 client/src/features/message/send/model/useSendMessage.ts create mode 100644 client/src/features/theme/index.ts create mode 100644 client/src/features/update/install/index.ts rename client/src/{widgets/sidebar => features/update/install/ui}/UpdateBanner.tsx (97%) create mode 100644 client/src/features/user/edit/index.ts rename client/src/{widgets/users => features/user/edit/ui}/UserForm.tsx (97%) create mode 100644 client/src/shared/api/index.ts create mode 100644 client/src/shared/hooks/index.ts create mode 100644 client/src/shared/hooks/useValidationErrors.ts create mode 100644 client/src/shared/ui/index.ts create mode 100644 client/src/widgets/channels/index.tsx create mode 100644 client/src/widgets/chat/index.ts create mode 100644 client/src/widgets/chat/model/types.ts create mode 100644 client/src/widgets/chat/model/useChat.ts create mode 100644 client/src/widgets/chat/ui/Chat.tsx rename client/src/widgets/chat/{ => ui}/ChatHeader.tsx (99%) rename client/src/widgets/chat/{ => ui}/ChatInput.tsx (100%) rename client/src/widgets/chat/{ => ui}/MessageList.tsx (96%) rename client/src/widgets/chat/{ => ui}/SessionSettingsBar.tsx (96%) rename client/src/widgets/chat/{ => ui}/SettingChip.tsx (100%) create mode 100644 client/src/widgets/cron/index.tsx create mode 100644 client/src/widgets/plugins/index.tsx rename client/src/widgets/sidebar/{ => ui}/AgentSection.tsx (94%) create mode 100644 client/src/widgets/sidebar/ui/AgentsPanel.tsx create mode 100644 client/src/widgets/sidebar/ui/SidebarHeader.tsx create mode 100644 client/src/widgets/sidebar/ui/SidebarMenu.tsx create mode 100644 client/src/widgets/sidebar/ui/SidebarSearch.tsx rename client/src/widgets/sidebar/{ => ui}/SyncProgressBar.tsx (100%) create mode 100644 client/src/widgets/skills/index.tsx create mode 100644 client/src/widgets/users/index.tsx create mode 100644 client/src/widgets/workspace/index.ts create mode 100644 client/src/widgets/workspace/ui/Workspace.tsx rename client/src/{pages/agent => widgets/workspace/ui}/WorkspaceFileTabs.tsx (99%) diff --git a/client/src/app/ThemedApp.tsx b/client/src/app/ThemedApp.tsx deleted file mode 100644 index 4896667..0000000 --- a/client/src/app/ThemedApp.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { ThemeProvider, CssBaseline } from '@mui/material'; -import { BrowserRouter } from 'react-router'; -import { themes } from './theme'; -import { useAppSelector } from './store/hooks'; -import App from './App.tsx'; - -export default function ThemedApp() { - const themeId = useAppSelector((s) => s.theme.themeId); - return ( - - - - - - - ); -} diff --git a/client/src/app/main.tsx b/client/src/app/main.tsx index 4cc7a68..56b0ace 100644 --- a/client/src/app/main.tsx +++ b/client/src/app/main.tsx @@ -1,13 +1,12 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { Provider } from 'react-redux'; -import { store } from './store/store'; -import ThemedApp from './ThemedApp.tsx'; +import { AppProviders } from './providers'; +import App from './App.tsx'; createRoot(document.getElementById('root')!).render( - - - + + + ); diff --git a/client/src/app/providers/AppProviders.tsx b/client/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..38b15f4 --- /dev/null +++ b/client/src/app/providers/AppProviders.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { ThemeProvider, CssBaseline } from '@mui/material'; +import { BrowserRouter } from 'react-router'; +import { store } from '../store/store'; +import { themes } from '../theme'; +import { useAppSelector } from '../store/hooks'; + +function ThemeBridge({ children }: { children: ReactNode }) { + const themeId = useAppSelector((s) => s.theme.themeId); + return ( + + + {children} + + ); +} + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/client/src/app/providers/index.ts b/client/src/app/providers/index.ts new file mode 100644 index 0000000..a70a843 --- /dev/null +++ b/client/src/app/providers/index.ts @@ -0,0 +1 @@ +export { AppProviders } from './AppProviders'; diff --git a/client/src/app/store/hooks.ts b/client/src/app/store/hooks.ts index 33bd9d7..7988133 100644 --- a/client/src/app/store/hooks.ts +++ b/client/src/app/store/hooks.ts @@ -3,23 +3,3 @@ import type { AppDispatch, RootState } from './store'; export const useAppDispatch = useDispatch.withTypes(); export const useAppSelector = useSelector.withTypes(); - -/** - * Hook to extract validation errors from RTK Query error - * Returns typed field errors or null - */ -export function useValidationErrors(error: unknown): Record | null { - if ( - typeof error === 'object' && - error !== null && - 'status' in error && - typeof (error as { status: unknown }).status === 'number' && - 'data' in error && - typeof (error as { data: unknown }).data === 'object' && - (error as { data: unknown }).data !== null && - error.status === 422 - ) { - return (error as { data: Record }).data; - } - return null; -} diff --git a/client/src/app/store/index.ts b/client/src/app/store/index.ts index 9394960..392ab05 100644 --- a/client/src/app/store/index.ts +++ b/client/src/app/store/index.ts @@ -1,10 +1,3 @@ export { store } from './store'; export type { RootState, AppDispatch } from './store'; export { useAppDispatch, useAppSelector } from './hooks'; -export * from '../../features/auth/api'; -export * from '../../entities/user/api'; -export * from '../../entities/agent/api'; -export * from '../../entities/conversation/api'; -export * from '../../entities/message/api'; -export { logout } from '../../features/auth/slice'; -export { setTheme } from '../../features/theme/slice'; diff --git a/client/src/app/store/store.ts b/client/src/app/store/store.ts index 833e3da..bca11ea 100644 --- a/client/src/app/store/store.ts +++ b/client/src/app/store/store.ts @@ -1,8 +1,8 @@ import { configureStore } from '@reduxjs/toolkit'; import { createLogger } from 'redux-logger'; -import { baseApi } from '../../shared/api/baseApi'; -import authReducer from '../../features/auth/slice'; -import themeReducer from '../../features/theme/slice'; +import { baseApi } from '../../shared/api'; +import { authReducer } from '../../features/auth'; +import { themeReducer } from '../../features/theme'; export const store = configureStore({ reducer: { diff --git a/client/src/entities/agent/index.ts b/client/src/entities/agent/index.ts new file mode 100644 index 0000000..b1c13e7 --- /dev/null +++ b/client/src/entities/agent/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/client/src/entities/channel/index.ts b/client/src/entities/channel/index.ts new file mode 100644 index 0000000..edb5e53 --- /dev/null +++ b/client/src/entities/channel/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export { default as ChatRow } from './ui/ChatRow'; +export { default as AuthRow } from './ui/AuthRow'; +export { default as providerEmoji } from './ui/providerEmoji'; diff --git a/client/src/widgets/channels/AuthRow.tsx b/client/src/entities/channel/ui/AuthRow.tsx similarity index 96% rename from client/src/widgets/channels/AuthRow.tsx rename to client/src/entities/channel/ui/AuthRow.tsx index 45e1c8e..a4b7f0e 100644 --- a/client/src/widgets/channels/AuthRow.tsx +++ b/client/src/entities/channel/ui/AuthRow.tsx @@ -1,5 +1,5 @@ import { Box, Typography, Chip } from '@mui/material'; -import type { ChannelAuth } from '../../entities/channel/api'; +import type { ChannelAuth } from '../api'; import providerEmoji from './providerEmoji'; export default function AuthRow({ profile }: { profile: ChannelAuth }) { diff --git a/client/src/widgets/channels/ChatRow.tsx b/client/src/entities/channel/ui/ChatRow.tsx similarity index 92% rename from client/src/widgets/channels/ChatRow.tsx rename to client/src/entities/channel/ui/ChatRow.tsx index 3dd624d..7a41be9 100644 --- a/client/src/widgets/channels/ChatRow.tsx +++ b/client/src/entities/channel/ui/ChatRow.tsx @@ -1,15 +1,14 @@ import { Box, Typography, Chip, IconButton } from '@mui/material'; import { Delete } from '@mui/icons-material'; -import type { ChannelChat } from '../../entities/channel/api'; +import type { ChannelChat } from '../api'; import providerEmoji from './providerEmoji'; -export default function ChatRow({ - channel, - onRemove, -}: { +interface ChatRowProps { channel: ChannelChat; onRemove: (name: string) => void; -}) { +} + +export default function ChatRow({ channel, onRemove }: ChatRowProps) { return ( = 86400000) return `${Math.round(ms / 86400000)}d`; @@ -32,13 +32,12 @@ function relativeTime(ms: number): string { return `${Math.floor(diff / 86400000)}d ago`; } -export default function CronRow({ - job, - onRemove, -}: { +interface CronRowProps { job: CronJob; onRemove: (id: string) => void; -}) { +} + +export default function CronRow({ job, onRemove }: CronRowProps) { const [toggleCron] = useToggleCronJobMutation(); const [localEnabled, setLocalEnabled] = useState(job.enabled); const [toggling, setToggling] = useState(false); diff --git a/client/src/entities/message/index.ts b/client/src/entities/message/index.ts new file mode 100644 index 0000000..3f834fc --- /dev/null +++ b/client/src/entities/message/index.ts @@ -0,0 +1,5 @@ +export * from './api'; +export { default as MessageBubble } from './ui/MessageBubble'; +export { default as ThinkingBlock } from './ui/ThinkingBlock'; +export { default as FileAttachments } from './ui/FileAttachments'; +export type { MessageLike } from './ui/MessageBubble'; diff --git a/client/src/widgets/chat/FileAttachments.tsx b/client/src/entities/message/ui/FileAttachments.tsx similarity index 95% rename from client/src/widgets/chat/FileAttachments.tsx rename to client/src/entities/message/ui/FileAttachments.tsx index 038fc3d..9d83381 100644 --- a/client/src/widgets/chat/FileAttachments.tsx +++ b/client/src/entities/message/ui/FileAttachments.tsx @@ -1,8 +1,8 @@ import { Box, Chip, useTheme } from '@mui/material'; import { InsertDriveFileOutlined } from '@mui/icons-material'; import { alpha } from '@mui/material/styles'; -import type { MessageFile } from '../../entities/message/api'; -import { API_BASE_URL } from '../../shared/api/baseApi'; +import { API_BASE_URL } from '../../../shared/api'; +import type { MessageFile } from '../api'; function formatFileSize(bytes: number) { if (bytes < 1024) return `${bytes} B`; diff --git a/client/src/widgets/chat/MessageBubble.tsx b/client/src/entities/message/ui/MessageBubble.tsx similarity index 94% rename from client/src/widgets/chat/MessageBubble.tsx rename to client/src/entities/message/ui/MessageBubble.tsx index 7582bc1..9948c5e 100644 --- a/client/src/widgets/chat/MessageBubble.tsx +++ b/client/src/entities/message/ui/MessageBubble.tsx @@ -1,12 +1,10 @@ import { useState, memo, useCallback } from 'react'; import { Box, Paper, Typography, IconButton, useTheme } from '@mui/material'; import { DeleteOutline, ContentCopy, Done } from '@mui/icons-material'; -import { useDeleteMessageMutation } from '../../entities/message/api'; -import DeleteButton from '../../shared/ui/DeleteButton'; -import MarkdownContent from '../../shared/ui/MarkdownContent'; +import { DeleteButton, MarkdownContent } from '../../../shared/ui'; import ThinkingBlock from './ThinkingBlock'; import FileAttachments from './FileAttachments'; -import type { Message, MessageFile } from '../../entities/message/api'; +import { useDeleteMessageMutation, type Message, type MessageFile } from '../api'; export type MessageLike = | Message diff --git a/client/src/widgets/chat/ThinkingBlock.tsx b/client/src/entities/message/ui/ThinkingBlock.tsx similarity index 96% rename from client/src/widgets/chat/ThinkingBlock.tsx rename to client/src/entities/message/ui/ThinkingBlock.tsx index f03299d..e520f57 100644 --- a/client/src/widgets/chat/ThinkingBlock.tsx +++ b/client/src/entities/message/ui/ThinkingBlock.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Box, Collapse, Typography } from '@mui/material'; import { ExpandMore } from '@mui/icons-material'; -import MarkdownContent from '../../shared/ui/MarkdownContent'; +import { MarkdownContent } from '../../../shared/ui'; interface ThinkingBlockProps { text: string; diff --git a/client/src/entities/plugin/index.ts b/client/src/entities/plugin/index.ts new file mode 100644 index 0000000..8f49872 --- /dev/null +++ b/client/src/entities/plugin/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export { default as PluginRow } from './ui/PluginRow'; diff --git a/client/src/widgets/plugins/PluginRow.tsx b/client/src/entities/plugin/ui/PluginRow.tsx similarity index 97% rename from client/src/widgets/plugins/PluginRow.tsx rename to client/src/entities/plugin/ui/PluginRow.tsx index 29ec586..2699d7b 100644 --- a/client/src/widgets/plugins/PluginRow.tsx +++ b/client/src/entities/plugin/ui/PluginRow.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { Box, Typography, Chip, CircularProgress, Switch } from '@mui/material'; -import { useTogglePluginMutation, type PluginInfo } from '../../entities/plugin/api'; +import { useTogglePluginMutation, type PluginInfo } from '../api'; function originLabel(origin: string): string { if (origin === 'bundled') return 'built-in'; diff --git a/client/src/entities/skill/index.ts b/client/src/entities/skill/index.ts new file mode 100644 index 0000000..a2c2b3e --- /dev/null +++ b/client/src/entities/skill/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export { default as SkillRow } from './ui/SkillRow'; diff --git a/client/src/widgets/skills/SkillRow.tsx b/client/src/entities/skill/ui/SkillRow.tsx similarity index 98% rename from client/src/widgets/skills/SkillRow.tsx rename to client/src/entities/skill/ui/SkillRow.tsx index fdd7c3a..12ff0aa 100644 --- a/client/src/widgets/skills/SkillRow.tsx +++ b/client/src/entities/skill/ui/SkillRow.tsx @@ -1,5 +1,5 @@ import { Box, Typography, Chip, Tooltip } from '@mui/material'; -import type { SkillInfo } from '../../entities/skill/api'; +import type { SkillInfo } from '../api'; function sourceLabel(source: string, bundled: boolean): string { if (bundled) return 'built-in'; diff --git a/client/src/entities/update/index.ts b/client/src/entities/update/index.ts new file mode 100644 index 0000000..b1c13e7 --- /dev/null +++ b/client/src/entities/update/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/client/src/entities/user/index.ts b/client/src/entities/user/index.ts new file mode 100644 index 0000000..465afb5 --- /dev/null +++ b/client/src/entities/user/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export { default as UserRow } from './ui/UserRow'; diff --git a/client/src/widgets/users/UserRow.tsx b/client/src/entities/user/ui/UserRow.tsx similarity index 94% rename from client/src/widgets/users/UserRow.tsx rename to client/src/entities/user/ui/UserRow.tsx index 31a6afd..ff35613 100644 --- a/client/src/widgets/users/UserRow.tsx +++ b/client/src/entities/user/ui/UserRow.tsx @@ -1,6 +1,6 @@ import { Box, Typography, IconButton, Chip } from '@mui/material'; import { Edit, Delete } from '@mui/icons-material'; -import type { User } from '../../entities/user/api'; +import type { User } from '../api'; function relativeDate(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); @@ -10,15 +10,13 @@ function relativeDate(iso: string): string { return new Date(iso).toLocaleDateString(); } -export default function UserRow({ - user, - onEdit, - onDelete, -}: { +interface UserRowProps { user: User; onEdit: (id: string) => void; onDelete: (id: string) => void; -}) { +} + +export default function UserRow({ user, onEdit, onDelete }: UserRowProps) { return ( void; - onCancel: () => void; - createAgent: (args: { name: string; interactive: boolean }) => { unwrap: () => Promise }; - isCreating: boolean; +export interface CreatedAgent { + slug: string; + dbId: string; + interactive: boolean; } -export default function CreateAgentForm({ - onCreated, - onCancel, - createAgent, - isCreating, -}: CreateAgentFormProps) { +interface CreateAgentFormProps { + onCreated: (agent: CreatedAgent) => void; + onCancel: () => void; +} + +export default function CreateAgentForm({ onCreated, onCancel }: CreateAgentFormProps) { const { sidebar } = useTheme().palette; + const [createAgent, { isLoading: isCreating }] = useCreateAgentMutation(); const [name, setName] = useState(''); const [mode, setMode] = useState<'quick' | 'configure'>('quick'); const [error, setError] = useState(''); diff --git a/client/src/features/agent/setup-terminal/index.ts b/client/src/features/agent/setup-terminal/index.ts new file mode 100644 index 0000000..d362f50 --- /dev/null +++ b/client/src/features/agent/setup-terminal/index.ts @@ -0,0 +1 @@ +export { default as TerminalPanel } from './ui/TerminalPanel'; diff --git a/client/src/widgets/sidebar/TerminalPanel.tsx b/client/src/features/agent/setup-terminal/ui/TerminalPanel.tsx similarity index 99% rename from client/src/widgets/sidebar/TerminalPanel.tsx rename to client/src/features/agent/setup-terminal/ui/TerminalPanel.tsx index 1305c9f..0ee7e6a 100644 --- a/client/src/widgets/sidebar/TerminalPanel.tsx +++ b/client/src/features/agent/setup-terminal/ui/TerminalPanel.tsx @@ -12,8 +12,8 @@ import { Close, CheckCircle } from '@mui/icons-material'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; -import { API_BASE_URL } from '../../shared/api/baseApi'; -import { useSyncAgentsMutation, useDeleteAgentMutation } from '../../entities/agent/api'; +import { API_BASE_URL } from '../../../../shared/api'; +import { useSyncAgentsMutation, useDeleteAgentMutation } from '../../../../entities/agent'; interface TerminalPanelProps { agentName: string; diff --git a/client/src/features/auth/index.ts b/client/src/features/auth/index.ts new file mode 100644 index 0000000..0aa07dd --- /dev/null +++ b/client/src/features/auth/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export { logout } from './slice'; +export { default as authReducer } from './slice'; +export { default as PrivateRoute } from './PrivateRoute'; diff --git a/client/src/features/channel/add/index.ts b/client/src/features/channel/add/index.ts new file mode 100644 index 0000000..1acaa5e --- /dev/null +++ b/client/src/features/channel/add/index.ts @@ -0,0 +1 @@ +export { default as AddChannelForm } from './ui/AddChannelForm'; diff --git a/client/src/widgets/channels/AddChannelForm.tsx b/client/src/features/channel/add/ui/AddChannelForm.tsx similarity index 97% rename from client/src/widgets/channels/AddChannelForm.tsx rename to client/src/features/channel/add/ui/AddChannelForm.tsx index 1c9fd4d..3881157 100644 --- a/client/src/widgets/channels/AddChannelForm.tsx +++ b/client/src/features/channel/add/ui/AddChannelForm.tsx @@ -17,9 +17,9 @@ import { useAddChannelMutation, CHANNEL_PROVIDERS, CHANNEL_FIELDS, + providerEmoji, type ChannelProvider, -} from '../../entities/channel/api'; -import providerEmoji from './providerEmoji'; +} from '../../../../entities/channel'; const inputSx = { mb: 1.5, diff --git a/client/src/features/chat/useChat.ts b/client/src/features/chat/useChat.ts deleted file mode 100644 index 8a83865..0000000 --- a/client/src/features/chat/useChat.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; -import type { MessageFile, Message } from '../../entities/message/api'; -import { API_BASE_URL, baseApi } from '../../shared/api/baseApi'; -import { useAppDispatch } from '../../app/store/hooks'; -import { useGetMessagesQuery, usePollMessagesQuery, messagesApi } from '../../entities/message/api'; -import type { MessagesResponse } from '../../entities/message/api'; - -const POLL_INTERVAL_MS = 5000; - -export interface ChatState { - messages: ReturnType['data'] extends infer D - ? D extends { items: infer I } - ? I extends (infer M)[] - ? M[] - : never - : never - : never; - isLoading: boolean; - isFetching: boolean; - hasMore: boolean; - isStreaming: boolean; - streamingText: string; - streamingThinking: string; - pendingUserText: string; - pendingFilesPreviews: MessageFile[]; - send: (text: string, files: File[]) => Promise; - loadMore: () => void; - scrollContainerRef: React.RefObject; - messagesEndRef: React.RefObject; - handleScroll: () => void; -} - -export default function useChat(conversationId: string | undefined) { - const [streamingText, setStreamingText] = useState(''); - const [streamingThinking, setStreamingThinking] = useState(''); - const [isStreaming, setIsStreaming] = useState(false); - const [pendingUserText, setPendingUserText] = useState(''); - const [pendingFilesPreviews, setPendingFilesPreviews] = useState([]); - const [loadMoreCursor, setLoadMoreCursor] = useState(); - - const abortRef = useRef(null); - const messagesEndRef = useRef(null); - const scrollContainerRef = useRef(null); - const isLoadingMore = useRef(false); - const prevScrollHeight = useRef(0); - const initialScrollDone = useRef(false); - const lastConvId = useRef(conversationId); - const scrollTickRef = useRef(0); - const lastMergedPollTs = useRef(undefined); - - const dispatch = useAppDispatch(); - - const { data, isLoading, isFetching, refetch } = useGetMessagesQuery( - { conversationId: conversationId!, before: loadMoreCursor }, - { skip: !conversationId } - ); - - const messages = useMemo(() => data?.items ?? [], [data?.items]); - const hasMore = (data as MessagesResponse | undefined)?.hasMore ?? false; - - // Polling: only fetch messages that are newer than the latest one we have. - // Skip while streaming so the SSE flow owns the update. - const lastMessageTs = messages.length > 0 ? messages[messages.length - 1].createdAt : undefined; - - const { data: pollData } = usePollMessagesQuery( - { conversationId: conversationId!, after: lastMessageTs }, - { - skip: !conversationId || isStreaming || isLoading, - pollingInterval: POLL_INTERVAL_MS, - refetchOnMountOrArgChange: true, - } - ); - - // Merge new polled items into the messages cache + trigger auto-scroll. - useEffect(() => { - if (!conversationId || !pollData || pollData.items.length === 0) return; - - // Dedup by the timestamp of the newest polled message: avoids re-merging - // the same response until new data arrives. - const newestTs = pollData.items[pollData.items.length - 1].createdAt; - if (lastMergedPollTs.current === newestTs) return; - lastMergedPollTs.current = newestTs; - - dispatch( - messagesApi.util.updateQueryData( - 'getMessages', - { conversationId, before: undefined }, - (draft) => { - const existing = new Set(draft.items.map((m: Message) => m._id)); - const additions = pollData.items.filter((m) => !existing.has(m._id)); - if (additions.length === 0) return; - draft.items = [...draft.items, ...additions]; - draft.total = draft.items.length; - } - ) - ); - - const container = scrollContainerRef.current; - if (!container) return; - const distanceFromBottom = - container.scrollHeight - container.scrollTop - container.clientHeight; - if (distanceFromBottom < 200) { - setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, 50); - } - }, [pollData, conversationId, dispatch]); - - useEffect(() => { - if (lastConvId.current !== conversationId) { - lastConvId.current = conversationId; - initialScrollDone.current = false; - lastMergedPollTs.current = undefined; - } - if (isLoadingMore.current) { - const container = scrollContainerRef.current; - if (container) { - container.scrollTop = container.scrollHeight - prevScrollHeight.current; - prevScrollHeight.current = 0; - } - isLoadingMore.current = false; - return; - } - if (!initialScrollDone.current && messages.length > 0) { - initialScrollDone.current = true; - setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'instant' }); - }, 80); - return; - } - if (pendingUserText || pendingFilesPreviews.length > 0) { - setTimeout(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, 150); - } - }, [messages, conversationId, pendingUserText, pendingFilesPreviews]); - - useEffect(() => { - if (!streamingText && !streamingThinking) return; - const now = Date.now(); - if (now - scrollTickRef.current < 200) return; - scrollTickRef.current = now; - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [streamingText, streamingThinking]); - - useEffect(() => { - abortRef.current?.abort(); - setIsStreaming(false); - setStreamingText(''); - setStreamingThinking(''); - setPendingUserText(''); - setPendingFilesPreviews([]); - setLoadMoreCursor(undefined); - }, [conversationId]); - - useEffect(() => { - return () => { - abortRef.current?.abort(); - }; - }, []); - - const handleScroll = useCallback(() => { - const container = scrollContainerRef.current; - if (!container || isLoadingMore.current || isFetching || !hasMore || isStreaming) return; - if (container.scrollTop < 150 && messages.length > 0) { - isLoadingMore.current = true; - prevScrollHeight.current = container.scrollHeight; - setLoadMoreCursor(messages[0].createdAt); - } - }, [isFetching, hasMore, isStreaming, messages]); - - const send = useCallback( - async (text: string, files: File[]) => { - const trimmed = text.trim(); - if ((!trimmed && files.length === 0) || !conversationId || isStreaming) return; - - const previews: MessageFile[] = files.map((f) => ({ - filename: f.name, - originalName: f.name, - mimetype: f.type, - size: f.size, - url: URL.createObjectURL(f), - })); - - setPendingUserText(trimmed); - setPendingFilesPreviews(previews); - setStreamingText(''); - setStreamingThinking(''); - setIsStreaming(true); - - const token = localStorage.getItem('token'); - const controller = new AbortController(); - abortRef.current = controller; - - try { - const form = new FormData(); - form.append('conversationId', conversationId); - if (trimmed) form.append('text', trimmed); - files.forEach((f) => form.append('files', f)); - - const res = await fetch(`${API_BASE_URL}/message/chat`, { - method: 'POST', - headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, - body: form, - signal: controller.signal, - }); - - if (!res.ok || !res.body) { - console.error('Chat request failed:', res.status); - return; - } - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let lineBuf = ''; - let accText = ''; - let accThinking = ''; - - const processLine = (line: string) => { - if (!line.startsWith('data: ')) return; - const jsonStr = line.slice(6).trim(); - if (!jsonStr || jsonStr === '[DONE]') return; - try { - const event = JSON.parse(jsonStr); - if (event.type === 'response.output_text.delta' && event.delta) { - accText += event.delta; - setStreamingText(accText); - } else if (event.type === 'response.thinking.delta' && event.delta) { - accThinking += event.delta; - setStreamingThinking(accThinking); - } - } catch { - /* skip */ - } - }; - - for (;;) { - const { done, value } = await reader.read(); - if (done) { - if (lineBuf.trim()) processLine(lineBuf); - break; - } - const chunk = decoder.decode(value, { stream: true }); - lineBuf += chunk; - const parts = lineBuf.split('\n'); - lineBuf = parts.pop()!; - parts.forEach(processLine); - } - - await refetch(); - if (messages.length === 0) { - dispatch(baseApi.util.invalidateTags(['Conversation'])); - } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; - console.error('Stream error:', err); - } finally { - setIsStreaming(false); - setStreamingText(''); - setStreamingThinking(''); - setPendingUserText(''); - setPendingFilesPreviews((prev) => { - prev.forEach((f) => URL.revokeObjectURL(f.url)); - return []; - }); - abortRef.current = null; - } - }, - [conversationId, isStreaming, refetch, dispatch, messages.length] - ); - - const loadMore = useCallback(() => { - if (messages.length > 0) { - isLoadingMore.current = true; - prevScrollHeight.current = scrollContainerRef.current?.scrollHeight ?? 0; - setLoadMoreCursor(messages[0].createdAt); - } - }, [messages]); - - return { - messages, - isLoading, - isFetching, - hasMore, - isStreaming, - streamingText, - streamingThinking, - pendingUserText, - pendingFilesPreviews, - send, - loadMore, - scrollContainerRef, - messagesEndRef, - handleScroll, - loadMoreCursor, - }; -} diff --git a/client/src/features/cron/add/index.ts b/client/src/features/cron/add/index.ts new file mode 100644 index 0000000..63e8c83 --- /dev/null +++ b/client/src/features/cron/add/index.ts @@ -0,0 +1 @@ +export { default as AddCronForm } from './ui/AddCronForm'; diff --git a/client/src/widgets/cron/AddCronForm.tsx b/client/src/features/cron/add/ui/AddCronForm.tsx similarity index 97% rename from client/src/widgets/cron/AddCronForm.tsx rename to client/src/features/cron/add/ui/AddCronForm.tsx index f5130bc..c320327 100644 --- a/client/src/widgets/cron/AddCronForm.tsx +++ b/client/src/features/cron/add/ui/AddCronForm.tsx @@ -14,9 +14,9 @@ import { Autocomplete, } from '@mui/material'; import { Close } from '@mui/icons-material'; -import { useAddCronJobMutation } from '../../entities/cron/api'; -import { useGetAgentsQuery } from '../../entities/agent/api'; -import { useGetConversationsQuery } from '../../entities/conversation/api'; +import { useAddCronJobMutation } from '../../../../entities/cron'; +import { useGetAgentsQuery } from '../../../../entities/agent'; +import { useGetConversationsQuery } from '../../../../entities/conversation'; type ScheduleKind = 'cron' | 'every' | 'at'; diff --git a/client/src/features/message/send/index.ts b/client/src/features/message/send/index.ts new file mode 100644 index 0000000..46e85dc --- /dev/null +++ b/client/src/features/message/send/index.ts @@ -0,0 +1,2 @@ +export { useSendMessage } from './model/useSendMessage'; +export type { SendMessageState } from './model/useSendMessage'; diff --git a/client/src/features/message/send/model/useSendMessage.ts b/client/src/features/message/send/model/useSendMessage.ts new file mode 100644 index 0000000..78cf199 --- /dev/null +++ b/client/src/features/message/send/model/useSendMessage.ts @@ -0,0 +1,154 @@ +import { useCallback, useRef, useState } from 'react'; +import { useAppDispatch } from '../../../../app/store/hooks'; +import { API_BASE_URL, baseApi } from '../../../../shared/api'; +import { useGetMessagesQuery, type MessageFile } from '../../../../entities/message'; + +interface UseSendMessageArgs { + conversationId: string | undefined; + refetch: ReturnType['refetch']; + hasMessages: boolean; +} + +export interface SendMessageState { + isStreaming: boolean; + streamingText: string; + streamingThinking: string; + pendingUserText: string; + pendingFilesPreviews: MessageFile[]; + send: (text: string, files: File[]) => Promise; + abort: () => void; +} + +/** + * Owns the fetch/stream lifecycle for sending a chat message. + * Keeps UI-facing state (streaming text, pending previews) local. + */ +export function useSendMessage({ + conversationId, + refetch, + hasMessages, +}: UseSendMessageArgs): SendMessageState { + const [streamingText, setStreamingText] = useState(''); + const [streamingThinking, setStreamingThinking] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [pendingUserText, setPendingUserText] = useState(''); + const [pendingFilesPreviews, setPendingFilesPreviews] = useState([]); + + const abortRef = useRef(null); + const dispatch = useAppDispatch(); + + const abort = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + }, []); + + const send = useCallback( + async (text: string, files: File[]) => { + const trimmed = text.trim(); + if ((!trimmed && files.length === 0) || !conversationId || isStreaming) return; + + const previews: MessageFile[] = files.map((f) => ({ + filename: f.name, + originalName: f.name, + mimetype: f.type, + size: f.size, + url: URL.createObjectURL(f), + })); + + setPendingUserText(trimmed); + setPendingFilesPreviews(previews); + setStreamingText(''); + setStreamingThinking(''); + setIsStreaming(true); + + const token = localStorage.getItem('token'); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const form = new FormData(); + form.append('conversationId', conversationId); + if (trimmed) form.append('text', trimmed); + files.forEach((f) => form.append('files', f)); + + const res = await fetch(`${API_BASE_URL}/message/chat`, { + method: 'POST', + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: form, + signal: controller.signal, + }); + + if (!res.ok || !res.body) { + console.error('Chat request failed:', res.status); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let lineBuf = ''; + let accText = ''; + let accThinking = ''; + + const processLine = (line: string) => { + if (!line.startsWith('data: ')) return; + const jsonStr = line.slice(6).trim(); + if (!jsonStr || jsonStr === '[DONE]') return; + try { + const event = JSON.parse(jsonStr); + if (event.type === 'response.output_text.delta' && event.delta) { + accText += event.delta; + setStreamingText(accText); + } else if (event.type === 'response.thinking.delta' && event.delta) { + accThinking += event.delta; + setStreamingThinking(accThinking); + } + } catch { + /* skip */ + } + }; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + if (lineBuf.trim()) processLine(lineBuf); + break; + } + const chunk = decoder.decode(value, { stream: true }); + lineBuf += chunk; + const parts = lineBuf.split('\n'); + lineBuf = parts.pop()!; + parts.forEach(processLine); + } + + await refetch(); + if (!hasMessages) { + dispatch(baseApi.util.invalidateTags(['Conversation'])); + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + console.error('Stream error:', err); + } finally { + setIsStreaming(false); + setStreamingText(''); + setStreamingThinking(''); + setPendingUserText(''); + setPendingFilesPreviews((prev) => { + prev.forEach((f) => URL.revokeObjectURL(f.url)); + return []; + }); + abortRef.current = null; + } + }, + [conversationId, isStreaming, refetch, dispatch, hasMessages] + ); + + return { + isStreaming, + streamingText, + streamingThinking, + pendingUserText, + pendingFilesPreviews, + send, + abort, + }; +} diff --git a/client/src/features/theme/ThemePicker.tsx b/client/src/features/theme/ThemePicker.tsx index 9f09e96..8e48e6c 100644 --- a/client/src/features/theme/ThemePicker.tsx +++ b/client/src/features/theme/ThemePicker.tsx @@ -8,7 +8,8 @@ import { } from '@mui/material'; import { Palette } from '@mui/icons-material'; import { themeConfigs, type ThemeId } from '../../app/theme'; -import { useAppDispatch, useAppSelector, setTheme } from '../../app/store'; +import { useAppDispatch, useAppSelector } from '../../app/store'; +import { setTheme } from './slice'; const themeEntries = Object.entries(themeConfigs) as [ThemeId, typeof themeConfigs[ThemeId]][]; diff --git a/client/src/features/theme/index.ts b/client/src/features/theme/index.ts new file mode 100644 index 0000000..1f994ab --- /dev/null +++ b/client/src/features/theme/index.ts @@ -0,0 +1,3 @@ +export { setTheme } from './slice'; +export { default as themeReducer } from './slice'; +export { default as ThemePicker } from './ThemePicker'; diff --git a/client/src/features/update/install/index.ts b/client/src/features/update/install/index.ts new file mode 100644 index 0000000..8506025 --- /dev/null +++ b/client/src/features/update/install/index.ts @@ -0,0 +1 @@ +export { default as UpdateBanner } from './ui/UpdateBanner'; diff --git a/client/src/widgets/sidebar/UpdateBanner.tsx b/client/src/features/update/install/ui/UpdateBanner.tsx similarity index 97% rename from client/src/widgets/sidebar/UpdateBanner.tsx rename to client/src/features/update/install/ui/UpdateBanner.tsx index 1e7a0e8..2be37cc 100644 --- a/client/src/widgets/sidebar/UpdateBanner.tsx +++ b/client/src/features/update/install/ui/UpdateBanner.tsx @@ -1,8 +1,8 @@ import { useState, useRef } from 'react'; import { Box, CircularProgress, useTheme } from '@mui/material'; import { SystemUpdateAlt } from '@mui/icons-material'; -import { useCheckUpdateQuery, useApplyUpdateMutation } from '../../entities/update/api'; -import { API_BASE_URL } from '../../shared/api/baseApi'; +import { API_BASE_URL } from '../../../../shared/api'; +import { useCheckUpdateQuery, useApplyUpdateMutation } from '../../../../entities/update'; export default function UpdateBanner() { const theme = useTheme(); diff --git a/client/src/features/user/edit/index.ts b/client/src/features/user/edit/index.ts new file mode 100644 index 0000000..4da68e8 --- /dev/null +++ b/client/src/features/user/edit/index.ts @@ -0,0 +1 @@ +export { default as UserForm } from './ui/UserForm'; diff --git a/client/src/widgets/users/UserForm.tsx b/client/src/features/user/edit/ui/UserForm.tsx similarity index 97% rename from client/src/widgets/users/UserForm.tsx rename to client/src/features/user/edit/ui/UserForm.tsx index f3682e4..6a9af86 100644 --- a/client/src/widgets/users/UserForm.tsx +++ b/client/src/features/user/edit/ui/UserForm.tsx @@ -6,7 +6,7 @@ import { useGetUserQuery, useCreateUserMutation, useUpdateUserMutation, -} from '../../entities/user/api'; +} from '../../../../entities/user'; type FieldErrors = Record; @@ -28,13 +28,12 @@ const inputSx = { const emptyValues = { name: '', lastName: '', email: '', password: '', phone: '' }; -export default function UserForm({ - userId, - onDone, -}: { +interface UserFormProps { userId: string | null; onDone: () => void; -}) { +} + +export default function UserForm({ userId, onDone }: UserFormProps) { const isEdit = Boolean(userId); const { data: existing, isLoading: loadingUser } = useGetUserQuery(userId!, { skip: !userId }); const [createUser, { isLoading: isCreating }] = useCreateUserMutation(); diff --git a/client/src/pages/agent/WorkspacePage.tsx b/client/src/pages/agent/WorkspacePage.tsx index 696a3d4..f808d31 100644 --- a/client/src/pages/agent/WorkspacePage.tsx +++ b/client/src/pages/agent/WorkspacePage.tsx @@ -1,86 +1,8 @@ -import { Link, useParams, useSearchParams } from 'react-router'; -import { Box, IconButton, Typography, CircularProgress } from '@mui/material'; -import { ArrowBack } from '@mui/icons-material'; -import { useGetAgentQuery } from '../../app/store'; -import WorkspaceFileTabs from './WorkspaceFileTabs'; +import { useParams } from 'react-router'; +import { Workspace } from '../../widgets/workspace'; export default function AgentWorkspacePage() { const { agentId } = useParams<{ agentId: string }>(); - const [searchParams] = useSearchParams(); - const returnConv = searchParams.get('return'); - const { data: agent, isLoading } = useGetAgentQuery(agentId!, { skip: !agentId }); - - const backHref = agentId && returnConv ? `/agent/${agentId}/chat/${returnConv}` : '/'; - - if (!agentId) { - return null; - } - - if (isLoading && !agent) { - return ( - - - - ); - } - - return ( - - - - - - - - {agent?.name ?? 'Agent'} - - - Workspace files - - - - - - - - - ); + if (!agentId) return null; + return ; } diff --git a/client/src/pages/agent/index.tsx b/client/src/pages/agent/index.tsx index be73052..cdedee3 100644 --- a/client/src/pages/agent/index.tsx +++ b/client/src/pages/agent/index.tsx @@ -1,18 +1,11 @@ -import { useState } from 'react'; import { useParams } from 'react-router'; import { Box, Typography } from '@mui/material'; -import useChat from '../../features/chat/useChat'; -import ChatHeader from '../../widgets/chat/ChatHeader'; -import SessionSettingsBar from '../../widgets/chat/SessionSettingsBar'; -import MessageList from '../../widgets/chat/MessageList'; -import ChatInput from '../../widgets/chat/ChatInput'; +import { Chat } from '../../widgets/chat'; -export default function AgentChat() { +export default function AgentChatPage() { const { agentId, conversationId } = useParams<{ agentId: string; conversationId: string }>(); - const [showSessionSettings, setShowSessionSettings] = useState(false); - const chat = useChat(conversationId); - if (!conversationId) { + if (!agentId || !conversationId) { return ( Select a conversation to start chatting @@ -20,33 +13,5 @@ export default function AgentChat() { ); } - return ( - - {agentId && ( - setShowSessionSettings((v) => !v)} - /> - )} - {showSessionSettings && agentId && ( - - )} - - - - ); + return ; } diff --git a/client/src/pages/channels/index.tsx b/client/src/pages/channels/index.tsx index 72ac056..b9ed8e3 100644 --- a/client/src/pages/channels/index.tsx +++ b/client/src/pages/channels/index.tsx @@ -1,106 +1,5 @@ -import { useState } from 'react'; -import { Box, Typography, CircularProgress, IconButton, Collapse } from '@mui/material'; -import { Add } from '@mui/icons-material'; -import { useListChannelsQuery, useRemoveChannelMutation } from '../../entities/channel/api'; -import ChatRow from '../../widgets/channels/ChatRow'; -import AuthRow from '../../widgets/channels/AuthRow'; -import AddChannelForm from '../../widgets/channels/AddChannelForm'; +import ChannelsPanel from '../../widgets/channels'; export default function ChannelsPage() { - const { data, isLoading, isFetching } = useListChannelsQuery(); - const [removeChannel] = useRemoveChannelMutation(); - const [showAdd, setShowAdd] = useState(false); - const [pendingOp, setPendingOp] = useState(false); - - const busy = isLoading || pendingOp || isFetching; - - const chat = data?.chat ?? []; - const auth = data?.auth ?? []; - - const handleRemove = async (name: string) => { - setPendingOp(true); - try { - await removeChannel({ name }).unwrap(); - } catch { - /* handled by RTK */ - } finally { - setPendingOp(false); - } - }; - - const handleAdded = () => { - setShowAdd(false); - }; - - return ( - - - - Channels - - {!busy && ( - - {chat.length} chat · {auth.length} auth - - )} - setShowAdd((prev) => !prev)} - sx={{ - bgcolor: showAdd ? 'primary.main' : 'action.hover', - color: showAdd ? 'primary.contrastText' : 'text.primary', - '&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' }, - }} - > - - - - - - - - - {busy ? ( - - - - ) : ( - <> - {chat.length > 0 && ( - - - Chat Channels - - {chat.map((c) => ( - - ))} - - )} - - {auth.length > 0 && ( - - - Auth Profiles - - {auth.map((a) => ( - - ))} - - )} - - {chat.length === 0 && auth.length === 0 && ( - - No channels configured - - )} - - )} - - ); + return ; } diff --git a/client/src/pages/cron/index.tsx b/client/src/pages/cron/index.tsx index 1d19d07..71668e9 100644 --- a/client/src/pages/cron/index.tsx +++ b/client/src/pages/cron/index.tsx @@ -1,78 +1,5 @@ -import { useState } from 'react'; -import { Box, Typography, CircularProgress, IconButton, Collapse } from '@mui/material'; -import { Add } from '@mui/icons-material'; -import { useListCronJobsQuery, useRemoveCronJobMutation } from '../../entities/cron/api'; -import CronRow from '../../widgets/cron/CronRow'; -import AddCronForm from '../../widgets/cron/AddCronForm'; +import CronPanel from '../../widgets/cron'; export default function CronPage() { - const { data, isLoading, isFetching } = useListCronJobsQuery(); - const [removeCron] = useRemoveCronJobMutation(); - const [showAdd, setShowAdd] = useState(false); - const [pendingOp, setPendingOp] = useState(false); - - const busy = isLoading || pendingOp || isFetching; - const jobs = data?.jobs ?? []; - - const handleRemove = async (id: string) => { - setPendingOp(true); - try { - await removeCron({ id }).unwrap(); - } catch { - /* handled by RTK */ - } finally { - setPendingOp(false); - } - }; - - const handleAdded = () => { - setShowAdd(false); - }; - - return ( - - - - Cron Jobs - - {!busy && ( - - {jobs.length} job{jobs.length !== 1 ? 's' : ''} - - )} - setShowAdd((prev) => !prev)} - sx={{ - bgcolor: showAdd ? 'primary.main' : 'action.hover', - color: showAdd ? 'primary.contrastText' : 'text.primary', - '&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' }, - }} - > - - - - - - - - - {busy ? ( - - - - ) : ( - <> - {jobs.map((job) => ( - - ))} - {jobs.length === 0 && ( - - No cron jobs configured - - )} - - )} - - ); + return ; } diff --git a/client/src/pages/login/index.tsx b/client/src/pages/login/index.tsx index 37e379d..c81b5e5 100644 --- a/client/src/pages/login/index.tsx +++ b/client/src/pages/login/index.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { Button, TextField, Card, Typography, Box, CircularProgress, Alert } from '@mui/material'; import { useFormik, FormikProvider, Form } from 'formik'; import { useNavigate } from 'react-router'; -import { useLoginMutation } from '../../app/store'; +import { useLoginMutation } from '../../features/auth'; export default function LoginPage() { const navigate = useNavigate(); diff --git a/client/src/pages/plugins/index.tsx b/client/src/pages/plugins/index.tsx index 76d6a44..3b61567 100644 --- a/client/src/pages/plugins/index.tsx +++ b/client/src/pages/plugins/index.tsx @@ -1,79 +1,5 @@ -import { useState } from 'react'; -import { Box, Typography, TextField, CircularProgress, InputAdornment } from '@mui/material'; -import { Search } from '@mui/icons-material'; -import { useListPluginsQuery } from '../../entities/plugin/api'; -import PluginRow from '../../widgets/plugins/PluginRow'; +import PluginsPanel from '../../widgets/plugins'; export default function PluginsPage() { - const { data: plugins, isLoading } = useListPluginsQuery(); - const [search, setSearch] = useState(''); - - const all = plugins ?? []; - const filtered = all.filter((p) => { - if (!search) return true; - const q = search.toLowerCase(); - return ( - p.name.toLowerCase().includes(q) || - p.id.toLowerCase().includes(q) || - p.description.toLowerCase().includes(q) - ); - }); - - const enabledCount = all.filter((p) => p.enabled).length; - - return ( - - - - Plugins - - {!isLoading && ( - - {enabledCount} of {all.length} enabled - - )} - - - setSearch(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - sx={{ - mb: 2, - '& .MuiOutlinedInput-root': { - borderRadius: 1.5, - '& input': { fontSize: '0.85rem', py: 1 }, - }, - }} - /> - - {isLoading ? ( - - - - ) : ( - - {filtered.map((p) => ( - - ))} - {filtered.length === 0 && ( - - {search ? 'No plugins match your search' : 'No plugins found'} - - )} - - )} - - ); + return ; } diff --git a/client/src/pages/skills/index.tsx b/client/src/pages/skills/index.tsx index cf35986..8f9c944 100644 --- a/client/src/pages/skills/index.tsx +++ b/client/src/pages/skills/index.tsx @@ -1,99 +1,5 @@ -import { useState } from 'react'; -import { Box, Typography, TextField, Chip, CircularProgress, InputAdornment } from '@mui/material'; -import { Search } from '@mui/icons-material'; -import { useListSkillsQuery } from '../../entities/skill/api'; -import SkillRow from '../../widgets/skills/SkillRow'; +import SkillsPanel from '../../widgets/skills'; export default function SkillsPage() { - const { data: skills, isLoading } = useListSkillsQuery(); - const [search, setSearch] = useState(''); - const [filter, setFilter] = useState<'all' | 'eligible' | 'missing'>('all'); - - const all = skills ?? []; - const filtered = all.filter((s) => { - if (filter === 'eligible' && !s.eligible) return false; - if (filter === 'missing' && s.eligible) return false; - if (!search) return true; - const q = search.toLowerCase(); - return s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q); - }); - - const eligibleCount = all.filter((s) => s.eligible).length; - - return ( - - - - Skills - - {!isLoading && ( - - {eligibleCount} of {all.length} eligible - - )} - - - - setSearch(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - sx={{ - '& .MuiOutlinedInput-root': { - borderRadius: 1.5, - '& input': { fontSize: '0.85rem', py: 1 }, - }, - }} - /> - {(['all', 'eligible', 'missing'] as const).map((f) => ( - setFilter(f)} - sx={{ - height: 32, - fontSize: '0.75rem', - fontWeight: 600, - textTransform: 'capitalize', - bgcolor: filter === f ? 'primary.main' : 'transparent', - color: filter === f ? 'primary.contrastText' : 'text.secondary', - '&:hover': { - bgcolor: filter === f ? 'primary.dark' : 'action.hover', - }, - }} - /> - ))} - - - {isLoading ? ( - - - - ) : ( - - {filtered.map((s) => ( - - ))} - {filtered.length === 0 && ( - - {search ? 'No skills match your search' : 'No skills found'} - - )} - - )} - - ); + return ; } diff --git a/client/src/pages/user/index.tsx b/client/src/pages/user/index.tsx index 1048d42..c5a8c2c 100644 --- a/client/src/pages/user/index.tsx +++ b/client/src/pages/user/index.tsx @@ -1,131 +1,5 @@ -import { useState } from 'react'; -import { - Box, - Typography, - TextField, - CircularProgress, - InputAdornment, - IconButton, - Collapse, -} from '@mui/material'; -import { Search, Add } from '@mui/icons-material'; -import { useGetUsersQuery, useDeleteUserMutation } from '../../entities/user/api'; -import UserRow from '../../widgets/users/UserRow'; -import UserForm from '../../widgets/users/UserForm'; +import UsersPanel from '../../widgets/users'; export default function UsersPage() { - const { data, isLoading, isFetching } = useGetUsersQuery(); - const [deleteUser] = useDeleteUserMutation(); - const [search, setSearch] = useState(''); - const [formMode, setFormMode] = useState<'closed' | 'add' | string>('closed'); - const [pendingOp, setPendingOp] = useState(false); - - const busy = isLoading || pendingOp || isFetching; - const users = data?.items ?? []; - - const filtered = users.filter((u) => { - if (!search) return true; - const q = search.toLowerCase(); - return ( - u.name.toLowerCase().includes(q) || - u.lastName.toLowerCase().includes(q) || - u.email.toLowerCase().includes(q) || - (u.phone && u.phone.toLowerCase().includes(q)) - ); - }); - - const handleDelete = async (id: string) => { - setPendingOp(true); - try { - await deleteUser(id).unwrap(); - } catch { - /* handled by RTK */ - } finally { - setPendingOp(false); - } - }; - - const handleFormDone = () => { - setFormMode('closed'); - }; - - const handleEdit = (id: string) => { - setFormMode(id); - }; - - const showForm = formMode !== 'closed'; - const editUserId = formMode !== 'closed' && formMode !== 'add' ? formMode : null; - - return ( - - - - Users - - {!busy && ( - - {users.length} user{users.length !== 1 ? 's' : ''} - - )} - setFormMode((prev) => (prev === 'add' ? 'closed' : 'add'))} - sx={{ - bgcolor: formMode === 'add' ? 'primary.main' : 'action.hover', - color: formMode === 'add' ? 'primary.contrastText' : 'text.primary', - '&:hover': { - bgcolor: formMode === 'add' ? 'primary.dark' : 'action.selected', - }, - }} - > - - - - - - - - - setSearch(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - sx={{ - mb: 2, - '& .MuiOutlinedInput-root': { - borderRadius: 1.5, - '& input': { fontSize: '0.85rem', py: 1 }, - }, - }} - /> - - {busy ? ( - - - - ) : ( - - {filtered.map((user) => ( - - ))} - {filtered.length === 0 && ( - - {search ? 'No users match your search' : 'No users found'} - - )} - - )} - - ); + return ; } diff --git a/client/src/shared/api/index.ts b/client/src/shared/api/index.ts new file mode 100644 index 0000000..26aa21d --- /dev/null +++ b/client/src/shared/api/index.ts @@ -0,0 +1 @@ +export { API_BASE_URL, baseApi } from './baseApi'; diff --git a/client/src/shared/hooks/index.ts b/client/src/shared/hooks/index.ts new file mode 100644 index 0000000..87deab5 --- /dev/null +++ b/client/src/shared/hooks/index.ts @@ -0,0 +1 @@ +export { useValidationErrors } from './useValidationErrors'; diff --git a/client/src/shared/hooks/useValidationErrors.ts b/client/src/shared/hooks/useValidationErrors.ts new file mode 100644 index 0000000..f4b258f --- /dev/null +++ b/client/src/shared/hooks/useValidationErrors.ts @@ -0,0 +1,15 @@ +export function useValidationErrors(error: unknown): Record | null { + if ( + typeof error === 'object' && + error !== null && + 'status' in error && + typeof (error as { status: unknown }).status === 'number' && + 'data' in error && + typeof (error as { data: unknown }).data === 'object' && + (error as { data: unknown }).data !== null && + (error as { status: number }).status === 422 + ) { + return (error as { data: Record }).data; + } + return null; +} diff --git a/client/src/shared/ui/index.ts b/client/src/shared/ui/index.ts new file mode 100644 index 0000000..a579060 --- /dev/null +++ b/client/src/shared/ui/index.ts @@ -0,0 +1,3 @@ +export { default as MarkdownContent } from './MarkdownContent'; +export { default as DeleteButton } from './DeleteButton'; +export { default as ProviderLogo } from './ProviderLogo'; diff --git a/client/src/widgets/channels/index.tsx b/client/src/widgets/channels/index.tsx new file mode 100644 index 0000000..15f096f --- /dev/null +++ b/client/src/widgets/channels/index.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { Box, CircularProgress, Collapse, IconButton, Typography } from '@mui/material'; +import { Add } from '@mui/icons-material'; +import { + AuthRow, + ChatRow, + useListChannelsQuery, + useRemoveChannelMutation, +} from '../../entities/channel'; +import { AddChannelForm } from '../../features/channel/add'; + +export default function ChannelsPanel() { + const { data, isLoading, isFetching } = useListChannelsQuery(); + const [removeChannel] = useRemoveChannelMutation(); + const [showAdd, setShowAdd] = useState(false); + const [pendingOp, setPendingOp] = useState(false); + + const busy = isLoading || pendingOp || isFetching; + + const chat = data?.chat ?? []; + const auth = data?.auth ?? []; + + const handleRemove = async (name: string) => { + setPendingOp(true); + try { + await removeChannel({ name }).unwrap(); + } catch { + /* handled by RTK */ + } finally { + setPendingOp(false); + } + }; + + return ( + + + + Channels + + {!busy && ( + + {chat.length} chat · {auth.length} auth + + )} + setShowAdd((prev) => !prev)} + sx={{ + bgcolor: showAdd ? 'primary.main' : 'action.hover', + color: showAdd ? 'primary.contrastText' : 'text.primary', + '&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' }, + }} + > + + + + + + setShowAdd(false)} /> + + + {busy ? ( + + + + ) : ( + <> + {chat.length > 0 && ( + + + Chat Channels + + {chat.map((c) => ( + + ))} + + )} + + {auth.length > 0 && ( + + + Auth Profiles + + {auth.map((a) => ( + + ))} + + )} + + {chat.length === 0 && auth.length === 0 && ( + + No channels configured + + )} + + )} + + ); +} diff --git a/client/src/widgets/chat/index.ts b/client/src/widgets/chat/index.ts new file mode 100644 index 0000000..5686137 --- /dev/null +++ b/client/src/widgets/chat/index.ts @@ -0,0 +1,8 @@ +export { default as Chat } from './ui/Chat'; +export { default as ChatHeader } from './ui/ChatHeader'; +export { default as ChatInput } from './ui/ChatInput'; +export { default as MessageList } from './ui/MessageList'; +export { default as SessionSettingsBar } from './ui/SessionSettingsBar'; +export { default as SettingChip } from './ui/SettingChip'; +export { useChat } from './model/useChat'; +export type { ChatState } from './model/types'; diff --git a/client/src/widgets/chat/model/types.ts b/client/src/widgets/chat/model/types.ts new file mode 100644 index 0000000..9f78fd1 --- /dev/null +++ b/client/src/widgets/chat/model/types.ts @@ -0,0 +1,23 @@ +import type { RefObject } from 'react'; +import type { Message, MessageFile } from '../../../entities/message'; + +export interface ChatState { + messages: Message[]; + isLoading: boolean; + isFetching: boolean; + hasMore: boolean; + loadMoreCursor: string | undefined; + + isStreaming: boolean; + streamingText: string; + streamingThinking: string; + pendingUserText: string; + pendingFilesPreviews: MessageFile[]; + + send: (text: string, files: File[]) => Promise; + loadMore: () => void; + handleScroll: () => void; + + scrollContainerRef: RefObject; + messagesEndRef: RefObject; +} diff --git a/client/src/widgets/chat/model/useChat.ts b/client/src/widgets/chat/model/useChat.ts new file mode 100644 index 0000000..a35a6ad --- /dev/null +++ b/client/src/widgets/chat/model/useChat.ts @@ -0,0 +1,187 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAppDispatch } from '../../../app/store/hooks'; +import { + messagesApi, + useGetMessagesQuery, + usePollMessagesQuery, + type Message, + type MessagesResponse, +} from '../../../entities/message'; +import { useSendMessage } from '../../../features/message/send'; +import type { ChatState } from './types'; + +const POLL_INTERVAL_MS = 5000; + +/** + * Composes message querying, polling, scroll behavior, and send-message state + * into a single `ChatState` consumed by the chat widget. + */ +export function useChat(conversationId: string | undefined): ChatState { + const [loadMoreCursor, setLoadMoreCursor] = useState(); + + const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const isLoadingMore = useRef(false); + const prevScrollHeight = useRef(0); + const initialScrollDone = useRef(false); + const lastConvId = useRef(conversationId); + const scrollTickRef = useRef(0); + const lastMergedPollTs = useRef(undefined); + + const dispatch = useAppDispatch(); + + const { data, isLoading, isFetching, refetch } = useGetMessagesQuery( + { conversationId: conversationId!, before: loadMoreCursor }, + { skip: !conversationId } + ); + + const messages = useMemo(() => data?.items ?? [], [data?.items]); + const hasMore = (data as MessagesResponse | undefined)?.hasMore ?? false; + + const { + isStreaming, + streamingText, + streamingThinking, + pendingUserText, + pendingFilesPreviews, + send, + abort, + } = useSendMessage({ + conversationId, + refetch, + hasMessages: messages.length > 0, + }); + + // Polling: only fetch messages newer than the latest one we have. + // Skip while streaming so SSE flow owns the update. + const lastMessageTs = messages.length > 0 ? messages[messages.length - 1].createdAt : undefined; + + const { data: pollData } = usePollMessagesQuery( + { conversationId: conversationId!, after: lastMessageTs }, + { + skip: !conversationId || isStreaming || isLoading, + pollingInterval: POLL_INTERVAL_MS, + refetchOnMountOrArgChange: true, + } + ); + + // Merge new polled items into the messages cache + trigger auto-scroll. + useEffect(() => { + if (!conversationId || !pollData || pollData.items.length === 0) return; + + // Dedup by newest polled timestamp: avoids re-merging the same data. + const newestTs = pollData.items[pollData.items.length - 1].createdAt; + if (lastMergedPollTs.current === newestTs) return; + lastMergedPollTs.current = newestTs; + + dispatch( + messagesApi.util.updateQueryData( + 'getMessages', + { conversationId, before: undefined }, + (draft) => { + const existing = new Set(draft.items.map((m) => m._id)); + const additions = pollData.items.filter((m) => !existing.has(m._id)); + if (additions.length === 0) return; + draft.items = [...draft.items, ...additions]; + draft.total = draft.items.length; + } + ) + ); + + const container = scrollContainerRef.current; + if (!container) return; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + if (distanceFromBottom < 200) { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 50); + } + }, [pollData, conversationId, dispatch]); + + useEffect(() => { + if (lastConvId.current !== conversationId) { + lastConvId.current = conversationId; + initialScrollDone.current = false; + lastMergedPollTs.current = undefined; + } + if (isLoadingMore.current) { + const container = scrollContainerRef.current; + if (container) { + container.scrollTop = container.scrollHeight - prevScrollHeight.current; + prevScrollHeight.current = 0; + } + isLoadingMore.current = false; + return; + } + if (!initialScrollDone.current && messages.length > 0) { + initialScrollDone.current = true; + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'instant' }); + }, 80); + return; + } + if (pendingUserText || pendingFilesPreviews.length > 0) { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 150); + } + }, [messages, conversationId, pendingUserText, pendingFilesPreviews]); + + useEffect(() => { + if (!streamingText && !streamingThinking) return; + const now = Date.now(); + if (now - scrollTickRef.current < 200) return; + scrollTickRef.current = now; + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [streamingText, streamingThinking]); + + const [prevConvId, setPrevConvId] = useState(conversationId); + if (prevConvId !== conversationId) { + setPrevConvId(conversationId); + abort(); + if (loadMoreCursor !== undefined) setLoadMoreCursor(undefined); + } + + useEffect(() => { + return () => { + abort(); + }; + }, [abort]); + + const handleScroll = useCallback(() => { + const container = scrollContainerRef.current; + if (!container || isLoadingMore.current || isFetching || !hasMore || isStreaming) return; + if (container.scrollTop < 150 && messages.length > 0) { + isLoadingMore.current = true; + prevScrollHeight.current = container.scrollHeight; + setLoadMoreCursor(messages[0].createdAt); + } + }, [isFetching, hasMore, isStreaming, messages]); + + const loadMore = useCallback(() => { + if (messages.length > 0) { + isLoadingMore.current = true; + prevScrollHeight.current = scrollContainerRef.current?.scrollHeight ?? 0; + setLoadMoreCursor(messages[0].createdAt); + } + }, [messages]); + + return { + messages, + isLoading, + isFetching, + hasMore, + loadMoreCursor, + isStreaming, + streamingText, + streamingThinking, + pendingUserText, + pendingFilesPreviews, + send, + loadMore, + handleScroll, + scrollContainerRef, + messagesEndRef, + }; +} diff --git a/client/src/widgets/chat/ui/Chat.tsx b/client/src/widgets/chat/ui/Chat.tsx new file mode 100644 index 0000000..1bfe88b --- /dev/null +++ b/client/src/widgets/chat/ui/Chat.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { Box } from '@mui/material'; +import { useChat } from '../model/useChat'; +import ChatHeader from './ChatHeader'; +import SessionSettingsBar from './SessionSettingsBar'; +import MessageList from './MessageList'; +import ChatInput from './ChatInput'; + +interface ChatProps { + agentId: string; + conversationId: string; +} + +/** + * Full chat experience for a given agent/conversation: header, + * optional session-settings bar, message list and input. + */ +export default function Chat({ agentId, conversationId }: ChatProps) { + const [showSessionSettings, setShowSessionSettings] = useState(false); + const chat = useChat(conversationId); + + return ( + + setShowSessionSettings((v) => !v)} + /> + {showSessionSettings && ( + + )} + + + + ); +} diff --git a/client/src/widgets/chat/ChatHeader.tsx b/client/src/widgets/chat/ui/ChatHeader.tsx similarity index 99% rename from client/src/widgets/chat/ChatHeader.tsx rename to client/src/widgets/chat/ui/ChatHeader.tsx index 3c2d87b..5ed3e1f 100644 --- a/client/src/widgets/chat/ChatHeader.tsx +++ b/client/src/widgets/chat/ui/ChatHeader.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Box, TextField, IconButton, Typography, CircularProgress } from '@mui/material'; import { Edit, Check, Settings, TuneOutlined } from '@mui/icons-material'; import { Link } from 'react-router'; -import { useGetAgentQuery, useUpdateAgentMutation } from '../../entities/agent/api'; +import { useGetAgentQuery, useUpdateAgentMutation } from '../../../entities/agent'; interface ChatHeaderProps { agentId: string; diff --git a/client/src/widgets/chat/ChatInput.tsx b/client/src/widgets/chat/ui/ChatInput.tsx similarity index 100% rename from client/src/widgets/chat/ChatInput.tsx rename to client/src/widgets/chat/ui/ChatInput.tsx diff --git a/client/src/widgets/chat/MessageList.tsx b/client/src/widgets/chat/ui/MessageList.tsx similarity index 96% rename from client/src/widgets/chat/MessageList.tsx rename to client/src/widgets/chat/ui/MessageList.tsx index c6b3f28..a69e2d9 100644 --- a/client/src/widgets/chat/MessageList.tsx +++ b/client/src/widgets/chat/ui/MessageList.tsx @@ -1,9 +1,9 @@ import { Box, Typography, CircularProgress } from '@mui/material'; -import type useChat from '../../features/chat/useChat'; -import MessageBubble from './MessageBubble'; +import { MessageBubble } from '../../../entities/message'; +import type { ChatState } from '../model/types'; interface MessageListProps { - chat: ReturnType; + chat: ChatState; } export default function MessageList({ chat }: MessageListProps) { diff --git a/client/src/widgets/chat/SessionSettingsBar.tsx b/client/src/widgets/chat/ui/SessionSettingsBar.tsx similarity index 96% rename from client/src/widgets/chat/SessionSettingsBar.tsx rename to client/src/widgets/chat/ui/SessionSettingsBar.tsx index 07a9dd3..9433900 100644 --- a/client/src/widgets/chat/SessionSettingsBar.tsx +++ b/client/src/widgets/chat/ui/SessionSettingsBar.tsx @@ -1,9 +1,6 @@ import { memo, useCallback } from 'react'; import { Box } from '@mui/material'; -import { - useGetSessionSettingsQuery, - usePatchSessionSettingsMutation, -} from '../../entities/agent/api'; +import { useGetSessionSettingsQuery, usePatchSessionSettingsMutation } from '../../../entities/agent'; import SettingChip from './SettingChip'; const THINKING_OPTIONS = ['inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const; diff --git a/client/src/widgets/chat/SettingChip.tsx b/client/src/widgets/chat/ui/SettingChip.tsx similarity index 100% rename from client/src/widgets/chat/SettingChip.tsx rename to client/src/widgets/chat/ui/SettingChip.tsx diff --git a/client/src/widgets/cron/index.tsx b/client/src/widgets/cron/index.tsx new file mode 100644 index 0000000..04f42d6 --- /dev/null +++ b/client/src/widgets/cron/index.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { Box, CircularProgress, Collapse, IconButton, Typography } from '@mui/material'; +import { Add } from '@mui/icons-material'; +import { + CronRow, + useListCronJobsQuery, + useRemoveCronJobMutation, +} from '../../entities/cron'; +import { AddCronForm } from '../../features/cron/add'; + +export default function CronPanel() { + const { data, isLoading, isFetching } = useListCronJobsQuery(); + const [removeCron] = useRemoveCronJobMutation(); + const [showAdd, setShowAdd] = useState(false); + const [pendingOp, setPendingOp] = useState(false); + + const busy = isLoading || pendingOp || isFetching; + const jobs = data?.jobs ?? []; + + const handleRemove = async (id: string) => { + setPendingOp(true); + try { + await removeCron({ id }).unwrap(); + } catch { + /* handled by RTK */ + } finally { + setPendingOp(false); + } + }; + + return ( + + + + Cron Jobs + + {!busy && ( + + {jobs.length} job{jobs.length !== 1 ? 's' : ''} + + )} + setShowAdd((prev) => !prev)} + sx={{ + bgcolor: showAdd ? 'primary.main' : 'action.hover', + color: showAdd ? 'primary.contrastText' : 'text.primary', + '&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' }, + }} + > + + + + + + setShowAdd(false)} /> + + + {busy ? ( + + + + ) : ( + <> + {jobs.map((job) => ( + + ))} + {jobs.length === 0 && ( + + No cron jobs configured + + )} + + )} + + ); +} diff --git a/client/src/widgets/plugins/index.tsx b/client/src/widgets/plugins/index.tsx new file mode 100644 index 0000000..ce47ceb --- /dev/null +++ b/client/src/widgets/plugins/index.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from 'react'; +import { Box, CircularProgress, InputAdornment, TextField, Typography } from '@mui/material'; +import { Search } from '@mui/icons-material'; +import { PluginRow, useListPluginsQuery } from '../../entities/plugin'; + +export default function PluginsPanel() { + const { data: plugins, isLoading } = useListPluginsQuery(); + const [search, setSearch] = useState(''); + + const all = useMemo(() => plugins ?? [], [plugins]); + const filtered = useMemo(() => { + if (!search) return all; + const q = search.toLowerCase(); + return all.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.id.toLowerCase().includes(q) || + p.description.toLowerCase().includes(q) + ); + }, [all, search]); + + const enabledCount = useMemo(() => all.filter((p) => p.enabled).length, [all]); + + return ( + + + + Plugins + + {!isLoading && ( + + {enabledCount} of {all.length} enabled + + )} + + + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ + mb: 2, + '& .MuiOutlinedInput-root': { + borderRadius: 1.5, + '& input': { fontSize: '0.85rem', py: 1 }, + }, + }} + /> + + {isLoading ? ( + + + + ) : ( + + {filtered.map((p) => ( + + ))} + {filtered.length === 0 && ( + + {search ? 'No plugins match your search' : 'No plugins found'} + + )} + + )} + + ); +} diff --git a/client/src/widgets/sidebar/index.tsx b/client/src/widgets/sidebar/index.tsx index 87b5227..ccb0d6e 100644 --- a/client/src/widgets/sidebar/index.tsx +++ b/client/src/widgets/sidebar/index.tsx @@ -1,97 +1,21 @@ -import { useState, useEffect, useRef } from 'react'; -import { - Box, - List, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - Typography, - useTheme, - TextField, - IconButton, - CircularProgress, -} from '@mui/material'; -import { - People, - Extension, - Psychology, - Forum, - Schedule, - Add, - Search, - KeyboardDoubleArrowUp, - SwapVert, -} from '@mui/icons-material'; -import { Link, useLocation } from 'react-router'; -import { - useGetAgentsQuery, - useCreateAgentMutation, - useSyncAgentsMutation, -} from '../../entities/agent/api'; -import { useGetAllConversationsQuery } from '../../entities/conversation/api'; -import ThemePicker from '../../features/theme/ThemePicker'; -import UpdateBanner from './UpdateBanner'; -import AgentSection from './AgentSection'; -import TerminalPanel from './TerminalPanel'; -import SyncProgressBar from './SyncProgressBar'; -import CreateAgentForm from './CreateAgentForm'; +import { useState } from 'react'; +import { Box, useTheme } from '@mui/material'; +import { UpdateBanner } from '../../features/update/install'; +import { ThemePicker } from '../../features/theme'; +import SidebarHeader from './ui/SidebarHeader'; +import SidebarSearch from './ui/SidebarSearch'; +import SidebarMenu from './ui/SidebarMenu'; +import AgentsPanel from './ui/AgentsPanel'; export const SIDEBAR_WIDTH = 240; -const menuItems = [ - { text: 'USERS', icon: , path: '/users' }, - { text: 'PLUGINS', icon: , path: '/plugins' }, - { text: 'SKILLS', icon: , path: '/skills' }, - { text: 'CHANNELS', icon: , path: '/channels' }, - { text: 'CRON', icon: , path: '/cron' }, -]; +interface SidebarProps { + onNavigate?: () => void; +} -export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) { - const location = useLocation(); - const theme = useTheme(); - const { sidebar } = theme.palette; - - const [showNewAgent, setShowNewAgent] = useState(false); +export default function Sidebar({ onNavigate }: SidebarProps) { + const { sidebar } = useTheme().palette; const [searchQuery, setSearchQuery] = useState(''); - const [collapseKey, setCollapseKey] = useState(0); - const [sortAlpha, setSortAlpha] = useState(false); - const [terminalAgent, setTerminalAgent] = useState<{ slug: string; dbId: string } | null>(null); - const [deletingAgentId, setDeletingAgentId] = useState(null); - - const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery(); - const { data: convData } = useGetAllConversationsQuery(); - const [createAgent, { isLoading: isCreating }] = useCreateAgentMutation(); - const [syncAgents, { isLoading: isSyncing }] = useSyncAgentsMutation(); - const [syncDone, setSyncDone] = useState(false); - const syncDoneTimer = useRef | null>(null); - const syncCalled = useRef(false); - - const allConversations = convData?.items ?? []; - const agents = sortAlpha - ? [...(agentsData?.items ?? [])].sort((a, b) => a.name.localeCompare(b.name)) - : (agentsData?.items ?? []); - - useEffect(() => { - if (syncCalled.current) return; - if (agentsLoading || !agentsData) return; - syncCalled.current = true; - syncAgents().then(() => { - setSyncDone(true); - syncDoneTimer.current = setTimeout(() => setSyncDone(false), 2500); - }); - return () => { - if (syncDoneTimer.current) clearTimeout(syncDoneTimer.current); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [agentsLoading]); - - const handleAgentCreated = (result: { slug: string; dbId: string; interactive: boolean }) => { - setShowNewAgent(false); - if (result.interactive) { - setTerminalAgent({ slug: result.slug, dbId: result.dbId }); - } - }; return ( void }) { alignSelf: 'flex-start', }} > - - - - OpenClaw - - - Client - - - - - setSearchQuery(e.target.value)} - slotProps={{ - input: { - startAdornment: , - }, - }} - sx={{ - '& .MuiOutlinedInput-root': { - bgcolor: sidebar.hover, - borderRadius: 1.5, - '& fieldset': { borderColor: 'transparent' }, - '&:hover fieldset': { borderColor: sidebar.text }, - '&.Mui-focused fieldset': { borderColor: sidebar.selectedBorder }, - '& input': { color: sidebar.selectedText, fontSize: '0.78rem', py: 0.7, px: 0.5 }, - }, - }} - /> - - - - {menuItems.map((item) => { - const isSelected = - location.pathname === item.path || location.pathname.startsWith(item.path + '/'); - return ( - - - {item.icon} - - {isSelected && ( - - )} - - - ); - })} - - - - - - Agents - - - {isSyncing && } - {!isSyncing && syncDone && ( - - )} - setSortAlpha((v) => !v)} - title={sortAlpha ? 'Unsort' : 'Sort A–Z'} - sx={{ - color: sortAlpha ? 'primary.main' : sidebar.text, - p: 0.3, - '&:hover': { color: 'primary.main' }, - }} - > - - - setCollapseKey((k) => k + 1)} - title="Collapse all" - sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: sidebar.selectedText } }} - > - - - setShowNewAgent((prev) => !prev)} - sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: 'success.main' } }} - > - - - - - - {isSyncing && } - - {showNewAgent && ( - setShowNewAgent(false)} - createAgent={createAgent} - isCreating={isCreating} - /> - )} - - - {agentsLoading ? ( - - - - ) : ( - - {agents.map((agent) => ( - c.agentId === agent._id)} - searchQuery={searchQuery || undefined} - collapseKey={collapseKey} - onNavigate={onNavigate} - disabled={deletingAgentId === agent._id} - /> - ))} - - )} - - - + + + + - - - {terminalAgent && ( - setTerminalAgent(null)} - onDeleting={setDeletingAgentId} - /> - )} ); } diff --git a/client/src/widgets/sidebar/AgentSection.tsx b/client/src/widgets/sidebar/ui/AgentSection.tsx similarity index 94% rename from client/src/widgets/sidebar/AgentSection.tsx rename to client/src/widgets/sidebar/ui/AgentSection.tsx index d46bc83..903a5db 100644 --- a/client/src/widgets/sidebar/AgentSection.tsx +++ b/client/src/widgets/sidebar/ui/AgentSection.tsx @@ -12,11 +12,9 @@ import { } from '@mui/material'; import { Add, ExpandMore, ExpandLess, SmartToy, DeleteOutline } from '@mui/icons-material'; import { useLocation, useNavigate } from 'react-router'; -import { useDeleteAgentMutation } from '../../entities/agent/api'; -import { useCreateConversationMutation } from '../../entities/conversation/api'; -import DeleteButton from '../../shared/ui/DeleteButton'; -import ProviderLogo from '../../shared/ui/ProviderLogo'; -import ConversationItem from './ConversationItem'; +import { useDeleteAgentMutation } from '../../../entities/agent'; +import { useCreateConversationMutation, ConversationItem } from '../../../entities/conversation'; +import { DeleteButton, ProviderLogo } from '../../../shared/ui'; interface AgentSectionProps { agent: { _id: string; name: string; model?: string | null }; diff --git a/client/src/widgets/sidebar/ui/AgentsPanel.tsx b/client/src/widgets/sidebar/ui/AgentsPanel.tsx new file mode 100644 index 0000000..c0b8298 --- /dev/null +++ b/client/src/widgets/sidebar/ui/AgentsPanel.tsx @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState } from 'react'; +import { Box, CircularProgress, IconButton, List, Typography, useTheme } from '@mui/material'; +import { Add, KeyboardDoubleArrowUp, SwapVert } from '@mui/icons-material'; +import { useGetAgentsQuery, useSyncAgentsMutation } from '../../../entities/agent'; +import { useGetAllConversationsQuery } from '../../../entities/conversation'; +import { CreateAgentForm, type CreatedAgent } from '../../../features/agent/create'; +import { TerminalPanel } from '../../../features/agent/setup-terminal'; +import AgentSection from './AgentSection'; +import SyncProgressBar from './SyncProgressBar'; + +interface AgentsPanelProps { + searchQuery: string; + onNavigate?: () => void; +} + +export default function AgentsPanel({ searchQuery, onNavigate }: AgentsPanelProps) { + const { sidebar } = useTheme().palette; + + const [showNewAgent, setShowNewAgent] = useState(false); + const [collapseKey, setCollapseKey] = useState(0); + const [sortAlpha, setSortAlpha] = useState(false); + const [terminalAgent, setTerminalAgent] = useState<{ slug: string; dbId: string } | null>(null); + const [deletingAgentId, setDeletingAgentId] = useState(null); + + const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery(); + const { data: convData } = useGetAllConversationsQuery(); + const [syncAgents, { isLoading: isSyncing }] = useSyncAgentsMutation(); + + const [syncDone, setSyncDone] = useState(false); + const syncDoneTimer = useRef | null>(null); + const syncCalled = useRef(false); + + const allConversations = convData?.items ?? []; + const agents = sortAlpha + ? [...(agentsData?.items ?? [])].sort((a, b) => a.name.localeCompare(b.name)) + : (agentsData?.items ?? []); + + useEffect(() => { + if (syncCalled.current) return; + if (agentsLoading || !agentsData) return; + syncCalled.current = true; + syncAgents().then(() => { + setSyncDone(true); + syncDoneTimer.current = setTimeout(() => setSyncDone(false), 2500); + }); + return () => { + if (syncDoneTimer.current) clearTimeout(syncDoneTimer.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentsLoading]); + + const handleAgentCreated = (result: CreatedAgent) => { + setShowNewAgent(false); + if (result.interactive) { + setTerminalAgent({ slug: result.slug, dbId: result.dbId }); + } + }; + + return ( + + + + Agents + + + {isSyncing && } + {!isSyncing && syncDone && ( + + )} + setSortAlpha((v) => !v)} + title={sortAlpha ? 'Unsort' : 'Sort A–Z'} + sx={{ + color: sortAlpha ? 'primary.main' : sidebar.text, + p: 0.3, + '&:hover': { color: 'primary.main' }, + }} + > + + + setCollapseKey((k) => k + 1)} + title="Collapse all" + sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: sidebar.selectedText } }} + > + + + setShowNewAgent((prev) => !prev)} + sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: 'success.main' } }} + > + + + + + + {isSyncing && } + + {showNewAgent && ( + setShowNewAgent(false)} + /> + )} + + + {agentsLoading ? ( + + + + ) : ( + + {agents.map((agent) => ( + c.agentId === agent._id)} + searchQuery={searchQuery || undefined} + collapseKey={collapseKey} + onNavigate={onNavigate} + disabled={deletingAgentId === agent._id} + /> + ))} + + )} + + + {terminalAgent && ( + setTerminalAgent(null)} + onDeleting={setDeletingAgentId} + /> + )} + + ); +} diff --git a/client/src/widgets/sidebar/ui/SidebarHeader.tsx b/client/src/widgets/sidebar/ui/SidebarHeader.tsx new file mode 100644 index 0000000..99335b8 --- /dev/null +++ b/client/src/widgets/sidebar/ui/SidebarHeader.tsx @@ -0,0 +1,33 @@ +import { Box, Typography, useTheme } from '@mui/material'; + +export default function SidebarHeader() { + const { sidebar } = useTheme().palette; + return ( + + + + OpenClaw + + + Client + + + ); +} diff --git a/client/src/widgets/sidebar/ui/SidebarMenu.tsx b/client/src/widgets/sidebar/ui/SidebarMenu.tsx new file mode 100644 index 0000000..c4ebb59 --- /dev/null +++ b/client/src/widgets/sidebar/ui/SidebarMenu.tsx @@ -0,0 +1,89 @@ +import type { ReactNode } from 'react'; +import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, useTheme } from '@mui/material'; +import { People, Extension, Psychology, Forum, Schedule } from '@mui/icons-material'; +import { Link, useLocation } from 'react-router'; + +interface MenuItem { + text: string; + icon: ReactNode; + path: string; +} + +const MENU_ITEMS: MenuItem[] = [ + { text: 'USERS', icon: , path: '/users' }, + { text: 'PLUGINS', icon: , path: '/plugins' }, + { text: 'SKILLS', icon: , path: '/skills' }, + { text: 'CHANNELS', icon: , path: '/channels' }, + { text: 'CRON', icon: , path: '/cron' }, +]; + +interface SidebarMenuProps { + onNavigate?: () => void; +} + +export default function SidebarMenu({ onNavigate }: SidebarMenuProps) { + const location = useLocation(); + const { sidebar } = useTheme().palette; + + return ( + + {MENU_ITEMS.map((item) => { + const isSelected = + location.pathname === item.path || location.pathname.startsWith(item.path + '/'); + return ( + + + {item.icon} + + {isSelected && ( + + )} + + + ); + })} + + ); +} diff --git a/client/src/widgets/sidebar/ui/SidebarSearch.tsx b/client/src/widgets/sidebar/ui/SidebarSearch.tsx new file mode 100644 index 0000000..975d6c6 --- /dev/null +++ b/client/src/widgets/sidebar/ui/SidebarSearch.tsx @@ -0,0 +1,37 @@ +import { Box, TextField, useTheme } from '@mui/material'; +import { Search } from '@mui/icons-material'; + +interface SidebarSearchProps { + value: string; + onChange: (v: string) => void; +} + +export default function SidebarSearch({ value, onChange }: SidebarSearchProps) { + const { sidebar } = useTheme().palette; + return ( + + onChange(e.target.value)} + slotProps={{ + input: { + startAdornment: , + }, + }} + sx={{ + '& .MuiOutlinedInput-root': { + bgcolor: sidebar.hover, + borderRadius: 1.5, + '& fieldset': { borderColor: 'transparent' }, + '&:hover fieldset': { borderColor: sidebar.text }, + '&.Mui-focused fieldset': { borderColor: sidebar.selectedBorder }, + '& input': { color: sidebar.selectedText, fontSize: '0.78rem', py: 0.7, px: 0.5 }, + }, + }} + /> + + ); +} diff --git a/client/src/widgets/sidebar/SyncProgressBar.tsx b/client/src/widgets/sidebar/ui/SyncProgressBar.tsx similarity index 100% rename from client/src/widgets/sidebar/SyncProgressBar.tsx rename to client/src/widgets/sidebar/ui/SyncProgressBar.tsx diff --git a/client/src/widgets/skills/index.tsx b/client/src/widgets/skills/index.tsx new file mode 100644 index 0000000..191e6f8 --- /dev/null +++ b/client/src/widgets/skills/index.tsx @@ -0,0 +1,109 @@ +import { useMemo, useState } from 'react'; +import { + Box, + Chip, + CircularProgress, + InputAdornment, + TextField, + Typography, +} from '@mui/material'; +import { Search } from '@mui/icons-material'; +import { SkillRow, useListSkillsQuery } from '../../entities/skill'; + +type SkillFilter = 'all' | 'eligible' | 'missing'; + +export default function SkillsPanel() { + const { data: skills, isLoading } = useListSkillsQuery(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('all'); + + const all = useMemo(() => skills ?? [], [skills]); + const filtered = useMemo(() => { + return all.filter((s) => { + if (filter === 'eligible' && !s.eligible) return false; + if (filter === 'missing' && s.eligible) return false; + if (!search) return true; + const q = search.toLowerCase(); + return s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q); + }); + }, [all, filter, search]); + + const eligibleCount = useMemo(() => all.filter((s) => s.eligible).length, [all]); + + return ( + + + + Skills + + {!isLoading && ( + + {eligibleCount} of {all.length} eligible + + )} + + + + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ + '& .MuiOutlinedInput-root': { + borderRadius: 1.5, + '& input': { fontSize: '0.85rem', py: 1 }, + }, + }} + /> + {(['all', 'eligible', 'missing'] as const).map((f) => ( + setFilter(f)} + sx={{ + height: 32, + fontSize: '0.75rem', + fontWeight: 600, + textTransform: 'capitalize', + bgcolor: filter === f ? 'primary.main' : 'transparent', + color: filter === f ? 'primary.contrastText' : 'text.secondary', + '&:hover': { + bgcolor: filter === f ? 'primary.dark' : 'action.hover', + }, + }} + /> + ))} + + + {isLoading ? ( + + + + ) : ( + + {filtered.map((s) => ( + + ))} + {filtered.length === 0 && ( + + {search ? 'No skills match your search' : 'No skills found'} + + )} + + )} + + ); +} diff --git a/client/src/widgets/users/index.tsx b/client/src/widgets/users/index.tsx new file mode 100644 index 0000000..7126812 --- /dev/null +++ b/client/src/widgets/users/index.tsx @@ -0,0 +1,134 @@ +import { useMemo, useState } from 'react'; +import { + Box, + CircularProgress, + Collapse, + IconButton, + InputAdornment, + TextField, + Typography, +} from '@mui/material'; +import { Add, Search } from '@mui/icons-material'; +import { + UserRow, + useDeleteUserMutation, + useGetUsersQuery, +} from '../../entities/user'; +import { UserForm } from '../../features/user/edit'; + +type FormMode = 'closed' | 'add' | string; + +export default function UsersPanel() { + const { data, isLoading, isFetching } = useGetUsersQuery(); + const [deleteUser] = useDeleteUserMutation(); + const [search, setSearch] = useState(''); + const [formMode, setFormMode] = useState('closed'); + const [pendingOp, setPendingOp] = useState(false); + + const busy = isLoading || pendingOp || isFetching; + const users = useMemo(() => data?.items ?? [], [data?.items]); + + const filtered = useMemo(() => { + if (!search) return users; + const q = search.toLowerCase(); + return users.filter( + (u) => + u.name.toLowerCase().includes(q) || + u.lastName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + (u.phone && u.phone.toLowerCase().includes(q)) + ); + }, [users, search]); + + const handleDelete = async (id: string) => { + setPendingOp(true); + try { + await deleteUser(id).unwrap(); + } catch { + /* handled by RTK */ + } finally { + setPendingOp(false); + } + }; + + const showForm = formMode !== 'closed'; + const editUserId = formMode !== 'closed' && formMode !== 'add' ? formMode : null; + + return ( + + + + Users + + {!busy && ( + + {users.length} user{users.length !== 1 ? 's' : ''} + + )} + setFormMode((prev) => (prev === 'add' ? 'closed' : 'add'))} + sx={{ + bgcolor: formMode === 'add' ? 'primary.main' : 'action.hover', + color: formMode === 'add' ? 'primary.contrastText' : 'text.primary', + '&:hover': { + bgcolor: formMode === 'add' ? 'primary.dark' : 'action.selected', + }, + }} + > + + + + + + setFormMode('closed')} /> + + + setSearch(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ + mb: 2, + '& .MuiOutlinedInput-root': { + borderRadius: 1.5, + '& input': { fontSize: '0.85rem', py: 1 }, + }, + }} + /> + + {busy ? ( + + + + ) : ( + + {filtered.map((user) => ( + setFormMode(id)} + onDelete={handleDelete} + /> + ))} + {filtered.length === 0 && ( + + {search ? 'No users match your search' : 'No users found'} + + )} + + )} + + ); +} diff --git a/client/src/widgets/workspace/index.ts b/client/src/widgets/workspace/index.ts new file mode 100644 index 0000000..398db2a --- /dev/null +++ b/client/src/widgets/workspace/index.ts @@ -0,0 +1,2 @@ +export { default as Workspace } from './ui/Workspace'; +export { default as WorkspaceFileTabs } from './ui/WorkspaceFileTabs'; diff --git a/client/src/widgets/workspace/ui/Workspace.tsx b/client/src/widgets/workspace/ui/Workspace.tsx new file mode 100644 index 0000000..6150505 --- /dev/null +++ b/client/src/widgets/workspace/ui/Workspace.tsx @@ -0,0 +1,85 @@ +import { Link, useSearchParams } from 'react-router'; +import { Box, IconButton, Typography, CircularProgress } from '@mui/material'; +import { ArrowBack } from '@mui/icons-material'; +import { useGetAgentQuery } from '../../../entities/agent'; +import WorkspaceFileTabs from './WorkspaceFileTabs'; + +interface WorkspaceProps { + agentId: string; +} + +export default function Workspace({ agentId }: WorkspaceProps) { + const [searchParams] = useSearchParams(); + const returnConv = searchParams.get('return'); + const { data: agent, isLoading } = useGetAgentQuery(agentId, { skip: !agentId }); + + const backHref = returnConv ? `/agent/${agentId}/chat/${returnConv}` : '/'; + + if (isLoading && !agent) { + return ( + + + + ); + } + + return ( + + + + + + + + {agent?.name ?? 'Agent'} + + + Workspace files + + + + + + + + + ); +} diff --git a/client/src/pages/agent/WorkspaceFileTabs.tsx b/client/src/widgets/workspace/ui/WorkspaceFileTabs.tsx similarity index 99% rename from client/src/pages/agent/WorkspaceFileTabs.tsx rename to client/src/widgets/workspace/ui/WorkspaceFileTabs.tsx index 5d7aa62..639faab 100644 --- a/client/src/pages/agent/WorkspaceFileTabs.tsx +++ b/client/src/widgets/workspace/ui/WorkspaceFileTabs.tsx @@ -10,13 +10,13 @@ import { } from '@mui/material'; import { alpha } from '@mui/material/styles'; import { Visibility, PostAdd } from '@mui/icons-material'; -import MarkdownContent from '../../shared/ui/MarkdownContent'; +import { MarkdownContent } from '../../../shared/ui'; import { useGetWorkspaceMetaQuery, useGetWorkspaceFileQuery, useSaveWorkspaceFileMutation, WORKSPACE_TAB_FILES, -} from '../../entities/agent/api'; +} from '../../../entities/agent'; export default function WorkspaceFileTabs({ agentId }: { agentId: string }) { const { data: meta } = useGetWorkspaceMetaQuery(agentId);