From caa59c4c50ed65ae360d508dd0fd6e0156db47b6 Mon Sep 17 00:00:00 2001 From: Phantom Date: Wed, 5 Nov 2025 14:14:40 +0800 Subject: [PATCH] refactor(Topics & Sessions): Style and code structure adjustments (#10868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(Tabs): extract shared styled components into separate file Move common styled components (ListItem, ListItemNameContainer, ListItemName, ListItemEditInput) from SessionItem.tsx and Topics.tsx into shared.tsx to improve code reuse and maintainability * refactor(components): extract ListContainer component for shared tab layouts Create reusable ListContainer component to standardize layout styling across tabs Replace manual div containers in Sessions and Topics components with new ListContainer * refactor(ListItem): convert styled component to Tailwind CSS function component - Convert ListItem from styled-components to Tailwind CSS function component - Maintain all original styling and hover/active states - Use HTMLDivElement props interface for proper TypeScript typing - Preserve CSS custom properties for theme variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(ListItemNameContainer): convert styled component to Tailwind CSS function component - Convert ListItemNameContainer from styled-components to Tailwind CSS function component - Simplify layout styles using Tailwind's utility classes - Use HTMLDivElement props interface for proper TypeScript typing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(ListItemName): convert styled component to Tailwind CSS function component - Convert ListItemName from styled-components to Tailwind CSS function component - Use inline styles for webkit-specific line clamping properties - Remove complex animations from component definition (can be added via CSS classes) - Use HTMLDivElement props interface for proper TypeScript typing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(ListItemEditInput): convert styled component to Tailwind CSS function component - Convert ListItemEditInput from styled-components to Tailwind CSS function component - Use proper InputHTMLAttributes type for input elements - Remove styled-components import as no longer needed - Maintain all original styling using Tailwind utility classes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(components): improve type safety and class ordering in shared components - Replace HTMLAttributes with more specific ComponentProps types - Reorder class names for better readability and consistency * refactor(components): update styling and class handling in list items - Replace deprecated classNames utility with cn from @heroui/react - Consolidate style properties into className using cn - Improve CSS selector syntax for better specificity - Standardize padding and border radius values * Revert "refactor(ListItemName): convert styled component to Tailwind CSS function component" This reverts commit 196136068db9fc27294ffebe8124dd0eeab64cf1. * style(shared): increase font size and remove redundant padding The font size was increased from 13px to 14px for better readability. Redundant padding in ListItemEditInput was removed to maintain consistent styling. * refactor(AddButton): simplify component by removing FC type and inline props Remove unnecessary FC type declaration and inline the Props interface with ButtonProps. Also clean up prop spreading by moving it to the end of the component. * style(Topics): remove redundant className and add overflow styles * refactor(components): extract MenuButton to shared components Move MenuButton implementation from individual components to shared module to reduce code duplication and improve maintainability * refactor(PendingIndicator): convert styled component to Tailwind CSS function component - Convert PendingIndicator from styled-components to Tailwind CSS function component - Use ComponentPropsWithoutRef<'div'> for consistent TypeScript typing - Replace styled-components attrs with Tailwind animate-pulse class - Use CSS custom properties for pulse-size variable - Remove styled-components import as no longer needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(components): replace styled indicators with shared StatusIndicator Consolidate PendingIndicator and FulfilledIndicator into a single StatusIndicator component with variant support * style(shared.tsx): adjust border styles for singlealone active state * refactor: use type-only imports for react props --------- Co-authored-by: Claude --- .../pages/home/Tabs/components/AddButton.tsx | 12 +- .../home/Tabs/components/SessionItem.tsx | 154 +------- .../pages/home/Tabs/components/Sessions.tsx | 5 +- .../src/pages/home/Tabs/components/Topics.tsx | 353 +++++------------- .../src/pages/home/Tabs/components/shared.tsx | 131 +++++++ 5 files changed, 258 insertions(+), 397 deletions(-) create mode 100644 src/renderer/src/pages/home/Tabs/components/shared.tsx 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 ( +
+ + +
+ ) +}