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

+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>
);
}