圆桌第二步支持按席位筛选会商记录,左侧角色卡与消息头像联动。

新增 seat-filter 归一化匹配逻辑,点击角色卡或气泡头像可只看该席位发言,并支持一键显示全部。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-12 16:36:58 +08:00
1 parent 9002ae157a
commit dd329e7a4f
3 files changed
+350 -36

No files matched your search

@@ -68,6 +68,12 @@ import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeat
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
seatFilterForSender,
seatFilterLabel,
senderMatchesSeatFilter,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import type {
AgentConfig,
ParallelSeatView,
@@ -100,6 +106,10 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
// ─── 并行显示(§5)────────────────────────────────────────────
parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[];
@@ -161,6 +171,8 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
seatFilter,
setSeatFilter,
parallelBatch,
selectedParallelSeats,
isParallelRunning,
@@ -201,6 +213,36 @@ export function Step2Panel(props: Step2PanelProps) {
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
// ─── 按席位筛选会商记录 ──────────────────────────────────────────────────
// 筛选状态在页面层(与左侧「参与角色 / 人工参与」卡片共用)。点卡片或点气泡头像
// 都把目标归一化成同一 SeatFilter 标识(见 lib/seat-filter),命中 = 标识相等。
// 仅在单列(非并行)视图启用;并行进行时主区另有「头像条选席位」机制,互不干扰。
const toggleSeatFilter = useCallback(
(filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter)),
[setSeatFilter],
);
const visibleDialogues = useMemo(
() =>
seatFilter
? step2RoundtableDialogues.filter((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
: step2RoundtableDialogues,
[seatFilter, step2RoundtableDialogues],
);
// 选中的席位若已不在对话里(切换草稿 / 重置后),自动清除筛选,避免卡在空列表。
useEffect(() => {
if (
seatFilter &&
!step2RoundtableDialogues.some((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
) {
setSeatFilter(null);
}
}, [seatFilter, step2RoundtableDialogues, setSeatFilter]);
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
@@ -485,20 +527,55 @@ export function Step2Panel(props: Step2PanelProps) {
})}
</div>
) : (
step2RoundtableDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
/>
))
<>
{/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */}
{seatFilter && (
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between gap-2 rounded-lg border border-primary/20 bg-[var(--brand-200)]/20 px-3 py-1.5 backdrop-blur-sm">
<span className="roundtable-chat-meta font-medium text-slate-600">
仅显示{" "}
<span className="font-bold text-primary">
{seatFilterLabel(seatFilter)}
</span>{" "}
的发言
</span>
<button
type="button"
onClick={() => setSeatFilter(null)}
className="roundtable-chat-meta inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 font-semibold text-primary transition hover:bg-primary/10"
>
<X className="size-3.5" />
显示全部
</button>
</div>
)}
{seatFilter && visibleDialogues.length === 0 ? (
<div className="py-10 text-center text-xs text-slate-400">
没有匹配「{seatFilterLabel(seatFilter)}」的消息
</div>
) : (
visibleDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
isAvatarSelected={
seatFilter !== null &&
senderMatchesSeatFilter(msg.sender, seatFilter)
}
onAvatarClick={() =>
toggleSeatFilter(seatFilterForSender(msg.sender))
}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
/>
))
)}
</>
)}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
@@ -706,6 +783,13 @@ export interface Step2MessageProps {
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
/**
* 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等
* 场景)则头像为纯展示,保持原样。
*/
onAvatarClick?: () => void;
/** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */
isAvatarSelected?: boolean;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: (
@@ -718,6 +802,8 @@ export interface Step2MessageProps {
export function Step2Message({
msg,
metric,
onAvatarClick,
isAvatarSelected = false,
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
@@ -732,9 +818,32 @@ export function Step2Message({
const hasContent = content.length > 0;
const presentedFiles = collectPresentedFiles(msg.steps);
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
const avatar = onAvatarClick ? (
<button
type="button"
onClick={onAvatarClick}
aria-pressed={isAvatarSelected}
title={
isAvatarSelected
? "取消筛选,显示全部发言"
: `只看 ${msg.sender} 的发言`
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
isAvatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
)}
>
{avatarInner}
</button>
) : (
avatarInner
);
return (
<MessageBubble
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
avatar={avatar}
senderName={msg.sender}
badge={msg.confidence}
time={msg.time}
@@ -0,0 +1,63 @@
/**
* 「按席位筛选会商记录」的匹配逻辑 —— 第二步左侧「参与角色 / 人工参与」卡片
* 与消息气泡头像共用同一套标识。
*
* 设计要点:把**任意一条气泡的 sender** 和**任意一张卡片**都归一化成同一个
* `SeatFilter` 字符串标识,于是「某气泡命中当前筛选」就等价于「两边标识相等」,
* 卡片点击与头像点击不再各写一套匹配规则。
*
* 三类标识:
* - `"coordinator"` —— 总控协调 / 系统 调度播报(对应「指挥员 / 主持人」卡片)
* - `"user"` —— 用户插话 / 澄清回复(对应「人工参与 · 用户」卡片)
* - `"agent:<显示名>"` —— 某 AI 席位的发言(对应该席位卡片)
*/
import { COMMANDER_SEAT_ID, type RoleEntry } from "./roundtable-constants";
export type SeatFilter = string;
/** 总控协调气泡固定使用的 sender(见 useStep2Orchestration 的 appendStreamingDialogue)。 */
export const COORDINATOR_SENDER = "总控协调";
export const COORDINATOR_SEAT_FILTER: SeatFilter = "coordinator";
export const USER_SEAT_FILTER: SeatFilter = "user";
const AGENT_PREFIX = "agent:";
/** 把一条气泡的 sender 归一化成筛选标识。 */
export function seatFilterForSender(sender: string): SeatFilter {
if (sender === COORDINATOR_SENDER || sender === "系统") {
return COORDINATOR_SEAT_FILTER;
}
if (sender.startsWith("用户")) {
return USER_SEAT_FILTER;
}
// 去掉「方案设计 (deepseek-…)」这种「模型偏好已保存」播报的模型后缀,回到席位基名,
// 让它与该席位的正常发言归到同一标识。
const base = sender.replace(/\s*\([^)]*\)\s*$/, "").trim();
return `${AGENT_PREFIX}${base}`;
}
/** 把「参与角色」里的一行(席位)映射成筛选标识。 */
export function seatFilterForRole(role: RoleEntry): SeatFilter {
// 指挥员 / 主持人(COMMANDER_SEAT_ID,human):对应总控协调 / 系统 调度播报流。
if (role.type === "human" || role.id === COMMANDER_SEAT_ID) {
return COORDINATOR_SEAT_FILTER;
}
return `${AGENT_PREFIX}${role.name}`;
}
/** 某 sender 是否命中当前筛选。 */
export function senderMatchesSeatFilter(
sender: string,
filter: SeatFilter,
): boolean {
return seatFilterForSender(sender) === filter;
}
/** 筛选提示条用的人类可读标签。 */
export function seatFilterLabel(filter: SeatFilter): string {
if (filter === COORDINATOR_SEAT_FILTER) return "指挥员 / 主持人";
if (filter === USER_SEAT_FILTER) return "用户";
if (filter.startsWith(AGENT_PREFIX)) return filter.slice(AGENT_PREFIX.length);
return filter;
}
@@ -86,6 +86,11 @@ import {
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import {
seatFilterForRole,
USER_SEAT_FILTER,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import {
buildChainSeatSelection,
type SeatSelectionResult,
@@ -215,6 +220,8 @@ export default function RoundtablePlanningPage() {
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
// 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。
const [seatFilter, setSeatFilter] = useState<SeatFilter | null>(null);
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
@@ -1153,6 +1160,8 @@ export default function RoundtablePlanningPage() {
step2Runs={step2Runs}
activeStep2RunId={activeStep2RunId}
onSelectStep2Run={handleSelectStep2Run}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
/>
{/* MIDDLE VIEWPORT */}
@@ -1216,6 +1225,8 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId}
roles={roles}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
parallelBatch={step2.parallelBatch}
selectedParallelSeats={step2.selectedParallelSeats}
isParallelRunning={step2.isParallelRunning}
@@ -1623,6 +1634,9 @@ interface LeftSidebarProps {
step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void;
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
}
function Step2SandboxSidebarSync({
@@ -1662,8 +1676,13 @@ function LeftSidebar({
step2Runs,
activeStep2RunId,
onSelectStep2Run,
seatFilter,
setSeatFilter,
}: LeftSidebarProps) {
const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
@@ -1708,13 +1727,25 @@ function LeftSidebar({
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
</div>
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => (
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
))}
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleRow
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</div>
<HumanParticipationCard />
<HumanParticipationCard
active={seatFilter === USER_SEAT_FILTER}
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
/>
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
@@ -1730,20 +1761,41 @@ function LeftSidebar({
<CollapsedSidebarSection title="参与角色">
<div className="flex flex-col items-center gap-2">
{roles.map((role) => (
<RoleAvatarBadge
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
size="h-8 w-8"
showSpeakingAnimation
/>
))}
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleAvatarBadge
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
size="h-8 w-8"
showSpeakingAnimation
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="人工参与">
<Man1Avatar size="h-8 w-8" />
<button
type="button"
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
aria-pressed={seatFilter === USER_SEAT_FILTER}
title={
seatFilter === USER_SEAT_FILTER
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
seatFilter === USER_SEAT_FILTER &&
"ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
<Man1Avatar size="h-8 w-8" />
</button>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="任务概览">
@@ -1897,14 +1949,50 @@ function TaskOverviewCard({ currentStep }: { currentStep: number }) {
);
}
function HumanParticipationCard() {
function HumanParticipationCard({
active = false,
onSelect,
}: {
/** 当前筛选是否命中「用户」(命中则高亮)。 */
active?: boolean;
/** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */
onSelect?: () => void;
} = {}) {
const clickable = Boolean(onSelect);
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
</div>
<div className="flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3">
<div
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
)}
>
<div className="flex min-w-0 items-center gap-3">
<Man1Avatar size="h-10 w-10" />
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
@@ -2318,6 +2406,8 @@ function RoleAvatarBadge({
agentNameToRole,
size = "h-7 w-7",
showSpeakingAnimation = false,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
@@ -2326,16 +2416,21 @@ function RoleAvatarBadge({
>;
size?: string;
showSpeakingAnimation?: boolean;
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
active?: boolean;
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
onSelect?: () => void;
}) {
const isOngoing = role.status === "研讨中";
const animate = showSpeakingAnimation && isOngoing;
const clickable = Boolean(onSelect);
return (
const inner = (
<div
title={`${role.name} · ${role.status}`}
className={cn(
"relative shrink-0 rounded-full",
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
{animate && (
@@ -2346,33 +2441,80 @@ function RoleAvatarBadge({
</div>
</div>
);
if (!clickable) {
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
}
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
{inner}
</button>
);
}
function RoleRow({
role,
agentNameToRole,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
active?: boolean;
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
onSelect?: () => void;
}) {
const isActive =
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
const isOngoing = role.status === "研讨中";
const isBootingUp = role.status === "正在上线";
const clickable = Boolean(onSelect);
return (
<div
id={`role-item-${role.id}`}
className={`flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition ${
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: `只看 ${role.name} 的发言`
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
? "bg-amber-50/60 border border-amber-100"
: "opacity-60"
}`}
: "opacity-60",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active &&
"ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100",
)}
>
<div className="flex min-w-0 items-center gap-3">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />