diff --git a/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx b/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx index d81f16e9..a24f5898 100644 --- a/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx +++ b/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx @@ -12,6 +12,7 @@ interface SearchablePickerPanelProps { emptyMessage: string; width?: PickerPanelWidth; renderItem: (item: T) => ReactNode; + listFooter?: ReactNode; footerIcon: ReactNode; footerLabel: string; onFooterClick: () => void; @@ -24,6 +25,7 @@ export default function SearchablePickerPanel({ emptyMessage, width = "wide", renderItem, + listFooter, footerIcon, footerLabel, onFooterClick, @@ -57,6 +59,7 @@ export default function SearchablePickerPanel({ )} + {listFooter} + ); + })} + ); } @@ -81,7 +86,7 @@ function PathTabsSegmented({ * - Content: colorBgContainer background, 24px padding, 8px radius * - Only the content area scrolls internally * - `actions` slot: right-aligned in the title row - * - `pathTabs`: desktop in title row; mobile full-width bar in content + * - `pathTabs`: desktop in title row (flex-wrap); mobile full-width wrapping bar * * Tabbed helpers: `PageShell.FillTabs` (Ant Tabs) and `PageShell.Tabbed` * (custom tab bar) pin the tab chrome and scroll only the body on desktop. @@ -105,7 +110,7 @@ function PageShell({ const titleActions = !isMobile && pathTabs ? ( <> - + {actions} ) : ( @@ -124,42 +129,27 @@ function PageShell({ overflow: "hidden", }} > - {/* Title row — fixed, never scrolls */} + {/* Title row — wraps so a long tab strip cannot squeeze CJK titles. */}
-
- + <div className={styles.titleCopy}> + <Title level={4} className={styles.titleText}> {title} {subtitle && ( - + {subtitle} )}
- {titleActions && ( -
{titleActions}
- )} + {titleActions ? ( +
{titleActions}
+ ) : null}
{agentScoped && ( @@ -190,7 +180,7 @@ function PageShell({ > {isMobile && pathTabs && (
- +
)} {children} diff --git a/dashboard/src/layouts/Sidebar.module.less b/dashboard/src/layouts/Sidebar.module.less index e2a62eb9..55ba74b5 100644 --- a/dashboard/src/layouts/Sidebar.module.less +++ b/dashboard/src/layouts/Sidebar.module.less @@ -198,34 +198,6 @@ } } -.composeRow { - display: flex; - flex-direction: column; - gap: 4px; - padding: 4px 12px 8px; -} - -.composeBtn { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - border: none; - border-radius: var(--fn-radius-md); - background: color-mix(in srgb, var(--fn-accent, #0033ff) 8%, transparent); - color: var(--fn-text-secondary); - font-size: 12px; - line-height: 1.3; - text-align: left; - padding: 7px 10px; - cursor: pointer; -} - -.composeBtn:hover { - background: color-mix(in srgb, var(--fn-accent, #0033ff) 14%, transparent); - color: var(--fn-text-primary); -} - .minimalPaneToggleCollapsed { display: flex; flex-direction: column; diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx index 037209f5..1b9cb7cc 100644 --- a/dashboard/src/layouts/Sidebar.tsx +++ b/dashboard/src/layouts/Sidebar.tsx @@ -6,15 +6,7 @@ import AvatarDropdown from "../components/AvatarDropdown"; import BrandMark from "../components/BrandMark"; import AppVersionBadge from "../components/AppVersionBadge"; import CurrentVersionBadge from "../components/CurrentVersionBadge"; -import { - ArrowRightLeft, - X, - ChevronDown, - MessageSquarePlus, - FolderPlus, - ListPlus, - Users, -} from "lucide-react"; +import { ArrowRightLeft, X, ChevronDown } from "lucide-react"; import { useLayoutMode } from "../context/LayoutModeContext"; import { useUserRole } from "../hooks/useUserRole"; import { useCurrentUser, useSetCurrentUser } from "../hooks/useCurrentUser"; @@ -558,47 +550,6 @@ export default function Sidebar({ : section.items, ); - const composeActions = ( -
- - - - -
- ); - const paneToggle = ( ) : ( <> - {composeActions} ( availableExperts={availableExperts.filter( (a) => a.agent_id !== agentId, )} + hostAgent={ + availableAgents.find((agent) => agent.agent_id === agentId) ?? + availableExperts.find((agent) => agent.agent_id === agentId) ?? + null + } onInsertExpertMention={insertExpertMention} availableSubagents={availableSubagents} onInsertSubagentMention={insertSubagentMention} diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index 3e2834a9..1faa12b5 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -47,6 +47,11 @@ import type { SlashMenuItem } from "../hooks/useSlashMentionInput"; import { SHORTCUT_ICON_TONE_CLASS } from "../utils/slashShortcutStyles"; import { isSttAvailable } from "../../../hooks/useVoiceInput"; import { resolveTurnModelOverride } from "../utils/chatMessages"; +import { expertMentionToken } from "../utils/expertMention"; +import { useAgent } from "../../../context/AgentContext"; +import { message } from "../../../utils/antdMessage"; +import { openGroupChat } from "../../../utils/openGroupChat"; +import { uniqueMemberIds } from "../../../utils/groupChats"; import { mentionedExpertIds, mentionedSubagentSlugs, @@ -109,6 +114,7 @@ interface ChatInputActionsRowProps { availableSkills?: SkillSpec[]; onInsertSkillCommand?: (slug: string) => void; availableExperts?: ChatAgentOption[]; + hostAgent?: ChatAgentOption | null; onInsertExpertMention?: (agent: ChatAgentOption) => void; availableSubagents?: AgentSubagentSummary[]; onInsertSubagentMention?: (subagent: AgentSubagentSummary) => void; @@ -159,6 +165,7 @@ export default function ChatInputActionsRow({ availableSkills, onInsertSkillCommand, availableExperts, + hostAgent, onInsertExpertMention, availableSubagents, onInsertSubagentMention, @@ -174,6 +181,8 @@ export default function ChatInputActionsRow({ }: ChatInputActionsRowProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const { setActiveAgent } = useAgent(); + const [groupStarting, setGroupStarting] = useState(false); const actionsRowRef = useRef(null); const [isCompact, setIsCompact] = useState(false); const [skillPickerOpen, setSkillPickerOpen] = useState(false); @@ -274,6 +283,53 @@ export default function ChatInputActionsRow({ closeCompactPicker(); }; + const handleEnterGroupChat = async (members: ChatAgentOption[]) => { + const memberIds = uniqueMemberIds([ + hostAgent?.agent_id, + ...members.map((item) => item.agent_id), + ]); + const known = new Map(); + if (hostAgent) known.set(hostAgent.agent_id, hostAgent.name); + for (const item of availableExperts ?? []) { + known.set(item.agent_id, item.name); + } + for (const item of members) { + known.set(item.agent_id, item.name); + } + const memberNames = memberIds.map((id) => known.get(id) ?? id); + if (memberIds.length < 2) { + message.error(t("projects.groupMembersRequired")); + return; + } + setGroupStarting(true); + try { + const { record, created } = await openGroupChat({ + memberIds, + memberNames, + }); + const prefill = memberNames + .map((name) => expertMentionToken(name)) + .join(" "); + message.success( + created + ? t("projects.groupCreated") + : t("chat.expertPickerGroupOpened"), + ); + setExpertPickerOpen(false); + closeCompactPicker(); + setActiveAgent(record.hostAgentId); + navigate(`/chat/${record.hostAgentId}/${record.threadId}`, { + state: { prefillInput: `${prefill} ` }, + }); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("projects.groupCreateFailed"), + ); + } finally { + setGroupStarting(false); + } + }; + const handleSubagentSelect = (subagent: AgentSubagentSummary) => { onInsertSubagentMention?.(subagent); setSubagentPickerOpen(false); @@ -680,6 +736,9 @@ export default function ChatInputActionsRow({ selectedAgentIds={mentionedExperts} onSelect={handleExpertSelect} onNavigateAway={closeCompactPicker} + hostAgent={hostAgent} + onEnterGroupChat={(members) => void handleEnterGroupChat(members)} + groupStarting={groupStarting} /> ); case "subagent": @@ -993,6 +1052,11 @@ export default function ChatInputActionsRow({ selectedAgentIds={mentionedExperts} onSelect={handleExpertSelect} onNavigateAway={() => setExpertPickerOpen(false)} + hostAgent={hostAgent} + onEnterGroupChat={(members) => + void handleEnterGroupChat(members) + } + groupStarting={groupStarting} /> } > diff --git a/dashboard/src/pages/Chat/components/ExpertPickerPopover.test.tsx b/dashboard/src/pages/Chat/components/ExpertPickerPopover.test.tsx new file mode 100644 index 00000000..98613821 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ExpertPickerPopover.test.tsx @@ -0,0 +1,64 @@ +import type { ComponentProps } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import ExpertPickerPopover, { + resolveGroupMembers, +} from "./ExpertPickerPopover"; +import type { ChatAgentOption } from "./ExpertAgentAvatar"; + +const agents: ChatAgentOption[] = [ + { agent_id: "a2", name: "研究员" }, + { agent_id: "a3", name: "设计师" }, +]; + +const host: ChatAgentOption = { agent_id: "a1", name: "分析师" }; + +function renderPicker( + props: Partial> = {}, +) { + return render( + + + , + ); +} + +describe("resolveGroupMembers", () => { + it("includes the host plus checked experts", () => { + const members = resolveGroupMembers(["a2"], agents, host); + expect(members.map((item) => item.agent_id)).toEqual(["a1", "a2"]); + }); +}); + +describe("ExpertPickerPopover", () => { + it("keeps row click as single-select and checkboxes for group chat", () => { + const onSelect = vi.fn(); + const onEnterGroupChat = vi.fn(); + renderPicker({ onSelect, onEnterGroupChat }); + + fireEvent.click(screen.getByRole("button", { name: "研究员" })); + expect(onSelect).toHaveBeenCalledWith(agents[0]); + expect(onEnterGroupChat).not.toHaveBeenCalled(); + + const enter = screen.getByRole("button", { + name: /expertPickerEnterGroupCount|进入群聊|Enter group chat/, + }); + expect(enter).toBeDisabled(); + + fireEvent.click(screen.getAllByRole("checkbox")[0]); + expect(enter).not.toBeDisabled(); + fireEvent.click(enter); + expect(onEnterGroupChat).toHaveBeenCalledWith([ + expect.objectContaining({ agent_id: "a1" }), + expect.objectContaining({ agent_id: "a2" }), + ]); + }); +}); diff --git a/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx b/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx index 8565044a..6c079ff1 100644 --- a/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx +++ b/dashboard/src/pages/Chat/components/ExpertPickerPopover.tsx @@ -1,10 +1,12 @@ -import { useCallback } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { GraduationCap } from "lucide-react"; +import { Checkbox } from "antd"; +import { GraduationCap, Users } from "lucide-react"; import SearchablePickerPanel, { pickerStyles, } from "../../../components/ChatPicker/SearchablePickerPanel"; +import { uniqueMemberIds } from "../../../utils/groupChats"; import ExpertAgentAvatar, { type ChatAgentOption } from "./ExpertAgentAvatar"; import styles from "../index.module.less"; @@ -15,6 +17,21 @@ interface ExpertPickerPopoverProps { selectedAgentIds: string[]; onSelect: (agent: ChatAgentOption) => void; onNavigateAway?: () => void; + hostAgent?: ChatAgentOption | null; + onEnterGroupChat?: (members: ChatAgentOption[]) => void; + groupStarting?: boolean; +} + +export function resolveGroupMembers( + checkedIds: string[], + agents: ChatAgentOption[], + hostAgent?: ChatAgentOption | null, +): ChatAgentOption[] { + const byId = new Map(agents.map((agent) => [agent.agent_id, agent])); + if (hostAgent) byId.set(hostAgent.agent_id, hostAgent); + return uniqueMemberIds([hostAgent?.agent_id, ...checkedIds]) + .map((id) => byId.get(id)) + .filter((agent): agent is ChatAgentOption => Boolean(agent)); } export default function ExpertPickerPopover({ @@ -22,9 +39,13 @@ export default function ExpertPickerPopover({ selectedAgentIds, onSelect, onNavigateAway, + hostAgent, + onEnterGroupChat, + groupStarting = false, }: ExpertPickerPopoverProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const [checkedIds, setCheckedIds] = useState([]); const filterFn = useCallback( (agent: ChatAgentOption, query: string) => @@ -33,6 +54,19 @@ export default function ExpertPickerPopover({ [], ); + const groupMembers = useMemo( + () => resolveGroupMembers(checkedIds, agents, hostAgent), + [agents, checkedIds, hostAgent], + ); + const canEnterGroup = Boolean(onEnterGroupChat) && groupMembers.length >= 2; + + const toggleChecked = (agentId: string, next: boolean) => { + setCheckedIds((prev) => { + if (next) return uniqueMemberIds([...prev, agentId]); + return prev.filter((id) => id !== agentId); + }); + }; + return ( +

+ {t("chat.expertPickerGroupHint")} +

+ + + ) : null + } renderItem={(agent) => { const active = selectedAgentIds.includes(agent.agent_id); + const checked = checkedIds.includes(agent.agent_id); return ( - + + ); }} /> diff --git a/dashboard/src/pages/Projects/index.module.less b/dashboard/src/pages/Projects/index.module.less new file mode 100644 index 00000000..756e37ab --- /dev/null +++ b/dashboard/src/pages/Projects/index.module.less @@ -0,0 +1,45 @@ +.zone { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tab { + flex: 1 1 7rem; + min-height: 40px; + margin: 0; + border: 1px solid var(--fn-border-secondary, rgba(0, 0, 0, 0.08)); + border-radius: 10px; + background: var(--fn-fill-tertiary, rgba(0, 0, 0, 0.04)); + color: var(--fn-text-secondary); + font-size: 15px; + font-weight: 500; + line-height: 22px; + cursor: pointer; + padding: 8px 12px; +} + +.tab:hover { + color: var(--fn-text-primary); + background: var(--fn-bg-hover, rgba(0, 0, 0, 0.06)); +} + +.tabActive { + background: var(--fn-bg-elevated, #fff); + border-color: var(--fn-color-brand, #1677ff); + color: var(--fn-text-primary); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/dashboard/src/pages/Projects/index.tsx b/dashboard/src/pages/Projects/index.tsx index a347b01f..0a30722c 100644 --- a/dashboard/src/pages/Projects/index.tsx +++ b/dashboard/src/pages/Projects/index.tsx @@ -1,4 +1,11 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { Button, Checkbox, @@ -7,7 +14,6 @@ import { Input, List, Modal, - Segmented, Space, Tag, } from "antd"; @@ -24,7 +30,10 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import PageShell from "../../layouts/PageShell"; import PageLoading from "../../components/PageLoading"; import { projectsApi, type Project } from "../../api/modules/projects"; -import { octopThreadsApi, type OctopThread } from "../../api/modules/octopThreads"; +import { + octopThreadsApi, + type OctopThread, +} from "../../api/modules/octopThreads"; import { useAgent, selectEnabledExperts } from "../../context/AgentContext"; import { onSessionEvent } from "../Chat/hooks/chatStore"; import { expertMentionToken } from "../Chat/utils/expertMention"; @@ -32,13 +41,10 @@ import { canPickDesktopFolder, pickDesktopFolder, } from "../../utils/desktopFolder"; -import { - groupChatTitle, - loadGroupChats, - saveGroupChat, - type GroupChatRecord, -} from "../../utils/groupChats"; +import { loadGroupChats, type GroupChatRecord } from "../../utils/groupChats"; +import { openGroupChat } from "../../utils/openGroupChat"; import { message } from "../../utils/antdMessage"; +import styles from "./index.module.less"; const CronJobsPage = lazy(() => import("../Control/CronJobs")); @@ -62,14 +68,18 @@ export default function ProjectsPage() { const [threads, setThreads] = useState< Array >([]); - const [groups, setGroups] = useState(() => loadGroupChats()); + const [groups, setGroups] = useState(() => + loadGroupChats(), + ); const [loading, setLoading] = useState(true); const [open, setOpen] = useState(false); const [groupOpen, setGroupOpen] = useState(false); const [pickingFolder, setPickingFolder] = useState(false); const [form] = Form.useForm<{ name: string; work_dir?: string }>(); const [groupForm] = Form.useForm<{ title?: string; members: string[] }>(); - const view = (searchParams.get("view") || "conversations") as WorkspaceView; + const rawView = searchParams.get("view"); + const view: WorkspaceView = + rawView === "projects" || rawView === "tasks" ? rawView : "conversations"; const enabledExperts = useMemo( () => selectEnabledExperts(agents, activeAgentId, { pinActive: false }), [agents, activeAgentId], @@ -141,19 +151,22 @@ export default function ProjectsPage() { }, [loadThreads]); useEffect(() => { - if (searchParams.get("new") === "1") { - setOpen(true); - const next = new URLSearchParams(searchParams); - next.delete("new"); - setSearchParams(next, { replace: true }); - } if (searchParams.get("group") === "1") { setGroupOpen(true); const next = new URLSearchParams(searchParams); next.delete("group"); + if (next.get("view") === "tasks" || next.get("view") === "projects") { + next.delete("view"); + } setSearchParams(next, { replace: true }); } - }, [searchParams, setSearchParams]); + if (searchParams.get("new") !== "1") return; + if (view === "tasks") return; + setOpen(true); + const next = new URLSearchParams(searchParams); + next.delete("new"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams, view]); const conversations = useMemo(() => { const groupByThread = new Map(groups.map((item) => [item.threadId, item])); @@ -250,30 +263,26 @@ export default function ProjectsPage() { message.error(t("projects.groupMembersRequired")); return; } - const hostId = members[0]; const named = members .map((id) => enabledExperts.find((agent) => agent.agent_id === id)?.name) .filter((name): name is string => Boolean(name)); try { - const created = await octopThreadsApi.create(hostId); - const title = groupChatTitle(values.title, named); - await octopThreadsApi.rename(hostId, created.thread_id, title); - const record: GroupChatRecord = { - id: created.thread_id, - threadId: created.thread_id, - hostAgentId: hostId, + const { record, created } = await openGroupChat({ memberIds: members, - title, - createdAt: Date.now(), - lastActive: Date.now(), - }; - setGroups(saveGroupChat(record)); + memberNames: named, + title: values.title, + }); + setGroups(loadGroupChats()); const prefill = named.map((name) => expertMentionToken(name)).join(" "); - message.success(t("projects.groupCreated")); + message.success( + created + ? t("projects.groupCreated") + : t("chat.expertPickerGroupOpened"), + ); setGroupOpen(false); groupForm.resetFields(); - setActiveAgent(hostId); - navigate(`/chat/${hostId}/${created.thread_id}`, { + setActiveAgent(record.hostAgentId); + navigate(`/chat/${record.hostAgentId}/${record.threadId}`, { state: { prefillInput: `${prefill} ` }, }); } catch (err) { @@ -283,55 +292,77 @@ export default function ProjectsPage() { } }; - const actionBar = ( - - - - - - - ); + const startNewTask = () => { + const params = new URLSearchParams(searchParams); + params.set("view", "tasks"); + params.set("new", "1"); + setSearchParams(params); + }; + + const zoneTabs: { value: WorkspaceView; label: string }[] = [ + { value: "conversations", label: t("projects.tabConversations") }, + { value: "tasks", label: t("projects.tabTasks") }, + { value: "projects", label: t("projects.tabProjects") }, + ]; return ( - - {actionBar} - setView(String(value) as WorkspaceView)} - options={[ - { value: "conversations", label: t("projects.tabConversations") }, - { value: "projects", label: t("projects.tabProjects") }, - { value: "tasks", label: t("projects.tabTasks") }, - ]} - /> - - } - > + +
+
+ {zoneTabs.map((tab) => ( + + ))} +
+
+ {view === "conversations" ? ( + <> + + + + ) : null} + {view === "tasks" ? ( + + ) : null} + {view === "projects" ? ( + + ) : null} +
+
+ {view === "tasks" ? ( }> @@ -394,11 +425,7 @@ export default function ProjectsPage() { renderItem={(item) => ( + ,