Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

### 变更

- 组织页改为应用内多标签浏览器(标签栏 + 地址栏 + iframe),进入时打开本机 openXYOS。`target=_blank` / `window.open` 收入应用内标签,不再弹出系统新窗口;去掉「在新窗口打开」。
- 组织页顶部蓝色状态栏增加「下载最新源码」主按钮:点击后先打开系统文件夹选择器,再下载最新 openXYOS 主分支源码。不必先打开「组织OS管理」。成功提示保存路径;管理抽屉仍保留同一操作作为次要入口。
- 对话与工作区共用一份列表:侧栏「对话」进入工作区「对话」标签;点开一条才进入聊天画布。工作区侧栏默认打开「项目」标签。
- 用户可见用词对齐:员工 = 已入编部门;同事 = 同一组织里一起聊的人;专家 / 智能助手 = 尚未入编的帮手。个性化「子智能体」改为「专项助手」。群聊选择「同事或智能助手」。
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
"optional": "Optional",
"embedTitle": "Organization console",
"openSidecar": "Open in new tab",
"browserNewTab": "New tab",
"addressPlaceholder": "Enter a URL",
"addressGo": "Go",
"homeTabTitle": "openXYOS",
"loadFailed": "Could not load organization module status",
"saveFailed": "Could not update organization module",
"actionFailed": "Action failed",
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
"optional": "可选",
"embedTitle": "组织控制台",
"openSidecar": "新窗口打开",
"browserNewTab": "新标签",
"addressPlaceholder": "输入网址",
"addressGo": "前往",
"homeTabTitle": "openXYOS",
"loadFailed": "无法加载组织模块状态",
"saveFailed": "无法更新组织模块",
"actionFailed": "操作失败",
Expand Down
131 changes: 131 additions & 0 deletions dashboard/src/pages/Organization/OrgMiniBrowser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useMemo, type MutableRefObject } from "react";
import { Button, Input } from "antd";
import { useTranslation } from "react-i18next";
import { ChromeTabBar } from "../../components/ChromeTabBar";
import {
buildEmbedSrc,
iframeSandboxFor,
type OrgBrowserTab,
} from "./orgBrowser";
import styles from "./Organization.module.less";

type OrgMiniBrowserProps = {
tabs: OrgBrowserTab[];
activeId: string;
addressValue: string;
sidecarOrigin: string;
disabledKeys: string[];
previewNonce: string;
sidecarUp: boolean;
previewBlank: boolean;
iframeRefs: MutableRefObject<Record<string, HTMLIFrameElement | null>>;
onAddressChange: (value: string) => void;
onAddressSubmit: () => void;
onSelectTab: (id: string) => void;
onCloseTab: (id: string) => void;
onNewTab: () => void;
onFrameLoad: () => void;
};

export default function OrgMiniBrowser({
tabs,
activeId,
addressValue,
sidecarOrigin,
disabledKeys,
previewNonce,
sidecarUp,
previewBlank,
iframeRefs,
onAddressChange,
onAddressSubmit,
onSelectTab,
onCloseTab,
onNewTab,
onFrameLoad,
}: OrgMiniBrowserProps) {
const { t } = useTranslation();
const chromeTabs = useMemo(
() =>
tabs.map((tab) => ({
key: tab.id,
label: tab.title,
tooltip: tab.url,
closable: tabs.length > 1,
})),
[tabs],
);

return (
<div className={styles.browserChrome} data-testid="org-mini-browser">
<ChromeTabBar
tabs={chromeTabs}
activeKey={activeId}
onChange={onSelectTab}
onClose={(key, event) => {
event.preventDefault();
onCloseTab(key);
}}
onNewTab={onNewTab}
newTabTitle={t("organization.browserNewTab")}
/>
<form
className={styles.addressBar}
onSubmit={(event) => {
event.preventDefault();
onAddressSubmit();
}}
>
<Input
data-testid="org-address-bar"
value={addressValue}
onChange={(event) => onAddressChange(event.target.value)}
placeholder={t("organization.addressPlaceholder")}
aria-label={t("organization.addressPlaceholder")}
/>
<Button htmlType="submit" data-testid="org-address-go">
{t("organization.addressGo")}
</Button>
</form>
<div className={styles.embedPane}>
{!sidecarUp && (
<div className={styles.previewOffline}>
{t("organization.previewOffline")}
</div>
)}
{previewBlank && (
<div className={styles.previewBlank} data-testid="org-preview-blank">
{t("organization.previewBlank")}
</div>
)}
{tabs.map((tab) => {
const src = buildEmbedSrc(tab.srcUrl, {
sidecarOrigin,
disabledKeys,
nonce: previewNonce,
});
const active = tab.id === activeId;
return (
<iframe
key={tab.id}
ref={(node) => {
iframeRefs.current[tab.id] = node;
}}
title={active ? t("organization.previewTitle") : `${tab.title}`}
src={src}
className={`${styles.embed} ${
active ? "" : styles.embedInactive
}`}
data-testid="org-browser-frame"
data-active={active ? "true" : "false"}
data-tab-id={tab.id}
sandbox={iframeSandboxFor(src, sidecarOrigin)}
allow="clipboard-read; clipboard-write"
onLoad={onFrameLoad}
/>
);
})}
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions dashboard/src/pages/Organization/Organization.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@
background: #f4f6ff !important;
}

.browserChrome {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}

.addressBar {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 6px 10px;
background: var(--fn-bg-secondary);
border-bottom: 1px solid var(--fn-border-secondary);
}

.addressBar :global(.ant-input) {
border-radius: 8px;
}

.embedPane {
position: relative;
flex: 1;
Expand All @@ -92,12 +113,19 @@

.embed {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}

.embedInactive {
visibility: hidden;
pointer-events: none;
}

.previewOffline {
position: absolute;
inset: 0;
Expand Down
100 changes: 86 additions & 14 deletions dashboard/src/pages/Organization/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import OrganizationPage from "./index";
import { orgModuleApi, type OrgOverview } from "../../api/modules/orgModule";
import { message } from "../../utils/antdMessage";
import { tryOpenInOrgBrowser } from "../../utils/orgBrowserHost";

const overview: OrgOverview = {
enabled: true,
Expand Down Expand Up @@ -84,24 +85,31 @@ describe("OrganizationPage", () => {
pickDesktopFolder.mockReset();
vi.mocked(orgModuleApi.overview).mockResolvedValue(overview);
vi.mocked(orgModuleApi.downloadSource).mockReset();
vi.mocked(orgModuleApi.startSidecar).mockReset();
vi.mocked(orgModuleApi.pack).mockReset();
vi.mocked(message.success).mockReset();
vi.mocked(message.error).mockReset();
});

it("defaults to the local embed and keeps extras behind the two status buttons", async () => {
it("embeds a multi-tab browser for local openXYOS and keeps extras behind drawers", async () => {
render(<OrganizationPage />);

await waitFor(() => {
expect(
screen.getByTitle("organization.previewTitle"),
).toBeInTheDocument();
expect(screen.getByTestId("org-mini-browser")).toBeInTheDocument();
});
const frame = screen.getByTitle("organization.previewTitle");
expect(frame).toHaveAttribute("src", "http://127.0.0.1:3780/");

const frame = screen.getByTestId("org-browser-frame");
expect(frame).toHaveAttribute(
"src",
expect.stringMatching(/^http:\/\/127\.0\.0\.1:3780\/\?freeos_embed=1/),
);
expect(screen.getByTestId("org-address-bar")).toHaveValue(
"http://127.0.0.1:3780/",
);
expect(screen.queryByText("organization.openSidecar")).toBeNull();
expect(screen.getByTestId("org-download-source")).toHaveTextContent(
"organization.downloadSourceBar",
);

expect(screen.queryByText("organization.assembleAction")).toBeNull();
expect(screen.queryByText("organization.startSidecarAction")).toBeNull();
expect(screen.queryByTestId("org-download-source-drawer")).toBeNull();
Expand All @@ -116,9 +124,7 @@ describe("OrganizationPage", () => {
await screen.findByText("organization.assembleTitle"),
).toBeInTheDocument();
expect(screen.getByText("organization.loopTitle")).toBeInTheDocument();
expect(
screen.getByTestId("org-download-source-drawer"),
).toBeInTheDocument();
expect(screen.getByTestId("org-download-source-drawer")).toBeInTheDocument();
expect(screen.queryByTestId("org-preview-blank")).toBeNull();
});

Expand Down Expand Up @@ -156,6 +162,73 @@ describe("OrganizationPage", () => {
expect(message.error).not.toHaveBeenCalled();
});

it("opens trapped window.open / host URLs as extra tabs", async () => {
render(<OrganizationPage />);
await screen.findByTestId("org-mini-browser");

expect(
tryOpenInOrgBrowser("https://github.com/XYAIStudio/openXYOS", "GitHub"),
).toBe(true);
await waitFor(() => {
expect(screen.getByTestId("org-address-bar")).toHaveValue(
"https://github.com/XYAIStudio/openXYOS",
);
});
expect(
screen
.getAllByTestId("org-browser-frame")
.some(
(node) =>
node.getAttribute("src") ===
"https://github.com/XYAIStudio/openXYOS",
),
).toBe(true);

expect(window.open("https://example.com/docs", "_blank")).toBeNull();
await waitFor(() => {
expect(screen.getByTestId("org-address-bar")).toHaveValue(
"https://example.com/docs",
);
});
});

it("navigates the active tab from the address bar", async () => {
render(<OrganizationPage />);
const address = await screen.findByTestId("org-address-bar");
fireEvent.change(address, {
target: { value: "https://example.com/path" },
});
fireEvent.submit(address.closest("form") as HTMLFormElement);
await waitFor(() => {
expect(screen.getByTestId("org-browser-frame")).toHaveAttribute(
"src",
"https://example.com/path",
);
});
});

it("auto-starts the sidecar when Organization is opened offline", async () => {
vi.mocked(orgModuleApi.overview).mockResolvedValue({
...overview,
sidecar_reachable: false,
sidecar_embed_ok: false,
start_available: true,
});
vi.mocked(orgModuleApi.startSidecar).mockResolvedValue({
started: true,
already: false,
reachable: true,
url: "http://127.0.0.1:3780",
command: "",
detail: "ok",
launcher: "",
});
render(<OrganizationPage />);
await waitFor(() => {
expect(orgModuleApi.startSidecar).toHaveBeenCalled();
});
});

it("surfaces a blank-preview error when livez is up but embed origin fails", async () => {
vi.mocked(orgModuleApi.overview).mockResolvedValue({
...overview,
Expand Down Expand Up @@ -217,9 +290,8 @@ describe("OrganizationPage", () => {
"organization.packPreviewEmployees",
);
await waitFor(() => {
expect(screen.getByTitle("organization.previewTitle")).toHaveAttribute(
"src",
expect.stringContaining("/employees"),
expect(screen.getByTestId("org-address-bar")).toHaveValue(
"http://127.0.0.1:3780/employees",
);
});
});
Expand Down
Loading
Loading