fix: 嵌套深链改为按路由去 chrome,顶层不再清除共用 embed.session
避免全量父页清掉老版内层深链仍依赖的 embed.session;深链问答/会商/大屏绘制由 isHostNestedSurfacePath 控制 chrome,并抽取对话历史 Tab 共享行组件。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user