diff --git a/wework/src/components/chat/ChatInput.tsx b/wework/src/components/chat/ChatInput.tsx index a67a53ade4..cf9ac2f7dd 100644 --- a/wework/src/components/chat/ChatInput.tsx +++ b/wework/src/components/chat/ChatInput.tsx @@ -130,6 +130,7 @@ export interface ChatInputProps { onCompositionEnd?: () => void onSubmit: (valueOverride?: string, options?: ChatSubmitOptions) => void | Promise disabled: boolean + pluginPickerIconOnly?: boolean submitDisabled?: boolean error?: string | null disabledReason?: string @@ -515,6 +516,7 @@ export const ChatInput = forwardRef(function Ch onCompositionEnd, onSubmit, disabled, + pluginPickerIconOnly = false, submitDisabled = false, error, disabledReason, @@ -818,6 +820,7 @@ export const ChatInput = forwardRef(function Ch , document.body )} - + + ) } diff --git a/wework/src/components/chat/composer/ComposerToolbar.test.tsx b/wework/src/components/chat/composer/ComposerToolbar.test.tsx index 21dc57e405..105a371395 100644 --- a/wework/src/components/chat/composer/ComposerToolbar.test.tsx +++ b/wework/src/components/chat/composer/ComposerToolbar.test.tsx @@ -71,4 +71,54 @@ describe('ComposerToolbar', () => { expect(screen.getByTestId('composer-toolbar')).toHaveAttribute('data-compact', 'true') expect(screen.getByTestId('quick-phrase-layout')).toHaveTextContent('icon') }) + + it('collapses the plugin picker trigger to an icon once a conversation has started', () => { + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 600, + height: 32, + top: 0, + right: 600, + bottom: 32, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) + + const { rerender } = render( + + ) + + expect(screen.getByTestId('composer-plugin-picker-button')).toHaveClass('h-8') + + rerender( + + ) + + expect(screen.getByTestId('composer-plugin-picker-button')).toHaveClass('h-7') + }) }) diff --git a/wework/src/components/chat/composer/ComposerToolbar.tsx b/wework/src/components/chat/composer/ComposerToolbar.tsx index 42f2e1351b..ae03f39e06 100644 --- a/wework/src/components/chat/composer/ComposerToolbar.tsx +++ b/wework/src/components/chat/composer/ComposerToolbar.tsx @@ -4,6 +4,7 @@ import type { CloudProject } from '@/api/deliveries' import { ActionMenu } from '@/components/common/ActionMenu' import type { ComposerSubmitOptions } from './ComposerTextarea' import { useTranslation } from '@/hooks/useTranslation' +import { Tooltip } from '@/components/ui/tooltip' import type { LocalDeviceApp, ModelOptions, RuntimeContextUsage, UnifiedModel } from '@/types/api' import { AddContextMenu } from './AddContextMenu' import type { ComposerCloudMentionCandidate } from './composerMentionCandidates' @@ -18,6 +19,7 @@ import type { QuickPhrase } from '@/tauri/appPreferences' interface ComposerToolbarProps { canSend: boolean disabled?: boolean + pluginPickerIconOnly?: boolean models: UnifiedModel[] selectedModel: UnifiedModel | null activeModel?: UnifiedModel | null @@ -61,6 +63,7 @@ const NARROW_MODEL_SELECTOR_MAX_WIDTH = 160 export function ComposerToolbar({ canSend, disabled = false, + pluginPickerIconOnly = false, models, selectedModel, activeModel, @@ -146,7 +149,7 @@ export function ComposerToolbar({ {leadingContext} @@ -194,25 +197,37 @@ export function ComposerToolbar({ ) : null} {isStreaming && !canSend ? ( - - ) : isStreaming && canSend ? ( -
+ + ) : isStreaming && canSend ? ( +
+ + +
) : ( - + + )}
diff --git a/wework/src/components/chat/composer/PluginPickerMenu.test.tsx b/wework/src/components/chat/composer/PluginPickerMenu.test.tsx index f6582a0d67..be9df04961 100644 --- a/wework/src/components/chat/composer/PluginPickerMenu.test.tsx +++ b/wework/src/components/chat/composer/PluginPickerMenu.test.tsx @@ -119,6 +119,20 @@ describe('PluginPickerMenu', () => { window.removeEventListener(SHOW_PLUGIN_TRIAL_GUIDE_EVENT, onShowGuide) }) + test('renders a single icon trigger when iconOnly is set', async () => { + const onListLocalApps = vi.fn().mockResolvedValue([githubApp, superpowersApp, echoIdApp]) + + render() + + const trigger = screen.getByTestId('composer-plugin-picker-button') + expect(trigger).toHaveClass('h-7', 'w-7', 'rounded-lg') + expect(trigger).not.toHaveTextContent('插件') + expect(screen.queryByTestId(/composer-plugin-preview-icon-/)).not.toBeInTheDocument() + + await userEvent.click(trigger) + expect(await screen.findByTestId('composer-plugin-picker-item-github')).toBeInTheDocument() + }) + test('orders available plugins by usage count then recent selection', async () => { recordPluginUsage('EchoID') recordPluginUsage('EchoID') diff --git a/wework/src/components/chat/composer/PluginPickerMenu.tsx b/wework/src/components/chat/composer/PluginPickerMenu.tsx index 429d523618..dd49a6aa98 100644 --- a/wework/src/components/chat/composer/PluginPickerMenu.tsx +++ b/wework/src/components/chat/composer/PluginPickerMenu.tsx @@ -7,6 +7,7 @@ import { } from '@/features/plugins/pluginTrial' import { composerAppPluginKey } from '@/features/plugins/composerPluginMetadata' import { useTranslation } from '@/hooks/useTranslation' +import { Tooltip } from '@/components/ui/tooltip' import { navigateTo } from '@/lib/navigation' import type { LocalDeviceApp } from '@/types/api' import { resolvePluginLogoUrl } from '@/components/plugins/plugin-assets' @@ -163,70 +164,78 @@ export function PluginPickerMenu({ return (
- + setOpen(!open) + }} + > + {iconOnly ? ( + + ) : ( + <> + {t('workbench.composer_plugins', '插件')} + + {apps.length > 3 && ( + +{apps.length - 3} + )} + + )} + + {open && (
void onSubmit: (submittedValue?: string, options?: ComposerSubmitOptions) => void disabled: boolean + pluginPickerIconOnly?: boolean submitDisabled?: boolean disabledReason?: string placeholder: string @@ -122,6 +123,7 @@ export const ProjectChatComposer = forwardRef - + + {open && typeof document !== 'undefined' && createPortal( diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index c7d96be6e8..da4db9444f 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -1791,7 +1791,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const [projectMenuOpenSignal, setProjectMenuOpenSignal] = useState(0) const [projectMenuAnchorElement, setProjectMenuAnchorElement] = useState(null) - const hasConversation = paneMessages.length > 0 || currentRuntimeTask + const hasConversation = paneMessages.length > 0 || Boolean(currentRuntimeTask) const hasMainBackground = Boolean(background.imagePath && background.inMain) const activeDevice = findWorkbenchDevice(devices, activeDeviceId) const activeDeviceSupportsGoal = Boolean(activeDevice && isClaudeCodeDevice(activeDevice)) @@ -2862,6 +2862,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onChange={paneSession.setInput} onSubmit={submitPaneInput} disabled={composerDisabled} + pluginPickerIconOnly={hasConversation} submitDisabled={paneSession.status.isSubmitting} error={paneSession.error} disabledReason={inlineComposerDisabledReason} diff --git a/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx b/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx index ad33b83677..1d1a4597ad 100644 --- a/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx +++ b/wework/src/components/layout/workspace-panels/TemporaryChatPanel.tsx @@ -535,6 +535,7 @@ export function TemporaryChatPanel({ onChange={setInput} onSubmit={send} disabled={false} + pluginPickerIconOnly error={error} placeholder={placeholder} variant="desktop" diff --git a/wework/src/components/ui/tooltip.test.tsx b/wework/src/components/ui/tooltip.test.tsx new file mode 100644 index 0000000000..8c752c08ba --- /dev/null +++ b/wework/src/components/ui/tooltip.test.tsx @@ -0,0 +1,57 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { Tooltip } from './tooltip' + +describe('Tooltip', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('shows the label on hover after the delay and hides on pointer leave', () => { + render( + + + + ) + const wrapper = screen.getByRole('button').parentElement as HTMLElement + const tooltip = screen.getByTestId('composer-tooltip') + expect(tooltip).toHaveClass('opacity-0') + + fireEvent.pointerEnter(wrapper) + act(() => { + vi.advanceTimersByTime(699) + }) + expect(tooltip).toHaveClass('opacity-0') + act(() => { + vi.advanceTimersByTime(1) + }) + expect(tooltip).toHaveClass('opacity-100') + expect(tooltip).toHaveTextContent('添加上下文') + + fireEvent.pointerLeave(wrapper) + expect(tooltip).toHaveClass('opacity-0') + }) + + test('hides on Escape while focused', () => { + render( + + + + ) + const wrapper = screen.getByRole('button').parentElement as HTMLElement + const tooltip = screen.getByTestId('composer-tooltip') + + fireEvent.focus(wrapper) + act(() => { + vi.advanceTimersByTime(700) + }) + expect(tooltip).toHaveClass('opacity-100') + + fireEvent.keyDown(wrapper, { key: 'Escape' }) + expect(tooltip).toHaveClass('opacity-0') + }) +}) diff --git a/wework/src/components/ui/tooltip.tsx b/wework/src/components/ui/tooltip.tsx new file mode 100644 index 0000000000..4b1ffb8ae5 --- /dev/null +++ b/wework/src/components/ui/tooltip.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface TooltipProps { + label: string + side?: 'top' | 'bottom' + align?: 'start' | 'center' | 'end' + testId?: string + children: ReactNode +} + +const SHOW_DELAY_MS = 700 + +const sidePosition = { + top: 'bottom-full mb-2', + bottom: 'top-full mt-2', +} + +const alignPosition = { + start: 'left-0', + center: 'left-1/2 -translate-x-1/2', + end: 'right-0', +} + +export function Tooltip({ label, side = 'top', align = 'center', testId, children }: TooltipProps) { + const [visible, setVisible] = useState(false) + const showTimerRef = useRef(null) + + const clearShowTimer = () => { + if (showTimerRef.current !== null) { + window.clearTimeout(showTimerRef.current) + showTimerRef.current = null + } + } + + const scheduleShow = () => { + clearShowTimer() + showTimerRef.current = window.setTimeout(() => { + setVisible(true) + showTimerRef.current = null + }, SHOW_DELAY_MS) + } + + const hide = () => { + clearShowTimer() + setVisible(false) + } + + useEffect(() => clearShowTimer, []) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') hide() + } + + return ( + + {children} + + {label} + + + ) +}