From 4646cbe815e1463d0568f70d30d98b25e11b8cf6 Mon Sep 17 00:00:00 2001 From: yixiang1 Date: Mon, 9 Mar 2026 18:07:16 +0800 Subject: [PATCH 1/8] feat: Introduce team/agent selection, add new icons, and update i18n for various chat input components. --- frontend/src/app/globals.css | 36 +-- frontend/src/components/icons/AgentIcon.tsx | 47 ++++ frontend/src/components/icons/ModelIcon.tsx | 41 ++++ frontend/src/components/ui/action-button.tsx | 22 +- .../tasks/components/AttachmentButton.tsx | 2 - .../tasks/components/CorrectionModeToggle.tsx | 6 +- .../components/chat/AddContextButton.tsx | 12 +- .../tasks/components/chat/ChatArea.tsx | 1 + .../clarification/ClarificationToggle.tsx | 6 +- .../tasks/components/input/ChatInputCard.tsx | 6 +- .../components/input/ChatInputControls.tsx | 58 +++-- .../components/selector/ModelSelector.tsx | 13 +- .../selector/SkillSelectorPopover.tsx | 3 +- .../selector/TeamSelectorButton.tsx | 220 ++++++++++++++++++ .../components/sidebar/ResizableSidebar.tsx | 2 +- .../tasks/components/sidebar/TaskSidebar.tsx | 22 +- frontend/src/i18n/locales/en/chat.json | 3 + frontend/src/i18n/locales/en/common.json | 5 + frontend/src/i18n/locales/zh-CN/chat.json | 3 + frontend/src/i18n/locales/zh-CN/common.json | 5 + frontend/tailwind.config.js | 11 +- 21 files changed, 446 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/icons/AgentIcon.tsx create mode 100644 frontend/src/components/icons/ModelIcon.tsx create mode 100644 frontend/src/features/tasks/components/selector/TeamSelectorButton.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6c7a2dcfb..fd3b5d581 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -10,43 +10,46 @@ :root { color-scheme: light; - /* ChatGPT Light Theme */ + /* Wegent Light Theme - Purple Primary */ --color-bg-base: 255 255 255; - --color-bg-surface: 249 249 249; + --color-bg-surface: 255 255 255; --color-bg-muted: 243 244 246; - --color-bg-hover: 229 231 235; - --color-border: 224 224 224; - --color-border-strong: 192 192 192; - --color-text-primary: 26 26 26; - --color-text-secondary: 102 102 102; - --color-text-muted: 160 160 160; + --color-bg-hover: 93 94 201 / 0.06; + --color-border: 228 228 228; + --color-border-strong: 200 200 200; + --color-border-light: 243 244 246; + --color-text-primary: 51 51 51; + --color-text-secondary: 99 99 99; + --color-text-muted: 147 147 147; --color-text-inverted: 255 255 255; --color-primary: 93 94 201; --color-primary-contrast: 255 255 255; --color-focus-ring: 93 94 201; - --color-scrollbar-track: 224 224 224; - --color-scrollbar-thumb: 192 192 192; + --color-scrollbar-track: 228 228 228; + --color-scrollbar-thumb: 200 200 200; --color-success: 34 197 94; --color-error: 239 68 68; --color-link: 93 94 201; - --color-code-bg: 246 248 250; + --color-code-bg: 243 244 246; --color-popover: 255 255 255; - --color-popover-foreground: 26 26 26; - --color-tooltip: 26 26 26; + --color-popover-foreground: 51 51 51; + --color-tooltip: 51 51 51; --color-tooltip-foreground: 255 255 255; - --shadow-popover: 0 12px 32px rgba(15, 23, 42, 0.12); + --shadow-popover: 0 12px 32px rgba(93, 94, 201, 0.12); + --shadow-sidebar: 0 4px 30px rgba(93, 94, 201, 0.1); --radius: 0.5rem; } [data-theme='dark'] { color-scheme: dark; - /* ChatGPT Dark Theme */ + /* Wegent Dark Theme - Purple Primary */ --color-bg-base: 14 15 15; --color-bg-surface: 26 28 28; --color-bg-muted: 33 36 36; - --color-bg-hover: 42 45 45; + --color-bg-hover: 118 119 218 / 0.1; --color-border: 42 45 45; --color-border-strong: 52 53 53; + --color-border-light: 42 45 45; --color-text-primary: 236 236 236; --color-text-secondary: 212 212 212; --color-text-muted: 160 160 160; @@ -65,6 +68,7 @@ --color-tooltip: 236 236 236; --color-tooltip-foreground: 14 15 15; --shadow-popover: 0 8px 24px rgba(0, 0, 0, 0.5); + --shadow-sidebar: 0 4px 30px rgba(0, 0, 0, 0.3); --radius: 0.5rem; } diff --git a/frontend/src/components/icons/AgentIcon.tsx b/frontend/src/components/icons/AgentIcon.tsx new file mode 100644 index 000000000..9b94727b0 --- /dev/null +++ b/frontend/src/components/icons/AgentIcon.tsx @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * AgentIcon Component + * + * Custom SVG icon for agent/team representation. + * Features multiple people silhouettes representing a team. + */ + +import React from 'react' + +interface AgentIconProps { + className?: string +} + +export function AgentIcon({ className }: AgentIconProps) { + return ( + + + + + + + + + + + + ) +} + +export default AgentIcon diff --git a/frontend/src/components/icons/ModelIcon.tsx b/frontend/src/components/icons/ModelIcon.tsx new file mode 100644 index 000000000..9444aa142 --- /dev/null +++ b/frontend/src/components/icons/ModelIcon.tsx @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * ModelIcon Component + * + * Custom SVG icon for AI model selection. + * Features a geometric diamond/cube design representing AI models. + */ + +import React from 'react' + +interface ModelIconProps { + className?: string +} + +export function ModelIcon({ className }: ModelIconProps) { + return ( + + + + + ) +} + +export default ModelIcon diff --git a/frontend/src/components/ui/action-button.tsx b/frontend/src/components/ui/action-button.tsx index 2b6593c35..311bb344a 100644 --- a/frontend/src/components/ui/action-button.tsx +++ b/frontend/src/components/ui/action-button.tsx @@ -12,6 +12,8 @@ interface ActionButtonProps { disabled?: boolean title?: string icon: React.ReactNode + /** Optional text label to display next to the icon */ + label?: string variant?: 'default' | 'outline' | 'loading' className?: string asChild?: boolean @@ -66,11 +68,20 @@ export function ActionButton({ disabled = false, title, icon, + label, variant = 'default', className = '', }: ActionButtonProps) { - // Base styles shared by all variants - const baseStyles = 'h-9 w-9 rounded-full flex-shrink-0' + // Determine if this is an icon-only button or has a label + const hasLabel = Boolean(label) + + // Base styles - different for icon-only vs with-label buttons + // Design spec: height 36px, border-radius 24px, border 1px #E4E4E4, bg white + // With label: padding 10px 12px 10px 10px, gap 4px + // Icon only: 36x36 circle with centered icon + const baseStyles = hasLabel + ? 'h-9 rounded-[24px] flex-shrink-0 pl-2.5 pr-3 py-2.5 gap-1 inline-flex items-center' + : 'h-9 w-9 rounded-full flex-shrink-0' if (variant === 'loading') { // Static loading state (non-clickable) @@ -79,24 +90,27 @@ export function ActionButton({ className={`relative ${baseStyles} flex items-center justify-center border border-border bg-base ${className}`} > {icon} + {label && {label}} ) } // Clickable button (default or outline) const buttonVariant = variant === 'outline' ? 'outline' : 'ghost' - const defaultClassName = variant === 'outline' ? '' : 'border border-border' + // No border for default variant, create clean flat button style + const defaultClassName = variant === 'outline' ? 'border border-border' : '' return ( ) } diff --git a/frontend/src/features/tasks/components/AttachmentButton.tsx b/frontend/src/features/tasks/components/AttachmentButton.tsx index 256abc215..016dc44fe 100644 --- a/frontend/src/features/tasks/components/AttachmentButton.tsx +++ b/frontend/src/features/tasks/components/AttachmentButton.tsx @@ -138,11 +138,9 @@ export default function AttachmentButton({
} - className="border-border bg-base text-text-primary hover:bg-hover" />
diff --git a/frontend/src/features/tasks/components/CorrectionModeToggle.tsx b/frontend/src/features/tasks/components/CorrectionModeToggle.tsx index f1dc63dd1..3c07b9ff8 100644 --- a/frontend/src/features/tasks/components/CorrectionModeToggle.tsx +++ b/frontend/src/features/tasks/components/CorrectionModeToggle.tsx @@ -151,15 +151,15 @@ export default function CorrectionModeToggle({
} + label={t('chat:correction.label')} className={cn( 'transition-colors', enabled - ? 'border-primary bg-primary/10 text-primary hover:bg-primary/20' - : 'border-border bg-base text-text-primary hover:bg-hover' + ? 'bg-primary/10 text-primary hover:bg-primary/20' + : 'text-text-primary hover:bg-hover' )} />
diff --git a/frontend/src/features/tasks/components/chat/AddContextButton.tsx b/frontend/src/features/tasks/components/chat/AddContextButton.tsx index 0800e89ce..f8b620499 100644 --- a/frontend/src/features/tasks/components/chat/AddContextButton.tsx +++ b/frontend/src/features/tasks/components/chat/AddContextButton.tsx @@ -5,6 +5,7 @@ 'use client' import React from 'react' +import { BookOpenText } from 'lucide-react' import { ActionButton } from '@/components/ui/action-button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useTranslation } from '@/hooks/useTranslation' @@ -14,9 +15,9 @@ interface AddContextButtonProps { } /** - * Add Context Button - Icon-only button that opens knowledge base selector - * Always displays "#" symbol with tooltip on hover - * Uses ActionButton for consistent 36px size with other control buttons + * Add Context Button - Button with icon and label that opens knowledge base selector + * Displays BookOpenText icon with "知识库" label + * Uses ActionButton for consistent styling with other control buttons */ export default function AddContextButton({ onClick }: AddContextButtonProps) { const { t } = useTranslation() @@ -27,11 +28,10 @@ export default function AddContextButton({ onClick }: AddContextButtonProps) {
#} + icon={} + label={t('knowledge:tooltip')} title={t('knowledge:tooltip')} - className="border-border bg-base text-text-primary hover:bg-hover" />
diff --git a/frontend/src/features/tasks/components/chat/ChatArea.tsx b/frontend/src/features/tasks/components/chat/ChatArea.tsx index 0498b3522..0748c3ad5 100644 --- a/frontend/src/features/tasks/components/chat/ChatArea.tsx +++ b/frontend/src/features/tasks/components/chat/ChatArea.tsx @@ -863,6 +863,7 @@ function ChatAreaContent({ taskInputMessage: chatState.taskInputMessage, setTaskInputMessage: chatState.setTaskInputMessage, selectedTeam: chatState.selectedTeam, + teams: teams, externalApiParams: chatState.externalApiParams, onTeamChange: chatState.handleTeamChange, onExternalApiParamsChange: chatState.handleExternalApiParamsChange, diff --git a/frontend/src/features/tasks/components/clarification/ClarificationToggle.tsx b/frontend/src/features/tasks/components/clarification/ClarificationToggle.tsx index 6a219b919..35fa0f0b6 100644 --- a/frontend/src/features/tasks/components/clarification/ClarificationToggle.tsx +++ b/frontend/src/features/tasks/components/clarification/ClarificationToggle.tsx @@ -40,15 +40,15 @@ export default function ClarificationToggle({
} + label={t('chat:clarification_toggle.label')} className={cn( 'transition-colors', enabled - ? 'border-primary bg-primary/10 text-primary hover:bg-primary/20' - : 'border-border bg-base text-text-primary hover:bg-hover' + ? 'bg-primary/10 text-primary hover:bg-primary/20' + : 'text-text-primary hover:bg-hover' )} />
diff --git a/frontend/src/features/tasks/components/input/ChatInputCard.tsx b/frontend/src/features/tasks/components/input/ChatInputCard.tsx index 348ad33b3..e3c9d82d3 100644 --- a/frontend/src/features/tasks/components/input/ChatInputCard.tsx +++ b/frontend/src/features/tasks/components/input/ChatInputCard.tsx @@ -27,6 +27,8 @@ export interface ChatInputCardProps extends Omit< // Team and external API selectedTeam: Team | null + /** Available teams for team selector */ + teams?: Team[] externalApiParams: Record onExternalApiParamsChange: (params: Record) => void onAppModeChange: (mode: string | undefined) => void @@ -92,6 +94,7 @@ export function ChatInputCard({ taskInputMessage, setTaskInputMessage, selectedTeam, + teams = [], onTeamChange, externalApiParams, onExternalApiParamsChange, @@ -214,7 +217,7 @@ export function ChatInputCard({ {/* Chat Input Card */}
void selectedModel: Model | null setSelectedModel: (model: Model | null) => void @@ -166,6 +169,7 @@ export interface ChatInputControlsProps { export function ChatInputControls({ taskType, selectedTeam, + teams = [], onTeamChange: _onTeamChange, selectedModel, setSelectedModel, @@ -366,10 +370,10 @@ export function ChatInputControls({ // Desktop layout: original full layout return (
{/* Generate Mode Selector - show when in video or image mode */} @@ -457,20 +461,32 @@ export function ChatInputControls({ {/* Non-generation mode controls (chat, code, etc.) */} {!isGenerationMode && ( <> - {/* Context Selection - only show for chat shell */} - {isChatShell(selectedTeam) && ( - - )} - {/* File Upload Button - show for shells that support attachments (Chat, ClaudeCode) */} {supportsAttachments(selectedTeam) && ( )} + {/* Divider between attachment and other controls */} + {supportsAttachments(selectedTeam) && selectedTeam && ( +
+ )} + + {/* Team Selector - show when teams are available and onTeamChange is provided */} + {teams.length > 0 && _onTeamChange && ( + { + if (team) { + _onTeamChange(team) + } + }} + teams={teams} + disabled={isLoading || isStreaming || hasMessages} + taskDetail={selectedTaskDetail} + hideSettingsLink={true} + /> + )} + {/* Skill Selector - show when skills are available */} {/* Skill selection is read-only after task creation (hasMessages) */} {availableSkills.length > 0 && onToggleSkill && ( @@ -487,6 +503,15 @@ export function ChatInputControls({ /> )} + {/* Context Selection - only show for chat shell */} + {isChatShell(selectedTeam) && ( + + )} + {/* Clarification Toggle Button - only show for chat shell */} {isChatShell(selectedTeam) && ( )} - {/* Model Selector */} + {/* Model Selector - placed at the end of left side buttons */} {selectedTeam && ( )} - {/* Deep Thinking Toggle Button - hidden for now */} - {/* {isChatShell(selectedTeam) && ( - - )} */} - {/* Send/Stop Button */} {renderSendButton()}
diff --git a/frontend/src/features/tasks/components/selector/ModelSelector.tsx b/frontend/src/features/tasks/components/selector/ModelSelector.tsx index b4c5e2b76..715f934cf 100644 --- a/frontend/src/features/tasks/components/selector/ModelSelector.tsx +++ b/frontend/src/features/tasks/components/selector/ModelSelector.tsx @@ -20,8 +20,9 @@ import React, { useState, useEffect, useMemo } from 'react' import { Cog6ToothIcon } from '@heroicons/react/24/outline' -import { Check, Brain, ChevronDown, Video, ImageIcon } from 'lucide-react' +import { Check, ChevronDown, Video, ImageIcon } from 'lucide-react' import { useRouter } from 'next/navigation' +import { ModelIcon } from '@/components/icons/ModelIcon' import { Checkbox } from '@/components/ui/checkbox' import { useTranslation } from '@/hooks/useTranslation' import { useMediaQuery } from '@/hooks/useMediaQuery' @@ -145,7 +146,7 @@ export default function ModelSelector({ case 'image': return ImageIcon default: - return Brain + return ModelIcon } }, [modelCategoryType]) @@ -245,11 +246,11 @@ export default function ModelSelector({ aria-controls="model-selector-popover" disabled={isDisabled} className={cn( - 'flex items-center gap-1 min-w-0 rounded-full pl-2.5 pr-3 py-2.5 h-9', - 'border transition-colors', + 'flex items-center gap-1 min-w-0 rounded-[24px] pl-2.5 pr-3 py-2.5 h-9', + 'transition-colors', modelSelection.isModelRequired - ? 'border-error text-error bg-error/5 hover:bg-error/10' - : 'border-border bg-base text-text-primary hover:bg-hover', + ? 'border border-error text-error bg-error/5 hover:bg-error/10' + : 'bg-transparent text-text-primary hover:bg-hover', modelSelection.isLoading || externalLoading ? 'animate-pulse' : '', 'focus:outline-none focus:ring-0', 'disabled:cursor-not-allowed disabled:opacity-50' diff --git a/frontend/src/features/tasks/components/selector/SkillSelectorPopover.tsx b/frontend/src/features/tasks/components/selector/SkillSelectorPopover.tsx index b9d0aa993..e4ae8acbe 100644 --- a/frontend/src/features/tasks/components/selector/SkillSelectorPopover.tsx +++ b/frontend/src/features/tasks/components/selector/SkillSelectorPopover.tsx @@ -282,12 +282,11 @@ const SkillSelectorPopover = forwardRef
setOpen(!open)} disabled={!hasSkills || disabled} icon={} + label={t('common:skillSelector.skill_button_label')} title={t('common:skillSelector.skill_button_tooltip')} - className="border-border bg-base text-text-primary hover:bg-hover" /> {selectedCount > 0 && ( void + teams: Team[] + disabled: boolean + taskDetail?: TaskDetail | null + hideSettingsLink?: boolean +} + +export default function TeamSelectorButton({ + selectedTeam, + setSelectedTeam, + teams, + disabled, + hideSettingsLink = false, +}: TeamSelectorButtonProps) { + const { t } = useTranslation() + const router = useRouter() + const [open, setOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const sharedBadgeStyle = getSharedBadgeStyle() + + // Filter teams by search query + const filteredTeams = teams.filter(team => + team.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const handleSelectTeam = (team: Team) => { + setSelectedTeam(team) + setOpen(false) + setSearchQuery('') + } + + const handleOpenChange = (newOpen: boolean) => { + if (disabled) return + setOpen(newOpen) + if (!newOpen) { + setSearchQuery('') + } + } + + if (!selectedTeam || teams.length === 0) return null + + return ( + + + + + +
+ setOpen(!open)} + disabled={disabled} + icon={} + label={t('common:teamSelector.agent_label', '智能体')} + /> +
+
+
+ +

+ {t('common:teamSelector.select_agent_tooltip', '选择智能体')} +

+
+
+ + +
+ {t('common:teams.select_team')} +
+ + {/* Search input */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t('common:teams.search_team')} + className="h-8 pl-7 text-sm" + /> +
+
+ + {/* Teams list */} +
+ {filteredTeams.length === 0 ? ( +
+ {searchQuery ? t('common:teams.no_match') : t('common:teams.no_match')} +
+ ) : ( + filteredTeams.map(team => { + const isSelected = selectedTeam?.id === team.id + const isSharedTeam = team.share_status === 2 && team.user?.user_name + const isGroupTeam = + team.namespace && team.namespace !== 'default' && team.namespace !== 'community' + + return ( +
handleSelectTeam(team)} + role="button" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleSelectTeam(team) + } + }} + > +
+ {isSelected && } +
+
+
+ + {team.name} + + {isGroupTeam && ( + + {team.namespace} + + )} + {isSharedTeam && ( + + {t('common:teams.shared_by', { author: team.user?.user_name })} + + )} +
+
+
+ ) + }) + )} +
+ + {/* Footer with settings link */} + {!hideSettingsLink && ( +
{ + router.push(paths.settings.team.getHref()) + setOpen(false) + }} + role="button" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + router.push(paths.settings.team.getHref()) + setOpen(false) + } + }} + > + + + {t('common:teams.manage')} + +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/features/tasks/components/sidebar/ResizableSidebar.tsx b/frontend/src/features/tasks/components/sidebar/ResizableSidebar.tsx index 413a23fe8..0b48c1d05 100644 --- a/frontend/src/features/tasks/components/sidebar/ResizableSidebar.tsx +++ b/frontend/src/features/tasks/components/sidebar/ResizableSidebar.tsx @@ -137,7 +137,7 @@ export default function ResizableSidebar({ return (
{/* Sidebar content container - hidden when collapsed */} diff --git a/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx b/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx index d7e628b02..bf3142dc2 100644 --- a/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx +++ b/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx @@ -269,11 +269,11 @@ export default function TaskSidebar({ Weibo Logo - Wegent + Wegent
{onToggleCollapsed && ( @@ -303,20 +303,18 @@ export default function TaskSidebar({
{/* New Conversation Button - only show in expanded mode, collapsed mode has it in the top combined button */} {!isCollapsed && ( -
+
)} @@ -377,7 +375,7 @@ export default function TaskSidebar({
{/* Auto-refresh indicator - shows when refreshing after page visibility or reconnect */} @@ -531,7 +529,7 @@ export default function TaskSidebar({ {/* User Menu - matches Figma: left-[20px] top-[852px] with border */} -
+
diff --git a/frontend/src/i18n/locales/en/chat.json b/frontend/src/i18n/locales/en/chat.json index 0467bafbf..924db1708 100644 --- a/frontend/src/i18n/locales/en/chat.json +++ b/frontend/src/i18n/locales/en/chat.json @@ -328,11 +328,13 @@ "chars": "chars" }, "upload": { + "attachment": "Attachment", "tooltip": "Supported file types: PDF, Word, PPT, Excel, TXT, Markdown, Images(JPG, PNG, GIF, BMP, WebP)\nMax file size: {{maxSize}} MB\nSupports multiple file uploads", "image_tooltip": "Supported file types: Images (JPG, PNG, GIF, BMP, WebP)\nMax file size: {{maxSize}} MB\nUpload reference images for generation", "wait_for_upload": "Please wait for file upload to complete" }, "clarification_toggle": { + "label": "Smart Follow-up", "enable": "Enable smart follow-up mode", "disable": "Disable smart follow-up mode" }, @@ -470,6 +472,7 @@ "exportSuccess": "Image exported" }, "correction": { + "label": "Cross-Validation", "enable": "Enable AI correction mode", "disable": "Disable AI correction mode", "select_model": "Select Correction Model", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index aa536d64a..316ed1815 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -304,6 +304,10 @@ "join_description_suffix": "\" will be added to your agent list, and you can use it to create and execute tasks." } }, + "teamSelector": { + "agent_label": "Agent", + "select_agent_tooltip": "Select agent" + }, "team": { "name": "Name", "name_required": "Team name is required", @@ -1286,6 +1290,7 @@ "no_matching_skills": "No matching skills", "already_selected": "Already selected", "skill_button_tooltip": "Select skills to use with this message", + "skill_button_label": "Skills", "manage_skills": "Manage" }, "mcpProviders": { diff --git a/frontend/src/i18n/locales/zh-CN/chat.json b/frontend/src/i18n/locales/zh-CN/chat.json index 07ffe830f..f8ade39a2 100644 --- a/frontend/src/i18n/locales/zh-CN/chat.json +++ b/frontend/src/i18n/locales/zh-CN/chat.json @@ -306,11 +306,13 @@ "chars": "字符" }, "upload": { + "attachment": "附件", "tooltip": "支持的文件类型: PDF, Word, PPT, Excel, TXT, Markdown, 图片(JPG, PNG, GIF, BMP, WebP)\n最大文件大小: {{maxSize}} MB\n支持多文件同时上传", "image_tooltip": "支持的文件类型: 图片 (JPG, PNG, GIF, BMP, WebP)\n最大文件大小: {{maxSize}} MB\n上传参考图片用于生成", "wait_for_upload": "请等待文件上传完成" }, "clarification_toggle": { + "label": "智能追问", "enable": "启用智能追问模式", "disable": "禁用智能追问模式" }, @@ -448,6 +450,7 @@ "exportSuccess": "图片已导出" }, "correction": { + "label": "交叉验证", "enable": "启用 AI 交叉验证", "disable": "禁用 AI 交叉验证", "select_model": "选择交叉验证模型", diff --git a/frontend/src/i18n/locales/zh-CN/common.json b/frontend/src/i18n/locales/zh-CN/common.json index 7e3ed5850..80dccdc91 100644 --- a/frontend/src/i18n/locales/zh-CN/common.json +++ b/frontend/src/i18n/locales/zh-CN/common.json @@ -304,6 +304,10 @@ "join_description_suffix": "」将添加到您的智能体列表中,您可以使用该智能体创建和执行任务。" } }, + "teamSelector": { + "agent_label": "智能体", + "select_agent_tooltip": "选择智能体" + }, "team": { "name": "名称", "name_required": "智能体名称不能为空", @@ -1280,6 +1284,7 @@ "no_matching_skills": "没有匹配的技能", "already_selected": "已选择", "skill_button_tooltip": "选择要在此消息中使用的技能", + "skill_button_label": "技能", "manage_skills": "管理技能" }, "mcpProviders": { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 2621f2f97..de333da17 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -46,13 +46,14 @@ export default { ], }, colors: { - // Custom project colors + // Custom project colors - Wegent Purple Theme base: withOpacity('--color-bg-base'), surface: withOpacity('--color-bg-surface'), muted: withOpacity('--color-bg-muted'), hover: withOpacity('--color-bg-hover'), border: withOpacity('--color-border'), 'border-strong': withOpacity('--color-border-strong'), + 'border-light': withOpacity('--color-border-light'), 'text-primary': withOpacity('--color-text-primary'), 'text-secondary': withOpacity('--color-text-secondary'), 'text-muted': withOpacity('--color-text-muted'), @@ -99,6 +100,14 @@ export default { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', + '2xl': '1rem', + '3xl': '1.5rem', + }, + boxShadow: { + sidebar: 'var(--shadow-sidebar)', + popover: 'var(--shadow-popover)', + 'card-hover': '0 4px 24px rgba(93, 94, 201, 0.06)', + 'input-focus': '0 0 0 2px rgba(93, 94, 201, 0.2)', }, keyframes: { 'accordion-down': { From 0e00c84f8ede52e1c89c171852a1e2cdf0bd5e0f Mon Sep 17 00:00:00 2001 From: yixiang1 Date: Tue, 10 Mar 2026 14:25:53 +0800 Subject: [PATCH 2/8] refactor(frontend): improve chat UI layout and input components Optimize chat area layout spacing and input component structure: - Adjust ChatArea spacing (pb-6 -> pb-10, marginBottom 20vh -> 12vh) - Constrain input container max-width to 820px for better readability - Simplify QuickAccessCards implementation - Refactor ChatInputCard layout structure - Update ChatInputControls button layout - Polish SendButton and TeamSelectorButton styles - Improve UnifiedRepositorySelector component Co-Authored-By: Claude Opus 4.6 --- .../tasks/components/chat/ChatArea.tsx | 10 +- .../components/chat/QuickAccessCards.tsx | 604 ++++++------------ .../tasks/components/input/ChatInputCard.tsx | 22 +- .../components/input/ChatInputControls.tsx | 9 +- .../tasks/components/input/SendButton.tsx | 221 +------ .../selector/TeamSelectorButton.tsx | 27 +- .../selector/UnifiedRepositorySelector.tsx | 6 +- 7 files changed, 248 insertions(+), 651 deletions(-) diff --git a/frontend/src/features/tasks/components/chat/ChatArea.tsx b/frontend/src/features/tasks/components/chat/ChatArea.tsx index 0748c3ad5..3c6e7fd85 100644 --- a/frontend/src/features/tasks/components/chat/ChatArea.tsx +++ b/frontend/src/features/tasks/components/chat/ChatArea.tsx @@ -1051,10 +1051,10 @@ function ChatAreaContent({
{taskType !== 'knowledge' && } @@ -1107,8 +1107,10 @@ function ChatAreaContent({ onClick={() => scrollToBottom(true)} />
-
- +
+
+ +
)} diff --git a/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx b/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx index 7446d0c59..f2bdb0e40 100644 --- a/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx +++ b/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx @@ -4,31 +4,21 @@ 'use client' -import { useEffect, useState, useCallback, useRef, useMemo } from 'react' -import { useRouter } from 'next/navigation' -import { - ChevronDownIcon, - Cog6ToothIcon, - CheckIcon, - MagnifyingGlassIcon, - SparklesIcon, -} from '@heroicons/react/24/outline' -import { Wand2 } from 'lucide-react' +import { useEffect, useState, useCallback, useRef } from 'react' +import { ChevronLeftIcon, ChevronRightIcon, SparklesIcon } from '@heroicons/react/24/outline' import { userApis } from '@/apis/user' import { QuickAccessTeam, Team } from '@/types/api' import { useTranslation } from '@/hooks/useTranslation' -import { Tag } from '@/components/ui/tag' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { paths } from '@/config/paths' -import { getSharedTagStyle as getSharedBadgeStyle } from '@/utils/styles' -import { TeamIconDisplay } from '@/features/settings/components/teams/TeamIconDisplay' -import TeamCreationWizard from '@/features/settings/components/wizard/TeamCreationWizard' -import { useMediaQuery } from '@/hooks/useMediaQuery' -import { MobileTeamSelector } from '@/features/tasks/components/selector' - -// Maximum number of quick access cards to display -const MAX_QUICK_ACCESS_CARDS = 4 + +// Container dimensions +const CONTAINER_WIDTH = 880 +const CONTAINER_HEIGHT = 108 + +// Card dimensions +const CARD_WIDTH = 154 +const CARD_GAP = 12 +const CARDS_PER_PAGE = 5 +const PAGE_SCROLL_AMOUNT = CARDS_PER_PAGE * CARD_WIDTH + (CARDS_PER_PAGE - 1) * CARD_GAP interface QuickAccessCardsProps { teams: Team[] @@ -37,10 +27,10 @@ interface QuickAccessCardsProps { currentMode: 'chat' | 'code' | 'knowledge' | 'task' | 'video' | 'image' isLoading?: boolean isTeamsLoading?: boolean - hideSelected?: boolean // Whether to hide the selected team from the cards + hideSelected?: boolean onRefreshTeams?: () => Promise - showWizardButton?: boolean // Whether to show the wizard button (only for chat mode) - defaultTeam?: Team | null // The default team for current mode (will be hidden from quick access cards) + showWizardButton?: boolean + defaultTeam?: Team | null } export function QuickAccessCards({ @@ -49,26 +39,22 @@ export function QuickAccessCards({ onTeamSelect, currentMode, isLoading, - isTeamsLoading, + isTeamsLoading: _isTeamsLoading, hideSelected = false, - onRefreshTeams, - showWizardButton = false, + onRefreshTeams: _onRefreshTeams, + showWizardButton: _showWizardButton = false, defaultTeam, }: QuickAccessCardsProps) { - const router = useRouter() - const { t } = useTranslation(['common', 'wizard']) + const { t } = useTranslation('common') const [quickAccessTeams, setQuickAccessTeams] = useState([]) const [isQuickAccessLoading, setIsQuickAccessLoading] = useState(true) const [clickedTeamId, setClickedTeamId] = useState(null) - const [showMoreTeams, setShowMoreTeams] = useState(false) - const [showWizard, setShowWizard] = useState(false) - const moreButtonRef = useRef(null) - const isMobile = useMediaQuery('(max-width: 767px)') + const scrollContainerRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) - // Define the extended team type for display type DisplayTeam = Team & { is_system: boolean; recommended_mode?: 'chat' | 'code' | 'both' } - // Fetch quick access teams useEffect(() => { const fetchQuickAccess = async () => { try { @@ -77,7 +63,6 @@ export function QuickAccessCards({ setQuickAccessTeams(response.teams) } catch (error) { console.error('Failed to fetch quick access teams:', error) - // Fallback: use first few teams from the teams list setQuickAccessTeams([]) } finally { setIsQuickAccessLoading(false) @@ -87,15 +72,17 @@ export function QuickAccessCards({ fetchQuickAccess() }, []) - // Filter teams by bind_mode based on current mode (same logic as TeamSelector) + // Filter teams by bind_mode based on current mode const filteredTeams = teams.filter(team => { - // If bind_mode is not set or is an empty array, the team supports all modes - if (!team.bind_mode || team.bind_mode.length === 0) return true - // Only show if current mode is in bind_mode + // Filter out teams with empty bind_mode array + if (Array.isArray(team.bind_mode) && team.bind_mode.length === 0) return false + // If bind_mode is not set (undefined/null), show in all modes + if (!team.bind_mode) return true + // Otherwise, only show if current mode is in bind_mode return team.bind_mode.includes(currentMode) }) - // Get display teams: quick access teams matched with full team data + // Get all quick access teams matched with full team data const allDisplayTeams: DisplayTeam[] = quickAccessTeams.length > 0 ? quickAccessTeams @@ -114,82 +101,56 @@ export function QuickAccessCards({ : // Fallback: show first teams from filtered list if no quick access configured filteredTeams.map(t => ({ ...t, is_system: false }) as DisplayTeam) - // Filter out selected team if hideSelected is true, and always filter out default team - const teamsAfterFilter = allDisplayTeams.filter(t => { - // Always hide default team from quick access cards + // Filter out default team only (keep selected team visible with selection state) + const displayTeams = allDisplayTeams.filter(t => { if (defaultTeam && t.id === defaultTeam.id) return false - // Hide selected team if hideSelected is true - if (hideSelected && selectedTeam && t.id === selectedTeam.id) return false return true }) - // Limit display teams to MAX_QUICK_ACCESS_CARDS - const displayTeams = teamsAfterFilter.slice(0, MAX_QUICK_ACCESS_CARDS) + const needsPagination = displayTeams.length > CARDS_PER_PAGE + + const checkScrollState = useCallback(() => { + const container = scrollContainerRef.current + if (!container) return + + setCanScrollLeft(container.scrollLeft > 0) + setCanScrollRight(container.scrollLeft < container.scrollWidth - container.clientWidth - 1) + }, []) - // Close dropdown when clicking outside useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (moreButtonRef.current && !moreButtonRef.current.contains(event.target as Node)) { - setShowMoreTeams(false) - } - } + const container = scrollContainerRef.current + if (!container) return - if (showMoreTeams) { - document.addEventListener('mousedown', handleClickOutside) - } + checkScrollState() + container.addEventListener('scroll', checkScrollState, { passive: true }) + window.addEventListener('resize', checkScrollState) return () => { - document.removeEventListener('mousedown', handleClickOutside) + container.removeEventListener('scroll', checkScrollState) + window.removeEventListener('resize', checkScrollState) } - }, [showMoreTeams]) + }, [checkScrollState, displayTeams]) - // Handle team selection from dropdown - const handleTeamSelectFromDropdown = useCallback( - (team: Team) => { - onTeamSelect(team) - setShowMoreTeams(false) - }, - [onTeamSelect] - ) - - // Search state for team list - const [searchQuery, setSearchQuery] = useState('') - - // Filter teams for dropdown based on search query (excluding default team) - const dropdownTeams = useMemo(() => { - // First filter out the default team - const teamsWithoutDefault = filteredTeams.filter(team => { - if (defaultTeam && team.id === defaultTeam.id) return false - return true - }) - - if (!searchQuery.trim()) return teamsWithoutDefault - const query = searchQuery.toLowerCase() - return teamsWithoutDefault.filter(team => team.name.toLowerCase().includes(query)) - }, [filteredTeams, searchQuery, defaultTeam]) - - // Get shared badge style - const sharedBadgeStyle = useMemo(() => getSharedBadgeStyle(), []) + const scrollLeft = () => { + const container = scrollContainerRef.current + if (!container) return + container.scrollBy({ left: -PAGE_SCROLL_AMOUNT, behavior: 'smooth' }) + } - // Reset search when dropdown closes - useEffect(() => { - if (!showMoreTeams) { - setSearchQuery('') - } - }, [showMoreTeams]) + const scrollRight = () => { + const container = scrollContainerRef.current + if (!container) return + container.scrollBy({ left: PAGE_SCROLL_AMOUNT, behavior: 'smooth' }) + } - // Handle team click - simply select the team with animation const handleTeamClick = useCallback( (team: DisplayTeam) => { - // Trigger click animation setClickedTeamId(team.id) - // Select the team after animation starts setTimeout(() => { onTeamSelect(team) }, 150) - // Reset the clicked state after animation completes setTimeout(() => { setClickedTeamId(null) }, 300) @@ -199,146 +160,96 @@ export function QuickAccessCards({ if (isLoading || isQuickAccessLoading) { return ( -
- {[1, 2, 3].map(i => ( -
-
-
-
- ))} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
) } - // Only show empty state when user truly has no teams available for current mode - // Don't show it when teams exist but are just filtered out (e.g., default team hidden) - if (filteredTeams.length === 0) { - // Show empty state guidance card when no teams are available + if (teams.length === 0) { return ( - <> -
- {/* Empty state guidance card */} -
-
-
- -
+
+
+
+
+
-

- {t('teams.no_teams_title')} -

-

{t('teams.no_teams_description')}

-
+

+ {t('teams.no_teams_title')} +

+

{t('teams.no_teams_description')}

- - {/* Team Creation Wizard Dialog */} - setShowWizard(false)} - onSuccess={async (teamId, _) => { - // Refresh teams list first to get the new team - if (onRefreshTeams) { - const refreshedTeams = await onRefreshTeams() - // Find and select the new team from refreshed list - const newTeam = refreshedTeams.find(t => t.id === teamId) - if (newTeam) { - onTeamSelect(newTeam) - } - } else { - // Fallback: try to find in current teams (may not work for newly created) - const newTeam = teams.find(t => t.id === teamId) - if (newTeam) { - onTeamSelect(newTeam) - } - } - }} - /> - +
) } - // Helper function to check if a team is a personal team (not public, not group) - const isPersonalTeam = (team: DisplayTeam) => { - const isPublic = 'user_id' in team && team.user_id === 0 - const isGroup = team.namespace && team.namespace !== 'default' - return !isPublic && !isGroup - } - - // Helper function to check if a team is a group team - const isGroupTeam = (team: DisplayTeam) => { - return team.namespace && team.namespace !== 'default' + // Don't show quick access cards if no teams are available after filtering + if (displayTeams.length === 0) { + return null } - // Render a single team card with optional tooltip const renderTeamCard = (team: DisplayTeam) => { const isSelected = selectedTeam?.id === team.id const isClicked = clickedTeamId === team.id + const description = team.description || t('teams.no_description') - const cardContent = ( + return (
!isClicked && handleTeamClick(team)} className={` - group relative flex items-center gap-1 h-[42px] px-4 - rounded-full border cursor-pointer transition-all duration-200 + group relative flex flex-col justify-center + cursor-pointer transition-all duration-200 ${ - isClicked - ? 'clicking-card border-primary bg-primary/10 ring-2 ring-primary/50' - : isSelected - ? 'border-primary bg-primary/5' - : 'border-border bg-base hover:bg-hover hover:border-border-strong hover:shadow-sm' + isSelected + ? 'border-l-[3px] border-l-primary border-y border-r border-[#EEEEEE]' + : 'border border-[#EEEEEE]' } + ${isClicked ? 'clicking-card' : ''} ${isClicked ? 'pointer-events-none' : ''} + ${!isSelected ? 'hover:shadow-[0_2px_12px_0_rgba(0,0,0,0.1)]' : ''} `} + style={{ + width: CARD_WIDTH, + height: 78, + padding: '8px 12px', + borderRadius: 20, + flexShrink: 0, + flexGrow: 0, + backgroundColor: isSelected ? 'rgba(20, 184, 166, 0.05)' : '#FFFFFF', + }} > - - - {team.name} - - - {/* Personal or Group badge */} - {isPersonalTeam(team) && ( - - {t('settings.personal')} - - )} - {isGroupTeam(team) && ( - - {team.namespace} - - )} -
- ) - - // Tooltip content: prioritize description, fallback to name - const tooltipText = team.description || team.name +
+ + {team.name} + +
- // Always wrap with Tooltip - return ( - - - {cardContent} - -

{tooltipText}

-
-
-
+

{description}

+
) } @@ -374,226 +285,71 @@ export function QuickAccessCards({ pulse-glow 0.3s ease-out, scale-bounce 0.3s ease-out; } + + .hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .hide-scrollbar::-webkit-scrollbar { + display: none; + } `} -
- {/* Show selected team first with highlighted style (only if not the default team) */} - {selectedTeam && (!defaultTeam || selectedTeam.id !== defaultTeam.id) && ( -
- - {selectedTeam.name} -
- )} - {displayTeams.map(team => renderTeamCard(team))} - - {/* More button - use MobileTeamSelector on mobile, dropdown on desktop */} - {isMobile ? ( - // Mobile: Use iOS-style drawer selector with "更多" text - dropdownTeams.length > 0 && selectedTeam ? ( - - ) : ( - // Fallback: Show a button with "更多" text if no team selected but teams exist - filteredTeams.length > 0 && ( - - ) - ) - ) : ( - // Desktop: Original dropdown -
+ +
+
+ {needsPagination && canScrollLeft && ( + )} - {/* Dropdown with team list */} - {showMoreTeams && ( -
- {/* Search input */} -
-
- - setSearchQuery(e.target.value)} - placeholder={t('teams.search_team')} - className="w-full pl-9 pr-3 py-2 text-sm bg-base border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 placeholder:text-text-muted" - autoFocus - /> -
-
- - {/* Team list */} -
- {isTeamsLoading ? ( -
- {t('actions.loading')} -
- ) : dropdownTeams.length === 0 ? ( -
- {t('teams.no_match')} -
- ) : ( - dropdownTeams.map(team => { - const isSelected = selectedTeam?.id === team.id - const isSharedTeam = team.share_status === 2 && team.user?.user_name - const isGroupTeamItem = team.namespace && team.namespace !== 'default' - const isPublicTeam = 'user_id' in team && team.user_id === 0 - const isPersonalTeamItem = !isPublicTeam && !isGroupTeamItem - - return ( -
handleTeamSelectFromDropdown(team)} - className={` - flex items-center gap-3 px-3 py-2 mx-1 my-0.5 rounded-md cursor-pointer - transition-colors duration-150 - ${ - isSelected - ? 'bg-primary/10 text-primary' - : 'hover:bg-hover text-text-primary' - } - `} - > - - - - {team.name} - - {isPersonalTeamItem && ( - - {t('settings.personal')} - - )} - {isGroupTeamItem && ( - - {team.namespace} - - )} - {isSharedTeam && ( - - {team.user?.user_name} - - )} -
- ) - }) - )} -
- - {/* Footer with settings link */} -
{ - setShowMoreTeams(false) - router.push(paths.settings.team.getHref()) - }} - > - - - {t('teams.manage')} - -
-
- )} +
+ {displayTeams.map(team => ( +
{renderTeamCard(team)}
+ ))}
- )} - - {/* Divider */} -
- - {/* Wizard button - quick create agent (only show in chat mode) */} - {showWizardButton && ( - - - - - - -

{t('wizard:wizard_button_tooltip')}

-
-
-
- )} -
- {/* Team Creation Wizard Dialog */} - {showWizardButton && ( - setShowWizard(false)} - onSuccess={async (teamId, _) => { - // Refresh teams list first to get the new team - if (onRefreshTeams) { - const refreshedTeams = await onRefreshTeams() - // Find and select the new team from refreshed list - const newTeam = refreshedTeams.find(t => t.id === teamId) - if (newTeam) { - onTeamSelect(newTeam) - } - } else { - // Fallback: try to find in current teams (may not work for newly created) - const newTeam = teams.find(t => t.id === teamId) - if (newTeam) { - onTeamSelect(newTeam) - } - } - }} - /> - )} + {needsPagination && canScrollRight && ( + + )} +
+
) } diff --git a/frontend/src/features/tasks/components/input/ChatInputCard.tsx b/frontend/src/features/tasks/components/input/ChatInputCard.tsx index e3c9d82d3..321cc5579 100644 --- a/frontend/src/features/tasks/components/input/ChatInputCard.tsx +++ b/frontend/src/features/tasks/components/input/ChatInputCard.tsx @@ -217,22 +217,22 @@ export function ChatInputCard({ {/* Chat Input Card */}
- {/* Drag Overlay */} - {isDragging && ( -
-
- + {isDragging && ( +
+
+ +
+

释放以上传文件

+

支持 PDF, Word, TXT, Markdown 等格式

-

释放以上传文件

-

支持 PDF, Word, TXT, Markdown 等格式

-
- )} + )} {/* Unified Badge Display - Knowledge bases and attachments */} +
)} - {/* Team Selector - show when teams are available and onTeamChange is provided */} - {teams.length > 0 && _onTeamChange && ( + {/* Team Selector - show when teams are available, onTeamChange is provided, and no messages yet */} + {teams.length > 0 && _onTeamChange && !hasMessages && ( { @@ -481,9 +481,10 @@ export function ChatInputControls({ } }} teams={teams} - disabled={isLoading || isStreaming || hasMessages} + disabled={isLoading || isStreaming} taskDetail={selectedTaskDetail} hideSettingsLink={true} + currentMode={taskType} /> )} diff --git a/frontend/src/features/tasks/components/input/SendButton.tsx b/frontend/src/features/tasks/components/input/SendButton.tsx index 200bd49a0..03b2f5468 100644 --- a/frontend/src/features/tasks/components/input/SendButton.tsx +++ b/frontend/src/features/tasks/components/input/SendButton.tsx @@ -4,13 +4,8 @@ 'use client' -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react' -import { Send, ChevronDown, Check } from 'lucide-react' -import { useTranslation } from '@/hooks/useTranslation' -import { useUser } from '@/features/common/UserContext' -import { userApis } from '@/apis/user' -import { useToast } from '@/hooks/use-toast' -import type { UserPreferences } from '@/types/api' +import React, { useRef, useCallback } from 'react' +import { ArrowUp } from 'lucide-react' import LoadingDots from '../message/LoadingDots' interface SendButtonProps { @@ -18,82 +13,18 @@ interface SendButtonProps { disabled?: boolean isLoading?: boolean className?: string - /** Hide dropdown toggle for mobile */ + /** @deprecated No longer used, kept for API compatibility */ compact?: boolean } -type SendKeyOption = 'enter' | 'cmd_enter' - export default function SendButton({ onClick, disabled = false, isLoading = false, className = '', - compact = false, }: SendButtonProps) { - const { t } = useTranslation() - const { toast } = useToast() - const { user, refresh } = useUser() - const [isDropdownOpen, setIsDropdownOpen] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const dropdownRef = useRef(null) const buttonRef = useRef(null) - // Get current send key preference from user context - const sendKey: SendKeyOption = (user?.preferences?.send_key as SendKeyOption) || 'enter' - - // Detect if Mac or Windows for display - const isMac = useMemo(() => { - return typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform) - }, []) - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setIsDropdownOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) - - // Handle send key change - const handleSendKeyChange = useCallback( - async (value: SendKeyOption) => { - if (value === sendKey) { - setIsDropdownOpen(false) - return - } - - setIsSaving(true) - try { - const preferences: UserPreferences = { send_key: value } - await userApis.updateUser({ preferences }) - await refresh() - toast({ - title: t('chat:send_button.preference_saved'), - }) - } catch (error) { - console.error('Failed to save send key preference:', error) - toast({ - variant: 'destructive', - title: t('chat:send_button.preference_save_failed'), - }) - } finally { - setIsSaving(false) - setIsDropdownOpen(false) - } - }, - [sendKey, refresh, toast, t] - ) - // Handle main button click (send message) const handleMainClick = useCallback( (e: React.MouseEvent) => { @@ -106,136 +37,26 @@ export default function SendButton({ [disabled, isLoading, onClick] ) - // Handle dropdown toggle click - const handleDropdownToggle = useCallback( - (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - if (!isSaving) { - setIsDropdownOpen(prev => !prev) - } - }, - [isSaving] - ) - - // Get shortcut display text - const getShortcutText = useCallback( - (option: SendKeyOption): string => { - if (option === 'enter') { - return 'Enter' - } - return isMac ? '⌘ Enter' : 'Ctrl Enter' - }, - [isMac] - ) - - // Get option label - const getOptionLabel = useCallback( - (option: SendKeyOption): string => { - if (option === 'enter') { - return t('chat:send_button.option_enter') - } - return isMac - ? t('chat:send_button.option_cmd_enter_mac') - : t('chat:send_button.option_cmd_enter_win') - }, - [isMac, t] - ) - return (
- {/* Main button container with pill shape - 36px height to match Figma */} -
- {/* Send button - icon only */} - - - {/* Divider and Dropdown toggle - hidden in compact mode */} - {!compact && ( - <> -
- - - )} -
- - {/* Dropdown menu */} - {isDropdownOpen && !compact && ( -
-
-
- {t('chat:send_button.shortcut_title')} -
- {(['enter', 'cmd_enter'] as SendKeyOption[]).map(option => ( - - ))} -
-
- )} + {/* Send button - circular with larger icon */} +
) } diff --git a/frontend/src/features/tasks/components/selector/TeamSelectorButton.tsx b/frontend/src/features/tasks/components/selector/TeamSelectorButton.tsx index fefe5351e..b83d37066 100644 --- a/frontend/src/features/tasks/components/selector/TeamSelectorButton.tsx +++ b/frontend/src/features/tasks/components/selector/TeamSelectorButton.tsx @@ -25,7 +25,7 @@ import { cn } from '@/lib/utils' import { paths } from '@/config/paths' import { useTranslation } from '@/hooks/useTranslation' import { getSharedTagStyle as getSharedBadgeStyle } from '@/utils/styles' -import type { Team, TaskDetail } from '@/types/api' +import type { Team, TaskDetail, TaskType } from '@/types/api' interface TeamSelectorButtonProps { selectedTeam: Team | null @@ -34,6 +34,8 @@ interface TeamSelectorButtonProps { disabled: boolean taskDetail?: TaskDetail | null hideSettingsLink?: boolean + /** Current mode for filtering teams by bind_mode */ + currentMode?: TaskType } export default function TeamSelectorButton({ @@ -42,6 +44,7 @@ export default function TeamSelectorButton({ teams, disabled, hideSettingsLink = false, + currentMode = 'chat', }: TeamSelectorButtonProps) { const { t } = useTranslation() const router = useRouter() @@ -49,8 +52,24 @@ export default function TeamSelectorButton({ const [searchQuery, setSearchQuery] = useState('') const sharedBadgeStyle = getSharedBadgeStyle() + // Filter teams by bind_mode based on current mode + const filteredTeamsByMode = React.useMemo(() => { + // First filter out teams with empty bind_mode array + const teamsWithValidBindMode = teams.filter(team => { + if (Array.isArray(team.bind_mode) && team.bind_mode.length === 0) return false + return true + }) + + return teamsWithValidBindMode.filter(team => { + // If bind_mode is not set (undefined/null), show in all modes + if (!team.bind_mode) return true + // Otherwise, only show if current mode is in bind_mode + return team.bind_mode.includes(currentMode) + }) + }, [teams, currentMode]) + // Filter teams by search query - const filteredTeams = teams.filter(team => + const filteredTeams = filteredTeamsByMode.filter(team => team.name.toLowerCase().includes(searchQuery.toLowerCase()) ) @@ -87,9 +106,7 @@ export default function TeamSelectorButton({ -

- {t('common:teamSelector.select_agent_tooltip', '选择智能体')} -

+

{t('common:teamSelector.select_agent_tooltip', '选择智能体')}

diff --git a/frontend/src/features/tasks/components/selector/UnifiedRepositorySelector.tsx b/frontend/src/features/tasks/components/selector/UnifiedRepositorySelector.tsx index d92783fd4..795c46a08 100644 --- a/frontend/src/features/tasks/components/selector/UnifiedRepositorySelector.tsx +++ b/frontend/src/features/tasks/components/selector/UnifiedRepositorySelector.tsx @@ -403,12 +403,12 @@ export default function UnifiedRepositorySelector({
Date: Tue, 10 Mar 2026 15:53:19 +0800 Subject: [PATCH 3/8] feat: Update UI styling for sidebar and quick access cards to align with new theme variables --- .../components/chat/QuickAccessCards.tsx | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx b/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx index f2bdb0e40..efcea5bc0 100644 --- a/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx +++ b/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx @@ -162,18 +162,16 @@ export function QuickAccessCards({ return (
{[1, 2, 3, 4, 5].map(i => (
{team.name}
-

{description}

+

{description}

) } @@ -298,26 +295,24 @@ export function QuickAccessCards({
{needsPagination && canScrollLeft && ( )} @@ -336,16 +331,15 @@ export function QuickAccessCards({ {needsPagination && canScrollRight && ( )}
From 817575ee8822f979d6275b369369a350a82dd532 Mon Sep 17 00:00:00 2001 From: yixiang1 Date: Tue, 10 Mar 2026 17:31:40 +0800 Subject: [PATCH 4/8] feat: Enhance sidebar UI with updated styling, layout adjustments, and improved hover effects --- .../layout/components/UserFloatingMenu.tsx | 8 +- .../projects/components/ProjectSection.tsx | 6 +- .../tasks/components/chat/ChatArea.tsx | 21 ++-- .../components/sidebar/ResizableSidebar.tsx | 4 +- .../tasks/components/sidebar/TaskSidebar.tsx | 102 +++++++++--------- 5 files changed, 68 insertions(+), 73 deletions(-) diff --git a/frontend/src/features/layout/components/UserFloatingMenu.tsx b/frontend/src/features/layout/components/UserFloatingMenu.tsx index 93ec5859a..504b03db4 100644 --- a/frontend/src/features/layout/components/UserFloatingMenu.tsx +++ b/frontend/src/features/layout/components/UserFloatingMenu.tsx @@ -95,13 +95,13 @@ export function UserFloatingMenu({ className = '' }: UserFloatingMenuProps) { onClick={handleToggleMenu} aria-expanded={isExpanded} aria-haspopup="true" - className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted transition-all duration-200 group" + className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted transition-all duration-200 group" >
-
- +
+ {userDisplayName} {isAdmin && ( @@ -112,7 +112,7 @@ export function UserFloatingMenu({ className = '' }: UserFloatingMenuProps) { )}
diff --git a/frontend/src/features/projects/components/ProjectSection.tsx b/frontend/src/features/projects/components/ProjectSection.tsx index c1179a610..22d99be3b 100644 --- a/frontend/src/features/projects/components/ProjectSection.tsx +++ b/frontend/src/features/projects/components/ProjectSection.tsx @@ -111,7 +111,7 @@ export function ProjectSection({ onTaskSelect }: ProjectSectionProps) { } return ( -
+
{/* Section Header */}
diff --git a/frontend/src/features/tasks/components/chat/ChatArea.tsx b/frontend/src/features/tasks/components/chat/ChatArea.tsx index 3c6e7fd85..a48c298c7 100644 --- a/frontend/src/features/tasks/components/chat/ChatArea.tsx +++ b/frontend/src/features/tasks/components/chat/ChatArea.tsx @@ -171,15 +171,15 @@ function ChatAreaContent({ // Derive available options and defaults from selected video model's config const videoConfig = videoModelSelection.selectedModel?.config?.videoConfig as | { - resolution?: string - ratio?: string - duration?: number - capabilities?: { - aspect_ratios?: { value: string }[] - resolutions?: { label: string }[] - durations_sec?: number[] - } + resolution?: string + ratio?: string + duration?: number + capabilities?: { + aspect_ratios?: { value: string }[] + resolutions?: { label: string }[] + durations_sec?: number[] } + } | undefined const videoCapabilities = videoConfig?.capabilities @@ -1093,9 +1093,10 @@ function ChatAreaContent({ > {/* Bottom gradient fade effect - text fades as it approaches the input, limited width to avoid overlapping scrollbar */}
{/* Sidebar content container - hidden when collapsed */} {!isCollapsed && ( diff --git a/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx b/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx index bf3142dc2..4e8bf6537 100644 --- a/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx +++ b/frontend/src/features/tasks/components/sidebar/TaskSidebar.tsx @@ -92,7 +92,7 @@ export default function TaskSidebar({ const scrollRef = useRef(null) // Use external state for search dialog (controlled by parent page) - const setIsSearchDialogOpen = onSearchDialogOpenChange ?? (() => {}) + const setIsSearchDialogOpen = onSearchDialogOpenChange ?? (() => { }) // Group chats collapse/expand state const [isGroupChatsExpanded, setIsGroupChatsExpanded] = useState(false) @@ -303,18 +303,20 @@ export default function TaskSidebar({
{/* New Conversation Button - only show in expanded mode, collapsed mode has it in the top combined button */} {!isCollapsed && ( -
+
)} @@ -327,11 +329,10 @@ export default function TaskSidebar({ - - -

- {shortcutDisplayText - ? t('common:tasks.search_hint_with_shortcut', { - shortcut: shortcutDisplayText, - }) - : t('common:tasks.search_placeholder_chat')} -

-
- -
- {totalUnreadCount > 0 && ( - - )} + + + + + + +

+ {shortcutDisplayText + ? t('common:tasks.search_hint_with_shortcut', { + shortcut: shortcutDisplayText, + }) + : t('common:tasks.search_placeholder_chat')} +

+
+
+
)} {isCollapsed && filteredGroupTasks.length > 0 && ( -
+
)} Date: Tue, 10 Mar 2026 18:19:28 +0800 Subject: [PATCH 5/8] fix(frontend): resolve ESLint errors and align QuickAccessCards loading state - Fix ESLint no-unused-vars errors by prefixing unused variables with underscore: - TaskSidebar: totalUnreadCount, handleMarkAllAsViewed - QuickAccessCards: hideSelected - Fix QuickAccessCards alignment in loading state to match ChatInputCard - Add w-full and mx-auto to loading skeleton container Co-Authored-By: Claude Opus 4.6 --- .../src/features/tasks/components/chat/QuickAccessCards.tsx | 6 +++--- .../src/features/tasks/components/sidebar/TaskSidebar.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx b/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx index efcea5bc0..b62c987b2 100644 --- a/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx +++ b/frontend/src/features/tasks/components/chat/QuickAccessCards.tsx @@ -40,7 +40,7 @@ export function QuickAccessCards({ currentMode, isLoading, isTeamsLoading: _isTeamsLoading, - hideSelected = false, + hideSelected: _hideSelected = false, onRefreshTeams: _onRefreshTeams, showWizardButton: _showWizardButton = false, defaultTeam, @@ -160,9 +160,9 @@ export function QuickAccessCards({ if (isLoading || isQuickAccessLoading) { return ( -
+
Date: Tue, 10 Mar 2026 18:58:38 +0800 Subject: [PATCH 6/8] fix(e2e): update chat image browser tests for new QuickAccessCards UI Adapt selectTestTeam() helper to work with the refactored QuickAccessCards component: - Remove dependency on deleted "More" button (removed in commit 0e00c84f) - Add pagination support using left/right scroll arrows - Update team card selector from button to div elements - Add support for new TeamSelectorButton component (added in commit 4646cbe8) - Use role="button" instead of role="option" in TeamSelectorButton popover Fixes two failing E2E tests: - should upload image via browser and verify model receives correct image_url format - should display model response after sending image Co-Authored-By: Claude Opus 4.6 --- .../tasks/chat-image-browser-e2e.spec.ts | 79 +++++++++++++------ 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts b/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts index cf21ef542..f54d71dee 100644 --- a/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts +++ b/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts @@ -252,6 +252,7 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { /** * Helper function to select the test team in the UI + * Updated to work with the new QuickAccessCards pagination design (removed "More" button) */ async function selectTestTeam(page: Page): Promise { try { @@ -267,39 +268,65 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { console.log('Saved initial page screenshot') // Strategy 1: Look for team card directly in QuickAccessCards - const teamCardButton = page.locator(`button:has-text("${TEST_TEAM_NAME}")`).first() - if (await teamCardButton.isVisible({ timeout: 3000 }).catch(() => false)) { - console.log('Found team card button directly, clicking...') - await teamCardButton.click() - await page.waitForTimeout(1000) - return true - } + // Note: Team cards are now div elements, not buttons + const quickAccessCards = page.locator('[data-tour="quick-access-cards"]') + if (await quickAccessCards.isVisible({ timeout: 3000 }).catch(() => false)) { + console.log('Found QuickAccessCards container') + + // Try to find the team card by text (cards are divs with team.name) + const teamCard = quickAccessCards.locator(`div:has-text("${TEST_TEAM_NAME}")`).first() + if (await teamCard.isVisible({ timeout: 2000 }).catch(() => false)) { + console.log('Found team card directly, clicking...') + await teamCard.click() + await page.waitForTimeout(1000) + return true + } - // Strategy 2: Look for QuickAccessCards "More" button and search for team - const moreButton = page.locator( - '[data-tour="quick-access-cards"] button:has-text("更多"), [data-tour="quick-access-cards"] button:has-text("More")' - ) - if (await moreButton.isVisible({ timeout: 3000 }).catch(() => false)) { - console.log('Found "More" button in QuickAccessCards') - // Use force click to bypass any remaining overlays - await moreButton.click({ force: true }) - await page.waitForTimeout(500) + // Strategy 1b: If not visible, try scrolling through pages using right arrow + console.log('Team card not visible, trying pagination...') + const rightArrow = quickAccessCards.locator('button[aria-label="Scroll right"]') + let attempts = 0 + const maxAttempts = 5 // Maximum number of pages to scroll through + + while (attempts < maxAttempts) { + // Check if right arrow exists and is visible + if (!(await rightArrow.isVisible({ timeout: 1000 }).catch(() => false))) { + console.log('No more pages to scroll') + break + } - // Search for the test team - const searchInput = page - .locator('input[placeholder*="搜索"], input[placeholder*="search" i]') - .first() - if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) { - await searchInput.fill(TEST_TEAM_NAME) + console.log(`Scrolling to next page (attempt ${attempts + 1})...`) + await rightArrow.click() await page.waitForTimeout(500) + + // Check if team card is now visible + if (await teamCard.isVisible({ timeout: 1000 }).catch(() => false)) { + console.log('Found team card after scrolling, clicking...') + await teamCard.click() + await page.waitForTimeout(1000) + return true + } + + attempts++ } + } - // Click on the team in the dropdown - const teamOption = page.locator(`[role="option"]:has-text("${TEST_TEAM_NAME}")`).first() + // Strategy 2: Try TeamSelectorButton in ChatInputControls (for new chat sessions) + // This button shows "智能体" or "Agent" with AgentIcon + const teamSelectorButton = page.locator( + 'button:has-text("智能体"), button:has-text("Agent")' + ).first() + if (await teamSelectorButton.isVisible({ timeout: 2000 }).catch(() => false)) { + console.log('Found TeamSelectorButton, clicking...') + await teamSelectorButton.click() + await page.waitForTimeout(500) + + // Look for the test team in the popover (uses role="button" instead of role="option") + const teamOption = page.locator(`[role="button"]:has-text("${TEST_TEAM_NAME}")`).first() if (await teamOption.isVisible({ timeout: 3000 }).catch(() => false)) { + console.log('Found team in TeamSelectorButton popover, selecting...') await teamOption.click() await page.waitForTimeout(1000) - console.log(`Selected team from More dropdown: ${TEST_TEAM_NAME}`) return true } } @@ -321,7 +348,7 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { } } - // Strategy 4: Direct click on team card if visible + // Strategy 4: Direct click on team card if visible anywhere on page const teamCard = page.locator(`text="${TEST_TEAM_NAME}"`).first() if (await teamCard.isVisible({ timeout: 3000 }).catch(() => false)) { await teamCard.click() From cb10decf54abcd2ac1bd541427b502012974914b Mon Sep 17 00:00:00 2001 From: Yi Xiang Date: Tue, 10 Mar 2026 19:08:37 +0800 Subject: [PATCH 7/8] fix(e2e): handle onboarding tour overlay blocking message input clicks Enhance dismissOnboardingTour() to properly handle driver.js overlay: - Press Escape multiple times to dismiss all tour steps - Verify overlay is dismissed before proceeding - Click outside overlay as fallback if still visible Add dismissOnboardingTour() call before clicking message input: - Prevents "subtree intercepts pointer events" error from driver-overlay - Use force: true as additional safety for click operations Fixes timeout errors in both image browser E2E tests caused by tour overlay blocking interactions. Co-Authored-By: Claude Opus 4.6 --- .../tasks/chat-image-browser-e2e.spec.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts b/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts index f54d71dee..cd225f7fc 100644 --- a/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts +++ b/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts @@ -239,11 +239,23 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { await page.waitForTimeout(500) console.log('Clicked close/skip button') } else { - // Press Escape to dismiss + // Press Escape multiple times to dismiss all tour steps + console.log('Pressing Escape to dismiss overlay...') + await page.keyboard.press('Escape') + await page.waitForTimeout(300) + // Press Escape again to ensure all steps are dismissed await page.keyboard.press('Escape') await page.waitForTimeout(500) console.log('Pressed Escape to dismiss overlay') } + + // Verify overlay is gone + if (await driverOverlay.isVisible({ timeout: 500 }).catch(() => false)) { + console.warn('Overlay still visible, trying to click outside') + // Click outside the overlay to dismiss it + await page.mouse.click(10, 10) + await page.waitForTimeout(500) + } } } catch (_error) { console.log('No onboarding tour found or already dismissed') @@ -594,8 +606,11 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { return } + // Dismiss any onboarding tour overlay before clicking input + await dismissOnboardingTour(page) + // For contentEditable elements, we need to click first, then type - await messageInput.click() + await messageInput.click({ force: true }) await page.keyboard.type('What is in this image?') // Step 5: Send message @@ -734,8 +749,11 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { return } + // Dismiss any onboarding tour overlay before clicking input + await dismissOnboardingTour(page) + // For contentEditable elements, we need to click first, then type - await messageInput.click() + await messageInput.click({ force: true }) await page.keyboard.type('Describe this image') // Look for send button From 5f7ef20221ae9a3d47c5cb05d3640bde108d961b Mon Sep 17 00:00:00 2001 From: Yi Xiang Date: Tue, 10 Mar 2026 19:10:54 +0800 Subject: [PATCH 8/8] fix(e2e): add dismissOnboardingTour before all clickable element interactions Add dismissOnboardingTour() calls before clicking: - Team card in QuickAccessCards (both direct and after scrolling) - Model selector button - Use force: true for all clicks to bypass any remaining overlays This ensures the driver-overlay is dismissed before every interaction attempt, preventing "subtree intercepts pointer events" errors. Co-Authored-By: Claude Opus 4.6 --- .../e2e/tests/tasks/chat-image-browser-e2e.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts b/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts index cd225f7fc..7fd56e6a4 100644 --- a/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts +++ b/frontend/e2e/tests/tasks/chat-image-browser-e2e.spec.ts @@ -289,7 +289,9 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { const teamCard = quickAccessCards.locator(`div:has-text("${TEST_TEAM_NAME}")`).first() if (await teamCard.isVisible({ timeout: 2000 }).catch(() => false)) { console.log('Found team card directly, clicking...') - await teamCard.click() + // Dismiss tour before clicking + await dismissOnboardingTour(page) + await teamCard.click({ force: true }) await page.waitForTimeout(1000) return true } @@ -314,7 +316,9 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { // Check if team card is now visible if (await teamCard.isVisible({ timeout: 1000 }).catch(() => false)) { console.log('Found team card after scrolling, clicking...') - await teamCard.click() + // Dismiss tour before clicking + await dismissOnboardingTour(page) + await teamCard.click({ force: true }) await page.waitForTimeout(1000) return true } @@ -400,7 +404,9 @@ test.describe('Chat Image Browser E2E with Mock Model Server', () => { // Check if model selection is required if (buttonText?.includes('Please select') || buttonText?.includes('请选择模型')) { console.log('Model selection required, clicking selector...') - await modelSelectorButton.click() + // Dismiss tour before clicking + await dismissOnboardingTour(page) + await modelSelectorButton.click({ force: true }) await page.waitForTimeout(500) // Look for our test model in the dropdown