diff --git a/CHANGELOG.md b/CHANGELOG.md index fa12307c..0224afc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,13 @@ ### 变更 +- 「对话」会话列表标题栏右侧补上微信式搜索与「+」新建群聊(接入已有 `openGroupChat`)。FreeOS 桌面组织导航始终嵌入 openXYOS 模块(`FREEOS_ORG_INTEGRATED=1` + `FREEOS_ORG_ENABLE=1`),并拉起 bundled Node 运行时;缺失运行时是打包/启动失败,组织页提供重启,而不是静默落到宿主 org-ui。非桌面未集成工作室仍可落到 `/organization/workspace`。已从 Dashboard 包删除宿主旧工作台 UI。装配 / 打包 / 循环仍走 `freeos org` CLI 与 `/api/org-module/*`。桌面首启仍落 `/chat/main`。 / Chat session list header now has WeChat-style search and a + new-group action. FreeOS desktop Org always claims integrated openXYOS and starts the bundled module runtime; a missing runtime is an error/recover overlay, not a host org-ui fallback. Non-desktop studio without integration still uses `/organization/workspace`. Old workbench UI deleted. Assemble/pack/loop remain CLI/API. Desktop first-run still lands on `/chat/main`. - Windows NSIS 文件复制结束后不再多点一次「下一步」才到结束页;结束页仍让用户勾选「运行 FreeOS」或直接关闭,不会自动启动、也不会自动关窗。静默安装(`/S`)仍不拉起界面。 / After Windows NSIS file copy, Setup advances to the finish page without an extra Next. The user still chooses Run FreeOS or close. No auto-launch / auto-close. Silent `/S` stays headless. - 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。 ### 修复 +- 组织嵌入仍走 openXYOS 自己的登录(`localStorage token` / sidecar JWT),不把 FreeOS 桌面访客(`auth_token` / `POST /api/auth/local-session`)写进组织房间。重启/恢复 sidecar 始终带 `FREEOS_ORG_LOCAL_TEST=1`,保留 `demo@demo.com` / `user@demo.com`。本机会话跳过组织映射行,不删除、不占用 openXYOS 用户。代理剥离宿主 Cookie 与 `X-FreeOS-*`。 / Org embed keeps openXYOS login; FreeOS local-session must not replace org users. Sidecar restart still seeds test accounts. Proxy drops host cookies and identity headers. - Windows 升级刷新 `~/.freeos/portable` 时,若目录节点被占用(无法改名为 `portable.previous`,报 “being used by another process”),先尝试结束残留的 portable / `launch.py` 进程,再把新运行时**原地覆盖**进现有文件夹,避免留下空的锁定 `portable` 桩,也不要求重启。 / If renaming `portable` → `portable.previous` fails because the directory is in use, stop leftover host processes and overlay the new runtime in place. - 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳。403 修复后的路径是:可选模型配置(云 Key / 本机,可跳过)→ 第一个智能体 `/chat/main`,不经过登录墙,也不停在工作台列表。`/setup` 在 guest 已创建后不再打回登录页。 - Windows 安装 / 覆盖安装 / 同版本重装会刷新 `~/.freeos/portable`:Setup 先结束仍在运行的 FreeOS,写入 `$INSTDIR\FREEOS_INSTALL_STAMP`,并清除已解压树里的 `FREEOS_STAMP`。下次启动按安装戳 + 包内戳重新解压 `packages/` 与内嵌 Dashboard,#88 及后续宿主/界面修复不必再手工热补。用户数据库与设置仍留在 `~/.freeos`。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index cbf02c66..17a0907d 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -185,8 +185,6 @@ "pauseReason": "Reason", "pauseResolveFailed": "Could not resolve this pause", "pauseResolved": "Pause updated", - "originalAppHint": "Unmigrated App verticals (workflows, contracts, …) still use the original preview — a transition, not the home.", - "originalAppAction": "Open original App (transition)", "catalogNative": "Host page", "catalogOriginalApp": "Original App", "metricPauses": "Pending pauses", @@ -197,7 +195,6 @@ "packReceipt": "Packed and pushed to the signed-in openXYOS tenant.", "packLanded": "Organization workspace {{tenant}}: employees {{employees}}, talent {{talent}}, skills {{skills}}, plugins {{plugins}}", "packPreviewEmployees": "The preview now opens Employees so you can confirm the result.", - "navWorkbench": "Workbench", "navWorkspace": "Workspace", "navAnnouncements": "Announcements", "navOrgChart": "Org chart", @@ -1534,6 +1531,7 @@ "bootstrapKickoff": "Hi — let's get started", "createSessionFailed": "Failed to create a session — please try again", "searchSessions": "Search conversations", + "newGroupChat": "New group chat", "noSearchResults": "No matching conversations", "expandMore": "Show more", "pin": "Pin", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 4b0542e0..55b824b1 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -185,8 +185,6 @@ "pauseReason": "原因", "pauseResolveFailed": "无法处理该暂停", "pauseResolved": "暂停已更新", - "originalAppHint": "尚未迁入的 App 垂类(流程、合同等)仍走原预览——只是过渡,不是首页。", - "originalAppAction": "打开原 App(过渡)", "catalogNative": "宿主页", "catalogOriginalApp": "原 App", "metricPauses": "待处理暂停", @@ -197,7 +195,6 @@ "packReceipt": "已打包并推送到当前登录的 openXYOS 租户。", "packLanded": "本组织工作区 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}", "packPreviewEmployees": "右侧预览已打开员工页,可直接核对。", - "navWorkbench": "工作台", "navWorkspace": "总览", "navAnnouncements": "通知公告", "navOrgChart": "组织架构", @@ -1534,6 +1531,7 @@ "bootstrapKickoff": "你好,我们开始吧", "createSessionFailed": "创建会话失败,请重试", "searchSessions": "搜索会话", + "newGroupChat": "新建群聊", "noSearchResults": "没有匹配的会话", "expandMore": "展开更多", "pin": "置顶", diff --git a/dashboard/src/org-ui/pages/agents/AgentsPage.tsx b/dashboard/src/org-ui/pages/agents/AgentsPage.tsx index b7a95a08..6e0e817e 100644 --- a/dashboard/src/org-ui/pages/agents/AgentsPage.tsx +++ b/dashboard/src/org-ui/pages/agents/AgentsPage.tsx @@ -17,7 +17,6 @@ export interface AgentsPageProps { locale: OrgLocale; /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ modules?: readonly string[]; - workbenchHref?: string; } const EMPTY_STATS: OrgColleagueStats = { @@ -52,12 +51,7 @@ const EMPTY_FORM: CompileForm = { capabilities: [], }; -export function AgentsPage({ - client, - session, - locale, - workbenchHref = "/organization", -}: AgentsPageProps) { +export function AgentsPage({ client, session, locale }: AgentsPageProps) { const labels = agentsLabels(locale); const [colleagues, setColleagues] = useState([]); const [stats, setStats] = useState(EMPTY_STATS); @@ -182,11 +176,6 @@ export function AgentsPage({

{labels.subtitle}

- {workbenchHref ? ( - - ) : null}
diff --git a/dashboard/src/org-ui/pages/agents/labels.ts b/dashboard/src/org-ui/pages/agents/labels.ts index 85f5ccbb..a73e1eae 100644 --- a/dashboard/src/org-ui/pages/agents/labels.ts +++ b/dashboard/src/org-ui/pages/agents/labels.ts @@ -12,7 +12,6 @@ const EN = { openExperts: "FreeOS Experts", openPersonalization: "Agent personalization", openEmployees: "Employees directory", - openWorkbench: "Organization workbench", compileTitle: "Compile a blueprint", compileHint: "Writes a tenant workspace (SOUL.md, skills, MEMORY) and registers a draft colleague. No talent-market sidecar, no file-upload studio.", @@ -84,7 +83,6 @@ const ZH: typeof EN = { openExperts: "FreeOS 专家", openPersonalization: "智能体个性化", openEmployees: "员工目录", - openWorkbench: "组织工作台", compileTitle: "编译蓝图", compileHint: "写入租户工作区(SOUL.md、技能、MEMORY)并登记为 draft 同事。没有边车人才市场,也没有资料上传工作室。", diff --git a/dashboard/src/org-ui/pages/settings/SettingsPage.tsx b/dashboard/src/org-ui/pages/settings/SettingsPage.tsx index d0504844..8d1518da 100644 --- a/dashboard/src/org-ui/pages/settings/SettingsPage.tsx +++ b/dashboard/src/org-ui/pages/settings/SettingsPage.tsx @@ -18,7 +18,6 @@ export interface SettingsPageProps { locale: OrgLocale; /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ modules?: readonly string[]; - workbenchHref?: string; } const EMPTY_PREFS: OrgPrefs = { name: "", description: "" }; @@ -28,12 +27,7 @@ const EMPTY_LINKS: OrgSystemSettingsLinks = { users: "/system-settings/users", }; -export function SettingsPage({ - client, - session, - locale, - workbenchHref = "/organization", -}: SettingsPageProps) { +export function SettingsPage({ client, session, locale }: SettingsPageProps) { const labels = settingsLabels(locale); const [catalog, setCatalog] = useState([]); const [toggles, setToggles] = useState>({}); @@ -132,11 +126,6 @@ export function SettingsPage({

{labels.subtitle}

- {workbenchHref ? ( - - ) : null}
diff --git a/dashboard/src/org-ui/pages/settings/labels.ts b/dashboard/src/org-ui/pages/settings/labels.ts index 86949e34..a736d28d 100644 --- a/dashboard/src/org-ui/pages/settings/labels.ts +++ b/dashboard/src/org-ui/pages/settings/labels.ts @@ -12,7 +12,6 @@ const EN = { openSystem: "FreeOS system settings", openModels: "Models & keys", openUsers: "Users", - openWorkbench: "Organization workbench", profileTitle: "Organization profile", profileHint: "Shown on this Organization module. Not the FreeOS instance name.", @@ -49,7 +48,6 @@ const ZH: typeof EN = { openSystem: "FreeOS 系统设置", openModels: "模型与密钥", openUsers: "用户", - openWorkbench: "组织工作台", profileTitle: "组织资料", profileHint: "只用于本组织模块,不是 FreeOS 实例名。", name: "组织名称", diff --git a/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx b/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx index 535fca51..089e551b 100644 --- a/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx +++ b/dashboard/src/org-ui/pages/workspace/WorkspacePage.tsx @@ -12,7 +12,6 @@ import { workspaceLabels } from "./labels"; import styles from "./WorkspacePage.module.css"; export interface OrgWorkspaceLinks { - workbench: string; announcements: string; organization: string; employees: string; @@ -32,12 +31,10 @@ export interface WorkspacePageProps { locale: OrgLocale; /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ modules?: readonly string[]; - workbenchHref?: string; links?: Partial; } const DEFAULT_LINKS: OrgWorkspaceLinks = { - workbench: "/organization", announcements: "/organization/announcements", organization: "/organization/org", employees: "/organization/employees", @@ -79,14 +76,12 @@ export function WorkspacePage({ client, session, locale, - workbenchHref, links, }: WorkspacePageProps) { const labels = workspaceLabels(locale); const hrefs: OrgWorkspaceLinks = { ...DEFAULT_LINKS, ...links, - workbench: workbenchHref || links?.workbench || DEFAULT_LINKS.workbench, }; const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(true); @@ -236,9 +231,6 @@ export function WorkspacePage({

{labels.hostHint}

{labels.roomHint}

-
>({}); const [workingIds, setWorkingIds] = useState>(new Set()); const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const [collapsedFolders, setCollapsedFolders] = useState>(() => loadCollapsedFolders(), ); @@ -494,6 +496,17 @@ export default function MinimalAgentSessionNav({ [activeAgentId, onPinActive, patchLocal], ); + const searchNeedle = searchQuery.trim().toLowerCase(); + const visibleAgents = useMemo(() => { + if (!searchNeedle) return sortedAgents; + return sortedAgents.filter((agent) => { + if (agent.name.toLowerCase().includes(searchNeedle)) return true; + return (byAgent[agent.agent_id] ?? []).some((session) => + session.name.toLowerCase().includes(searchNeedle), + ); + }); + }, [byAgent, searchNeedle, sortedAgents]); + if (agents.length === 0) { return (
@@ -513,8 +526,18 @@ export default function MinimalAgentSessionNav({ return (
- {sortedAgents.map((agent) => { - const list = byAgent[agent.agent_id] ?? []; + + {visibleAgents.map((agent) => { + const list = (byAgent[agent.agent_id] ?? []).filter((session) => + searchNeedle + ? session.name.toLowerCase().includes(searchNeedle) || + agent.name.toLowerCase().includes(searchNeedle) + : true, + ); const ready = isAgentChatReady(agent.state); const expanded = !collapsedFolders.has(agent.agent_id); diff --git a/dashboard/src/pages/Chat/components/SessionList.test.tsx b/dashboard/src/pages/Chat/components/SessionList.test.tsx new file mode 100644 index 00000000..7872a3b2 --- /dev/null +++ b/dashboard/src/pages/Chat/components/SessionList.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import type { OctopAgent } from "../../../context/AgentContext"; +import type { Session } from "../hooks/useSessions"; +import SessionList from "./SessionList"; + +vi.mock("../../../context/AgentContext", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../../context/AgentContext") + >(); + return { + ...actual, + useAgent: () => ({ setActiveAgent: vi.fn() }), + }; +}); + +function agent(id: string, name: string): OctopAgent { + return { + id: Number(id.replace(/\D/g, "") || 1), + agent_id: id, + name, + description: "desc", + persona_mbti: null, + default_model: null, + system_prompt: null, + template_name: null, + state: "running", + last_error: null, + icon: null, + icon_name: null, + icon_url: null, + color: null, + config: {}, + }; +} + +const sessions: Session[] = [ + { + id: "s1", + name: "周会纪要", + threadId: "s1", + updatedAt: null, + channelType: "dashboard", + hasActivity: true, + pinned: false, + }, + { + id: "s2", + name: "代码审查", + threadId: "s2", + updatedAt: null, + channelType: "dashboard", + hasActivity: true, + pinned: false, + }, +]; + +describe("SessionList", () => { + it("filters sessions from the header search control", () => { + render( + + + , + ); + + expect(screen.getByText("周会纪要")).toBeInTheDocument(); + expect(screen.getByText("代码审查")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("chat-session-search-toggle")); + fireEvent.change(screen.getByTestId("chat-session-search"), { + target: { value: "周会" }, + }); + expect(screen.getByText("周会纪要")).toBeInTheDocument(); + expect(screen.queryByText("代码审查")).toBeNull(); + }); +}); diff --git a/dashboard/src/pages/Chat/components/SessionList.tsx b/dashboard/src/pages/Chat/components/SessionList.tsx index c8b76640..f8b7aa1e 100644 --- a/dashboard/src/pages/Chat/components/SessionList.tsx +++ b/dashboard/src/pages/Chat/components/SessionList.tsx @@ -9,7 +9,6 @@ import { Trash2, Pin, PinOff, - Search, GitFork, } from "lucide-react"; import type { Session } from "../hooks/useSessions"; @@ -18,6 +17,7 @@ import { isAgentChatReady } from "../../../utils/agentError"; import { showConfirmModal } from "../../../utils/confirmModal"; import { ExpertIcon } from "../../Experts/components/iconForName"; import SessionChannelIcon from "./SessionChannelIcon"; +import SessionListToolbar from "./SessionListToolbar"; import SharedExpertHint from "./SharedExpertHint"; import styles from "../index.module.less"; @@ -442,31 +442,13 @@ export default function SessionList({ () => activeAgentId ?? sortedAgents[0]?.agent_id ?? null, [activeAgentId, sortedAgents], ); - const activeAgent = useMemo( - () => sortedAgents.find((a) => a.agent_id === expandedAgentId) ?? null, - [sortedAgents, expandedAgentId], - ); - const showSessions = isAgentChatReady(activeAgent?.state); - return (
- {showSessions ? ( -
- - setSearchQuery(e.target.value)} - placeholder={t("chat.searchSessions", "搜索会话")} - aria-label={t("chat.searchSessions", "搜索会话")} - /> -
- ) : null} + {agents.length === 0 ? (
diff --git a/dashboard/src/pages/Chat/components/SessionListToolbar.test.tsx b/dashboard/src/pages/Chat/components/SessionListToolbar.test.tsx new file mode 100644 index 00000000..24841505 --- /dev/null +++ b/dashboard/src/pages/Chat/components/SessionListToolbar.test.tsx @@ -0,0 +1,135 @@ +import type { ComponentProps } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OctopAgent } from "../../../context/AgentContext"; +import SessionListToolbar from "./SessionListToolbar"; + +const navigate = vi.fn(); +const setActiveAgent = vi.fn(); + +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigate }; +}); + +vi.mock("../../../context/AgentContext", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../../context/AgentContext") + >(); + return { + ...actual, + useAgent: () => ({ setActiveAgent }), + }; +}); + +vi.mock("../../../utils/openGroupChat", () => ({ + openGroupChat: vi.fn(), +})); + +import { openGroupChat } from "../../../utils/openGroupChat"; + +vi.mock("../../../utils/antdMessage", () => ({ + message: { success: vi.fn(), error: vi.fn() }, +})); + +function agent(id: string, name: string): OctopAgent { + return { + id: Number(id.replace(/\D/g, "") || 1), + agent_id: id, + name, + description: null, + persona_mbti: null, + default_model: null, + system_prompt: null, + template_name: null, + state: "running", + last_error: null, + icon: null, + icon_name: null, + icon_url: null, + color: null, + config: {}, + }; +} + +function renderToolbar( + props: Partial> = {}, +) { + const onSearchQueryChange = props.onSearchQueryChange ?? vi.fn(); + return render( + + + , + ); +} + +describe("SessionListToolbar", () => { + beforeEach(() => { + navigate.mockReset(); + setActiveAgent.mockReset(); + openGroupChat.mockReset(); + }); + + it("puts search and new-group controls on the conversation header", () => { + renderToolbar(); + expect(screen.getByTestId("chat-session-toolbar")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "对话" })).toBeInTheDocument(); + expect( + screen.getByTestId("chat-session-search-toggle"), + ).toBeInTheDocument(); + expect(screen.getByTestId("chat-session-new-group")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-session-search")).toBeNull(); + }); + + it("opens the search field and reports query changes", () => { + const onSearchQueryChange = vi.fn(); + renderToolbar({ onSearchQueryChange }); + fireEvent.click(screen.getByTestId("chat-session-search-toggle")); + const input = screen.getByTestId("chat-session-search"); + fireEvent.change(input, { target: { value: "周会" } }); + expect(onSearchQueryChange).toHaveBeenCalledWith("周会"); + }); + + it("creates a group chat from the plus button", async () => { + openGroupChat.mockResolvedValue({ + record: { + id: "t1", + threadId: "t1", + hostAgentId: "a1", + memberIds: ["a1", "a2"], + title: "群", + createdAt: 1, + lastActive: 1, + }, + created: true, + }); + renderToolbar(); + fireEvent.click(screen.getByTestId("chat-session-new-group")); + fireEvent.click(screen.getByLabelText("分析师")); + fireEvent.click(screen.getByLabelText("研究员")); + fireEvent.click(screen.getByRole("button", { name: /创\s*建/ })); + await waitFor(() => { + expect(openGroupChat).toHaveBeenCalledWith( + expect.objectContaining({ + memberIds: ["a1", "a2"], + memberNames: ["分析师", "研究员"], + }), + ); + }); + expect(setActiveAgent).toHaveBeenCalledWith("a1"); + expect(navigate).toHaveBeenCalledWith("/chat/a1/t1", { + state: { prefillInput: "@分析师 @研究员 " }, + }); + }); +}); diff --git a/dashboard/src/pages/Chat/components/SessionListToolbar.tsx b/dashboard/src/pages/Chat/components/SessionListToolbar.tsx new file mode 100644 index 00000000..e3b41964 --- /dev/null +++ b/dashboard/src/pages/Chat/components/SessionListToolbar.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Checkbox, Form, Input, Modal } from "antd"; +import { Plus, Search } from "lucide-react"; +import { useAgent } from "../../../context/AgentContext"; +import type { OctopAgent } from "../../../context/AgentContext"; +import { message } from "../../../utils/antdMessage"; +import { openGroupChat } from "../../../utils/openGroupChat"; +import { expertMentionToken } from "../utils/expertMention"; +import styles from "../index.module.less"; + +interface SessionListToolbarProps { + searchQuery: string; + onSearchQueryChange: (value: string) => void; + agents: OctopAgent[]; +} + +export default function SessionListToolbar({ + searchQuery, + onSearchQueryChange, + agents, +}: SessionListToolbarProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { setActiveAgent } = useAgent(); + const [searchOpen, setSearchOpen] = useState(false); + const [groupOpen, setGroupOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [form] = Form.useForm<{ title?: string; members: string[] }>(); + + const showSearch = searchOpen || Boolean(searchQuery.trim()); + const newGroupLabel = t("chat.newGroupChat", "新建群聊"); + + const createGroup = async () => { + const values = await form.validateFields(); + const members = values.members || []; + if (members.length < 2) { + message.error( + t("projects.groupMembersRequired", "请至少选择两位(同事或智能助手)"), + ); + return; + } + const named = members + .map((id) => agents.find((agent) => agent.agent_id === id)?.name) + .filter((name): name is string => Boolean(name)); + setCreating(true); + try { + const { record, created } = await openGroupChat({ + memberIds: members, + memberNames: named, + title: values.title, + }); + const prefill = named.map((name) => expertMentionToken(name)).join(" "); + message.success( + created + ? t("projects.groupCreated", "群聊已创建") + : t("chat.expertPickerGroupOpened", "已进入群聊"), + ); + setGroupOpen(false); + form.resetFields(); + 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 { + setCreating(false); + } + }; + + return ( +
+
+

+ {t("nav.conversations", "对话")} +

+
+ + +
+
+ {showSearch ? ( +
+ + onSearchQueryChange(e.target.value)} + placeholder={t("chat.searchSessions", "搜索会话")} + aria-label={t("chat.searchSessions", "搜索会话")} + data-testid="chat-session-search" + autoFocus + /> +
+ ) : null} + + { + setGroupOpen(false); + form.resetFields(); + }} + onOk={() => void createGroup()} + okText={t("common.create", "创建")} + confirmLoading={creating} + destroyOnHidden + > +
+ + + + { + if (!value || value.length < 2) { + throw new Error( + t( + "projects.groupMembersRequired", + "请至少选择两位(同事或智能助手)", + ), + ); + } + }, + }, + ]} + > + ({ + label: agent.name, + value: agent.agent_id, + }))} + /> + +
+
+
+ ); +} diff --git a/dashboard/src/pages/Organization/OrgMiniBrowser.tsx b/dashboard/src/pages/Organization/OrgMiniBrowser.tsx index 7600cd4c..d787863d 100644 --- a/dashboard/src/pages/Organization/OrgMiniBrowser.tsx +++ b/dashboard/src/pages/Organization/OrgMiniBrowser.tsx @@ -1,6 +1,6 @@ /** * Leftover iframe chrome for the optional Node sidecar (`FREEOS_ORG_SIDECAR=1`). - * The default Organization workbench (`index.tsx`) does not mount this. + * Organization nav mounts `OrganizationEntry` (openXYOS embed); this is unused by routes. * Core org pages live in `dashboard/src/org-ui` and talk to `/api/org-module/*`. */ import { useMemo, type MutableRefObject } from "react"; diff --git a/dashboard/src/pages/Organization/OrganizationEntry.test.tsx b/dashboard/src/pages/Organization/OrganizationEntry.test.tsx index 69446fbc..f2b0de92 100644 --- a/dashboard/src/pages/Organization/OrganizationEntry.test.tsx +++ b/dashboard/src/pages/Organization/OrganizationEntry.test.tsx @@ -1,101 +1,122 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import OrganizationEntry from "./OrganizationEntry"; -import { orgModuleApi, type OrgOverview } from "../../api/modules/orgModule"; - -const overview: OrgOverview = { - enabled: true, - runtime: "in_host", - sidecar_optional: true, - sidecar_reachable: false, - sidecar_embed_ok: false, - sidecar_url: "http://127.0.0.1:3780", - start_available: false, - install_ready: false, - start_command: "", - home: "/tmp", - last_sync: null, - freeos: { - employees: 0, - employee_states: {}, - agents: 0, - spawned_colleagues: 0, - org_skills: 0, - skill_packages: 0, - mcp: 0, - tasks: 0, - }, - openxyos: { - reachable: false, - url: "http://127.0.0.1:3780", - detail: "", - modules: 12, - governance: true, - tenant_id: "default", - approvals: 0, - }, - last_loop: null, - notes: [], - catalog: [], -}; +import { orgModuleApi } from "../../api/modules/orgModule"; +import { isDesktopShell } from "../../utils/desktopShell"; vi.mock("../../api/modules/orgModule", () => ({ orgModuleApi: { - identityStatus: vi.fn(async () => ({ - integrated: true, - authority: "organization", - })), - overview: vi.fn(async () => overview), - setEnabled: vi.fn(), + identityStatus: vi.fn(), + probeLivez: vi.fn(), startSidecar: vi.fn(), restartSidecar: vi.fn(), - probeLivez: vi.fn(), - assemble: vi.fn(), - produce: vi.fn(), - pack: vi.fn(), - runLoop: vi.fn(), - setModules: vi.fn(), - downloadSource: vi.fn(), }, })); -vi.mock("../../hooks/useServerTimezone", () => ({ - useServerTimezone: () => "UTC", -})); - -vi.mock("../../utils/desktopFolder", () => ({ - pickDesktopFolder: vi.fn(), - canPickDesktopFolder: () => false, +vi.mock("../../utils/desktopShell", () => ({ + isDesktopShell: vi.fn(), })); -vi.mock("../../utils/antdMessage", () => ({ - message: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, -})); +function renderEntry() { + return render( + + + } /> + workspace
} + /> + + , + ); +} describe("OrganizationEntry", () => { beforeEach(() => { - vi.mocked(orgModuleApi.overview).mockResolvedValue(overview); + vi.mocked(isDesktopShell).mockReturnValue(false); + vi.mocked(orgModuleApi.identityStatus).mockReset(); + vi.mocked(orgModuleApi.probeLivez).mockReset(); + vi.mocked(orgModuleApi.startSidecar).mockReset(); + vi.mocked(orgModuleApi.restartSidecar).mockReset(); + vi.mocked(orgModuleApi.probeLivez).mockResolvedValue({ + reachable: true, + url: "http://127.0.0.1:3780", + detail: "ok", + }); + vi.mocked(orgModuleApi.startSidecar).mockResolvedValue({ + started: false, + already: true, + reachable: true, + }); + }); + + it("embeds openXYOS when desktop organization is integrated", async () => { vi.mocked(orgModuleApi.identityStatus).mockResolvedValue({ integrated: true, authority: "organization", }); + renderEntry(); + expect(await screen.findByTestId("org-openxyos-frame")).toBeInTheDocument(); + expect(screen.getByTestId("org-openxyos-frame")).toHaveAttribute( + "src", + "/organization-app/dashboard?freeos_embed=1", + ); + expect(screen.queryByTestId("org-native-workbench")).toBeNull(); + expect(screen.queryByTestId("org-ui-workspace")).toBeNull(); + }); + + it("falls back to the host org-ui workspace only when not desktop and not integrated", async () => { + vi.mocked(orgModuleApi.identityStatus).mockResolvedValue({ + integrated: false, + authority: "studio", + }); + renderEntry(); + expect(await screen.findByTestId("org-ui-workspace")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + }); + + it("keeps the openXYOS landing on FreeOS desktop even if identity is not integrated", async () => { + vi.mocked(isDesktopShell).mockReturnValue(true); + vi.mocked(orgModuleApi.identityStatus).mockResolvedValue({ + integrated: false, + authority: "studio", + }); + renderEntry(); + expect(await screen.findByTestId("org-openxyos-frame")).toBeInTheDocument(); + expect(screen.queryByTestId("org-ui-workspace")).toBeNull(); + }); + + it("shows a restart overlay on desktop when the module runtime is down", async () => { + vi.mocked(isDesktopShell).mockReturnValue(true); + vi.mocked(orgModuleApi.identityStatus).mockResolvedValue({ + integrated: true, + authority: "dual", + }); + vi.mocked(orgModuleApi.probeLivez).mockResolvedValue({ + reachable: false, + url: "http://127.0.0.1:3780", + detail: "down", + }); + vi.mocked(orgModuleApi.startSidecar).mockResolvedValue({ + started: false, + already: false, + reachable: false, + }); + renderEntry(); + await waitFor(() => { + expect(screen.getByTestId("org-sidecar-gate")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("org-ui-workspace")).toBeNull(); + expect(screen.queryByTestId("org-openxyos-frame")).toBeNull(); }); - it("keeps the native workbench as Organization home when desktop is integrated", async () => { - render( - - - } /> - - , + it("falls back to the host org-ui workspace when identity status fails", async () => { + vi.mocked(orgModuleApi.identityStatus).mockRejectedValue( + new Error("organization identity unavailable"), ); - expect( - await screen.findByTestId("org-native-workbench"), - ).toBeInTheDocument(); - expect( - await screen.findByTestId("org-original-app-hint"), - ).toBeInTheDocument(); + renderEntry(); + expect(await screen.findByTestId("org-ui-workspace")).toBeInTheDocument(); expect(document.querySelector("iframe")).toBeNull(); }); }); diff --git a/dashboard/src/pages/Organization/OrganizationEntry.tsx b/dashboard/src/pages/Organization/OrganizationEntry.tsx index c8148b2f..4e52b92d 100644 --- a/dashboard/src/pages/Organization/OrganizationEntry.tsx +++ b/dashboard/src/pages/Organization/OrganizationEntry.tsx @@ -1,9 +1,139 @@ -import OrganizationPage from "./index"; +import { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { Spin } from "antd"; +import { orgModuleApi } from "../../api/modules/orgModule"; +import { isDesktopShell } from "../../utils/desktopShell"; +import OrgRestartOverlay from "./OrgRestartOverlay"; +import { + shouldEmbedOpenXYOS, + shouldFallbackToHostWorkspace, +} from "./orgLanding"; +import { sidecarPreviewGate } from "./sidecarLivez"; + +const OPENXYOS_EMBED = "/organization-app/dashboard?freeos_embed=1"; /** - * Organization home is the native workbench. Desktop managed Node + iframe - * remains a transitional preview for unmigrated App verticals, not this route. + * Organization nav opens the integrated openXYOS module (same-origin embed). + * FreeOS desktop always takes this path: missing Node is a recover overlay, + * not a Navigate to the host org-ui workspace. + * Non-desktop studio without FREEOS_ORG_INTEGRATED still falls back to + * /organization/workspace. + * First-run still lands on /chat/main — only this nav entry changed. */ export default function OrganizationEntry() { - return ; + const desktop = isDesktopShell(); + const [integrated, setIntegrated] = useState( + desktop ? true : null, + ); + const [livezOk, setLivezOk] = useState(false); + const [probed, setProbed] = useState(false); + const [restarting, setRestarting] = useState(false); + const [autoTried, setAutoTried] = useState(false); + + useEffect(() => { + let active = true; + void orgModuleApi + .identityStatus() + .then((status) => { + if (active) setIntegrated(Boolean(status.integrated) || desktop); + }) + .catch(() => { + if (active && !desktop) setIntegrated(false); + }); + return () => { + active = false; + }; + }, [desktop]); + + const embed = shouldEmbedOpenXYOS({ desktop, integrated }); + const fallback = shouldFallbackToHostWorkspace({ desktop, integrated }); + + useEffect(() => { + if (!embed) return; + let active = true; + void orgModuleApi + .probeLivez() + .then((result) => { + if (active) setLivezOk(Boolean(result.reachable)); + }) + .catch(() => { + if (active) setLivezOk(false); + }) + .finally(() => { + if (active) setProbed(true); + }); + return () => { + active = false; + }; + }, [embed]); + + useEffect(() => { + if (!embed || !probed || livezOk || autoTried) return; + setAutoTried(true); + setRestarting(true); + let active = true; + void orgModuleApi + .startSidecar() + .then(() => orgModuleApi.probeLivez()) + .then((result) => { + if (active) setLivezOk(Boolean(result.reachable)); + }) + .catch(() => { + if (active) setLivezOk(false); + }) + .finally(() => { + if (active) setRestarting(false); + }); + return () => { + active = false; + }; + }, [embed, probed, livezOk, autoTried]); + + const onRestart = () => { + setRestarting(true); + void orgModuleApi + .restartSidecar() + .then(() => orgModuleApi.probeLivez()) + .then((result) => setLivezOk(Boolean(result.reachable))) + .catch(() => setLivezOk(false)) + .finally(() => setRestarting(false)); + }; + + if (fallback) { + return ; + } + if (!embed || !probed) { + return ( +
+ +
+ ); + } + + const gate = sidecarPreviewGate({ livezOk, restarting }); + if (gate !== "open") { + return ( + + ); + } + + return ( +
+