From 37517c27783ccb5fd99cda1e49ee49ba1752dc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 28 Aug 2026 13:01:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B5=8C=E5=A5=97=E6=B7=B1=E9=93=BE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E8=B7=AF=E7=94=B1=E5=8E=BB=20chrome?= =?UTF-8?q?=EF=BC=8C=E9=A1=B6=E5=B1=82=E4=B8=8D=E5=86=8D=E6=B8=85=E9=99=A4?= =?UTF-8?q?=E5=85=B1=E7=94=A8=20embed.session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免全量父页清掉老版内层深链仍依赖的 embed.session;深链问答/会商/大屏绘制由 isHostNestedSurfacePath 控制 chrome,并抽取对话历史 Tab 共享行组件。 Co-authored-by: Cursor --- .../src/core/embed/embed-session.test.ts | 51 +-- frontend-web/src/core/embed/embed-session.ts | 297 ++++++------------ .../page-layout/host-nested-surface.test.ts | 70 +++++ .../core/page-layout/host-nested-surface.ts | 21 ++ frontend-web/src/pages/PageRoutes.tsx | 18 +- .../chats-history/RoundtableHistoryTab.tsx | 179 +++++++++++ .../pages/chats-history/WritingHistoryTab.tsx | 149 +++++++++ .../chats-history/history-tab-shared.tsx | 160 ++++++++++ 8 files changed, 695 insertions(+), 250 deletions(-) create mode 100644 frontend-web/src/core/page-layout/host-nested-surface.test.ts create mode 100644 frontend-web/src/core/page-layout/host-nested-surface.ts create mode 100644 frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx create mode 100644 frontend-web/src/pages/chats-history/WritingHistoryTab.tsx create mode 100644 frontend-web/src/pages/chats-history/history-tab-shared.tsx diff --git a/frontend-web/src/core/embed/embed-session.test.ts b/frontend-web/src/core/embed/embed-session.test.ts index d3e867be..28638e42 100644 --- a/frontend-web/src/core/embed/embed-session.test.ts +++ b/frontend-web/src/core/embed/embed-session.test.ts @@ -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); }); }); diff --git a/frontend-web/src/core/embed/embed-session.ts b/frontend-web/src/core/embed/embed-session.ts index cb44090b..721d6954 100644 --- a/frontend-web/src/core/embed/embed-session.ts +++ b/frontend-web/src/core/embed/embed-session.ts @@ -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 }); } diff --git a/frontend-web/src/core/page-layout/host-nested-surface.test.ts b/frontend-web/src/core/page-layout/host-nested-surface.test.ts new file mode 100644 index 00000000..84d28786 --- /dev/null +++ b/frontend-web/src/core/page-layout/host-nested-surface.test.ts @@ -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); +}); diff --git a/frontend-web/src/core/page-layout/host-nested-surface.ts b/frontend-web/src/core/page-layout/host-nested-surface.ts new file mode 100644 index 00000000..1138699d --- /dev/null +++ b/frontend-web/src/core/page-layout/host-nested-surface.ts @@ -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; +} diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index 3901e75a..50bb0dc4 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -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% diff --git a/frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx b/frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx new file mode 100644 index 00000000..637cb801 --- /dev/null +++ b/frontend-web/src/pages/chats-history/RoundtableHistoryTab.tsx @@ -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(null); + const [renameTitle, setRenameTitle] = useState(""); + const [shareId, setShareId] = useState(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 ( +
+ 加载中… +
+ ); + } + + if (filtered.length === 0) { + return ( +
+ {keyword ? "没有匹配的会商记录" : "暂无会商记录"} +
+ ); + } + + return ( + <> + {filtered.map((draft) => { + const status = statusOf(draft); + return ( + + + · + {formatRelative(draft.updatedAt)} + + } + onOpen={() => openDraft(draft)} + > + { + setRenameId(draft.id); + setRenameTitle(draft.title || ""); + }} + > + + + setShareId(draft.id)}> + + + void handleDelete(draft)} + > + + + + ); + })} + + setRenameId(null)} + onConfirm={(title) => renameId && void handleRename(renameId, title)} + /> + + { + if (!o) setShareId(null); + }} + /> + + ); +} diff --git a/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx b/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx new file mode 100644 index 00000000..90a3ace0 --- /dev/null +++ b/frontend-web/src/pages/chats-history/WritingHistoryTab.tsx @@ -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(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 ( +
+ 加载中… +
+ ); + } + + if (filtered.length === 0) { + return ( +
+ {keyword ? "没有匹配的写作记录" : "暂无写作记录"} +
+ ); + } + + return ( + <> + {filtered.map((session) => { + const { label, color } = STATUS_LABELS[session.status] ?? { + label: session.status, + color: "text-muted-foreground", + }; + return ( + + + · + {formatRelative(session.updated_at)} + + } + onOpen={() => openSession(session)} + > + { + setRenameId(session.id); + setRenameTitle(session.title); + }} + > + + + void handleDelete(session)} + > + + + + ); + })} + + setRenameId(null)} + onConfirm={(title) => renameId && void handleRename(renameId, title)} + /> + + ); +} diff --git a/frontend-web/src/pages/chats-history/history-tab-shared.tsx b/frontend-web/src/pages/chats-history/history-tab-shared.tsx new file mode 100644 index 00000000..eefa49b3 --- /dev/null +++ b/frontend-web/src/pages/chats-history/history-tab-shared.tsx @@ -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 ( + {label} + ); +} + +/** + * 单条历史记录行。onOpen 由各 Tab 提供(多数是暂存 sessionStorage 后跳转 + * 对应工作台,由页面挂载时消费),操作按钮经 children 传入。 + */ +export function HistoryRow({ + title, + meta, + onOpen, + children, +}: { + title: ReactNode; + meta: ReactNode; + onOpen: () => void; + children?: ReactNode; +}) { + return ( +
+ + {/* Row actions — kept out of the open button so clicks don't trigger it. */} +
+ {children} +
+
+ ); +} + +/** 重命名 / 删除图标操作按钮(与「对话」Tab 的收藏/置顶按钮同规格)。 */ +export function HistoryRowIconButton({ + title, + onClick, + children, +}: { + title: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +export function RenameIcon() { + return ; +} + +export function DeleteIcon() { + return ; +} + +/** + * 自包含重命名弹窗:打开时用 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 ( + (o ? undefined : onCancel())}> + + + {dialogTitle} + +
+ setValue(e.target.value)} + placeholder="输入新名称" + onKeyDown={(e) => { + if (e.key === "Enter" && !isIMEComposing(e)) { + e.preventDefault(); + if (value.trim()) onConfirm(value.trim()); + } + }} + /> +
+ + + + +
+
+ ); +}