diff --git a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx index 62feecd226..2a5a93357c 100644 --- a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx @@ -1,22 +1,16 @@ import type { ButtonProps } from '@heroui/react' import { Button, cn } from '@heroui/react' import { PlusIcon } from 'lucide-react' -import type { FC } from 'react' -interface Props extends ButtonProps { - children: React.ReactNode -} - -const AddButton: FC = ({ children, className, ...props }) => { +const AddButton = ({ children, className, ...props }: ButtonProps) => { return ( ) diff --git a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx index d4dcc4cb95..dd2ba50099 100644 --- a/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx +++ b/src/renderer/src/pages/home/Tabs/components/SessionItem.tsx @@ -1,3 +1,4 @@ +import { cn } from '@heroui/react' import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { isMac } from '@renderer/config/constant' import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' @@ -19,14 +20,14 @@ import { ContextMenuSubTrigger, ContextMenuTrigger } from '@renderer/ui/context-menu' -import { classNames } from '@renderer/utils' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { Tooltip } from 'antd' import { MenuIcon, XIcon } from 'lucide-react' import type { FC } from 'react' import React, { memo, startTransition, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' + +import { ListItem, ListItemEditInput, ListItemName, ListItemNameContainer, MenuButton, StatusIndicator } from './shared' // const logger = loggerService.withContext('AgentItem') @@ -67,7 +68,6 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress }> { e.stopPropagation() if (isConfirmingDeletion || e.ctrlKey || e.metaKey) { @@ -115,20 +115,21 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress <> - startEdit(session.name ?? '')} - title={session.name ?? session.id} - style={{ - borderRadius: 'var(--list-item-border-radius)', - cursor: isEditing ? 'default' : 'pointer' - }}> - {isPending && !isActive && } - {isFulfilled && !isActive && } - + title={session.name ?? session.id}> + {isPending && !isActive && } + {isFulfilled && !isActive && } + {isEditing ? ( - ) => handleValueChange(e.target.value)} @@ -138,14 +139,14 @@ const SessionItem: FC = ({ session, agentId, onDelete, onPress /> ) : ( <> - + - + )} - - + + = ({ session, agentId, onDelete, onPress ) } -const SessionListItem = styled.div` - padding: 7px 12px; - border-radius: var(--list-item-border-radius); - font-size: 13px; - display: flex; - flex-direction: column; - justify-content: space-between; - cursor: pointer; - width: calc(var(--assistants-width) - 20px); - margin-bottom: 8px; - - .menu { - opacity: 0; - color: var(--color-text-3); - } - - &:hover { - background-color: var(--color-list-item-hover); - transition: background-color 0.1s; - - .menu { - opacity: 1; - } - } - - &.active { - background-color: var(--color-list-item); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - .menu { - opacity: 1; - - &:hover { - color: var(--color-text-2); - } - } - } - - &.singlealone { - border-radius: 0 !important; - &:hover { - background-color: var(--color-background-soft); - } - &.active { - border-left: 2px solid var(--color-primary); - box-shadow: none; - } - } -` - -const SessionNameContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 4px; - height: 20px; - justify-content: space-between; -` - -const SessionName = styled.div` - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - font-size: 13px; - position: relative; -` - -const SessionEditInput = styled.input` - background: var(--color-background); - border: none; - color: var(--color-text-1); - font-size: 13px; - font-family: inherit; - padding: 2px 6px; - width: 100%; - outline: none; - padding: 0; -` - -const MenuButton = styled.div` - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - min-width: 20px; - min-height: 20px; - .anticon { - font-size: 12px; - } -` - -const PendingIndicator = styled.div.attrs({ - className: 'animation-pulse' -})` - --pulse-size: 5px; - width: 5px; - height: 5px; - position: absolute; - left: 3px; - top: 15px; - border-radius: 50%; - background-color: var(--color-status-warning); -` - -const FulfilledIndicator = styled.div.attrs({ - className: 'animation-pulse' -})` - --pulse-size: 5px; - width: 5px; - height: 5px; - position: absolute; - left: 3px; - top: 15px; - border-radius: 50%; - background-color: var(--color-status-success); -` - export default memo(SessionItem) diff --git a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx index 5a36ce5d22..b56ef9f9c2 100644 --- a/src/renderer/src/pages/home/Tabs/components/Sessions.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Sessions.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next' import AddButton from './AddButton' import SessionItem from './SessionItem' +import { ListContainer } from './shared' // const logger = loggerService.withContext('SessionsTab') @@ -95,7 +96,7 @@ const Sessions: React.FC = ({ agentId }) => { if (error) return return ( -
+ {t('agent.session.add.title')} @@ -118,7 +119,7 @@ const Sessions: React.FC = ({ agentId }) => { /> )} -
+ ) } diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index 04e8bead7a..65bfdd8b3f 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -1,3 +1,4 @@ +import { cn } from '@heroui/react' import { DraggableVirtualList } from '@renderer/components/DraggableList' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' @@ -17,7 +18,7 @@ import store from '@renderer/store' import { newMessagesActions } from '@renderer/store/newMessage' import { setGenerating } from '@renderer/store/runtime' import type { Assistant, Topic } from '@renderer/types' -import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils' +import { removeSpecialCharactersForFileName } from '@renderer/utils' import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy' import { exportMarkdownToJoplin, @@ -53,6 +54,15 @@ import { useDispatch, useSelector } from 'react-redux' import styled from 'styled-components' import AddButton from './AddButton' +import { + ListContainer, + ListItem, + ListItemEditInput, + ListItemName, + ListItemNameContainer, + MenuButton, + StatusIndicator +} from './shared' interface Props { assistant: Assistant @@ -73,8 +83,6 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se const topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic) const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics) - const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)' - const [deletingTopicId, setDeletingTopicId] = useState(null) const deleteTimerRef = useRef(null) const [editingTopicId, setEditingTopicId] = useState(null) @@ -489,252 +497,107 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se const singlealone = topicPosition === 'right' && position === 'right' return ( - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} className="mb-2"> - {t('chat.add.topic.title')} - - }> - {(topic) => { - const isActive = topic.id === activeTopic?.id - const topicName = topic.name.replace('`', '') - const topicPrompt = topic.prompt - const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt + + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} className="mb-2"> + {t('chat.add.topic.title')} + + + {(topic) => { + const isActive = topic.id === activeTopic?.id + const topicName = topic.name.replace('`', '') + const topicPrompt = topic.prompt + const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt - const getTopicNameClassName = () => { - if (isRenaming(topic.id)) return 'shimmer' - if (isNewlyRenamed(topic.id)) return 'typing' - return '' - } + const getTopicNameClassName = () => { + if (isRenaming(topic.id)) return 'shimmer' + if (isNewlyRenamed(topic.id)) return 'typing' + return '' + } - return ( - - setTargetTopic(topic)} - className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} - onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)} - style={{ - borderRadius, - cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer' - }}> - {isPending(topic.id) && !isActive && } - {isFulfilled(topic.id) && !isActive && } - - {editingTopicId === topic.id && topicEdit.isEditing ? ( - e.stopPropagation()} - /> - ) : ( - { - setEditingTopicId(topic.id) - topicEdit.startEdit(topic.name) - }}> - {topicName} - + return ( + + setTargetTopic(topic)} + className={cn( + isActive ? 'active' : undefined, + singlealone ? 'singlealone' : undefined, + editingTopicId === topic.id && topicEdit.isEditing ? 'cursor-default' : 'cursor-pointer', + showTopicTime ? 'rounded-2xl' : 'rounded-[var(--list-item-border-radius)]' )} - {!topic.pinned && ( - - {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} - - }> - { - if (e.ctrlKey || e.metaKey) { - handleConfirmDelete(topic, e) - } else if (deletingTopicId === topic.id) { - handleConfirmDelete(topic, e) - } else { - handleDeleteClick(topic.id, e) - } + onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}> + {isPending(topic.id) && !isActive && } + {isFulfilled(topic.id) && !isActive && } + + {editingTopicId === topic.id && topicEdit.isEditing ? ( + e.stopPropagation()} + /> + ) : ( + { + setEditingTopicId(topic.id) + topicEdit.startEdit(topic.name) }}> - {deletingTopicId === topic.id ? ( - - ) : ( - - )} + {topicName} + + )} + {!topic.pinned && ( + + {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} + + }> + { + if (e.ctrlKey || e.metaKey) { + handleConfirmDelete(topic, e) + } else if (deletingTopicId === topic.id) { + handleConfirmDelete(topic, e) + } else { + handleDeleteClick(topic.id, e) + } + }}> + {deletingTopicId === topic.id ? ( + + ) : ( + + )} + + + )} + {topic.pinned && ( + + - + )} + + {topicPrompt && ( + + {fullTopicPrompt} + )} - {topic.pinned && ( - - - + {showTopicTime && ( + {dayjs(topic.createdAt).format('MM/DD HH:mm')} )} - - {topicPrompt && ( - - {fullTopicPrompt} - - )} - {showTopicTime && {dayjs(topic.createdAt).format('MM/DD HH:mm')}} - - - ) - }} - + + + ) + }} + + ) } -const TopicListItem = styled.div` - padding: 7px 12px; - border-radius: var(--list-item-border-radius); - font-size: 13px; - display: flex; - flex-direction: column; - justify-content: space-between; - cursor: pointer; - width: calc(var(--assistants-width) - 20px); - - .menu { - opacity: 0; - color: var(--color-text-3); - } - - &:hover { - background-color: var(--color-list-item-hover); - transition: background-color 0.1s; - - .menu { - opacity: 1; - } - } - - &.active { - background-color: var(--color-list-item); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - .menu { - opacity: 1; - - &:hover { - color: var(--color-text-2); - } - } - } - &.singlealone { - border-radius: 0 !important; - &:hover { - background-color: var(--color-background-soft); - } - &.active { - border-left: 2px solid var(--color-primary); - box-shadow: none; - } - } -` - -const TopicNameContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 4px; - height: 20px; - justify-content: space-between; -` - -const TopicName = styled.div` - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - font-size: 13px; - position: relative; - will-change: background-position, width; - - --color-shimmer-mid: var(--color-text-1); - --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); - - &.shimmer { - background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end)); - background-size: 200% 100%; - background-clip: text; - color: transparent; - animation: shimmer 3s linear infinite; - } - - &.typing { - display: block; - -webkit-line-clamp: unset; - -webkit-box-orient: unset; - white-space: nowrap; - overflow: hidden; - animation: typewriter 0.5s steps(40, end); - } - - @keyframes shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } - } - - @keyframes typewriter { - from { - width: 0; - } - to { - width: 100%; - } - } -` - -const TopicEditInput = styled.input` - background: var(--color-background); - border: none; - color: var(--color-text-1); - font-size: 13px; - font-family: inherit; - padding: 2px 6px; - width: 100%; - outline: none; - padding: 0; -` - -const PendingIndicator = styled.div.attrs({ - className: 'animation-pulse' -})` - --pulse-size: 5px; - width: 5px; - height: 5px; - position: absolute; - left: 3px; - top: 15px; - border-radius: 50%; - background-color: var(--color-status-warning); -` - -const FulfilledIndicator = styled.div.attrs({ - className: 'animation-pulse' -})` - --pulse-size: 5px; - width: 5px; - height: 5px; - position: absolute; - left: 3px; - top: 15px; - border-radius: 50%; - background-color: var(--color-status-success); -` - const TopicPromptText = styled.div` color: var(--color-text-2); font-size: 12px; @@ -751,15 +614,3 @@ const TopicTime = styled.div` color: var(--color-text-3); font-size: 11px; ` - -const MenuButton = styled.div` - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - min-width: 20px; - min-height: 20px; - .anticon { - font-size: 12px; - } -` diff --git a/src/renderer/src/pages/home/Tabs/components/shared.tsx b/src/renderer/src/pages/home/Tabs/components/shared.tsx new file mode 100644 index 0000000000..2ef0278984 --- /dev/null +++ b/src/renderer/src/pages/home/Tabs/components/shared.tsx @@ -0,0 +1,131 @@ +import { cn } from '@heroui/react' +import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from 'react' +import { useMemo } from 'react' +import styled from 'styled-components' + +export const ListItem = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => { + return ( +
+ {children} +
+ ) +} +export const ListItemNameContainer = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => { + return ( +
+ {children} +
+ ) +} + +// This component involves complex animations and will not be migrated for now. +export const ListItemName = styled.div` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 14px; + position: relative; + will-change: background-position, width; + + --color-shimmer-mid: var(--color-text-1); + --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); + + &.shimmer { + background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end)); + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: shimmer 3s linear infinite; + } + + &.typing { + display: block; + -webkit-line-clamp: unset; + -webkit-box-orient: unset; + white-space: nowrap; + overflow: hidden; + animation: typewriter 0.5s steps(40, end); + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } + + @keyframes typewriter { + from { + width: 0; + } + to { + width: 100%; + } + } +` + +export const ListItemEditInput = ({ className, ...props }: ComponentPropsWithRef<'input'>) => { + return ( + + ) +} + +export const ListContainer = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => { + return ( +
+ {children} +
+ ) +} + +export const MenuButton = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => { + return ( +
+ {children} +
+ ) +} + +export const StatusIndicator = ({ variant }: { variant: 'pending' | 'fulfilled' }) => { + const colors = useMemo(() => { + switch (variant) { + case 'pending': + return { + wave: 'bg-warning-400', + back: 'bg-warning-500' + } + case 'fulfilled': + return { + wave: 'bg-success-400', + back: 'bg-success-500' + } + } + }, [variant]) + return ( +
+ + +
+ ) +}