client refactoring

This commit is contained in:
Davit
2026-04-16 23:16:29 +04:00
parent 833e27692c
commit 1fc9b2bc29
81 changed files with 1555 additions and 1365 deletions
-17
View File
@@ -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 (
<ThemeProvider theme={themes[themeId]}>
<CssBaseline />
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
);
}
+5 -6
View File
@@ -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(
<StrictMode>
<Provider store={store}>
<ThemedApp />
</Provider>
<AppProviders>
<App />
</AppProviders>
</StrictMode>
);
+27
View File
@@ -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 (
<ThemeProvider theme={themes[themeId]}>
<CssBaseline />
{children}
</ThemeProvider>
);
}
export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<ThemeBridge>
<BrowserRouter>{children}</BrowserRouter>
</ThemeBridge>
</Provider>
);
}
+1
View File
@@ -0,0 +1 @@
export { AppProviders } from './AppProviders';
-20
View File
@@ -3,23 +3,3 @@ import type { AppDispatch, RootState } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
/**
* Hook to extract validation errors from RTK Query error
* Returns typed field errors or null
*/
export function useValidationErrors(error: unknown): Record<string, string[]> | 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<string, string[]> }).data;
}
return null;
}
-7
View File
@@ -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';
+3 -3
View File
@@ -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: {
+1
View File
@@ -0,0 +1 @@
export * from './api';
+4
View File
@@ -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';
@@ -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 }) {
@@ -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 (
<Box
sx={{
@@ -0,0 +1,2 @@
export * from './api';
export { default as ConversationItem } from './ui/ConversationItem';
@@ -11,11 +11,8 @@ import {
} from '@mui/material';
import { ChatBubbleOutline, DeleteOutline, Edit, Check } from '@mui/icons-material';
import { Link, useLocation, useNavigate } from 'react-router';
import {
useUpdateConversationMutation,
useDeleteConversationMutation,
} from '../../entities/conversation/api';
import DeleteButton from '../../shared/ui/DeleteButton';
import { DeleteButton } from '../../../shared/ui';
import { useUpdateConversationMutation, useDeleteConversationMutation } from '../api';
interface ConversationItemProps {
agentId: string;
+2
View File
@@ -0,0 +1,2 @@
export * from './api';
export { default as CronRow } from './ui/CronRow';
@@ -1,7 +1,7 @@
import { useRef, useState } from 'react';
import { Box, Typography, Chip, CircularProgress, Switch, IconButton, Tooltip } from '@mui/material';
import { Delete, Error as ErrorIcon } from '@mui/icons-material';
import { useToggleCronJobMutation, type CronJob } from '../../entities/cron/api';
import { useToggleCronJobMutation, type CronJob } from '../api';
function formatMs(ms: number): string {
if (ms >= 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);
+5
View File
@@ -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';
@@ -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`;
@@ -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
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
export * from './api';
export { default as PluginRow } from './ui/PluginRow';
@@ -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';
+2
View File
@@ -0,0 +1,2 @@
export * from './api';
export { default as SkillRow } from './ui/SkillRow';
@@ -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';
+1
View File
@@ -0,0 +1 @@
export * from './api';
+2
View File
@@ -0,0 +1,2 @@
export * from './api';
export { default as UserRow } from './ui/UserRow';
@@ -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 (
<Box
sx={{
@@ -0,0 +1,2 @@
export { default as CreateAgentForm } from './ui/CreateAgentForm';
export type { CreatedAgent } from './ui/CreateAgentForm';
@@ -9,21 +9,22 @@ import {
Divider,
useTheme,
} from '@mui/material';
import { useCreateAgentMutation } from '../../../../entities/agent';
interface CreateAgentFormProps {
onCreated: (agent: { slug: string; dbId: string; interactive: boolean }) => void;
onCancel: () => void;
createAgent: (args: { name: string; interactive: boolean }) => { unwrap: () => Promise<any> };
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('');
@@ -0,0 +1 @@
export { default as TerminalPanel } from './ui/TerminalPanel';
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
export * from './api';
export { logout } from './slice';
export { default as authReducer } from './slice';
export { default as PrivateRoute } from './PrivateRoute';
+1
View File
@@ -0,0 +1 @@
export { default as AddChannelForm } from './ui/AddChannelForm';
@@ -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,
-297
View File
@@ -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<typeof useGetMessagesQuery>['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<void>;
loadMore: () => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
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<MessageFile[]>([]);
const [loadMoreCursor, setLoadMoreCursor] = useState<string | undefined>();
const abortRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isLoadingMore = useRef(false);
const prevScrollHeight = useRef(0);
const initialScrollDone = useRef(false);
const lastConvId = useRef(conversationId);
const scrollTickRef = useRef(0);
const lastMergedPollTs = useRef<string | undefined>(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,
};
}
+1
View File
@@ -0,0 +1 @@
export { default as AddCronForm } from './ui/AddCronForm';
@@ -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';
@@ -0,0 +1,2 @@
export { useSendMessage } from './model/useSendMessage';
export type { SendMessageState } from './model/useSendMessage';
@@ -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<typeof useGetMessagesQuery>['refetch'];
hasMessages: boolean;
}
export interface SendMessageState {
isStreaming: boolean;
streamingText: string;
streamingThinking: string;
pendingUserText: string;
pendingFilesPreviews: MessageFile[];
send: (text: string, files: File[]) => Promise<void>;
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<MessageFile[]>([]);
const abortRef = useRef<AbortController | null>(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,
};
}
+2 -1
View File
@@ -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]][];
+3
View File
@@ -0,0 +1,3 @@
export { setTheme } from './slice';
export { default as themeReducer } from './slice';
export { default as ThemePicker } from './ThemePicker';
@@ -0,0 +1 @@
export { default as UpdateBanner } from './ui/UpdateBanner';
@@ -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();
+1
View File
@@ -0,0 +1 @@
export { default as UserForm } from './ui/UserForm';
@@ -6,7 +6,7 @@ import {
useGetUserQuery,
useCreateUserMutation,
useUpdateUserMutation,
} from '../../entities/user/api';
} from '../../../../entities/user';
type FieldErrors = Record<string, string[]>;
@@ -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();
+4 -82
View File
@@ -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 (
<Box
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}
>
<CircularProgress size={28} />
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: { xs: '100vh', md: 'calc(100vh - 48px)' },
minWidth: 0,
width: '100%',
overflowX: 'hidden',
}}
>
<Box
sx={{
px: { xs: 1.5, md: 2 },
py: 1.5,
pl: { xs: 7, md: 2 },
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 1,
minWidth: 0,
}}
>
<IconButton
component={Link}
to={backHref}
size="small"
aria-label="Back"
sx={{ flexShrink: 0 }}
>
<ArrowBack sx={{ fontSize: 22 }} />
</IconButton>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="h6" fontWeight={600} noWrap>
{agent?.name ?? 'Agent'}
</Typography>
<Typography variant="caption" color="text.secondary">
Workspace files
</Typography>
</Box>
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
overflow: 'auto',
px: { xs: 2, md: 3 },
py: 2,
}}
>
<WorkspaceFileTabs agentId={agentId} />
</Box>
</Box>
);
if (!agentId) return null;
return <Workspace agentId={agentId} />;
}
+4 -39
View File
@@ -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 (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Typography color="text.secondary">Select a conversation to start chatting</Typography>
@@ -20,33 +13,5 @@ export default function AgentChat() {
);
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: {
xs: '100vh',
md: 'calc(100vh - 48px)',
},
minWidth: 0,
width: '100%',
overflowX: 'hidden',
}}
>
{agentId && (
<ChatHeader
agentId={agentId}
conversationId={conversationId}
showSessionSettings={showSessionSettings}
onToggleSessionSettings={() => setShowSessionSettings((v) => !v)}
/>
)}
{showSessionSettings && agentId && (
<SessionSettingsBar agentId={agentId} conversationId={conversationId} />
)}
<MessageList chat={chat} />
<ChatInput onSend={chat.send} isStreaming={chat.isStreaming} />
</Box>
);
return <Chat agentId={agentId} conversationId={conversationId} />;
}
+2 -103
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Channels
</Typography>
{!busy && (
<Typography variant="body2" color="text.secondary">
{chat.length} chat · {auth.length} auth
</Typography>
)}
<IconButton
size="small"
onClick={() => setShowAdd((prev) => !prev)}
sx={{
bgcolor: showAdd ? 'primary.main' : 'action.hover',
color: showAdd ? 'primary.contrastText' : 'text.primary',
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
}}
>
<Add sx={{ fontSize: 20 }} />
</IconButton>
</Box>
<Collapse in={showAdd}>
<AddChannelForm onDone={handleAdded} />
</Collapse>
{busy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<>
{chat.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography
variant="overline"
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
>
Chat Channels
</Typography>
{chat.map((c) => (
<ChatRow key={c.id} channel={c} onRemove={handleRemove} />
))}
</Box>
)}
{auth.length > 0 && (
<Box>
<Typography
variant="overline"
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
>
Auth Profiles
</Typography>
{auth.map((a) => (
<AuthRow key={a.id} profile={a} />
))}
</Box>
)}
{chat.length === 0 && auth.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
No channels configured
</Typography>
)}
</>
)}
</Box>
);
return <ChannelsPanel />;
}
+2 -75
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Cron Jobs
</Typography>
{!busy && (
<Typography variant="body2" color="text.secondary">
{jobs.length} job{jobs.length !== 1 ? 's' : ''}
</Typography>
)}
<IconButton
size="small"
onClick={() => setShowAdd((prev) => !prev)}
sx={{
bgcolor: showAdd ? 'primary.main' : 'action.hover',
color: showAdd ? 'primary.contrastText' : 'text.primary',
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
}}
>
<Add sx={{ fontSize: 20 }} />
</IconButton>
</Box>
<Collapse in={showAdd}>
<AddCronForm onDone={handleAdded} />
</Collapse>
{busy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<>
{jobs.map((job) => (
<CronRow key={job.id} job={job} onRemove={handleRemove} />
))}
{jobs.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
No cron jobs configured
</Typography>
)}
</>
)}
</Box>
);
return <CronPanel />;
}
+1 -1
View File
@@ -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();
+2 -76
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Plugins
</Typography>
{!isLoading && (
<Typography variant="body2" color="text.secondary">
{enabledCount} of {all.length} enabled
</Typography>
)}
</Box>
<TextField
fullWidth
size="small"
placeholder="Search plugins..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
</InputAdornment>
),
},
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
'& input': { fontSize: '0.85rem', py: 1 },
},
}}
/>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<Box>
{filtered.map((p) => (
<PluginRow key={p.id} plugin={p} />
))}
{filtered.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
{search ? 'No plugins match your search' : 'No plugins found'}
</Typography>
)}
</Box>
)}
</Box>
);
return <PluginsPanel />;
}
+2 -96
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Skills
</Typography>
{!isLoading && (
<Typography variant="body2" color="text.secondary">
{eligibleCount} of {all.length} eligible
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
fullWidth
size="small"
placeholder="Search skills..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
</InputAdornment>
),
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
'& input': { fontSize: '0.85rem', py: 1 },
},
}}
/>
{(['all', 'eligible', 'missing'] as const).map((f) => (
<Chip
key={f}
label={f}
size="small"
variant="filled"
onClick={() => 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',
},
}}
/>
))}
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<Box>
{filtered.map((s) => (
<SkillRow key={s.name} skill={s} />
))}
{filtered.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
{search ? 'No skills match your search' : 'No skills found'}
</Typography>
)}
</Box>
)}
</Box>
);
return <SkillsPanel />;
}
+2 -128
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Users
</Typography>
{!busy && (
<Typography variant="body2" color="text.secondary">
{users.length} user{users.length !== 1 ? 's' : ''}
</Typography>
)}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<Add sx={{ fontSize: 20 }} />
</IconButton>
</Box>
<Collapse in={showForm}>
<UserForm userId={editUserId} onDone={handleFormDone} />
</Collapse>
<TextField
fullWidth
size="small"
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
</InputAdornment>
),
},
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
'& input': { fontSize: '0.85rem', py: 1 },
},
}}
/>
{busy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<Box>
{filtered.map((user) => (
<UserRow key={user._id} user={user} onEdit={handleEdit} onDelete={handleDelete} />
))}
{filtered.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
{search ? 'No users match your search' : 'No users found'}
</Typography>
)}
</Box>
)}
</Box>
);
return <UsersPanel />;
}
+1
View File
@@ -0,0 +1 @@
export { API_BASE_URL, baseApi } from './baseApi';
+1
View File
@@ -0,0 +1 @@
export { useValidationErrors } from './useValidationErrors';
@@ -0,0 +1,15 @@
export function useValidationErrors(error: unknown): Record<string, string[]> | 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<string, string[]> }).data;
}
return null;
}
+3
View File
@@ -0,0 +1,3 @@
export { default as MarkdownContent } from './MarkdownContent';
export { default as DeleteButton } from './DeleteButton';
export { default as ProviderLogo } from './ProviderLogo';
+105
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Channels
</Typography>
{!busy && (
<Typography variant="body2" color="text.secondary">
{chat.length} chat · {auth.length} auth
</Typography>
)}
<IconButton
size="small"
onClick={() => setShowAdd((prev) => !prev)}
sx={{
bgcolor: showAdd ? 'primary.main' : 'action.hover',
color: showAdd ? 'primary.contrastText' : 'text.primary',
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
}}
>
<Add sx={{ fontSize: 20 }} />
</IconButton>
</Box>
<Collapse in={showAdd}>
<AddChannelForm onDone={() => setShowAdd(false)} />
</Collapse>
{busy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<>
{chat.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography
variant="overline"
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
>
Chat Channels
</Typography>
{chat.map((c) => (
<ChatRow key={c.id} channel={c} onRemove={handleRemove} />
))}
</Box>
)}
{auth.length > 0 && (
<Box>
<Typography
variant="overline"
sx={{ px: 2, color: 'text.secondary', fontWeight: 700 }}
>
Auth Profiles
</Typography>
{auth.map((a) => (
<AuthRow key={a.id} profile={a} />
))}
</Box>
)}
{chat.length === 0 && auth.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
No channels configured
</Typography>
)}
</>
)}
</Box>
);
}
+8
View File
@@ -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';
+23
View File
@@ -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<void>;
loadMore: () => void;
handleScroll: () => void;
scrollContainerRef: RefObject<HTMLDivElement | null>;
messagesEndRef: RefObject<HTMLDivElement | null>;
}
+187
View File
@@ -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<string | undefined>();
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isLoadingMore = useRef(false);
const prevScrollHeight = useRef(0);
const initialScrollDone = useRef(false);
const lastConvId = useRef(conversationId);
const scrollTickRef = useRef(0);
const lastMergedPollTs = useRef<string | undefined>(undefined);
const dispatch = useAppDispatch();
const { data, isLoading, isFetching, refetch } = useGetMessagesQuery(
{ conversationId: conversationId!, before: loadMoreCursor },
{ skip: !conversationId }
);
const messages = useMemo<Message[]>(() => 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,
};
}
+46
View File
@@ -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 (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: { xs: '100vh', md: 'calc(100vh - 48px)' },
minWidth: 0,
width: '100%',
overflowX: 'hidden',
}}
>
<ChatHeader
agentId={agentId}
conversationId={conversationId}
showSessionSettings={showSessionSettings}
onToggleSessionSettings={() => setShowSessionSettings((v) => !v)}
/>
{showSessionSettings && (
<SessionSettingsBar agentId={agentId} conversationId={conversationId} />
)}
<MessageList chat={chat} />
<ChatInput onSend={chat.send} isStreaming={chat.isStreaming} />
</Box>
);
}
@@ -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;
@@ -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<typeof useChat>;
chat: ChatState;
}
export default function MessageList({ chat }: MessageListProps) {
@@ -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;
+77
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Cron Jobs
</Typography>
{!busy && (
<Typography variant="body2" color="text.secondary">
{jobs.length} job{jobs.length !== 1 ? 's' : ''}
</Typography>
)}
<IconButton
size="small"
onClick={() => setShowAdd((prev) => !prev)}
sx={{
bgcolor: showAdd ? 'primary.main' : 'action.hover',
color: showAdd ? 'primary.contrastText' : 'text.primary',
'&:hover': { bgcolor: showAdd ? 'primary.dark' : 'action.selected' },
}}
>
<Add sx={{ fontSize: 20 }} />
</IconButton>
</Box>
<Collapse in={showAdd}>
<AddCronForm onDone={() => setShowAdd(false)} />
</Collapse>
{busy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<>
{jobs.map((job) => (
<CronRow key={job.id} job={job} onRemove={handleRemove} />
))}
{jobs.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
No cron jobs configured
</Typography>
)}
</>
)}
</Box>
);
}
+79
View File
@@ -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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Plugins
</Typography>
{!isLoading && (
<Typography variant="body2" color="text.secondary">
{enabledCount} of {all.length} enabled
</Typography>
)}
</Box>
<TextField
fullWidth
size="small"
placeholder="Search plugins..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
</InputAdornment>
),
},
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
'& input': { fontSize: '0.85rem', py: 1 },
},
}}
/>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<Box>
{filtered.map((p) => (
<PluginRow key={p.id} plugin={p} />
))}
{filtered.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
{search ? 'No plugins match your search' : 'No plugins found'}
</Typography>
)}
</Box>
)}
</Box>
);
}
+17 -339
View File
@@ -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: <People sx={{ fontSize: 18 }} />, path: '/users' },
{ text: 'PLUGINS', icon: <Extension sx={{ fontSize: 18 }} />, path: '/plugins' },
{ text: 'SKILLS', icon: <Psychology sx={{ fontSize: 18 }} />, path: '/skills' },
{ text: 'CHANNELS', icon: <Forum sx={{ fontSize: 18 }} />, path: '/channels' },
{ text: 'CRON', icon: <Schedule sx={{ fontSize: 18 }} />, 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<string | null>(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<ReturnType<typeof setTimeout> | 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 (
<Box
@@ -107,258 +31,12 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
alignSelf: 'flex-start',
}}
>
<Box
sx={{
p: 3,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Box component="img" src="/logo_128.png" alt="OpenClaw" sx={{ width: 16, height: 16 }} />
<Typography
variant="h6"
component="span"
sx={{ fontWeight: 700, letterSpacing: '1px', color: 'error.main' }}
>
OpenClaw
</Typography>
<Typography
variant="h6"
component="span"
sx={{ fontWeight: 700, letterSpacing: '1px', color: sidebar.selectedText }}
>
Client
</Typography>
</Box>
<Box sx={{ px: 2, mb: 1, flexShrink: 0 }}>
<TextField
fullWidth
size="small"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
slotProps={{
input: {
startAdornment: <Search sx={{ fontSize: 16, color: sidebar.text, mr: 0.5 }} />,
},
}}
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 },
},
}}
/>
</Box>
<List sx={{ px: 2, py: 0, flexShrink: 0 }}>
{menuItems.map((item) => {
const isSelected =
location.pathname === item.path || location.pathname.startsWith(item.path + '/');
return (
<ListItem key={item.text} disablePadding sx={{ mb: 0.2 }}>
<ListItemButton
component={Link}
to={item.path}
onClick={onNavigate}
selected={isSelected}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.5,
textDecoration: 'none',
position: 'relative',
'&:hover': {
bgcolor: sidebar.hover,
'& .MuiListItemText-primary': { color: sidebar.selectedText },
'& .MuiListItemIcon-root': { color: sidebar.selectedText },
},
'&.Mui-selected': {
bgcolor: sidebar.selectedBg,
boxShadow: '0 2px 8px rgba(44, 44, 40, 0.06)',
'&:hover': { bgcolor: sidebar.selectedBg },
'& .MuiListItemText-primary': { color: sidebar.selectedText },
'& .MuiListItemIcon-root': { color: sidebar.selectedBorder },
},
}}
>
<ListItemIcon sx={{ minWidth: 28, color: sidebar.text }}>{item.icon}</ListItemIcon>
<ListItemText
primary={item.text}
sx={{
'& .MuiListItemText-primary': {
color: sidebar.text,
fontSize: '0.7rem',
fontWeight: 600,
letterSpacing: '1px',
},
}}
/>
{isSelected && (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: sidebar.selectedBorder,
ml: 1,
}}
/>
)}
</ListItemButton>
</ListItem>
);
})}
</List>
<Box
sx={{
px: 2,
mt: 3,
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
flexShrink: 0,
}}
>
<Typography
sx={{
color: sidebar.text,
fontSize: '0.7rem',
fontWeight: 700,
letterSpacing: '1.5px',
textTransform: 'uppercase',
}}
>
Agents
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
{isSyncing && <CircularProgress size={11} sx={{ color: sidebar.text, opacity: 0.6 }} />}
{!isSyncing && syncDone && (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: 'success.main',
opacity: 1,
animation: 'fadeOut 2.5s ease-in forwards',
'@keyframes fadeOut': {
'0%': { opacity: 1 },
'60%': { opacity: 1 },
'100%': { opacity: 0 },
},
}}
/>
)}
<IconButton
size="small"
onClick={() => setSortAlpha((v) => !v)}
title={sortAlpha ? 'Unsort' : 'Sort AZ'}
sx={{
color: sortAlpha ? 'primary.main' : sidebar.text,
p: 0.3,
'&:hover': { color: 'primary.main' },
}}
>
<SwapVert sx={{ fontSize: 15 }} />
</IconButton>
<IconButton
size="small"
onClick={() => setCollapseKey((k) => k + 1)}
title="Collapse all"
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: sidebar.selectedText } }}
>
<KeyboardDoubleArrowUp sx={{ fontSize: 15 }} />
</IconButton>
<IconButton
size="small"
onClick={() => setShowNewAgent((prev) => !prev)}
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: 'success.main' } }}
>
<Add sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
{isSyncing && <SyncProgressBar />}
{showNewAgent && (
<CreateAgentForm
onCreated={handleAgentCreated}
onCancel={() => setShowNewAgent(false)}
createAgent={createAgent}
isCreating={isCreating}
/>
)}
<Box
sx={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { bgcolor: 'transparent' },
'&::-webkit-scrollbar-thumb': {
bgcolor: sidebar.border,
borderRadius: 2,
'&:hover': { bgcolor: sidebar.text },
},
}}
>
{agentsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={18} sx={{ color: sidebar.text }} />
</Box>
) : (
<List disablePadding>
{agents.map((agent) => (
<AgentSection
key={agent._id}
agent={agent}
conversations={allConversations.filter((c) => c.agentId === agent._id)}
searchQuery={searchQuery || undefined}
collapseKey={collapseKey}
onNavigate={onNavigate}
disabled={deletingAgentId === agent._id}
/>
))}
</List>
)}
</Box>
</Box>
<SidebarHeader />
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
<SidebarMenu onNavigate={onNavigate} />
<AgentsPanel searchQuery={searchQuery} onNavigate={onNavigate} />
<UpdateBanner />
<ThemePicker />
{terminalAgent && (
<TerminalPanel
agentName={terminalAgent.slug}
agentDbId={terminalAgent.dbId}
open
onClose={() => setTerminalAgent(null)}
onDeleting={setDeletingAgentId}
/>
)}
</Box>
);
}
@@ -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 };
@@ -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<string | null>(null);
const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery();
const { data: convData } = useGetAllConversationsQuery();
const [syncAgents, { isLoading: isSyncing }] = useSyncAgentsMutation();
const [syncDone, setSyncDone] = useState(false);
const syncDoneTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<Box
sx={{
px: 2,
mt: 3,
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
flexShrink: 0,
}}
>
<Typography
sx={{
color: sidebar.text,
fontSize: '0.7rem',
fontWeight: 700,
letterSpacing: '1.5px',
textTransform: 'uppercase',
}}
>
Agents
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
{isSyncing && <CircularProgress size={11} sx={{ color: sidebar.text, opacity: 0.6 }} />}
{!isSyncing && syncDone && (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: 'success.main',
opacity: 1,
animation: 'fadeOut 2.5s ease-in forwards',
'@keyframes fadeOut': {
'0%': { opacity: 1 },
'60%': { opacity: 1 },
'100%': { opacity: 0 },
},
}}
/>
)}
<IconButton
size="small"
onClick={() => setSortAlpha((v) => !v)}
title={sortAlpha ? 'Unsort' : 'Sort AZ'}
sx={{
color: sortAlpha ? 'primary.main' : sidebar.text,
p: 0.3,
'&:hover': { color: 'primary.main' },
}}
>
<SwapVert sx={{ fontSize: 15 }} />
</IconButton>
<IconButton
size="small"
onClick={() => setCollapseKey((k) => k + 1)}
title="Collapse all"
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: sidebar.selectedText } }}
>
<KeyboardDoubleArrowUp sx={{ fontSize: 15 }} />
</IconButton>
<IconButton
size="small"
onClick={() => setShowNewAgent((prev) => !prev)}
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: 'success.main' } }}
>
<Add sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
{isSyncing && <SyncProgressBar />}
{showNewAgent && (
<CreateAgentForm
onCreated={handleAgentCreated}
onCancel={() => setShowNewAgent(false)}
/>
)}
<Box
sx={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { bgcolor: 'transparent' },
'&::-webkit-scrollbar-thumb': {
bgcolor: sidebar.border,
borderRadius: 2,
'&:hover': { bgcolor: sidebar.text },
},
}}
>
{agentsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={18} sx={{ color: sidebar.text }} />
</Box>
) : (
<List disablePadding>
{agents.map((agent) => (
<AgentSection
key={agent._id}
agent={agent}
conversations={allConversations.filter((c) => c.agentId === agent._id)}
searchQuery={searchQuery || undefined}
collapseKey={collapseKey}
onNavigate={onNavigate}
disabled={deletingAgentId === agent._id}
/>
))}
</List>
)}
</Box>
{terminalAgent && (
<TerminalPanel
agentName={terminalAgent.slug}
agentDbId={terminalAgent.dbId}
open
onClose={() => setTerminalAgent(null)}
onDeleting={setDeletingAgentId}
/>
)}
</Box>
);
}
@@ -0,0 +1,33 @@
import { Box, Typography, useTheme } from '@mui/material';
export default function SidebarHeader() {
const { sidebar } = useTheme().palette;
return (
<Box
sx={{
p: 3,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Box component="img" src="/logo_128.png" alt="OpenClaw" sx={{ width: 16, height: 16 }} />
<Typography
variant="h6"
component="span"
sx={{ fontWeight: 700, letterSpacing: '1px', color: 'error.main' }}
>
OpenClaw
</Typography>
<Typography
variant="h6"
component="span"
sx={{ fontWeight: 700, letterSpacing: '1px', color: sidebar.selectedText }}
>
Client
</Typography>
</Box>
);
}
@@ -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: <People sx={{ fontSize: 18 }} />, path: '/users' },
{ text: 'PLUGINS', icon: <Extension sx={{ fontSize: 18 }} />, path: '/plugins' },
{ text: 'SKILLS', icon: <Psychology sx={{ fontSize: 18 }} />, path: '/skills' },
{ text: 'CHANNELS', icon: <Forum sx={{ fontSize: 18 }} />, path: '/channels' },
{ text: 'CRON', icon: <Schedule sx={{ fontSize: 18 }} />, path: '/cron' },
];
interface SidebarMenuProps {
onNavigate?: () => void;
}
export default function SidebarMenu({ onNavigate }: SidebarMenuProps) {
const location = useLocation();
const { sidebar } = useTheme().palette;
return (
<List sx={{ px: 2, py: 0, flexShrink: 0 }}>
{MENU_ITEMS.map((item) => {
const isSelected =
location.pathname === item.path || location.pathname.startsWith(item.path + '/');
return (
<ListItem key={item.text} disablePadding sx={{ mb: 0.2 }}>
<ListItemButton
component={Link}
to={item.path}
onClick={onNavigate}
selected={isSelected}
sx={{
borderRadius: 1.5,
py: 0.6,
px: 1.5,
textDecoration: 'none',
position: 'relative',
'&:hover': {
bgcolor: sidebar.hover,
'& .MuiListItemText-primary': { color: sidebar.selectedText },
'& .MuiListItemIcon-root': { color: sidebar.selectedText },
},
'&.Mui-selected': {
bgcolor: sidebar.selectedBg,
boxShadow: '0 2px 8px rgba(44, 44, 40, 0.06)',
'&:hover': { bgcolor: sidebar.selectedBg },
'& .MuiListItemText-primary': { color: sidebar.selectedText },
'& .MuiListItemIcon-root': { color: sidebar.selectedBorder },
},
}}
>
<ListItemIcon sx={{ minWidth: 28, color: sidebar.text }}>{item.icon}</ListItemIcon>
<ListItemText
primary={item.text}
sx={{
'& .MuiListItemText-primary': {
color: sidebar.text,
fontSize: '0.7rem',
fontWeight: 600,
letterSpacing: '1px',
},
}}
/>
{isSelected && (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: sidebar.selectedBorder,
ml: 1,
}}
/>
)}
</ListItemButton>
</ListItem>
);
})}
</List>
);
}
@@ -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 (
<Box sx={{ px: 2, mb: 1, flexShrink: 0 }}>
<TextField
fullWidth
size="small"
placeholder="Search..."
value={value}
onChange={(e) => onChange(e.target.value)}
slotProps={{
input: {
startAdornment: <Search sx={{ fontSize: 16, color: sidebar.text, mr: 0.5 }} />,
},
}}
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 },
},
}}
/>
</Box>
);
}
+109
View File
@@ -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<SkillFilter>('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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Skills
</Typography>
{!isLoading && (
<Typography variant="body2" color="text.secondary">
{eligibleCount} of {all.length} eligible
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
fullWidth
size="small"
placeholder="Search skills..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
</InputAdornment>
),
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
'& input': { fontSize: '0.85rem', py: 1 },
},
}}
/>
{(['all', 'eligible', 'missing'] as const).map((f) => (
<Chip
key={f}
label={f}
size="small"
variant="filled"
onClick={() => 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',
},
}}
/>
))}
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<Box>
{filtered.map((s) => (
<SkillRow key={s.name} skill={s} />
))}
{filtered.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
{search ? 'No skills match your search' : 'No skills found'}
</Typography>
)}
</Box>
)}
</Box>
);
}
+134
View File
@@ -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<FormMode>('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 (
<Box sx={{ p: 3, maxWidth: 800, mx: 'auto', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
Users
</Typography>
{!busy && (
<Typography variant="body2" color="text.secondary">
{users.length} user{users.length !== 1 ? 's' : ''}
</Typography>
)}
<IconButton
size="small"
onClick={() => 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',
},
}}
>
<Add sx={{ fontSize: 20 }} />
</IconButton>
</Box>
<Collapse in={showForm}>
<UserForm userId={editUserId} onDone={() => setFormMode('closed')} />
</Collapse>
<TextField
fullWidth
size="small"
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search sx={{ fontSize: 18, color: 'text.secondary' }} />
</InputAdornment>
),
},
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 1.5,
'& input': { fontSize: '0.85rem', py: 1 },
},
}}
/>
{busy ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress size={24} />
</Box>
) : (
<Box>
{filtered.map((user) => (
<UserRow
key={user._id}
user={user}
onEdit={(id) => setFormMode(id)}
onDelete={handleDelete}
/>
))}
{filtered.length === 0 && (
<Typography sx={{ color: 'text.secondary', py: 4, textAlign: 'center' }}>
{search ? 'No users match your search' : 'No users found'}
</Typography>
)}
</Box>
)}
</Box>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as Workspace } from './ui/Workspace';
export { default as WorkspaceFileTabs } from './ui/WorkspaceFileTabs';
@@ -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 (
<Box
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}
>
<CircularProgress size={28} />
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: { xs: '100vh', md: 'calc(100vh - 48px)' },
minWidth: 0,
width: '100%',
overflowX: 'hidden',
}}
>
<Box
sx={{
px: { xs: 1.5, md: 2 },
py: 1.5,
pl: { xs: 7, md: 2 },
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 1,
minWidth: 0,
}}
>
<IconButton
component={Link}
to={backHref}
size="small"
aria-label="Back"
sx={{ flexShrink: 0 }}
>
<ArrowBack sx={{ fontSize: 22 }} />
</IconButton>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="h6" fontWeight={600} noWrap>
{agent?.name ?? 'Agent'}
</Typography>
<Typography variant="caption" color="text.secondary">
Workspace files
</Typography>
</Box>
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
overflow: 'auto',
px: { xs: 2, md: 3 },
py: 2,
}}
>
<WorkspaceFileTabs agentId={agentId} />
</Box>
</Box>
);
}
@@ -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);