From b909e4ae201931f49e15a700c0f6eea6f4969e98 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sun, 19 Apr 2026 00:16:48 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style(hetero-agent):=20add=20het?= =?UTF-8?q?ero-mode=20actions=20bar=20(#13963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(hetero-agent): add hetero-mode actions bar with copy/delete only Hide edit, regenerate, branching, translate, tts, share and delAndRegenerate for heterogeneous-agent sessions where these actions don't apply. Introduce `mode: 'hetero'` on MessageActionsConfig and dispatch to dedicated Hetero action bars for user, assistant, and assistant-group messages. Co-Authored-By: Claude Opus 4.7 (1M context) * ♻️ refactor(conversation): replace per-role action hooks with declarative action registry Replace the 4 duplicate per-role action hooks (useUserActions / useAssistantActions / useGroupActions / Task.useAssistantActions) and the 4 copies of stripHandleClick / buildActionsMap / dispatch logic with a single registry + universal MessageActionBar renderer. Each action (copy / del / edit / regenerate / delAndRegenerate / continueGeneration / translate / tts / share / collapse / branching) is now a standalone module under components/MessageActionBar/actions/. Config is declarative — string slot keys (e.g. ['copy', 'divider', 'del']) resolved against the registry at render time. Hetero-agent sessions drop the special mode flag; they just declare copy-only slot lists via config. Dev-mode branching becomes a registry key instead of a factory. Deletes ErrorActionsBar (handled in-place via slot lists), the dead Supervisor/Actions folder, and the HeteroActionsBar scaffold introduced in the previous commit. Net: -1900 lines, one place to add a new action. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../Messages/Assistant/Actions/Error.tsx | 24 -- .../Messages/Assistant/Actions/index.tsx | 247 +++--------------- .../Assistant/Actions/useAssistantActions.ts | 190 -------------- .../Messages/AssistantGroup/Actions/index.tsx | 247 +++--------------- .../AssistantGroup/Actions/useGroupActions.ts | 195 -------------- .../Contexts/MessageActionProvider.tsx | 4 +- .../Messages/GroupTasks/index.tsx | 7 +- .../Messages/Supervisor/Actions/index.tsx | 184 ------------- .../Supervisor/Actions/useGroupActions.ts | 171 ------------ .../Messages/Task/Actions/Error.tsx | 24 -- .../Messages/Task/Actions/index.tsx | 219 +++------------- .../Task/Actions/useAssistantActions.ts | 151 ----------- .../Conversation/Messages/Task/index.tsx | 4 +- .../Conversation/Messages/Tasks/index.tsx | 7 +- .../Messages/User/Actions/index.tsx | 158 ++--------- .../Messages/User/Actions/useUserActions.ts | 122 --------- .../Conversation/Messages/User/index.tsx | 11 +- .../MessageActionBar/actions/branching.ts | 33 +++ .../MessageActionBar/actions/collapse.ts | 30 +++ .../actions/continueGeneration.ts | 33 +++ .../MessageActionBar/actions/copy.ts | 33 +++ .../MessageActionBar/actions/del.ts | 25 ++ .../actions/delAndRegenerate.ts | 28 ++ .../MessageActionBar/actions/edit.ts | 29 ++ .../MessageActionBar/actions/regenerate.ts | 48 ++++ .../MessageActionBar/actions/share.tsx | 45 ++++ .../MessageActionBar/actions/translate.ts | 39 +++ .../MessageActionBar/actions/tts.ts | 24 ++ .../MessageActionBar/defineAction.ts | 6 + .../components/MessageActionBar/index.tsx | 116 ++++++++ .../components/MessageActionBar/types.ts | 33 +++ .../MessageActionBar/useBuildActions.ts | 38 +++ src/features/Conversation/Messages/index.tsx | 4 +- src/features/Conversation/types/ui.ts | 35 +-- .../Conversation/useActionsBarConfig.ts | 93 +++---- .../Conversation/useActionsBarConfig.ts | 63 +---- 36 files changed, 771 insertions(+), 1949 deletions(-) delete mode 100644 src/features/Conversation/Messages/Assistant/Actions/Error.tsx delete mode 100644 src/features/Conversation/Messages/Assistant/Actions/useAssistantActions.ts delete mode 100644 src/features/Conversation/Messages/AssistantGroup/Actions/useGroupActions.ts delete mode 100644 src/features/Conversation/Messages/Supervisor/Actions/index.tsx delete mode 100644 src/features/Conversation/Messages/Supervisor/Actions/useGroupActions.ts delete mode 100644 src/features/Conversation/Messages/Task/Actions/Error.tsx delete mode 100644 src/features/Conversation/Messages/Task/Actions/useAssistantActions.ts delete mode 100644 src/features/Conversation/Messages/User/Actions/useUserActions.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/branching.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/collapse.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/continueGeneration.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/copy.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/del.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/delAndRegenerate.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/edit.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/regenerate.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/share.tsx create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/translate.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/actions/tts.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/defineAction.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/index.tsx create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/types.ts create mode 100644 src/features/Conversation/Messages/components/MessageActionBar/useBuildActions.ts diff --git a/src/features/Conversation/Messages/Assistant/Actions/Error.tsx b/src/features/Conversation/Messages/Assistant/Actions/Error.tsx deleted file mode 100644 index 61e88df2fb..0000000000 --- a/src/features/Conversation/Messages/Assistant/Actions/Error.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { type ActionIconGroupEvent } from '@lobehub/ui'; -import { ActionIconGroup } from '@lobehub/ui'; -import { memo } from 'react'; - -import { type AssistantActions } from './useAssistantActions'; - -interface ErrorActionsBarProps { - actions: AssistantActions; - onActionClick: (event: ActionIconGroupEvent) => void; -} - -export const ErrorActionsBar = memo(({ actions, onActionClick }) => { - const { regenerate, copy, edit, del, divider } = actions; - - return ( - - ); -}); - -ErrorActionsBar.displayName = 'ErrorActionsBar'; diff --git a/src/features/Conversation/Messages/Assistant/Actions/index.tsx b/src/features/Conversation/Messages/Assistant/Actions/index.tsx index 28e5620386..639cde050d 100644 --- a/src/features/Conversation/Messages/Assistant/Actions/index.tsx +++ b/src/features/Conversation/Messages/Assistant/Actions/index.tsx @@ -1,222 +1,61 @@ import { type UIChatMessage } from '@lobechat/types'; -import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui'; -import { ActionIconGroup, createRawModal, Flexbox } from '@lobehub/ui'; -import { memo, useCallback, useMemo } from 'react'; +import { Flexbox } from '@lobehub/ui'; +import { memo, useMemo } from 'react'; import { ReactionPicker } from '../../../components/Reaction'; -import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal'; +import type { MessageActionsConfig } from '../../../types'; import { - createStore, - messageStateSelectors, - Provider, - useConversationStore, - useConversationStoreApi, -} from '../../../store'; -import type { - MessageActionItem, - MessageActionItemOrDivider, - MessageActionsConfig, -} from '../../../types'; -import { ErrorActionsBar } from './Error'; -import { useAssistantActions } from './useAssistantActions'; + MessageActionBar, + type MessageActionContext, + type MessageActionSlot, +} from '../../components/MessageActionBar'; -// Helper to strip handleClick from action items before passing to ActionIconGroup -const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => { - if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType; - const { children, ...rest } = item as MessageActionItem; - const baseItem = { ...rest } as MessageActionItem; - delete (baseItem as { handleClick?: unknown }).handleClick; - if (children) { - return { - ...baseItem, - children: children.map((child) => { - const nextChild = { ...child } as MessageActionItem; - delete (nextChild as { handleClick?: unknown }).handleClick; - return nextChild; - }), - } as ActionIconGroupItemType; - } - return baseItem as ActionIconGroupItemType; -}; - -// Build action items map for handleAction lookup -const buildActionsMap = (items: MessageActionItemOrDivider[]): Map => { - const map = new Map(); - for (const item of items) { - if ('key' in item && item.key) { - map.set(String(item.key), item as MessageActionItem); - // Also index children for submenu items - if ('children' in item && item.children) { - for (const child of item.children) { - if (child.key) { - map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem); - } - } - } - } - } - return map; -}; +const DEFAULT_BAR_WITH_TOOLS: MessageActionSlot[] = ['delAndRegenerate', 'copy']; +const DEFAULT_BAR: MessageActionSlot[] = ['edit', 'copy']; +const DEFAULT_MENU: MessageActionSlot[] = [ + 'edit', + 'copy', + 'collapse', + 'divider', + 'tts', + 'translate', + 'divider', + 'share', + 'divider', + 'regenerate', + 'delAndRegenerate', + 'del', +]; +const ERROR_BAR: MessageActionSlot[] = ['regenerate', 'del']; +const ERROR_MENU: MessageActionSlot[] = ['edit', 'copy', 'divider', 'del']; interface AssistantActionsBarProps { actionsConfig?: MessageActionsConfig; data: UIChatMessage; id: string; - index: number; } -export const AssistantActionsBar = memo( - ({ actionsConfig, id, data, index }) => { - const { error, tools } = data; - const store = useConversationStoreApi(); +export const AssistantActionsBar = memo(({ actionsConfig, id, data }) => { + const ctx = useMemo(() => ({ data, id, role: 'assistant' }), [data, id]); - const handleOpenShareModal = useCallback(() => { - createRawModal( - (props: ShareModalProps) => ( - { - const state = store.getState(); - return createStore({ - context: state.context, - hooks: state.hooks, - skipFetch: state.skipFetch, - }); - }} - > - - - ), - { - message: data, - }, - { onCloseKey: 'onCancel', openKey: 'open' }, - ); - }, [data, store]); + const { error, tools } = data; - const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id)); + if (error) { + return ; + } - const defaultActions = useAssistantActions({ - data, - id, - index, - onOpenShareModal: handleOpenShareModal, - }); + const defaultBar = tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR; - const hasTools = !!tools; - - // Get collapse/expand action based on current state - const collapseAction = isCollapsed ? defaultActions.expand : defaultActions.collapse; - - // Create extra actions from factory functions - const extraBarItems = useMemo(() => { - if (!actionsConfig?.extraBarActions) return []; - return actionsConfig.extraBarActions - .map((factory) => factory(id)) - .filter((item): item is NonNullable => item !== null); - }, [actionsConfig?.extraBarActions, id]); - - const extraMenuItems = useMemo(() => { - if (!actionsConfig?.extraMenuActions) return []; - return actionsConfig.extraMenuActions - .map((factory) => factory(id)) - .filter((item): item is NonNullable => item !== null); - }, [actionsConfig?.extraMenuActions, id]); - - // Use external config if provided, otherwise use defaults - // Append extra actions from factories - const barItems = useMemo(() => { - const base = - actionsConfig?.bar ?? - (hasTools - ? [defaultActions.delAndRegenerate, defaultActions.copy] - : [defaultActions.edit, defaultActions.copy]); - return [...base, ...extraBarItems]; - }, [ - actionsConfig?.bar, - hasTools, - defaultActions.delAndRegenerate, - defaultActions.copy, - defaultActions.edit, - extraBarItems, - ]); - - const menuItems = useMemo(() => { - const base = actionsConfig?.menu ?? [ - defaultActions.edit, - defaultActions.copy, - collapseAction, - defaultActions.divider, - defaultActions.tts, - defaultActions.translate, - defaultActions.divider, - defaultActions.share, - defaultActions.divider, - defaultActions.regenerate, - defaultActions.delAndRegenerate, - defaultActions.del, - ]; - return [...base, ...extraMenuItems]; - }, [ - actionsConfig?.menu, - defaultActions.edit, - defaultActions.copy, - collapseAction, - defaultActions.divider, - defaultActions.tts, - defaultActions.translate, - defaultActions.share, - defaultActions.regenerate, - defaultActions.delAndRegenerate, - defaultActions.del, - extraMenuItems, - ]); - - // Strip handleClick for DOM safety - const items = useMemo( - () => - barItems - .filter((item) => item && !('disabled' in item && item.disabled)) - .map(stripHandleClick), - [barItems], - ); - const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]); - - // Build actions map for click handling - const allActions = useMemo( - () => buildActionsMap([...barItems, ...menuItems]), - [barItems, menuItems], - ); - - const handleAction = useCallback( - (event: ActionIconGroupEvent) => { - // Handle submenu items (e.g., translate -> zh-CN) - if (event.keyPath && event.keyPath.length > 1) { - const parentKey = event.keyPath.at(-1); - const childKey = event.keyPath[0]; - const parent = allActions.get(parentKey!); - if (parent && 'children' in parent && parent.children) { - const child = parent.children.find((c) => c.key === childKey); - child?.handleClick?.(); - return; - } - } - - // Handle regular actions - const action = allActions.get(event.key); - action?.handleClick?.(); - }, - [allActions], - ); - - if (error) return ; - - return ( - - - - - ); - }, -); + return ( + + + + + ); +}); AssistantActionsBar.displayName = 'AssistantActionsBar'; diff --git a/src/features/Conversation/Messages/Assistant/Actions/useAssistantActions.ts b/src/features/Conversation/Messages/Assistant/Actions/useAssistantActions.ts deleted file mode 100644 index 39a06ce683..0000000000 --- a/src/features/Conversation/Messages/Assistant/Actions/useAssistantActions.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { type ActionIconGroupItemType } from '@lobehub/ui'; -import { copyToClipboard } from '@lobehub/ui'; -import { App } from 'antd'; -import { css, cx } from 'antd-style'; -import { - Copy, - Edit, - LanguagesIcon, - ListChevronsDownUp, - ListChevronsUpDown, - ListRestart, - Play, - RotateCcw, - Share2, - Trash, -} from 'lucide-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { localeOptions } from '@/locales/resources'; -import { type UIChatMessage } from '@/types/index'; - -import { messageStateSelectors, useConversationStore } from '../../../store'; - -const translateStyle = css` - .ant-dropdown-menu-sub { - overflow-y: scroll; - max-height: 400px; - } -`; - -export interface ActionItem extends ActionIconGroupItemType { - children?: Array<{ handleClick?: () => void; key: string; label: string }>; - handleClick?: () => void | Promise; -} - -export interface AssistantActions { - collapse: ActionItem; - copy: ActionItem; - del: ActionItem; - delAndRegenerate: ActionItem; - divider: { type: 'divider' }; - edit: ActionItem; - expand: ActionItem; - regenerate: ActionItem; - share: ActionItem; - translate: ActionItem; - tts: ActionItem; -} - -interface UseAssistantActionsParams { - data: UIChatMessage; - id: string; - index: number; - onOpenShareModal?: () => void; -} - -export const useAssistantActions = ({ - id, - data, - onOpenShareModal, -}: UseAssistantActionsParams): AssistantActions => { - const { t } = useTranslation(['common', 'chat']); - const { message } = App.useApp(); - - // Get state from ConversationStore - const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id)); - const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id)); - - // Get actions from ConversationStore - const [ - toggleMessageEditing, - toggleMessageCollapsed, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - ttsMessage, - delAndRegenerateMessage, - ] = useConversationStore((s) => [ - s.toggleMessageEditing, - s.toggleMessageCollapsed, - s.deleteMessage, - s.regenerateAssistantMessage, - s.translateMessage, - s.ttsMessage, - s.delAndRegenerateMessage, - ]); - - return useMemo( - () => ({ - collapse: { - handleClick: () => toggleMessageCollapsed(id), - icon: ListChevronsDownUp, - key: 'collapse', - label: t('messageAction.collapse', { ns: 'chat' }), - }, - copy: { - handleClick: async () => { - await copyToClipboard(data.content); - message.success(t('copySuccess')); - }, - icon: Copy, - key: 'copy', - label: t('copy'), - }, - del: { - danger: true, - handleClick: () => deleteMessage(id), - icon: Trash, - key: 'del', - label: t('delete'), - }, - delAndRegenerate: { - disabled: isRegenerating, - handleClick: () => delAndRegenerateMessage(id), - icon: ListRestart, - key: 'delAndRegenerate', - label: t('messageAction.delAndRegenerate', { ns: 'chat' }), - }, - divider: { - type: 'divider', - }, - edit: { - handleClick: () => { - toggleMessageEditing(id, true); - }, - icon: Edit, - key: 'edit', - label: t('edit'), - }, - expand: { - handleClick: () => toggleMessageCollapsed(id), - icon: ListChevronsUpDown, - key: 'expand', - label: t('messageAction.expand', { ns: 'chat' }), - }, - regenerate: { - disabled: isRegenerating, - handleClick: () => { - regenerateAssistantMessage(id); - if (data.error) deleteMessage(id); - }, - icon: RotateCcw, - key: 'regenerate', - label: t('regenerate'), - spin: isRegenerating || undefined, - }, - share: { - handleClick: onOpenShareModal, - icon: Share2, - key: 'share', - label: t('share'), - }, - translate: { - children: localeOptions.map((i) => ({ - key: i.value, - label: t(`lang.${i.value}`), - onClick: () => translateMessage(id, i.value), - })), - icon: LanguagesIcon, - key: 'translate', - label: t('translate.action', { ns: 'chat' }), - popupClassName: cx(translateStyle), - }, - tts: { - handleClick: () => ttsMessage(id), - icon: Play, - key: 'tts', - label: t('tts.action', { ns: 'chat' }), - }, - }), - [ - t, - id, - data.content, - data.error, - isRegenerating, - isCollapsed, - toggleMessageEditing, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - ttsMessage, - delAndRegenerateMessage, - toggleMessageCollapsed, - onOpenShareModal, - message, - ], - ); -}; diff --git a/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx b/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx index 55da1a3ac5..a59fb86538 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx @@ -1,61 +1,28 @@ import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types'; -import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui'; -import { ActionIconGroup, createRawModal, Flexbox } from '@lobehub/ui'; -import { memo, useCallback, useMemo } from 'react'; +import { Flexbox } from '@lobehub/ui'; +import { memo, useMemo } from 'react'; import { ReactionPicker } from '../../../components/Reaction'; -import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal'; +import type { MessageActionsConfig } from '../../../types'; import { - createStore, - messageStateSelectors, - Provider, - useConversationStore, - useConversationStoreApi, -} from '../../../store'; -import type { - MessageActionItem, - MessageActionItemOrDivider, - MessageActionsConfig, -} from '../../../types'; -import { useGroupActions } from './useGroupActions'; + MessageActionBar, + type MessageActionContext, + type MessageActionSlot, +} from '../../components/MessageActionBar'; -// Helper to strip handleClick from action items before passing to ActionIconGroup -const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => { - if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType; - const { children, ...rest } = item as MessageActionItem; - const baseItem = { ...rest } as MessageActionItem; - delete (baseItem as { handleClick?: unknown }).handleClick; - if (children) { - return { - ...baseItem, - children: children.map((child) => { - const nextChild = { ...child } as MessageActionItem; - delete (nextChild as { handleClick?: unknown }).handleClick; - return nextChild; - }), - } as ActionIconGroupItemType; - } - return baseItem as ActionIconGroupItemType; -}; - -// Build action items map for handleAction lookup -const buildActionsMap = (items: MessageActionItemOrDivider[]): Map => { - const map = new Map(); - for (const item of items) { - if ('key' in item && item.key) { - map.set(String(item.key), item as MessageActionItem); - // Also index children for submenu items - if ('children' in item && item.children) { - for (const child of item.children) { - if (child.key) { - map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem); - } - } - } - } - } - return map; -}; +const DEFAULT_BAR_WITH_TOOLS: MessageActionSlot[] = ['delAndRegenerate', 'copy']; +const DEFAULT_BAR: MessageActionSlot[] = ['edit', 'copy']; +const DEFAULT_MENU: MessageActionSlot[] = [ + 'edit', + 'copy', + 'collapse', + 'divider', + 'share', + 'divider', + 'regenerate', + 'del', +]; +const EMPTY_GROUP_BAR: MessageActionSlot[] = ['continueGeneration', 'delAndRegenerate', 'del']; interface GroupActionsProps { actionsConfig?: MessageActionsConfig; @@ -65,165 +32,29 @@ interface GroupActionsProps { id: string; } -/** - * Actions bar for group messages with content (has assistant message content) - */ -const WithContentId = memo(({ actionsConfig, id, data, contentBlock }) => { - const { tools } = data; - const store = useConversationStoreApi(); - const handleOpenShareModal = useCallback(() => { - createRawModal( - (props: ShareModalProps) => ( - { - const state = store.getState(); - return createStore({ - context: state.context, - hooks: state.hooks, - skipFetch: state.skipFetch, - }); - }} - > - - - ), - { - message: data, - }, - { onCloseKey: 'onCancel', openKey: 'open' }, - ); - }, [data, store]); - - const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id)); - - const defaultActions = useGroupActions({ - contentBlock, - data, - id, - onOpenShareModal: handleOpenShareModal, - }); - - const hasTools = !!tools; - - // Get collapse/expand action based on current state - const collapseAction = isCollapsed ? defaultActions.expand : defaultActions.collapse; - - // Use external config if provided, otherwise use defaults - const barItems = - actionsConfig?.bar ?? - (hasTools - ? [defaultActions.delAndRegenerate, defaultActions.copy] - : [defaultActions.edit, defaultActions.copy]); - - const menuItems = actionsConfig?.menu ?? [ - defaultActions.edit, - defaultActions.copy, - collapseAction, - defaultActions.divider, - defaultActions.share, - defaultActions.divider, - defaultActions.regenerate, - defaultActions.del, - ]; - - // Strip handleClick for DOM safety - const items = useMemo( - () => - barItems - .filter((item) => item && !('disabled' in item && item.disabled)) - .map(stripHandleClick), - [barItems], - ); - const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]); - - // Build actions map for click handling - const allActions = useMemo( - () => buildActionsMap([...barItems, ...menuItems]), - [barItems, menuItems], - ); - - const handleAction = useCallback( - (event: ActionIconGroupEvent) => { - // Handle submenu items (e.g., translate -> zh-CN) - if (event.keyPath && event.keyPath.length > 1) { - const parentKey = event.keyPath.at(-1); - const childKey = event.keyPath[0]; - const parent = allActions.get(parentKey!); - if (parent && 'children' in parent && parent.children) { - const child = parent.children.find((c) => c.key === childKey); - child?.handleClick?.(); - return; - } - } - - // Handle regular actions - const action = allActions.get(event.key); - action?.handleClick?.(); - }, - [allActions], - ); - - return ( - - - - - ); -}); - -WithContentId.displayName = 'GroupActionsWithContentId'; - -/** - * Actions bar for group messages without content (empty assistant response) - */ -const WithoutContentId = memo>( - ({ actionsConfig, id, data }) => { - const defaultActions = useGroupActions({ - data, - id, - }); - - // Use external config if provided, otherwise use defaults - const barItems = actionsConfig?.bar ?? [ - defaultActions.continueGeneration, - defaultActions.delAndRegenerate, - defaultActions.del, - ]; - - // Strip handleClick for DOM safety - const items = useMemo(() => barItems.map(stripHandleClick), [barItems]); - - // Build actions map for click handling - const allActions = useMemo(() => buildActionsMap(barItems), [barItems]); - - const handleAction = useCallback( - (event: ActionIconGroupEvent) => { - const action = allActions.get(event.key); - action?.handleClick?.(); - }, - [allActions], - ); - - return ; - }, -); - -WithoutContentId.displayName = 'GroupActionsWithoutContentId'; - -/** - * Main GroupActionsBar component that renders appropriate variant - */ export const GroupActionsBar = memo( ({ actionsConfig, id, data, contentBlock, contentId }) => { - if (!contentId) return ; + const ctx = useMemo( + () => ({ contentBlock, data, id, role: 'group' }), + [contentBlock, data, id], + ); + + // Empty group (no assistant content) — only allows continuing / reset / delete + if (!contentId) { + return ; + } + + const defaultBar = data.tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR; return ( - + + + + ); }, ); diff --git a/src/features/Conversation/Messages/AssistantGroup/Actions/useGroupActions.ts b/src/features/Conversation/Messages/AssistantGroup/Actions/useGroupActions.ts deleted file mode 100644 index 2809674e94..0000000000 --- a/src/features/Conversation/Messages/AssistantGroup/Actions/useGroupActions.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { type ActionIconGroupItemType } from '@lobehub/ui'; -import { copyToClipboard } from '@lobehub/ui'; -import { App } from 'antd'; -import { - Copy, - Edit, - LanguagesIcon, - ListChevronsDownUp, - ListChevronsUpDown, - ListRestart, - RotateCcw, - Share2, - StepForward, - Trash, -} from 'lucide-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { localeOptions } from '@/locales/resources'; -import { type AssistantContentBlock, type UIChatMessage } from '@/types/index'; - -import { dataSelectors, messageStateSelectors, useConversationStore } from '../../../store'; - -export interface ActionItem extends ActionIconGroupItemType { - children?: Array<{ handleClick?: () => void; key: string; label: string }>; - handleClick?: () => void | Promise; -} - -export interface GroupActions { - collapse: ActionItem; - continueGeneration: ActionItem; - copy: ActionItem; - del: ActionItem; - delAndRegenerate: ActionItem; - divider: { type: 'divider' }; - edit: ActionItem; - expand: ActionItem; - regenerate: ActionItem; - share: ActionItem; - translate: ActionItem; -} - -interface UseGroupActionsParams { - contentBlock?: AssistantContentBlock; - data: UIChatMessage; - id: string; - onOpenShareModal?: () => void; -} - -export const useGroupActions = ({ - id, - data, - contentBlock, - onOpenShareModal, -}: UseGroupActionsParams): GroupActions => { - const { t } = useTranslation(['common', 'chat']); - const { message } = App.useApp(); - - // Get state from ConversationStore - const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id)); - const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id)); - const lastBlockId = useConversationStore(dataSelectors.findLastMessageId(id)); - const isContinuing = useConversationStore((s) => - lastBlockId ? messageStateSelectors.isMessageContinuing(lastBlockId)(s) : false, - ); - - // Get actions from ConversationStore - const [ - toggleMessageEditing, - toggleMessageCollapsed, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - delAndRegenerateMessage, - continueGenerationMessage, - ] = useConversationStore((s) => [ - s.toggleMessageEditing, - s.toggleMessageCollapsed, - s.deleteMessage, - s.regenerateAssistantMessage, - s.translateMessage, - s.delAndRegenerateMessage, - s.continueGenerationMessage, - ]); - - return useMemo( - () => ({ - collapse: { - handleClick: () => toggleMessageCollapsed(id), - icon: ListChevronsDownUp, - key: 'collapse', - label: t('messageAction.collapse', { ns: 'chat' }), - }, - continueGeneration: { - disabled: isContinuing, - handleClick: () => { - if (!lastBlockId) return; - continueGenerationMessage(id, lastBlockId); - }, - icon: StepForward, - key: 'continueGeneration', - label: t('messageAction.continueGeneration', { ns: 'chat' }), - spin: isContinuing || undefined, - }, - copy: { - handleClick: async () => { - if (!contentBlock) return; - await copyToClipboard(contentBlock.content); - message.success(t('copySuccess')); - }, - icon: Copy, - key: 'copy', - label: t('copy'), - }, - del: { - danger: true, - handleClick: () => deleteMessage(id), - icon: Trash, - key: 'del', - label: t('delete'), - }, - delAndRegenerate: { - disabled: isRegenerating, - handleClick: () => delAndRegenerateMessage(id), - icon: ListRestart, - key: 'delAndRegenerate', - label: t('messageAction.delAndRegenerate', { ns: 'chat' }), - }, - divider: { - type: 'divider', - }, - edit: { - handleClick: () => { - if (!contentBlock) return; - - toggleMessageEditing(contentBlock.id, true); - }, - icon: Edit, - key: 'edit', - label: t('edit'), - }, - expand: { - handleClick: () => toggleMessageCollapsed(id), - icon: ListChevronsUpDown, - key: 'expand', - label: t('messageAction.expand', { ns: 'chat' }), - }, - regenerate: { - disabled: isRegenerating, - handleClick: () => { - regenerateAssistantMessage(id); - if (data.error) deleteMessage(id); - }, - icon: RotateCcw, - key: 'regenerate', - label: t('regenerate'), - spin: isRegenerating || undefined, - }, - share: { - handleClick: onOpenShareModal, - icon: Share2, - key: 'share', - label: t('share'), - }, - translate: { - children: localeOptions.map((i) => ({ - handleClick: () => translateMessage(id, i.value), - key: i.value, - label: t(`lang.${i.value}`), - })), - icon: LanguagesIcon, - key: 'translate', - label: t('translate.action', { ns: 'chat' }), - }, - }), - [ - id, - contentBlock, - data.error, - isRegenerating, - isContinuing, - isCollapsed, - lastBlockId, - toggleMessageEditing, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - delAndRegenerateMessage, - toggleMessageCollapsed, - continueGenerationMessage, - onOpenShareModal, - message, - ], - ); -}; diff --git a/src/features/Conversation/Messages/Contexts/MessageActionProvider.tsx b/src/features/Conversation/Messages/Contexts/MessageActionProvider.tsx index 4f2f0d70d2..9d2c3dee7d 100644 --- a/src/features/Conversation/Messages/Contexts/MessageActionProvider.tsx +++ b/src/features/Conversation/Messages/Contexts/MessageActionProvider.tsx @@ -24,13 +24,13 @@ interface SingletonPortalProps { index: number; } -const AssistantActionsRenderer: FC = ({ id, index }) => { +const AssistantActionsRenderer: FC = ({ id }) => { const actionsConfig = useConversationStore((s) => s.actionsBar?.assistant); const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual); if (!item) return null; - return ; + return ; }; const UserActionsRenderer: FC = ({ id }) => { diff --git a/src/features/Conversation/Messages/GroupTasks/index.tsx b/src/features/Conversation/Messages/GroupTasks/index.tsx index c16aa8f8eb..e624205f0b 100644 --- a/src/features/Conversation/Messages/GroupTasks/index.tsx +++ b/src/features/Conversation/Messages/GroupTasks/index.tsx @@ -19,7 +19,6 @@ import TaskItem from './TaskItem'; interface GroupTasksMessageProps { id: string; - index: number; } /** @@ -62,7 +61,7 @@ const GroupTasksAvatar = memo<{ avatars: { avatar?: string; background?: string GroupTasksAvatar.displayName = 'GroupTasksAvatar'; -const GroupTasksMessage = memo(({ id, index }) => { +const GroupTasksMessage = memo(({ id }) => { const { t } = useTranslation('chat'); const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!; const actionsConfig = useConversationStore((s) => s.actionsBar?.assistant); @@ -126,6 +125,7 @@ const GroupTasksMessage = memo(({ id, index }) => { } avatar={{ title }} customAvatarRender={() => } id={id} @@ -133,9 +133,6 @@ const GroupTasksMessage = memo(({ id, index }) => { placement="left" time={createdAt} titleAddon={{t('task.groupTasks', { count: tasks.length })}} - actions={ - - } > {tasks.map((task) => ( diff --git a/src/features/Conversation/Messages/Supervisor/Actions/index.tsx b/src/features/Conversation/Messages/Supervisor/Actions/index.tsx deleted file mode 100644 index be30448f32..0000000000 --- a/src/features/Conversation/Messages/Supervisor/Actions/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import type { AssistantContentBlock, UIChatMessage } from '@lobechat/types'; -import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui'; -import { ActionIconGroup, createRawModal, Flexbox } from '@lobehub/ui'; -import { memo, useCallback, useMemo } from 'react'; - -import { ReactionPicker } from '../../../components/Reaction'; -import ShareMessageModal, { type ShareModalProps } from '../../../components/ShareMessageModal'; -import { - createStore, - messageStateSelectors, - Provider, - useConversationStore, - useConversationStoreApi, -} from '../../../store'; -import type { - MessageActionItem, - MessageActionItemOrDivider, - MessageActionsConfig, -} from '../../../types'; -import { useGroupActions } from './useGroupActions'; - -// Helper to strip handleClick from action items before passing to ActionIconGroup -const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => { - if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType; - const { children, ...rest } = item as MessageActionItem; - const baseItem = { ...rest } as MessageActionItem; - delete (baseItem as { handleClick?: unknown }).handleClick; - if (children) { - return { - ...baseItem, - children: children.map((child) => { - const nextChild = { ...child } as MessageActionItem; - delete (nextChild as { handleClick?: unknown }).handleClick; - return nextChild; - }), - } as ActionIconGroupItemType; - } - return baseItem as ActionIconGroupItemType; -}; - -// Build action items map for handleAction lookup -const buildActionsMap = (items: MessageActionItemOrDivider[]): Map => { - const map = new Map(); - for (const item of items) { - if ('key' in item && item.key) { - map.set(String(item.key), item as MessageActionItem); - // Also index children for submenu items - if ('children' in item && item.children) { - for (const child of item.children) { - if (child.key) { - map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem); - } - } - } - } - } - return map; -}; - -interface GroupActionsProps { - actionsConfig?: MessageActionsConfig; - contentBlock?: AssistantContentBlock; - contentId?: string; - data: UIChatMessage; - id: string; -} - -/** - * Actions bar for group messages with content (has assistant message content) - */ -const WithContentId = memo(({ actionsConfig, id, data, contentBlock }) => { - const store = useConversationStoreApi(); - const handleOpenShareModal = useCallback(() => { - createRawModal( - (props: ShareModalProps) => ( - { - const state = store.getState(); - return createStore({ - context: state.context, - hooks: state.hooks, - skipFetch: state.skipFetch, - }); - }} - > - - - ), - { - message: data, - }, - { onCloseKey: 'onCancel', openKey: 'open' }, - ); - }, [data, store]); - - const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id)); - - const defaultActions = useGroupActions({ - contentBlock, - data, - id, - onOpenShareModal: handleOpenShareModal, - }); - - // Get collapse/expand action based on current state - const collapseAction = isCollapsed ? defaultActions.expand : defaultActions.collapse; - - // Use external config if provided, otherwise use defaults - const barItems = actionsConfig?.bar ?? [defaultActions.copy]; - - const menuItems = actionsConfig?.menu ?? [ - defaultActions.copy, - collapseAction, - defaultActions.divider, - defaultActions.share, - defaultActions.divider, - defaultActions.regenerate, - defaultActions.del, - ]; - - // Strip handleClick for DOM safety - const items = useMemo( - () => - barItems - .filter((item) => item && !('disabled' in item && item.disabled)) - .map(stripHandleClick), - [barItems], - ); - const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]); - - // Build actions map for click handling - const allActions = useMemo( - () => buildActionsMap([...barItems, ...menuItems]), - [barItems, menuItems], - ); - - const handleAction = useCallback( - (event: ActionIconGroupEvent) => { - // Handle submenu items (e.g., translate -> zh-CN) - if (event.keyPath && event.keyPath.length > 1) { - const parentKey = event.keyPath.at(-1); - const childKey = event.keyPath[0]; - const parent = allActions.get(parentKey!); - if (parent && 'children' in parent && parent.children) { - const child = parent.children.find((c) => c.key === childKey); - child?.handleClick?.(); - return; - } - } - - // Handle regular actions - const action = allActions.get(event.key); - action?.handleClick?.(); - }, - [allActions], - ); - - return ( - - - - - ); -}); - -WithContentId.displayName = 'GroupActionsWithContentId'; - -/** - * Main GroupActionsBar component that renders appropriate variant - */ -export const GroupActionsBar = memo( - ({ actionsConfig, id, data, contentBlock }) => { - return ( - - ); - }, -); - -GroupActionsBar.displayName = 'GroupActionsBar'; diff --git a/src/features/Conversation/Messages/Supervisor/Actions/useGroupActions.ts b/src/features/Conversation/Messages/Supervisor/Actions/useGroupActions.ts deleted file mode 100644 index 31c411e2bf..0000000000 --- a/src/features/Conversation/Messages/Supervisor/Actions/useGroupActions.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { type ActionIconGroupItemType } from '@lobehub/ui'; -import { copyToClipboard } from '@lobehub/ui'; -import { App } from 'antd'; -import { - Copy, - LanguagesIcon, - ListChevronsDownUp, - ListChevronsUpDown, - ListRestart, - RotateCcw, - Share2, - Trash, -} from 'lucide-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { localeOptions } from '@/locales/resources'; -import { type AssistantContentBlock, type UIChatMessage } from '@/types/index'; - -import { dataSelectors, messageStateSelectors, useConversationStore } from '../../../store'; - -export interface ActionItem extends ActionIconGroupItemType { - children?: Array<{ handleClick?: () => void; key: string; label: string }>; - handleClick?: () => void | Promise; -} - -export interface GroupActions { - collapse: ActionItem; - copy: ActionItem; - del: ActionItem; - delAndRegenerate: ActionItem; - divider: { type: 'divider' }; - expand: ActionItem; - regenerate: ActionItem; - share: ActionItem; - translate: ActionItem; -} - -interface UseGroupActionsParams { - contentBlock?: AssistantContentBlock; - data: UIChatMessage; - id: string; - onOpenShareModal?: () => void; -} - -export const useGroupActions = ({ - id, - data, - contentBlock, - onOpenShareModal, -}: UseGroupActionsParams): GroupActions => { - const { t } = useTranslation(['common', 'chat']); - const { message } = App.useApp(); - - // Get state from ConversationStore - const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id)); - const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id)); - const lastBlockId = useConversationStore(dataSelectors.findLastMessageId(id)); - const isContinuing = useConversationStore((s) => - lastBlockId ? messageStateSelectors.isMessageContinuing(lastBlockId)(s) : false, - ); - - // Get actions from ConversationStore - const [ - toggleMessageEditing, - toggleMessageCollapsed, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - delAndRegenerateMessage, - continueGenerationMessage, - ] = useConversationStore((s) => [ - s.toggleMessageEditing, - s.toggleMessageCollapsed, - s.deleteMessage, - s.regenerateAssistantMessage, - s.translateMessage, - s.delAndRegenerateMessage, - s.continueGenerationMessage, - ]); - - return useMemo( - () => ({ - collapse: { - handleClick: () => toggleMessageCollapsed(id), - icon: ListChevronsDownUp, - key: 'collapse', - label: t('messageAction.collapse', { ns: 'chat' }), - }, - copy: { - handleClick: async () => { - if (!contentBlock) return; - await copyToClipboard(contentBlock.content); - message.success(t('copySuccess')); - }, - icon: Copy, - key: 'copy', - label: t('copy'), - }, - del: { - danger: true, - handleClick: () => deleteMessage(id), - icon: Trash, - key: 'del', - label: t('delete'), - }, - delAndRegenerate: { - disabled: isRegenerating, - handleClick: () => delAndRegenerateMessage(id), - icon: ListRestart, - key: 'delAndRegenerate', - label: t('messageAction.delAndRegenerate', { ns: 'chat' }), - }, - divider: { - type: 'divider', - }, - expand: { - handleClick: () => toggleMessageCollapsed(id), - icon: ListChevronsUpDown, - key: 'expand', - label: t('messageAction.expand', { ns: 'chat' }), - }, - regenerate: { - disabled: isRegenerating, - handleClick: () => { - regenerateAssistantMessage(id); - if (data.error) deleteMessage(id); - }, - icon: RotateCcw, - key: 'regenerate', - label: t('regenerate'), - spin: isRegenerating || undefined, - }, - share: { - handleClick: onOpenShareModal, - icon: Share2, - key: 'share', - label: t('share'), - }, - translate: { - children: localeOptions.map((i) => ({ - handleClick: () => translateMessage(id, i.value), - key: i.value, - label: t(`lang.${i.value}`), - })), - icon: LanguagesIcon, - key: 'translate', - label: t('translate.action', { ns: 'chat' }), - }, - }), - [ - t, - id, - contentBlock, - data.error, - isRegenerating, - isContinuing, - isCollapsed, - lastBlockId, - toggleMessageEditing, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - delAndRegenerateMessage, - toggleMessageCollapsed, - continueGenerationMessage, - onOpenShareModal, - message, - ], - ); -}; diff --git a/src/features/Conversation/Messages/Task/Actions/Error.tsx b/src/features/Conversation/Messages/Task/Actions/Error.tsx deleted file mode 100644 index 61e88df2fb..0000000000 --- a/src/features/Conversation/Messages/Task/Actions/Error.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { type ActionIconGroupEvent } from '@lobehub/ui'; -import { ActionIconGroup } from '@lobehub/ui'; -import { memo } from 'react'; - -import { type AssistantActions } from './useAssistantActions'; - -interface ErrorActionsBarProps { - actions: AssistantActions; - onActionClick: (event: ActionIconGroupEvent) => void; -} - -export const ErrorActionsBar = memo(({ actions, onActionClick }) => { - const { regenerate, copy, edit, del, divider } = actions; - - return ( - - ); -}); - -ErrorActionsBar.displayName = 'ErrorActionsBar'; diff --git a/src/features/Conversation/Messages/Task/Actions/index.tsx b/src/features/Conversation/Messages/Task/Actions/index.tsx index 3d4001de89..6fa6bb3b94 100644 --- a/src/features/Conversation/Messages/Task/Actions/index.tsx +++ b/src/features/Conversation/Messages/Task/Actions/index.tsx @@ -1,194 +1,57 @@ import { type UIChatMessage } from '@lobechat/types'; -import { type ActionIconGroupEvent, type ActionIconGroupItemType } from '@lobehub/ui'; -import { ActionIconGroup, createRawModal } from '@lobehub/ui'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useMemo } from 'react'; -import { useEventCallback } from '@/hooks/useEventCallback'; - -import { type ShareModalProps } from '../../../components/ShareMessageModal'; -import ShareMessageModal from '../../../components/ShareMessageModal'; -import { createStore, Provider, useConversationStoreApi } from '../../../store'; +import type { MessageActionsConfig } from '../../../types'; import { - type MessageActionItem, - type MessageActionItemOrDivider, - type MessageActionsConfig, -} from '../../../types'; -import { ErrorActionsBar } from './Error'; -import { useAssistantActions } from './useAssistantActions'; + MessageActionBar, + type MessageActionContext, + type MessageActionSlot, +} from '../../components/MessageActionBar'; -// Helper to strip handleClick from action items before passing to ActionIconGroup -const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => { - if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType; - const { children, ...rest } = item as MessageActionItem; - const baseItem = { ...rest } as MessageActionItem; - delete (baseItem as { handleClick?: unknown }).handleClick; - if (children) { - return { - ...baseItem, - children: children.map((child) => { - const nextChild = { ...child } as MessageActionItem; - delete (nextChild as { handleClick?: unknown }).handleClick; - return nextChild; - }), - } as ActionIconGroupItemType; - } - return baseItem as ActionIconGroupItemType; -}; - -// Build action items map for handleAction lookup -const buildActionsMap = (items: MessageActionItemOrDivider[]): Map => { - const map = new Map(); - for (const item of items) { - if ('key' in item && item.key) { - map.set(String(item.key), item as MessageActionItem); - // Also index children for submenu items - if ('children' in item && item.children) { - for (const child of item.children) { - if (child.key) { - map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem); - } - } - } - } - } - return map; -}; +const DEFAULT_BAR_WITH_TOOLS: MessageActionSlot[] = ['copy']; +const DEFAULT_BAR: MessageActionSlot[] = ['edit', 'copy']; +const DEFAULT_MENU: MessageActionSlot[] = [ + 'edit', + 'copy', + 'collapse', + 'divider', + 'share', + 'divider', + 'regenerate', + 'del', +]; +const ERROR_BAR: MessageActionSlot[] = ['regenerate', 'del']; +const ERROR_MENU: MessageActionSlot[] = ['edit', 'copy', 'divider', 'del']; interface AssistantActionsBarProps { actionsConfig?: MessageActionsConfig; data: UIChatMessage; id: string; - index: number; } -export const AssistantActionsBar = memo( - ({ actionsConfig, id, data, index }) => { - const { error, tools } = data; - const store = useConversationStoreApi(); - const handleOpenShareModal = useEventCallback(() => { - createRawModal( - (props: ShareModalProps) => ( - { - const state = store.getState(); - return createStore({ - context: state.context, - hooks: state.hooks, - skipFetch: state.skipFetch, - }); - }} - > - - - ), - { - message: data, - }, - { onCloseKey: 'onCancel', openKey: 'open' }, - ); - }); +/** + * Action bar for Task / Tasks / GroupTasks messages. Uses `assistant` role + * context but with a slimmer default menu (no tts / translate / + * delAndRegenerate). + */ +export const AssistantActionsBar = memo(({ actionsConfig, id, data }) => { + const ctx = useMemo(() => ({ data, id, role: 'assistant' }), [data, id]); - const defaultActions = useAssistantActions({ - data, - id, - index, - onOpenShareModal: handleOpenShareModal, - }); + const { error, tools } = data; - const hasTools = !!tools; + if (error) { + return ; + } - // Get collapse/expand action based on current state - const collapseAction = defaultActions.collapse; + const defaultBar = tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR; - // Create extra actions from factory functions - const extraBarItems = useMemo(() => { - if (!actionsConfig?.extraBarActions) return []; - return actionsConfig.extraBarActions - .map((factory) => factory(id)) - .filter((item): item is NonNullable => item !== null); - }, [actionsConfig?.extraBarActions, id]); + return ( + + ); +}); - const extraMenuItems = useMemo(() => { - if (!actionsConfig?.extraMenuActions) return []; - return actionsConfig.extraMenuActions - .map((factory) => factory(id)) - .filter((item): item is NonNullable => item !== null); - }, [actionsConfig?.extraMenuActions, id]); - - // Use external config if provided, otherwise use defaults - // Append extra actions from factories - const barItems = useMemo(() => { - const base = - actionsConfig?.bar ?? - (hasTools ? [defaultActions.copy] : [defaultActions.edit, defaultActions.copy]); - return [...base, ...extraBarItems]; - }, [actionsConfig?.bar, hasTools, defaultActions.copy, defaultActions.edit, extraBarItems]); - - const menuItems = useMemo(() => { - const base = actionsConfig?.menu ?? [ - defaultActions.edit, - defaultActions.copy, - collapseAction, - defaultActions.divider, - defaultActions.share, - defaultActions.divider, - defaultActions.regenerate, - defaultActions.del, - ]; - return [...base, ...extraMenuItems]; - }, [ - actionsConfig?.menu, - defaultActions.edit, - defaultActions.copy, - collapseAction, - defaultActions.divider, - defaultActions.share, - defaultActions.regenerate, - defaultActions.del, - extraMenuItems, - ]); - - // Strip handleClick for DOM safety - const items = useMemo( - () => - barItems - .filter((item) => item && !('disabled' in item && item.disabled)) - .map(stripHandleClick), - [barItems], - ); - const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]); - - // Build actions map for click handling - const allActions = useMemo( - () => buildActionsMap([...barItems, ...menuItems]), - [barItems, menuItems], - ); - - const handleAction = useCallback( - (event: ActionIconGroupEvent) => { - // Handle submenu items (e.g., translate -> zh-CN) - if (event.keyPath && event.keyPath.length > 1) { - const parentKey = event.keyPath.at(-1); - const childKey = event.keyPath[0]; - const parent = allActions.get(parentKey!); - if (parent && 'children' in parent && parent.children) { - const child = parent.children.find((c) => c.key === childKey); - child?.handleClick?.(); - return; - } - } - - // Handle regular actions - const action = allActions.get(event.key); - action?.handleClick?.(); - }, - [allActions], - ); - - if (error) return ; - - return ; - }, -); - -AssistantActionsBar.displayName = 'AssistantActionsBar'; +AssistantActionsBar.displayName = 'TaskAssistantActionsBar'; diff --git a/src/features/Conversation/Messages/Task/Actions/useAssistantActions.ts b/src/features/Conversation/Messages/Task/Actions/useAssistantActions.ts deleted file mode 100644 index 157dcfd5b3..0000000000 --- a/src/features/Conversation/Messages/Task/Actions/useAssistantActions.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { type ActionIconGroupItemType } from '@lobehub/ui'; -import { copyToClipboard } from '@lobehub/ui'; -import { App } from 'antd'; -import { - Copy, - Edit, - ListChevronsDownUp, - ListRestart, - RotateCcw, - Share2, - Trash, -} from 'lucide-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { type UIChatMessage } from '@/types/index'; - -import { messageStateSelectors, useConversationStore } from '../../../store'; - -export interface ActionItem extends ActionIconGroupItemType { - children?: Array<{ handleClick?: () => void; key: string; label: string }>; - handleClick?: () => void | Promise; -} - -export interface AssistantActions { - collapse: ActionItem; - copy: ActionItem; - del: ActionItem; - divider: { type: 'divider' }; - edit: ActionItem; - regenerate: ActionItem; - share: ActionItem; -} - -interface UseAssistantActionsParams { - data: UIChatMessage; - id: string; - index: number; - onOpenShareModal?: () => void; -} - -export const useAssistantActions = ({ - id, - data, - onOpenShareModal, -}: UseAssistantActionsParams): AssistantActions => { - const { t } = useTranslation(['common', 'chat']); - const { message } = App.useApp(); - - // Get state from ConversationStore - const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(id)); - const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id)); - - // Get actions from ConversationStore - const [ - toggleMessageEditing, - toggleMessageCollapsed, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - ttsMessage, - delAndRegenerateMessage, - ] = useConversationStore((s) => [ - s.toggleMessageEditing, - s.toggleMessageCollapsed, - s.deleteMessage, - s.regenerateAssistantMessage, - s.translateMessage, - s.ttsMessage, - s.delAndRegenerateMessage, - ]); - - return useMemo( - () => ({ - collapse: { - handleClick: () => toggleMessageCollapsed(id), - icon: ListChevronsDownUp, - key: 'collapse', - label: t('messageAction.collapse', { ns: 'chat' }), - }, - copy: { - handleClick: async () => { - await copyToClipboard(data.content); - message.success(t('copySuccess')); - }, - icon: Copy, - key: 'copy', - label: t('copy'), - }, - del: { - danger: true, - handleClick: () => deleteMessage(id), - icon: Trash, - key: 'del', - label: t('delete'), - }, - delAndRegenerate: { - disabled: isRegenerating, - handleClick: () => delAndRegenerateMessage(id), - icon: ListRestart, - key: 'delAndRegenerate', - label: t('messageAction.delAndRegenerate', { ns: 'chat' }), - }, - divider: { - type: 'divider', - }, - edit: { - handleClick: () => { - toggleMessageEditing(id, true); - }, - icon: Edit, - key: 'edit', - label: t('edit'), - }, - regenerate: { - disabled: isRegenerating, - handleClick: () => { - regenerateAssistantMessage(id); - if (data.error) deleteMessage(id); - }, - icon: RotateCcw, - key: 'regenerate', - label: t('regenerate'), - spin: isRegenerating || undefined, - }, - share: { - handleClick: onOpenShareModal, - icon: Share2, - key: 'share', - label: t('share'), - }, - }), - [ - t, - id, - data.content, - data.error, - isRegenerating, - isCollapsed, - toggleMessageEditing, - deleteMessage, - regenerateAssistantMessage, - translateMessage, - ttsMessage, - delAndRegenerateMessage, - toggleMessageCollapsed, - onOpenShareModal, - message, - ], - ); -}; diff --git a/src/features/Conversation/Messages/Task/index.tsx b/src/features/Conversation/Messages/Task/index.tsx index 781f71c905..ab8bfc0697 100644 --- a/src/features/Conversation/Messages/Task/index.tsx +++ b/src/features/Conversation/Messages/Task/index.tsx @@ -69,6 +69,7 @@ const TaskMessage = memo(({ id, index, disableEditing }) => { } avatar={{ ...avatar, title }} customAvatarRender={(_, node) => {node}} customErrorRender={(error) => } @@ -79,9 +80,6 @@ const TaskMessage = memo(({ id, index, disableEditing }) => { placement={'left'} time={createdAt} titleAddon={{t('task.subtask')}} - actions={ - - } error={ errorContent && error && (message === LOADING_FLAT || !message) ? errorContent : undefined } diff --git a/src/features/Conversation/Messages/Tasks/index.tsx b/src/features/Conversation/Messages/Tasks/index.tsx index dfb0dcb37e..600aba2c80 100644 --- a/src/features/Conversation/Messages/Tasks/index.tsx +++ b/src/features/Conversation/Messages/Tasks/index.tsx @@ -16,10 +16,9 @@ import TaskItem from './TaskItem'; interface TasksMessageProps { id: string; - index: number; } -const TasksMessage = memo(({ id, index }) => { +const TasksMessage = memo(({ id }) => { const { t } = useTranslation('chat'); const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!; const actionsConfig = useConversationStore((s) => s.actionsBar?.assistant); @@ -39,6 +38,7 @@ const TasksMessage = memo(({ id, index }) => { } avatar={avatar} customAvatarRender={(_, node) => {node}} id={id} @@ -46,9 +46,6 @@ const TasksMessage = memo(({ id, index }) => { placement="left" time={createdAt} titleAddon={{t('task.batchTasks', { count: tasks.length })}} - actions={ - - } > {tasks.map((task) => ( diff --git a/src/features/Conversation/Messages/User/Actions/index.tsx b/src/features/Conversation/Messages/User/Actions/index.tsx index 8af06a3f63..cc5aa3b15b 100644 --- a/src/features/Conversation/Messages/User/Actions/index.tsx +++ b/src/features/Conversation/Messages/User/Actions/index.tsx @@ -1,166 +1,60 @@ import { type UIChatMessage } from '@lobechat/types'; -import { type ActionIconGroupEvent, type ActionIconGroupItemType } from '@lobehub/ui'; -import { ActionIconGroup, Flexbox } from '@lobehub/ui'; -import { memo, useCallback, useMemo } from 'react'; +import { Flexbox } from '@lobehub/ui'; +import { memo, useMemo } from 'react'; import { MESSAGE_ACTION_BAR_PORTAL_ATTRIBUTES } from '@/const/messageActionPortal'; import { useUserStore } from '@/store/user'; import { userGeneralSettingsSelectors } from '@/store/user/selectors'; +import { type MessageActionsConfig } from '../../../types'; import { - type MessageActionItem, - type MessageActionItemOrDivider, - type MessageActionsConfig, -} from '../../../types'; + MessageActionBar, + type MessageActionContext, + type MessageActionSlot, +} from '../../components/MessageActionBar'; import MessageBranch from '../../components/MessageBranch'; -import { useUserActions } from './useUserActions'; -// Helper to strip handleClick from action items before passing to ActionIconGroup -const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => { - if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType; - const { children, ...rest } = item as MessageActionItem; - const baseItem = { ...rest } as MessageActionItem; - delete (baseItem as { handleClick?: unknown }).handleClick; - if (children) { - return { - ...baseItem, - children: children.map((child) => { - const nextChild = { ...child } as MessageActionItem; - delete (nextChild as { handleClick?: unknown }).handleClick; - return nextChild; - }), - } as ActionIconGroupItemType; - } - return baseItem as ActionIconGroupItemType; -}; - -// Build action items map for handleAction lookup -const buildActionsMap = (items: MessageActionItemOrDivider[]): Map => { - const map = new Map(); - for (const item of items) { - if ('key' in item && item.key) { - map.set(String(item.key), item as MessageActionItem); - // Also index children for submenu items - if ('children' in item && item.children) { - for (const child of item.children) { - if (child.key) { - map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem); - } - } - } - } - } - return map; -}; +const DEFAULT_BAR: MessageActionSlot[] = ['regenerate', 'edit', 'copy']; +const DEFAULT_MENU: MessageActionSlot[] = [ + 'edit', + 'copy', + 'divider', + 'tts', + 'translate', + 'divider', + 'regenerate', + 'del', +]; interface UserActionsProps { actionsConfig?: MessageActionsConfig; data: UIChatMessage; - disableEditing?: boolean; id: string; } export const UserActionsBar = memo(({ actionsConfig, id, data }) => { - // Get default actions from hook - const defaultActions = useUserActions({ data, id }); - - // Create extra actions from factory functions - const extraBarItems = useMemo(() => { - if (!actionsConfig?.extraBarActions) return []; - return actionsConfig.extraBarActions - .map((factory) => factory(id)) - .filter((item): item is NonNullable => item !== null); - }, [actionsConfig?.extraBarActions, id]); - - const extraMenuItems = useMemo(() => { - if (!actionsConfig?.extraMenuActions) return []; - return actionsConfig.extraMenuActions - .map((factory) => factory(id)) - .filter((item): item is NonNullable => item !== null); - }, [actionsConfig?.extraMenuActions, id]); - - // Use external config if provided, otherwise use defaults - // Append extra actions from factories - const barItems = useMemo(() => { - const base = actionsConfig?.bar ?? [ - defaultActions.regenerate, - defaultActions.edit, - defaultActions.copy, - ]; - return [...base, ...extraBarItems]; - }, [actionsConfig?.bar, defaultActions.regenerate, defaultActions.edit, extraBarItems]); - - const menuItems = useMemo(() => { - const base = actionsConfig?.menu ?? [ - defaultActions.edit, - defaultActions.copy, - defaultActions.divider, - defaultActions.tts, - defaultActions.translate, - defaultActions.divider, - defaultActions.regenerate, - defaultActions.del, - ]; - return [...base, ...extraMenuItems]; - }, [ - actionsConfig?.menu, - defaultActions.edit, - defaultActions.copy, - defaultActions.divider, - defaultActions.tts, - defaultActions.translate, - defaultActions.regenerate, - defaultActions.del, - extraMenuItems, - ]); - - // Strip handleClick for DOM safety - const items = useMemo(() => barItems.map(stripHandleClick), [barItems]); - const menu = useMemo(() => menuItems.map(stripHandleClick), [menuItems]); - - // Build actions map for click handling - const allActions = useMemo( - () => buildActionsMap([...barItems, ...menuItems]), - [barItems, menuItems], + const ctx = useMemo(() => ({ data, id, role: 'user' }), [data, id]); + return ( + ); - - const handleAction = useCallback( - (event: ActionIconGroupEvent) => { - // Handle submenu items (e.g., translate -> zh-CN) - if (event.keyPath && event.keyPath.length > 1) { - const parentKey = event.keyPath.at(-1); - const childKey = event.keyPath[0]; - const parent = allActions.get(parentKey!); - if (parent && 'children' in parent && parent.children) { - const child = parent.children.find((c) => c.key === childKey); - child?.handleClick?.(); - return; - } - } - - // Handle regular actions - const action = allActions.get(event.key); - action?.handleClick?.(); - }, - [allActions], - ); - - return ; }); UserActionsBar.displayName = 'UserActionsBar'; interface ActionsProps { - actionsConfig?: MessageActionsConfig; data: UIChatMessage; disableEditing?: boolean; id: string; - index: number; } const actionBarHolder = (
); + const Actions = memo(({ id, data, disableEditing }) => { const { branch } = data; const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode); diff --git a/src/features/Conversation/Messages/User/Actions/useUserActions.ts b/src/features/Conversation/Messages/User/Actions/useUserActions.ts deleted file mode 100644 index 57b1a4caa5..0000000000 --- a/src/features/Conversation/Messages/User/Actions/useUserActions.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { type ActionIconGroupItemType } from '@lobehub/ui'; -import { copyToClipboard } from '@lobehub/ui'; -import { App } from 'antd'; -import { Copy, Edit, LanguagesIcon, Play, RotateCcw, Trash } from 'lucide-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { localeOptions } from '@/locales/resources'; -import { cleanSpeakerTag } from '@/store/chat/utils/cleanSpeakerTag'; -import { type UIChatMessage } from '@/types/index'; - -import { messageStateSelectors, useConversationStore } from '../../../store'; - -export interface ActionItem extends ActionIconGroupItemType { - children?: Array<{ handleClick?: () => void; key: string; label: string }>; - handleClick?: () => void | Promise; -} - -export interface UserActions { - copy: ActionItem; - del: ActionItem; - divider: { type: 'divider' }; - edit: ActionItem; - regenerate: ActionItem; - translate: ActionItem; - tts: ActionItem; -} - -interface UseUserActionsParams { - data: UIChatMessage; - id: string; -} - -export const useUserActions = ({ id, data }: UseUserActionsParams): UserActions => { - const { t } = useTranslation('common'); - const { message } = App.useApp(); - - // Get state from ConversationStore - const isRegenerating = useConversationStore(messageStateSelectors.isMessageRegenerating(id)); - - // Get actions from ConversationStore - const [toggleMessageEditing, deleteMessage, regenerateUserMessage, translateMessage, ttsMessage] = - useConversationStore((s) => [ - s.toggleMessageEditing, - s.deleteMessage, - s.regenerateUserMessage, - s.translateMessage, - s.ttsMessage, - ]); - - return useMemo( - () => ({ - copy: { - handleClick: async () => { - await copyToClipboard(cleanSpeakerTag(data.content)); - message.success(t('copySuccess')); - }, - icon: Copy, - key: 'copy', - label: t('copy'), - }, - del: { - danger: true, - handleClick: () => deleteMessage(id), - icon: Trash, - key: 'del', - label: t('delete'), - }, - divider: { - type: 'divider', - }, - edit: { - handleClick: () => { - toggleMessageEditing(id, true); - }, - icon: Edit, - key: 'edit', - label: t('edit'), - }, - regenerate: { - disabled: isRegenerating, - handleClick: () => { - regenerateUserMessage(id); - if (data.error) deleteMessage(id); - }, - icon: RotateCcw, - key: 'regenerate', - label: t('regenerate'), - spin: isRegenerating || undefined, - }, - translate: { - children: localeOptions.map((i) => ({ - key: i.value, - label: t(`lang.${i.value}`), - onClick: () => translateMessage(id, i.value), - })), - icon: LanguagesIcon, - key: 'translate', - label: t('translate.action', { ns: 'chat' }), - }, - tts: { - handleClick: () => ttsMessage(id), - icon: Play, - key: 'tts', - label: t('tts.action', { ns: 'chat' }), - }, - }), - [ - t, - id, - data.content, - data.error, - isRegenerating, - toggleMessageEditing, - deleteMessage, - regenerateUserMessage, - translateMessage, - ttsMessage, - message, - ], - ); -}; diff --git a/src/features/Conversation/Messages/User/index.tsx b/src/features/Conversation/Messages/User/index.tsx index 6cffaeb4bc..bfce6ce97c 100644 --- a/src/features/Conversation/Messages/User/index.tsx +++ b/src/features/Conversation/Messages/User/index.tsx @@ -29,7 +29,6 @@ interface UserMessageProps { const UserMessage = memo(({ id, disableEditing, index }) => { const item = useConversationStore(dataSelectors.getDisplayMessageById(id), isEqual)!; - const actionsConfig = useConversationStore((s) => s.actionsBar?.user); const { content, createdAt, error, role, extra, targetId } = item; const { t } = useTranslation('chat'); @@ -76,6 +75,7 @@ const UserMessage = memo(({ id, disableEditing, index }) => { return ( } avatar={{ avatar, title }} editing={editing} id={id} @@ -86,15 +86,6 @@ const UserMessage = memo(({ id, disableEditing, index }) => { showTitle={false} time={createdAt} titleAddon={dmIndicator} - actions={ - - } onDoubleClick={onDoubleClick} onMouseEnter={onMouseEnter} > diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/branching.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/branching.ts new file mode 100644 index 0000000000..252dea1a50 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/branching.ts @@ -0,0 +1,33 @@ +import { App } from 'antd'; +import { Split } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useChatStore } from '@/store/chat'; + +import { defineAction } from '../defineAction'; + +export const branchingAction = defineAction({ + key: 'branching', + useBuild: (ctx) => { + const { t } = useTranslation('common'); + const { message } = App.useApp(); + const [topic, openThreadCreator] = useChatStore((s) => [s.activeTopicId, s.openThreadCreator]); + + return useMemo( + () => ({ + handleClick: () => { + if (!topic) { + message.warning(t('branchingRequiresSavedTopic')); + return; + } + openThreadCreator(ctx.id); + }, + icon: Split, + key: 'branching', + label: t('branching'), + }), + [t, ctx.id, topic, openThreadCreator, message], + ); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/collapse.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/collapse.ts new file mode 100644 index 0000000000..96deb556a4 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/collapse.ts @@ -0,0 +1,30 @@ +import { ListChevronsDownUp, ListChevronsUpDown } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { messageStateSelectors, useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +/** + * Toggle between collapse and expand based on message state. A single key + * (`collapse`) swaps its icon/label depending on whether the message is + * currently collapsed. + */ +export const collapseAction = defineAction({ + key: 'collapse', + useBuild: (ctx) => { + const { t } = useTranslation('chat'); + const isCollapsed = useConversationStore(messageStateSelectors.isMessageCollapsed(ctx.id)); + const toggleMessageCollapsed = useConversationStore((s) => s.toggleMessageCollapsed); + + return useMemo( + () => ({ + handleClick: () => toggleMessageCollapsed(ctx.id), + icon: isCollapsed ? ListChevronsUpDown : ListChevronsDownUp, + key: 'collapse', + label: t(isCollapsed ? 'messageAction.expand' : 'messageAction.collapse'), + }), + [t, ctx.id, isCollapsed, toggleMessageCollapsed], + ); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/continueGeneration.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/continueGeneration.ts new file mode 100644 index 0000000000..333c3da618 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/continueGeneration.ts @@ -0,0 +1,33 @@ +import { StepForward } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { dataSelectors, messageStateSelectors, useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +export const continueGenerationAction = defineAction({ + key: 'continueGeneration', + useBuild: (ctx) => { + const { t } = useTranslation('chat'); + const lastBlockId = useConversationStore(dataSelectors.findLastMessageId(ctx.id)); + const isContinuing = useConversationStore((s) => + lastBlockId ? messageStateSelectors.isMessageContinuing(lastBlockId)(s) : false, + ); + const continueGenerationMessage = useConversationStore((s) => s.continueGenerationMessage); + + return useMemo(() => { + if (ctx.role !== 'group') return null; + return { + disabled: isContinuing, + handleClick: () => { + if (!lastBlockId) return; + continueGenerationMessage(ctx.id, lastBlockId); + }, + icon: StepForward, + key: 'continueGeneration', + label: t('messageAction.continueGeneration'), + spin: isContinuing || undefined, + }; + }, [t, ctx.id, ctx.role, lastBlockId, isContinuing, continueGenerationMessage]); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/copy.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/copy.ts new file mode 100644 index 0000000000..2f88b59814 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/copy.ts @@ -0,0 +1,33 @@ +import { copyToClipboard } from '@lobehub/ui'; +import { App } from 'antd'; +import { Copy } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { cleanSpeakerTag } from '@/store/chat/utils/cleanSpeakerTag'; + +import { defineAction } from '../defineAction'; + +export const copyAction = defineAction({ + key: 'copy', + useBuild: (ctx) => { + const { t } = useTranslation('common'); + const { message } = App.useApp(); + + return useMemo(() => { + const raw = + ctx.role === 'group' ? (ctx.contentBlock?.content ?? ctx.data.content) : ctx.data.content; + const content = ctx.role === 'user' ? cleanSpeakerTag(raw) : raw; + + return { + handleClick: async () => { + await copyToClipboard(content); + message.success(t('copySuccess')); + }, + icon: Copy, + key: 'copy', + label: t('copy'), + }; + }, [t, message, ctx.role, ctx.data.content, ctx.contentBlock?.content]); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/del.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/del.ts new file mode 100644 index 0000000000..79b641b9ac --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/del.ts @@ -0,0 +1,25 @@ +import { Trash } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +export const delAction = defineAction({ + key: 'del', + useBuild: (ctx) => { + const { t } = useTranslation('common'); + const deleteMessage = useConversationStore((s) => s.deleteMessage); + + return useMemo( + () => ({ + danger: true, + handleClick: () => deleteMessage(ctx.id), + icon: Trash, + key: 'del', + label: t('delete'), + }), + [t, ctx.id, deleteMessage], + ); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/delAndRegenerate.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/delAndRegenerate.ts new file mode 100644 index 0000000000..5d47985082 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/delAndRegenerate.ts @@ -0,0 +1,28 @@ +import { ListRestart } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { messageStateSelectors, useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +export const delAndRegenerateAction = defineAction({ + key: 'delAndRegenerate', + useBuild: (ctx) => { + const { t } = useTranslation('chat'); + const isRegenerating = useConversationStore( + messageStateSelectors.isMessageRegenerating(ctx.id), + ); + const delAndRegenerateMessage = useConversationStore((s) => s.delAndRegenerateMessage); + + return useMemo( + () => ({ + disabled: isRegenerating, + handleClick: () => delAndRegenerateMessage(ctx.id), + icon: ListRestart, + key: 'delAndRegenerate', + label: t('messageAction.delAndRegenerate'), + }), + [t, ctx.id, isRegenerating, delAndRegenerateMessage], + ); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/edit.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/edit.ts new file mode 100644 index 0000000000..02bb3ec3ec --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/edit.ts @@ -0,0 +1,29 @@ +import { Edit } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +export const editAction = defineAction({ + key: 'edit', + useBuild: (ctx) => { + const { t } = useTranslation('common'); + const toggleMessageEditing = useConversationStore((s) => s.toggleMessageEditing); + + return useMemo(() => { + // group edits the inner content block; other roles edit the message itself + const targetId = ctx.role === 'group' ? ctx.contentBlock?.id : ctx.id; + + return { + handleClick: () => { + if (!targetId) return; + toggleMessageEditing(targetId, true); + }, + icon: Edit, + key: 'edit', + label: t('edit'), + }; + }, [t, ctx.role, ctx.id, ctx.contentBlock?.id, toggleMessageEditing]); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/regenerate.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/regenerate.ts new file mode 100644 index 0000000000..f75cc6bcd3 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/regenerate.ts @@ -0,0 +1,48 @@ +import { RotateCcw } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { messageStateSelectors, useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +export const regenerateAction = defineAction({ + key: 'regenerate', + useBuild: (ctx) => { + const { t } = useTranslation('common'); + const isRegenerating = useConversationStore( + messageStateSelectors.isMessageRegenerating(ctx.id), + ); + const [regenerateUserMessage, regenerateAssistantMessage, deleteMessage] = useConversationStore( + (s) => [s.regenerateUserMessage, s.regenerateAssistantMessage, s.deleteMessage], + ); + + return useMemo( + () => ({ + disabled: isRegenerating, + handleClick: () => { + if (ctx.role === 'user') { + regenerateUserMessage(ctx.id); + if (ctx.data.error) deleteMessage(ctx.id); + } else { + regenerateAssistantMessage(ctx.id); + if (ctx.data.error) deleteMessage(ctx.id); + } + }, + icon: RotateCcw, + key: 'regenerate', + label: t('regenerate'), + spin: isRegenerating || undefined, + }), + [ + t, + ctx.id, + ctx.role, + ctx.data.error, + isRegenerating, + regenerateUserMessage, + regenerateAssistantMessage, + deleteMessage, + ], + ); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/share.tsx b/src/features/Conversation/Messages/components/MessageActionBar/actions/share.tsx new file mode 100644 index 0000000000..33995c5e42 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/share.tsx @@ -0,0 +1,45 @@ +import { createRawModal } from '@lobehub/ui'; +import { Share2 } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import ShareMessageModal, { type ShareModalProps } from '../../../../components/ShareMessageModal'; +import { createStore, Provider, useConversationStoreApi } from '../../../../store'; +import { defineAction } from '../defineAction'; + +export const shareAction = defineAction({ + key: 'share', + useBuild: (ctx) => { + const { t } = useTranslation('common'); + const storeApi = useConversationStoreApi(); + + return useMemo(() => { + if (ctx.role === 'user') return null; + return { + handleClick: () => { + createRawModal( + (props: ShareModalProps) => ( + { + const state = storeApi.getState(); + return createStore({ + context: state.context, + hooks: state.hooks, + skipFetch: state.skipFetch, + }); + }} + > + + + ), + { message: ctx.data }, + { onCloseKey: 'onCancel', openKey: 'open' }, + ); + }, + icon: Share2, + key: 'share', + label: t('share'), + }; + }, [t, ctx.role, ctx.data, storeApi]); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/translate.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/translate.ts new file mode 100644 index 0000000000..94e768cb67 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/translate.ts @@ -0,0 +1,39 @@ +import { css, cx } from 'antd-style'; +import { LanguagesIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { localeOptions } from '@/locales/resources'; + +import { useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +const translateStyle = css` + .ant-dropdown-menu-sub { + overflow-y: scroll; + max-height: 400px; + } +`; + +export const translateAction = defineAction({ + key: 'translate', + useBuild: (ctx) => { + const { t } = useTranslation(['common', 'chat']); + const translateMessage = useConversationStore((s) => s.translateMessage); + + return useMemo( + () => ({ + children: localeOptions.map((i) => ({ + handleClick: () => translateMessage(ctx.id, i.value), + key: i.value, + label: t(`lang.${i.value}`, { ns: 'common' }), + })), + icon: LanguagesIcon, + key: 'translate', + label: t('translate.action', { ns: 'chat' }), + popupClassName: cx(translateStyle), + }), + [t, ctx.id, translateMessage], + ); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/actions/tts.ts b/src/features/Conversation/Messages/components/MessageActionBar/actions/tts.ts new file mode 100644 index 0000000000..98f37f20a9 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/actions/tts.ts @@ -0,0 +1,24 @@ +import { Play } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useConversationStore } from '../../../../store'; +import { defineAction } from '../defineAction'; + +export const ttsAction = defineAction({ + key: 'tts', + useBuild: (ctx) => { + const { t } = useTranslation('chat'); + const ttsMessage = useConversationStore((s) => s.ttsMessage); + + return useMemo( + () => ({ + handleClick: () => ttsMessage(ctx.id), + icon: Play, + key: 'tts', + label: t('tts.action'), + }), + [t, ctx.id, ttsMessage], + ); + }, +}); diff --git a/src/features/Conversation/Messages/components/MessageActionBar/defineAction.ts b/src/features/Conversation/Messages/components/MessageActionBar/defineAction.ts new file mode 100644 index 0000000000..64b53d54d3 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/defineAction.ts @@ -0,0 +1,6 @@ +import { type MessageActionDefinition } from './types'; + +/** + * Identity helper that narrows an action definition's type at the call site. + */ +export const defineAction = (def: MessageActionDefinition): MessageActionDefinition => def; diff --git a/src/features/Conversation/Messages/components/MessageActionBar/index.tsx b/src/features/Conversation/Messages/components/MessageActionBar/index.tsx new file mode 100644 index 0000000000..5df7beecb0 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/index.tsx @@ -0,0 +1,116 @@ +import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui'; +import { ActionIconGroup } from '@lobehub/ui'; +import { memo, useCallback, useMemo } from 'react'; + +import { type MessageActionItem, type MessageActionItemOrDivider } from '../../../types'; +import { DIVIDER_KEY, type MessageActionContext, type MessageActionSlot } from './types'; +import { useBuildActions } from './useBuildActions'; + +const DIVIDER: MessageActionItemOrDivider = { type: 'divider' }; + +const stripHandleClick = (item: MessageActionItemOrDivider): ActionIconGroupItemType => { + if ('type' in item && item.type === 'divider') return item as unknown as ActionIconGroupItemType; + const { children, ...rest } = item as MessageActionItem; + const baseItem = { ...rest } as MessageActionItem; + delete (baseItem as { handleClick?: unknown }).handleClick; + if (children) { + return { + ...baseItem, + children: children.map((child) => { + const nextChild = { ...child } as MessageActionItem; + delete (nextChild as { handleClick?: unknown }).handleClick; + return nextChild; + }), + } as ActionIconGroupItemType; + } + return baseItem as ActionIconGroupItemType; +}; + +const buildActionsMap = (items: MessageActionItemOrDivider[]): Map => { + const map = new Map(); + for (const item of items) { + if ('key' in item && item.key) { + map.set(String(item.key), item as MessageActionItem); + if ('children' in item && item.children) { + for (const child of item.children) { + if (child.key) { + map.set(`${item.key}.${child.key}`, child as unknown as MessageActionItem); + } + } + } + } + } + return map; +}; + +const resolveSlots = ( + slots: MessageActionSlot[], + built: Record, +): MessageActionItemOrDivider[] => { + const out: MessageActionItemOrDivider[] = []; + for (const slot of slots) { + if (slot === DIVIDER_KEY) { + out.push(DIVIDER); + continue; + } + const item = built[slot]; + if (item) out.push(item); + } + return out; +}; + +interface MessageActionBarProps { + /** Bar slots (always visible as icons) */ + bar: MessageActionSlot[]; + /** Runtime context passed to every action's builder */ + ctx: MessageActionContext; + /** Menu slots (shown in the overflow dropdown); defaults to `bar` when omitted */ + menu?: MessageActionSlot[]; +} + +/** + * Universal action bar. Resolves declarative slot keys (`'copy'`, `'edit'`, + * `'divider'`, ...) against the registry and renders an ActionIconGroup. + */ +export const MessageActionBar = memo(({ ctx, bar, menu }) => { + const built = useBuildActions(ctx); + + const barItems = useMemo(() => resolveSlots(bar, built), [bar, built]); + const menuItems = useMemo(() => (menu ? resolveSlots(menu, built) : undefined), [menu, built]); + + const items = useMemo( + () => barItems.filter((item) => !('disabled' in item && item.disabled)).map(stripHandleClick), + [barItems], + ); + const menuStripped = useMemo(() => menuItems?.map(stripHandleClick), [menuItems]); + + const allActions = useMemo( + () => buildActionsMap([...barItems, ...(menuItems ?? [])]), + [barItems, menuItems], + ); + + const handleAction = useCallback( + (event: ActionIconGroupEvent) => { + if (event.keyPath && event.keyPath.length > 1) { + const parentKey = event.keyPath.at(-1); + const childKey = event.keyPath[0]; + const parent = allActions.get(parentKey!); + if (parent && 'children' in parent && parent.children) { + const child = parent.children.find((c) => c.key === childKey); + child?.handleClick?.(); + return; + } + } + const action = allActions.get(event.key); + action?.handleClick?.(); + }, + [allActions], + ); + + return ; +}); + +MessageActionBar.displayName = 'MessageActionBar'; + +export type { MessageActionContext, MessageActionSlot } from './types'; +export { DIVIDER_KEY } from './types'; diff --git a/src/features/Conversation/Messages/components/MessageActionBar/types.ts b/src/features/Conversation/Messages/components/MessageActionBar/types.ts new file mode 100644 index 0000000000..9e6bdadfc8 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/types.ts @@ -0,0 +1,33 @@ +import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types'; + +import { type MessageActionItem } from '../../../types'; + +export type MessageRole = 'user' | 'assistant' | 'group'; + +/** + * Runtime context an action builder receives. All fields except `role`/`id` + * may vary — actions decide what they care about. + */ +export interface MessageActionContext { + contentBlock?: AssistantContentBlock; + data: UIChatMessage; + id: string; + role: MessageRole; +} + +/** + * A registered action. `useBuild` is a hook — called unconditionally for every + * message, returns `null` when the action doesn't apply to the current role. + */ +export interface MessageActionDefinition { + key: string; + useBuild: (ctx: MessageActionContext) => MessageActionItem | null; +} + +/** + * Slot in a bar/menu list. A string is an action key; `'divider'` inserts a + * divider. + */ +export type MessageActionSlot = string; + +export const DIVIDER_KEY = 'divider'; diff --git a/src/features/Conversation/Messages/components/MessageActionBar/useBuildActions.ts b/src/features/Conversation/Messages/components/MessageActionBar/useBuildActions.ts new file mode 100644 index 0000000000..0a970c66c7 --- /dev/null +++ b/src/features/Conversation/Messages/components/MessageActionBar/useBuildActions.ts @@ -0,0 +1,38 @@ +import { type MessageActionItem } from '../../../types'; +import { branchingAction } from './actions/branching'; +import { collapseAction } from './actions/collapse'; +import { continueGenerationAction } from './actions/continueGeneration'; +import { copyAction } from './actions/copy'; +import { delAction } from './actions/del'; +import { delAndRegenerateAction } from './actions/delAndRegenerate'; +import { editAction } from './actions/edit'; +import { regenerateAction } from './actions/regenerate'; +import { shareAction } from './actions/share'; +import { translateAction } from './actions/translate'; +import { ttsAction } from './actions/tts'; +import { type MessageActionContext } from './types'; + +/** + * Calls every registered action's `useBuild` hook for the given context. + * + * Returns a record keyed by action `key`. Hook order is fixed — don't change + * this call sequence without updating React dev expectations. + * + * Actions that don't apply to the current role return `null` and are simply + * absent from the result when consumed. + */ +export const useBuildActions = ( + ctx: MessageActionContext, +): Record => ({ + branching: branchingAction.useBuild(ctx), + collapse: collapseAction.useBuild(ctx), + continueGeneration: continueGenerationAction.useBuild(ctx), + copy: copyAction.useBuild(ctx), + del: delAction.useBuild(ctx), + delAndRegenerate: delAndRegenerateAction.useBuild(ctx), + edit: editAction.useBuild(ctx), + regenerate: regenerateAction.useBuild(ctx), + share: shareAction.useBuild(ctx), + translate: translateAction.useBuild(ctx), + tts: ttsAction.useBuild(ctx), +}); diff --git a/src/features/Conversation/Messages/index.tsx b/src/features/Conversation/Messages/index.tsx index 0eceb70ba4..8be1c171b9 100644 --- a/src/features/Conversation/Messages/index.tsx +++ b/src/features/Conversation/Messages/index.tsx @@ -162,11 +162,11 @@ const MessageItem = memo( ); } case 'tasks': { - return ; + return ; } case 'groupTasks': { - return ; + return ; } case 'agentCouncil': { diff --git a/src/features/Conversation/types/ui.ts b/src/features/Conversation/types/ui.ts index 7155d16bae..d276ed3286 100644 --- a/src/features/Conversation/types/ui.ts +++ b/src/features/Conversation/types/ui.ts @@ -29,33 +29,24 @@ export interface MessageActionItem extends ActionIconGroupItemType { export type MessageActionItemOrDivider = MessageActionItem | { type: 'divider' }; /** - * Factory function type for creating message-specific actions - * Receives message id and returns an action item or null + * Action slot reference. A registered action key (e.g. `'copy'`) or the + * reserved `'divider'` literal. + * + * Uses declarative keys rather than pre-built items so per-message action + * construction stays lazy and per-session/role config lives at the route + * layer (see `useActionsBarConfig`). */ -export type MessageActionFactory = (id: string) => MessageActionItem | null; +export type MessageActionSlot = string; /** - * Action configuration for a specific message type + * Action configuration for a specific message type. Lists of registered + * action keys resolved at render-time against the action registry. */ export interface MessageActionsConfig { - /** - * Actions to display in the action bar (always visible) - */ - bar?: MessageActionItemOrDivider[]; - /** - * Extra actions to add to the bar, created per-message using factory functions. - * These are appended after the default/configured bar actions. - */ - extraBarActions?: MessageActionFactory[]; - /** - * Extra actions to add to the menu, created per-message using factory functions. - * These are appended after the default/configured menu actions. - */ - extraMenuActions?: MessageActionFactory[]; - /** - * Actions to display in the dropdown menu - */ - menu?: MessageActionItemOrDivider[]; + /** Bar slots (always visible as icons) */ + bar?: MessageActionSlot[]; + /** Menu slots (overflow dropdown); when omitted the role's default menu is used */ + menu?: MessageActionSlot[]; } /** diff --git a/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts b/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts index 7ce7f82369..569cd25235 100644 --- a/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts +++ b/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts @@ -1,77 +1,50 @@ 'use client'; -import { App } from 'antd'; -import { Split } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; +import { useMemo } from 'react'; -import { - type ActionsBarConfig, - type MessageActionFactory, - type MessageActionItem, -} from '@/features/Conversation/types'; +import { type ActionsBarConfig, type MessageActionSlot } from '@/features/Conversation/types'; import { useAgentStore } from '@/store/agent'; import { agentSelectors } from '@/store/agent/selectors'; -import { useChatStore } from '@/store/chat'; import { useUserStore } from '@/store/user'; import { userGeneralSettingsSelectors } from '@/store/user/selectors'; /** - * Hook to create a branching action factory function. - * The factory function takes a message id and returns the branching action - * with proper ChatStore integration. + * Hetero-agent (ACP) sessions only support copy + delete — edit / regenerate / + * branching / translate / tts / share don't apply because the external + * runtime owns message lifecycle. */ -export const useBranchingActionFactory = (): MessageActionFactory => { - const { t } = useTranslation('common'); - const { message } = App.useApp(); - - const [topic, openThreadCreator] = useChatStore((s) => [s.activeTopicId, s.openThreadCreator]); - - return useCallback( - (id: string): MessageActionItem | null => { - return { - handleClick: () => { - if (!topic) { - message.warning(t('branchingRequiresSavedTopic')); - return; - } - openThreadCreator(id); - }, - icon: Split, - key: 'branching', - label: t('branching'), - }; - }, - [topic, openThreadCreator], - ); +const HETERO_USER: { bar: MessageActionSlot[]; menu: MessageActionSlot[] } = { + bar: ['copy'], + menu: ['copy', 'divider', 'del'], +}; + +const HETERO_ASSISTANT: { bar: MessageActionSlot[]; menu: MessageActionSlot[] } = { + bar: ['copy'], + menu: ['copy', 'divider', 'del'], }; -/** - * Hook to generate actionsBar configuration with branching support. - * This creates the complete actionsBar config to be passed to ChatList. - */ export const useActionsBarConfig = (): ActionsBarConfig => { - const branchingFactory = useBranchingActionFactory(); const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode); const hasACPProvider = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous); - return useMemo( - () => ({ - assistant: { - extraBarActions: isDevMode && !hasACPProvider ? [branchingFactory] : [], - }, - // For ACP agents, only show copy + delete in the assistant group action bar - ...(hasACPProvider - ? { - assistantGroup: { - extraBarActions: [], - }, - } - : {}), - user: { - extraBarActions: isDevMode ? [branchingFactory] : [], - }, - }), - [branchingFactory, hasACPProvider, isDevMode], - ); + return useMemo(() => { + if (hasACPProvider) { + return { + assistant: HETERO_ASSISTANT, + assistantGroup: HETERO_ASSISTANT, + user: HETERO_USER, + }; + } + + // Dev mode adds `branching` to the default bars. Everything else falls + // back to each role's component-level defaults. + if (isDevMode) { + return { + assistant: { bar: ['edit', 'copy', 'branching'] }, + user: { bar: ['regenerate', 'edit', 'copy', 'branching'] }, + }; + } + + return {}; + }, [hasACPProvider, isDevMode]); }; diff --git a/src/routes/(main)/group/features/Conversation/useActionsBarConfig.ts b/src/routes/(main)/group/features/Conversation/useActionsBarConfig.ts index 4b774bd006..89f25c2063 100644 --- a/src/routes/(main)/group/features/Conversation/useActionsBarConfig.ts +++ b/src/routes/(main)/group/features/Conversation/useActionsBarConfig.ts @@ -1,63 +1,12 @@ 'use client'; -import { App } from 'antd'; -import { Split } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; +import { useMemo } from 'react'; -import { - type ActionsBarConfig, - type MessageActionFactory, - type MessageActionItem, -} from '@/features/Conversation/types'; -import { useChatStore } from '@/store/chat'; +import { type ActionsBarConfig } from '@/features/Conversation/types'; /** - * Hook to create a branching action factory function. - * The factory function takes a message id and returns the branching action - * with proper ChatStore integration. + * Group-chat conversation action bar config. Currently relies on each role's + * component-level defaults — no per-session overrides are needed yet. */ -export const useBranchingActionFactory = (): MessageActionFactory => { - const { t } = useTranslation('common'); - const { message } = App.useApp(); - - const [topic, openThreadCreator] = useChatStore((s) => [s.activeTopicId, s.openThreadCreator]); - - return useCallback( - (id: string): MessageActionItem | null => { - return { - handleClick: () => { - if (!topic) { - message.warning(t('branchingRequiresSavedTopic')); - return; - } - openThreadCreator(id); - }, - icon: Split, - key: 'branching', - label: t('branching'), - }; - }, - [topic, openThreadCreator], - ); -}; - -/** - * Hook to generate actionsBar configuration with branching support. - * This creates the complete actionsBar config to be passed to ChatList. - */ -export const useActionsBarConfig = (): ActionsBarConfig => { - const branchingFactory = useBranchingActionFactory(); - - return useMemo( - () => ({ - assistant: { - // extraBarActions: [branchingFactory], - }, - user: { - // extraBarActions: [branchingFactory], - }, - }), - [branchingFactory], - ); -}; +export const useActionsBarConfig = (): ActionsBarConfig => + useMemo(() => ({}), []);