fix: 嵌套深链改为按路由去 chrome,顶层不再清除共用 embed.session

避免全量父页清掉老版内层深链仍依赖的 embed.session;深链问答/会商/大屏绘制由 isHostNestedSurfacePath 控制 chrome,并抽取对话历史 Tab 共享行组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-28 13:01:46 +08:00
1 parent 1764d0d5f2
commit 37517c2778
8 files changed
+695 -250

No files matched your search

@@ -1,6 +1,5 @@
/**
* embed-session.ts 浏览上下文隔离单测。
* 运行方式(项目无 vitest):
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
* node --test .tmp-tests/embed-session.test.mjs
@@ -82,14 +81,11 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
asTop();
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
asIframe();
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
assert.equal(isEmbedActive(), true);
assert.equal(isChromeHidden(), true);
assert.ok(storage.getItem("embed.session.nested"));
assert.equal(storage.getItem("embed.session"), null);
asTop();
assert.equal(isEmbedActive(), false);
@@ -97,11 +93,9 @@ test("nested embed=1 does not hide chrome on the same-origin top window", () =>
});
});
test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
test("top ignores old nested embed.session without clearing it", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
syncEmbedSessionFromSearch("");
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
storage.setItem(
"embed.session",
JSON.stringify({
@@ -113,15 +107,14 @@ test("top parent reclaims pollution from an old nested iframe (no topOwned)", ()
isolate: false,
}),
);
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
// 内层老版仍要读到 embed=true
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
});
});
test("top sync after old nested pollution does not mark owner", () => {
test("top sync does not wipe unowned embed.session", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
@@ -137,12 +130,11 @@ test("top sync after old nested pollution does not mark owner", () => {
);
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
});
});
test("nested iframe reclaims a polluted shared embed.session key", () => {
test("nested iframe leaves the shared embed.session for old inner pages", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
@@ -159,11 +151,11 @@ test("nested iframe reclaims a polluted shared embed.session key", () => {
asIframe();
syncEmbedSessionFromSearch("?embed=1");
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
assert.ok(storage.getItem("embed.session.nested"));
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
@@ -184,31 +176,7 @@ test("nested iframe keeps the top window's own embed session", () => {
});
});
test("nested reclaim strips embed but keeps top isolate", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
test("top reclaim strips embed but keeps isolate without nested help", () => {
test("top ignore strips chrome in memory but keeps isolate", () => {
withSharedTab(({ storage, asTop }) => {
asTop();
storage.setItem(
@@ -224,5 +192,6 @@ test("top reclaim strips embed but keeps isolate without nested help", () => {
);
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
assert.equal(JSON.parse(storage.getItem("embed.session") || "{}").embed, true);
});
});
+98 -199
View File
@@ -16,34 +16,17 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
* 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate`
* 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。
*
* 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏;
* `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。
*
* 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响):
* - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage
* (跨标签共享 → 会污染正常页)。
* - 主题走 next-themes 的 `forcedTheme`(见 App.tsx `AppProviders`)——只在本标签页生效、
* 不写 localStorage `strategy-theme`;嵌入侧绝不调 `setTheme`。
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
* localStorage 偏好。
*
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
* 路由看起来却没变。因此:
* - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`;
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
* 同源陷阱:sessionStorage 按「顶层标签页 × origin」分区,同一标签里所有同源文档共用一份。
* 全量父页套外部系统、外部再套咱们老版 `embed=1` 深链时,内层会写共用的 `embed.session`。
* **禁止**在顶层父页清掉这份键——内层老版还靠它藏导航栏。顶层只在「本窗自己从 URL 认领」时
* 才把 embed 当成自己的;否则读取时忽略 chrome 标志、且不写回存储。
* 嵌套新版写 `embed.session.nested`,与顶层键分开。深链问答/会商/大屏绘制另外按路由去 chrome
* (见 `isHostNestedSurfacePath`),不依赖这份会话。
*/
const EMBED_SESSION_KEY = "embed.session";
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
/**
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
*/
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
@@ -61,7 +44,6 @@ function getSessionStorage(): Storage | null {
}
}
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
function isTopLevelWindow(): boolean {
const w = getWindow();
if (!w) return true;
@@ -78,28 +60,14 @@ function embedSessionKey(): string {
export type EmbedTheme = "light" | "dark";
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
export interface EmbedSession {
/** 嵌入模式:去全局 chrome + 强制主题。 */
embed: boolean;
/** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */
theme: EmbedTheme;
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
hideJump: boolean;
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
hideHeader: boolean;
/**
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
* 一个干净的全屏页面(如「大屏绘制」`?hideChrome=1`),其余行为与正常标签页完全一致。
*/
hideChrome: boolean;
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
isolate: boolean;
/**
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
*/
/** 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省表示可能是同源嵌套页写的共用键。 */
topOwned?: boolean;
}
@@ -142,49 +110,12 @@ function emptySession(): EmbedSession {
};
}
function stripChromeFlags(session: EmbedSession): EmbedSession {
return {
...session,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
}
function persistTopSession(next: EmbedSession): void {
const store = getSessionStorage();
if (!store) return;
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(EMBED_SESSION_KEY);
return;
}
const payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
hideHeader: next.hideHeader,
hideChrome: next.hideChrome,
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
}
function isTopChromeOwned(session?: EmbedSession | null): boolean {
const w = getWindow();
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
return session?.topOwned === true;
}
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
function markTopChromeOwner(session: EmbedSession): void {
const w = getWindow();
if (!w || !isTopLevelWindow()) return;
@@ -192,31 +123,25 @@ function markTopChromeOwner(session: EmbedSession): void {
w[TOP_CHROME_OWNER_FLAG] = true;
}
/**
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
*/
function reclaimUnownedTopChrome(): void {
if (!isTopLevelWindow()) return;
function readStoredSession(): EmbedSession | null {
try {
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur || !sessionHidesChrome(cur)) return;
if (isTopChromeOwned(cur)) {
const w = getWindow();
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
return;
}
persistTopSession(stripChromeFlags(cur));
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
/* noop */
return null;
}
}
function readSharedTopSession(): EmbedSession | null {
try {
return parseEmbedSession(getSessionStorage()?.getItem(EMBED_SESSION_KEY) ?? null);
} catch {
return null;
}
}
/**
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
* 旧版嵌套页写过共用键时,新版嵌套页复制一份到 nested 键自己用;**不删除**共用键,
* 避免老版内层丢掉 embed 后把导航栏放出来。
*/
function migrateLegacySharedSessionIfNested(): void {
if (isTopLevelWindow()) return;
@@ -230,30 +155,12 @@ function migrateLegacySharedSessionIfNested(): void {
}
}
function reclaimPollutedTopChrome(): void {
if (isTopLevelWindow()) return;
try {
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur) return;
// 顶层已认领(内存标记或 session.topOwned)则不动。
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
persistTopSession(stripChromeFlags(cur));
} catch {
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
}
}
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
type FlagParam = "on" | "off" | null;
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
}
/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */
function readFlagParam(search: string, key: string): FlagParam {
const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase();
if (!v) return null;
@@ -262,32 +169,26 @@ function readFlagParam(search: string, key: string): FlagParam {
return null;
}
/** 读 URL 里的 `embed` 三态。 */
export function readEmbedParam(search: string): FlagParam {
return readFlagParam(search, "embed");
}
/** 读 URL 里的 `hideJump` 三态。 */
export function readHideJumpParam(search: string): FlagParam {
return readFlagParam(search, "hideJump");
}
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
export function readHideHeaderParam(search: string): FlagParam {
return readFlagParam(search, "hideHeader");
}
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
export function readHideChromeParam(search: string): FlagParam {
return readFlagParam(search, "hideChrome");
}
/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */
export function readIsolateParam(search: string): FlagParam {
return readFlagParam(search, "isolate");
}
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim();
}
@@ -301,34 +202,32 @@ function searchEnablesChromeHide(search: string): boolean {
);
}
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
/**
* 读本浏览上下文的嵌入会话。顶层若看到无主(非 topOwned)的 chrome 标志,视为同源
* 嵌套页写的,**读取时忽略、不写回**,以免清掉内层老版仍在用的 `embed.session`。
*/
export function readEmbedSession(): EmbedSession | null {
try {
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
reclaimUnownedTopChrome();
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
return null;
const s = readStoredSession();
if (!s) return null;
if (isTopLevelWindow() && sessionHidesChrome(s) && !isTopChromeOwned(s)) {
return {
...s,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
topOwned: false,
};
}
return s;
}
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
const store = getSessionStorage();
if (!store) return;
const key = embedSessionKey();
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(key);
return;
}
const payload: EmbedSession = {
let payload: EmbedSession = {
embed: next.embed,
theme: next.theme,
hideJump: next.hideJump,
@@ -337,37 +236,56 @@ function writeEmbedSession(next: EmbedSession): void {
isolate: next.isolate,
};
if (next.topOwned) payload.topOwned = true;
// 顶层未认领时,写 isolate 等字段不得覆盖内层老版仍占用的 chrome 标志。
if (
isTopLevelWindow() &&
key === EMBED_SESSION_KEY &&
!payload.topOwned &&
getWindow()?.[TOP_CHROME_OWNER_FLAG] !== true
) {
const shared = readSharedTopSession();
if (shared && sessionHidesChrome(shared) && !shared.topOwned) {
payload = {
...payload,
embed: shared.embed,
theme: shared.theme,
hideJump: shared.hideJump,
hideHeader: shared.hideHeader,
hideChrome: shared.hideChrome,
};
}
}
if (
!payload.embed &&
!payload.hideJump &&
!payload.hideHeader &&
!payload.hideChrome &&
!payload.isolate
) {
store.removeItem(key);
return;
}
store.setItem(key, JSON.stringify(payload));
} catch {
/* noop */
}
}
/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */
export function isEmbedActive(): boolean {
return readEmbedSession()?.embed === true;
}
/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */
export function isJumpBarHidden(): boolean {
return readEmbedSession()?.hideJump === true;
}
/**
* Whether this tab should hide its global Header.
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
* deliberately leaves PageSidebar visible.
*/
export function isHeaderHidden(): boolean {
const session = readEmbedSession();
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
}
/**
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
* 与 `isEmbedActive()` 的差别:`hideChrome` 不强制主题、不隔离鉴权,只去 chrome。
*/
export function isChromeHidden(): boolean {
const s = readEmbedSession();
return s?.embed === true || s?.hideChrome === true;
@@ -377,28 +295,38 @@ export function isHideChromeActive(): boolean {
return readEmbedSession()?.hideChrome === true;
}
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
* 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。
*/
export function isIsolatedSession(): boolean {
return readEmbedSession()?.isolate === true;
}
/**
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
* 之前置位的开关不会被抹掉。
*
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
*/
export function syncEmbedSessionFromSearch(search: string): void {
migrateLegacySharedSessionIfNested();
if (isTopLevelWindow()) reclaimUnownedTopChrome();
if (!isTopLevelWindow()) {
migrateLegacySharedSessionIfNested();
applySearchToSession(search, readStoredSession() ?? emptySession(), false);
return;
}
const cur = readEmbedSession() ?? emptySession();
const stored = readStoredSession() ?? emptySession();
if (searchEnablesChromeHide(search)) {
applySearchToSession(search, stored, true);
return;
}
if (isTopChromeOwned(stored)) {
applySearchToSession(search, stored, true);
return;
}
// 无主共用键:只处理 isolate URL,绝不把 chrome 标志写成 false 覆盖内层。
const isolateParam = readIsolateParam(search);
if (isolateParam === null) return;
const isolate = isolateParam === "on";
writeEmbedSession({ ...stored, isolate, topOwned: false });
}
function applySearchToSession(
search: string,
cur: EmbedSession,
topOwned: boolean,
): void {
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -425,27 +353,11 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (isolateParam === "on") next.isolate = true;
else if (isolateParam === "off") next.isolate = false;
if (isTopLevelWindow()) {
if (searchEnablesChromeHide(search)) {
next.topOwned = true;
} else if (!isTopChromeOwned(next)) {
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
} else {
next.topOwned = true;
}
}
next.topOwned = topOwned;
writeEmbedSession(next);
if (isTopLevelWindow()) markTopChromeOwner(next);
else reclaimPollutedTopChrome();
if (topOwned) markTopChromeOwner(next);
}
/**
* 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。
* 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入,
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
*/
function currentHashSearch(): string {
const w = getWindow();
if (!w) return "";
@@ -454,18 +366,10 @@ function currentHashSearch(): string {
return i >= 0 ? hash.slice(i) : "";
}
/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */
export function syncEmbedSessionFromCurrentUrl(): void {
syncEmbedSessionFromSearch(currentHashSearch());
}
/**
* 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用:
* - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁);
* - URL 显式 `embed=0` → 不强制(undefined);
* - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。
* 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。
*/
export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined {
const param = readEmbedParam(search);
if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(search));
@@ -474,12 +378,7 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
return session?.embed ? session.theme : undefined;
}
/**
* 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。
* 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]]
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
*/
export function setIsolatedSession(on: boolean): void {
const cur = readEmbedSession() ?? emptySession();
writeEmbedSession({ ...cur, isolate: on });
const stored = readStoredSession() ?? emptySession();
writeEmbedSession({ ...stored, isolate: on });
}
@@ -0,0 +1,70 @@
/**
* host-nested-surface 深链嵌套页路径判定。
* npx esbuild src/core/page-layout/host-nested-surface.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/host-nested-surface.test.mjs
* node --test .tmp-tests/host-nested-surface.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import { isHostNestedSurfacePath } from "./host-nested-surface";
test("matches deeplink single-agent workspaces", () => {
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task-sentiment/agent/abc/chats",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-fx/agent/abc/chats/thread-1",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-action/agent/abc/chats/new",
),
true,
);
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task-agentfx/agent/agentfx-analyst/chats",
),
true,
);
});
test("matches deeplink roundtable and dashboard-agent", () => {
assert.equal(
isHostNestedSurfacePath(
"/page/strategy/task-management/plan-task/plan-task-fx/roundtable",
),
true,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/dashboard-agent"),
true,
);
});
test("does not match full-mode pages we use ourselves", () => {
assert.equal(
isHostNestedSurfacePath("/page/strategy/task-management/xk-task-jc"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/task-management/plan-task-sentiment"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/strategy/qa/multi-agent-roundtable"),
false,
);
assert.equal(
isHostNestedSurfacePath("/page/workspace/roundtable/planning"),
false,
);
assert.equal(isHostNestedSurfacePath("/page/workspace/chats/new"), false);
});
@@ -0,0 +1,21 @@
/**
* 外部系统 iframe 深链套用的页面:单智能体问答、多智能体会商、大屏绘制。
* 这些路由只给宿主嵌套,不走全量 Header / PageSidebar;chrome 由路径决定,
* 不依赖(也不改写)同源 iframe 共用的 `embed.session`。
*/
/** 任务管理下的深链会商(goPath=`*-A`)。 */
const TASK_ROUNDTABLE_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action)\/roundtable(?:\/|$)/;
/** 任务管理下的深链单智能体问答(含 agentfx)。 */
const TASK_AGENT_CHATS_RE =
/\/task-management\/(?:plan-task-sentiment|plan-task\/plan-task-fx|plan-task\/plan-task-action|plan-task-agentfx)\/agent\/[^/]+\/chats/;
export function isHostNestedSurfacePath(pathname: string): boolean {
const path = (pathname || "").split("?")[0] || "";
if (path.includes("/strategy/dashboard-agent")) return true;
if (TASK_ROUNDTABLE_RE.test(path)) return true;
if (TASK_AGENT_CHATS_RE.test(path)) return true;
return false;
}
+8 -10
View File
@@ -16,6 +16,7 @@ import {
readHideHeaderParam,
readHideChromeParam,
} from "@/core/embed/embed-session";
import { isHostNestedSurfacePath } from "@/core/page-layout/host-nested-surface";
import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
@@ -91,23 +92,20 @@ function PageLayout() {
location.pathname.startsWith("/page/workspace/chats/")
);
}, [location.pathname, location.search]);
// 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页,
// 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
const forceChromeForDashboardAgent =
!forceCollapsedSidebar && location.pathname.includes("/strategy/dashboard-agent");
// 深链嵌套页(单智能体问答 / 多智能体会商 / 大屏绘制)只给宿主 iframe 套用,
// 路径上就不带全局 Header / PageSidebar,不靠改写共用 embed.session。
const hostNestedSurface = isHostNestedSurfacePath(location.pathname);
const pageSidebarAllowed =
(forceChromeForDashboardAgent || chromeByPreference) &&
chromeByPreference &&
!isChatsIframeWorkspacePath(location.pathname) &&
!embedActive &&
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
!chromeHidden;
!chromeHidden &&
!hostNestedSurface;
const compactToolboxAllowed =
!chromeByPreference &&
!forceChromeForDashboardAgent &&
!embedActive &&
!chromeHidden &&
!hostNestedSurface &&
!isChatsIframeWorkspacePath(location.pathname);
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
@@ -0,0 +1,179 @@
"use client";
/**
* 「对话历史」页 —— 多智能体会商 Tab。
*
* 数据与原侧栏「多智能体会商」列表同源(当前用户的个人草稿 roundtable_drafts,
* useRoundtableDrafts 不带 taskId);点击行为也一致:暂存 sessionStorage
* 「roundtable:pending-draft」后跳转会商页,由页面挂载时消费并加载该草稿。
* 任务维度会商(task-draft,按 taskId 全任务共享、不分权)不随个人历史走,
* 仍只在对应任务工作区里查看。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Share2 } from "lucide-react";
import { toast } from "sonner";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
RenameIcon,
} from "./history-tab-shared";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
/** 草稿后台作业状态 → 小标签(映射与原侧栏列表一致)。 */
function statusOf(draft: DraftMeta): { label: string; className: string } {
if (draft.status === "running" || draft.status === "queued") {
return { label: "进行中", className: "text-primary" };
}
if (draft.status === "awaiting_input") {
return { label: "待澄清", className: "text-amber-500" };
}
if (draft.status === "error" || draft.status === "cancelled") {
return { label: "出错", className: "text-rose-500" };
}
// 无进行中作业时展示本次会商所用业务链(无链条 → 自由模式)。
return { label: draft.chainTitle || "自由模式", className: "text-muted-foreground" };
}
export function RoundtableHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const [shareId, setShareId] = useState<string | null>(null);
useEffect(() => {
void refresh();
}, [refresh]);
const keyword = query.trim().toLowerCase();
const filtered = keyword
? drafts.filter((draft) =>
(draft.title || "未命名草稿").toLowerCase().includes(keyword),
)
: drafts;
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
const openDraft = (draft: DraftMeta) => {
sessionStorage.setItem("roundtable:pending-draft", draft.id);
navigate(ROUNDTABLE_PATH);
};
const handleRename = async (id: string, title: string) => {
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
toast.success("已删除会商记录");
} catch {
toast.error("删除失败,请重试");
}
};
if (loading && drafts.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的会商记录" : "暂无会商记录"}
</div>
);
}
return (
<>
{filtered.map((draft) => {
const status = statusOf(draft);
return (
<HistoryRow
key={draft.id}
title={desensitize(draft.title || "未命名草稿")}
meta={
<>
<HistoryStatusPill
label={status.label}
className={status.className}
/>
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(draft.updatedAt)}</span>
</>
}
onOpen={() => openDraft(draft)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(draft.id);
setRenameTitle(draft.title || "");
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton title="分享" onClick={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground size-4" />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(draft)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名会商"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(null);
}}
/>
</>
);
}
@@ -0,0 +1,149 @@
"use client";
/**
* 「对话历史」页 —— AI写作 Tab。
*
* 数据与原侧栏「最近写作」列表同源(useAIWritingSessions);点击行为一致:
* 暂存 sessionStorage「ai-writing:pending-session」后跳转 AI 写作页,由
* AIWritingPanel 挂载时消费并打开该会话。
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import {
formatRelative,
STATUS_LABELS,
} from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import type { AIWritingSessionSummary } from "@/open-canvas/api/ai-writing-sessions";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
RenameIcon,
} from "./history-tab-shared";
const AI_WRITING_PATH = "/page/workspace/ai-writing";
export function WritingHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { sessions, loading, refresh, rename, remove } = useAIWritingSessions();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const keyword = query.trim().toLowerCase();
const filtered = keyword
? sessions.filter((session) => session.title.toLowerCase().includes(keyword))
: sessions;
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
const openSession = (session: AIWritingSessionSummary) => {
sessionStorage.setItem("ai-writing:pending-session", JSON.stringify(session));
navigate(AI_WRITING_PATH);
};
const handleRename = async (id: string, title: string) => {
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (session: AIWritingSessionSummary) => {
if (!window.confirm("确定删除这条写作记录吗?此操作不可恢复。")) return;
try {
await remove(session.id);
toast.success("已删除写作记录");
} catch {
toast.error("删除失败,请重试");
}
};
if (loading && sessions.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的写作记录" : "暂无写作记录"}
</div>
);
}
return (
<>
{filtered.map((session) => {
const { label, color } = STATUS_LABELS[session.status] ?? {
label: session.status,
color: "text-muted-foreground",
};
return (
<HistoryRow
key={session.id}
title={desensitize(session.title)}
meta={
<>
<HistoryStatusPill label={label} className={color} />
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(session.updated_at)}</span>
</>
}
onOpen={() => openSession(session)}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(session.id);
setRenameTitle(session.title);
}}
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(session)}
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名写作"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
</>
);
}
@@ -0,0 +1,160 @@
"use client";
/**
* 「对话历史」页(/page/workspace/chats)业务历史 Tab(多智能体会商 / AI写作 /
* 深度研究)的共享小组件。行样式与「对话」Tab 保持一致:标题行 + 次行元信息 +
* 悬浮操作按钮,让四个 Tab 在同一页里观感统一。
*/
import { useEffect, useState, type ReactNode } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
/** 次行状态小标签(对齐侧栏列表的状态药丸配色)。 */
export function HistoryStatusPill({
label,
className,
}: {
label: string;
className?: string;
}) {
return (
<span className={cn("text-[10px] font-medium", className)}>{label}</span>
);
}
/**
* 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转
* 对应工作台,由页面挂载时消费),操作按钮经 children 传入。
*/
export function HistoryRow({
title,
meta,
onOpen,
children,
}: {
title: ReactNode;
meta: ReactNode;
onOpen: () => void;
children?: ReactNode;
}) {
return (
<div className="group/chat-row relative flex flex-col gap-2 border-b">
<button
type="button"
onClick={onOpen}
className="block w-full p-4 pr-24 text-left"
>
<div className="flex items-center gap-1.5">
<span className="truncate">{title}</span>
</div>
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-sm">
{meta}
</div>
</button>
{/* Row actions — kept out of the open button so clicks don't trigger it. */}
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
{children}
</div>
</div>
);
}
/** 重命名 / 删除图标操作按钮(与「对话」Tab 的收藏/置顶按钮同规格)。 */
export function HistoryRowIconButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className="size-8"
title={title}
aria-label={title}
onClick={onClick}
>
{children}
</Button>
);
}
export function RenameIcon() {
return <Pencil className="text-muted-foreground size-4" />;
}
export function DeleteIcon() {
return <Trash2 className="text-muted-foreground size-4" />;
}
/**
* 自包含重命名弹窗:打开时用 initialTitle 初始化输入框,确认时回调新标题。
* 三个 Tab 的重命名接口都是「(id, title) → Promise」,由调用方闭包绑定 id。
*/
export function HistoryRenameDialog({
open,
initialTitle,
dialogTitle,
onCancel,
onConfirm,
}: {
open: boolean;
initialTitle: string;
dialogTitle: string;
onCancel: () => void;
onConfirm: (title: string) => void;
}) {
const [value, setValue] = useState(initialTitle);
useEffect(() => {
if (open) setValue(initialTitle);
}, [open, initialTitle]);
return (
<Dialog open={open} onOpenChange={(o) => (o ? undefined : onCancel())}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
if (value.trim()) onConfirm(value.trim());
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button disabled={!value.trim()} onClick={() => onConfirm(value.trim())}>
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}