fix: 节流会商流式写文件预览,避免 Maximum update depth

write_file 的 content 在每个 SSE chunk 中持续增长,原先会反复触发 React 状态更新并打断整页;现改为按身份入参去重、节流发布 stub,并收口第 3 步为 Markdown 报告工作台。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-18 23:23:46 +08:00
1 parent dcfaa199bf
commit 30799f17be
14 files changed
+644 -703

No files matched your search

@@ -155,6 +155,7 @@ export function ArtifactFileDetail({
onDownloadClick,
toolbarActions,
headerLeadingAction,
headerLeadingActionBare = false,
researchSessionId,
}: {
className?: string;
@@ -192,6 +193,11 @@ export function ArtifactFileDetail({
toolbarActions?: ReactNode;
/** Replaces the left file-type badge for an embedded workspace action. */
headerLeadingAction?: ReactNode;
/**
* 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。
* 默认保留原有徽标容器,避免影响普通聊天和产物预览。
*/
headerLeadingActionBare?: boolean;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
@@ -1205,7 +1211,13 @@ export function ArtifactFileDetail({
<Artifact className={cn(className)}>
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
<div
className={cn(
"inline-flex min-w-0 items-center gap-2",
!headerLeadingActionBare &&
"bg-muted/65 rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8",
)}
>
{headerLeadingAction ?? (
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
{fileTypeLabel}
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
children: ReactNode;
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : [];
setArtifactsState((previous) =>
previous.length === normalized.length &&
previous.every((artifact, index) => artifact === normalized[index])
? previous
: normalized,
);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
@@ -65,18 +71,24 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen);
const sidebar = useSidebarSafe();
const select = useCallback(
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact));
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
if (!autoSelect) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
);
@@ -86,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
setSelectedArtifact(artifact);
setSelectedArtifactKey(previewKey);
setAutoSelect(true);
setAutoOpen(true);
setOpenState(true);
selectedArtifactKeyRef.current = previewKey;
autoOpenRef.current = true;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? previous : true));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
@@ -99,13 +113,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
[sidebar],
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
const deselect = useCallback(() => {
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
setAutoSelect((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? false : previous));
suppressedAutoOpenKeyRef.current = null;
}, []);
const setOpen = useCallback((isOpen: boolean) => {
if (!isOpen && autoOpenRef.current) {
autoOpenRef.current = false;
setAutoOpen((previous) => (previous ? false : previous));
setAutoSelect((previous) => (previous ? false : previous));
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
}
setOpenState((previous) => (previous === isOpen ? previous : isOpen));
}, []);
const value: ArtifactsContextType = {
artifacts,
@@ -114,16 +141,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
open,
autoOpen,
autoSelect,
setOpen: (isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
setOpen,
selectedArtifact,
selectedArtifactKey,
@@ -65,6 +65,22 @@
padding: 0 12px;
}
/* 收起态也要预留与展开态一致的左右边距。此前 56px 宽度叠加内边距后,图标卡只剩
约 36px 可用空间,导致图标与边框被挤变形。80px 壳体保留 56px 的完整点击区域。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="false"] {
width: 80px;
padding: 0 12px;
}
/* 第二步左侧各级内容卡沿用 8BF 的 4px 圆角;头像、状态徽标仍保持圆形。 */
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .eightbf-roundtable__step2-sidebar-header > .rounded-2xl,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-role-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-human-row,
.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .roundtable-step2-run-card {
border-radius: 4px !important;
}
/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其
#enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */
.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input {
@@ -76,4 +76,22 @@
background: #21958a;
}
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
/* 第三步只保留「总结报告 + 沙箱」。沿用第一、二步的 4px 面板语言。 */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-source-panel {
border: 1px solid rgb(43 143 254 / 50%);
border-radius: 4px;
background: #032650;
padding: 12px;
}
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-report-panel {
border-color: rgb(43 143 254 / 50%);
border-radius: 4px !important;
background: #032650;
box-shadow: none;
}
/* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */
.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input {
flex: 0 0 auto;
}
@@ -8,7 +8,6 @@ import {
Sparkles,
X,
} from "lucide-react";
import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
@@ -27,6 +26,8 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
selectedModel?: string;
onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null;
/** 仅保留上传、模型选择和发送的紧凑输入区(用于会商第 3 步)。 */
hideUtilityActions?: boolean;
}
/** Keep attachment metadata presentation local to this position-only composer. */
@@ -67,6 +68,7 @@ export function PositionIntentInput({
selectedModel,
onSelectedModelChange,
commonQuestions,
hideUtilityActions = false,
}: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not
@@ -76,6 +78,12 @@ export function PositionIntentInput({
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
// 这里的模型列表会随着会商流的状态频繁刷新。TDesign Select 会在 options 的引用
// 每次变化时执行一次内部同步;流式收尾阶段恰好会形成 effect 更新循环并打断整页。
// 原生 select 是受控且无内部同步状态,三个步骤共用这个输入框后也能保持稳定。
const modelValue = modelOptions.some((option) => option.value === selectedModel)
? selectedModel
: (modelOptions[0]?.value ?? "");
const pickCommonQuestion = (question: AgentQuestion) => {
onValueChange(question.prompt);
@@ -242,16 +250,19 @@ export function PositionIntentInput({
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
>
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
<TSelect
size="small"
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
value={selectedModel}
options={modelOptions}
<select
aria-label="切换模型"
value={modelValue}
disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))}
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
popupProps={{ overlayClassName: "z-[80]" }}
/>
onChange={(event) => onSelectedModelChange?.(event.target.value)}
className="h-7 w-[150px] shrink-0 cursor-pointer appearance-none bg-transparent px-1 pr-5 text-xs font-bold text-[#dceaff] outline-none disabled:cursor-not-allowed disabled:opacity-60"
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-[#0b3168] text-[#dceaff]">
{option.label}
</option>
))}
</select>
</div>
)}
<button
@@ -271,25 +282,29 @@ export function PositionIntentInput({
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
</button>
<button
id="suggest-followup-btn"
type="button"
onClick={onFollowUp}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Repeat className="size-3 text-slate-500" />
继续追问
</button>
{!hideUtilityActions && (
<>
<button
id="suggest-followup-btn"
type="button"
onClick={onFollowUp}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Repeat className="size-3 text-slate-500" />
继续追问
</button>
<button
id="skip-question-btn"
type="button"
onClick={onSkip}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<X className="size-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
<button
id="skip-question-btn"
type="button"
onClick={onSkip}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<X className="size-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
</>
)}
{footerAction ??
(!hideEnterRoundtable && (
@@ -309,15 +324,17 @@ export function PositionIntentInput({
{enterButtonLabel}
</button>
))}
<button
id="save-draft-btn"
type="button"
onClick={onManualSave}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Save className="size-3 text-slate-500" />
保存草稿
</button>
{!hideUtilityActions && (
<button
id="save-draft-btn"
type="button"
onClick={onManualSave}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Save className="size-3 text-slate-500" />
保存草稿
</button>
)}
</div>
</div>
</div>
@@ -584,18 +584,55 @@ export async function streamMultiAgent(
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
// value actually changes (the reader loop sees many frames per phase).
//
// `write_file` 的 `content`、`str_replace` 的 old_str/new_str 会在每个
// tool_call chunk 中持续增长。它们属于实时预览数据,已经由 onUpstreamFrame
// 写入 stub;若用它们判断 phase 变化,会在单次工具调用里反复触发 React 状态
// 更新,严重时会形成 Maximum update depth loop。因此这里只比较工具的身份入参。
const phaseArgsSignature = (args?: Record<string, unknown>) => {
if (!args) return "";
const streamedPayloadKeys = new Set([
"content",
"old_str",
"new_str",
"patch",
"diff",
]);
try {
return JSON.stringify(
Object.fromEntries(
Object.entries(args)
.filter(([key]) => !streamedPayloadKeys.has(key))
.sort(([left], [right]) => left.localeCompare(right)),
),
);
} catch {
return "[unserializable]";
}
};
let currentPhase: StreamPhase = "connecting";
let currentPhaseName = "";
let currentPhaseArgsSignature = "";
req.onPhaseChange?.("connecting");
const setPhase = (
next: StreamPhase,
name?: string,
args?: Record<string, unknown>,
) => {
// 同一个 phase + 同一个 toolName 才认为是"重复",此时不再回调上层;
// 但 args 单独是新的(如同一工具被多次 call 携带不同入参)时也不回调 —— 上层
// appendStepIfNew 用 `phase:toolName` 做 key 去重,本来也不会因 args 变化产生新步骤。
if (next === currentPhase && !name) return;
const nextName = name ?? "";
const nextArgsSignature = phaseArgsSignature(args);
// 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths
// 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。
if (
next === currentPhase &&
nextName === currentPhaseName &&
nextArgsSignature === currentPhaseArgsSignature
) {
return;
}
currentPhase = next;
currentPhaseName = nextName;
currentPhaseArgsSignature = nextArgsSignature;
req.onPhaseChange?.(next, name, args);
};
@@ -152,8 +152,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
// 第 3 步**主页** = 总结报告(进第三步默认自动跑「方案总结报告智能体」)。
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
// 第 3 步只保留「总结报告」工作台;旧的最终方案展示 / HTML 绘制入口不再开放。
const [summaryMode, setSummaryMode] = useState(true);
const [drawMode, setDrawMode] = useState(false);
// 文件沙箱(2026-06-13 改内联):点文件卡片在右侧滑入沙箱面板、左侧席位栏收起
@@ -443,12 +442,10 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
intentReady={intentReady}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
coordinatorPrompt={coordinatorPrompt}
savedSummary={savedSummary}
summary={summaryFlow}
onViewStatic={() => setSummaryMode(false)}
taskArtifactSubmission={taskArtifactSubmission}
/>
);
@@ -1038,6 +1035,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
threadId={fileSandboxTarget.threadId}
onClose={handleCloseFileSandbox}
allowVirtualMarkdownEdit
headerLeadingActionBare
headerLeadingAction={(
<TaskArtifactSubmitButton
compact
@@ -24,8 +24,6 @@ import {
Loader2,
Network,
Paperclip,
Pause,
Play,
Sparkles,
X,
} from "lucide-react";
@@ -101,7 +99,6 @@ export interface Step2PanelProps {
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
@@ -152,7 +149,6 @@ export interface Step2PanelProps {
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
@@ -171,7 +167,6 @@ export function Step2Panel(props: Step2PanelProps) {
useStep1InputAppearance = false,
step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
isOrchestrating,
pendingClarification,
clarificationDraft,
@@ -203,7 +198,6 @@ export function Step2Panel(props: Step2PanelProps) {
setAgentConfigs,
setSelectedAgentId,
onPauseRoundtable,
onResumeRoundtable,
onHandleInjectToRoundtable,
onSubmitClarification,
onSavePersona,
@@ -381,9 +375,6 @@ export function Step2Panel(props: Step2PanelProps) {
prevArtifactsOpenRef.current = artifactsOpenForFlow;
}, [artifactsOpenForFlow]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
@@ -406,13 +397,14 @@ export function Step2Panel(props: Step2PanelProps) {
const handleInject = useCallback(() => {
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]);
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onHandleInjectToRoundtable, injectFiles]);
const isSandboxStreaming =
isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
// 顶部「会商运行」状态在等待用户输入时仍可能保持 true,不能据此把发送键误显示成暂停。
// 仅在初始化或实际流式输出时,沿用第一步输入框的暂停态。
const isStep2Responding = isInitializing || isSandboxStreaming;
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
@@ -456,30 +448,7 @@ export function Step2Panel(props: Step2PanelProps) {
</option>
))}
</select>
{!isRoundTableRunning ? (
<button
type="button"
onClick={onResumeRoundtable}
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm ${
pauseFlash
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
: ""
}`}
>
<Play className="w-3.5 h-3.5 fill-emerald-800" />
恢复会商
</button>
) : (
<button
type="button"
onClick={onPauseRoundtable}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
>
<Pause className="w-3.5 h-3.5" />
暂停会商
</button>
)}
{/* 后台挂起:放在暂停/恢复按钮右侧。查看历史记录时隐藏(isViewingHistory),
{/* 后台挂起:查看历史记录时隐藏(isViewingHistory),
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
<button
@@ -661,7 +630,12 @@ export function Step2Panel(props: Step2PanelProps) {
</>
)}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
{!step2AtBottom && (
<ScrollToBottomButton
onClick={scrollStep2ToBottom}
className="!bottom-1/2 translate-y-1/2"
/>
)}
</div>
{/* Clarification card */}
@@ -748,11 +722,11 @@ export function Step2Panel(props: Step2PanelProps) {
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
charactersLeft={Math.max(0, 500 - customAgentInput.length)}
isSending={false}
canSend
isSending={isStep2Responding}
canSend={customAgentInput.trim().length > 0 || injectFiles.length > 0}
pendingClarification={false}
onSend={handleInject}
onStop={() => undefined}
onStop={onPauseRoundtable}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
@@ -1199,14 +1173,13 @@ function PresentFileCard({
{filename}
</span>
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
</div>
</button>
{submitButton && <div className="mt-1 flex justify-end">{submitButton}</div>}
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
{submitButton}
</div>
</div>
);
}
@@ -29,6 +29,42 @@ import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
/** 从 write_file 虚拟地址反查流式工具调用中的正文,供任务产物提交直接使用。 */
function virtualMarkdownContent(
artifact: string | null,
messages: unknown[] | undefined,
): string | undefined {
if (!artifact?.startsWith("write-file:") || !messages?.length) return undefined;
try {
const url = new URL(artifact);
const toolCallId = url.searchParams.get("tool_call_id");
const path = decodeURIComponent(url.pathname).trim();
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const toolCalls = (message as { tool_calls?: unknown }).tool_calls;
if (!Array.isArray(toolCalls)) continue;
for (const call of toolCalls) {
if (!call || typeof call !== "object") continue;
const toolCall = call as {
id?: unknown;
name?: unknown;
args?: { path?: unknown; content?: unknown };
};
if (toolCall.name !== "write_file") continue;
if (toolCallId && toolCall.id !== toolCallId) continue;
if (!toolCallId && toolCall.args?.path !== path) continue;
return typeof toolCall.args?.content === "string"
? toolCall.args.content
: undefined;
}
}
} catch {
// 虚拟路径解析失败时交给提交层走它原有的真实文件读取兜底。
}
return undefined;
}
interface Step2SandboxLayoutProps {
children?: React.ReactNode;
/** 当前预览文件所属的席位 thread id。 */
@@ -73,6 +109,10 @@ export function Step2SandboxLayout({
deselect,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const selectedArtifactContent = useMemo(
() => virtualMarkdownContent(selectedArtifact, seatMessages),
[selectedArtifact, seatMessages],
);
const artifactPanelOpen = artifactsOpen;
// sidePanel(流程图等)非空时即使沙箱没开也要把右栏撑开。
@@ -120,9 +160,9 @@ export function Step2SandboxLayout({
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize
headerLeadingActionBare
headerLeadingAction={(
<TaskArtifactSubmitButton
compact
@@ -130,6 +170,7 @@ export function Step2SandboxLayout({
target={taskArtifactSubmission?.targetFor({
threadId: previewThreadId,
path: selectedArtifact,
content: selectedArtifactContent,
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
/>
)}
@@ -9,33 +9,27 @@
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
*/
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowRight,
CheckCircle,
Database,
Download,
Eye,
FileText,
Loader2,
PanelLeftClose,
PanelLeftOpen,
Paperclip,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
Workflow,
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
@@ -44,12 +38,6 @@ import {
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
import {
detectFlowIntent,
extractFlowGraph,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -80,7 +68,6 @@ interface Step3SummaryModeProps {
intentReady: ReportIntent | null;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
coordinatorPrompt?: string | null;
@@ -88,8 +75,6 @@ interface Step3SummaryModeProps {
savedSummary?: DraftStep3SummarySnapshot | null;
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
summary: UseStep3SummaryReturn;
/** 切到「最终方案展示」静态页(次要入口)。 */
onViewStatic: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
@@ -105,12 +90,10 @@ export function Step3SummaryMode({
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
coordinatorPrompt,
savedSummary,
summary,
onViewStatic,
taskArtifactSubmission,
}: Step3SummaryModeProps) {
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
@@ -144,42 +127,7 @@ export function Step3SummaryMode({
const { open: artifactsOpen } = useArtifacts();
// ── 结构化流程图(Phase 1:后端「抽取+校验」)────────────────────────────
// 数据来源优先级:
// · flowPhase==="done" → 用后端校验后的结构化图 summary.flowGraph 绘制;
// · 抽取/校验进行中 → 不画(用户看对话气泡里实时滚动的 JSON + 状态条),等校验完成再绘制;
// · idle/error → 回退到对话气泡里解析 flow-json(手动 flow 续问路径 / 旧逻辑兜底)。
const flowGraph = useMemo(() => {
if (summary.flowPhase === "done" && summary.flowGraph) {
return { graph: summary.flowGraph, streaming: false };
}
if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
return null;
}
for (let i = dialogues.length - 1; i >= 0; i--) {
const d = dialogues[i];
if (d.avatarType === "human") continue;
const content = d.content ?? "";
if (!content.includes(FLOW_FENCE_OPEN)) continue;
const graph = extractFlowGraph(content);
if (graph) return { graph, streaming: d.streaming === true };
}
return null;
}, [dialogues, summary.flowPhase, summary.flowGraph]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
if (flowAutoOpenedRef.current) return;
flowAutoOpenedRef.current = true;
// taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布
// (即便此前 md 沙箱已自动打开,也切到流程图)。
// 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。
if (summary.autoFlow || !artifactsOpen) setFlowView(true);
}, [flowGraph, artifactsOpen, summary.autoFlow]);
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
// 沙箱里已写出的 Markdown 总结报告。
const mdArtifact = useMemo(() => {
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
const msg = msgs?.[0];
@@ -200,25 +148,16 @@ export function Step3SummaryMode({
return null;
}, [seatStubMessages]);
// 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着,
// 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。
const handleAsk = (message: string, files?: File[]) => {
if (detectFlowIntent(message)) {
flowAutoOpenedRef.current = true;
setFlowView(true);
}
ask(message, files);
};
const handleAsk = (message: string, files?: File[]) => ask(message, files);
// 流程图抽取/校验的过程(思考 `<think>` + 文字说明 + flow-json 代码块)全部原样交给
// Step2Message 渲染:它用 splitInlineReasoning 把思考折叠成思考块、其余按 markdown 渲染
// (flow-json 显示为代码块)。用户要求显示完整过程、不只显示代码块,故不再 strip。
// 报告生成过程(思考、文字说明、文件写入步骤)交由 Step2Message 原样渲染,
// 保持与第二步一致的流式过程可见性。
const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 右侧沙箱露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
const rightPanelVisible = artifactsOpen;
const prevRightPanelVisibleRef = useRef(false);
useEffect(() => {
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
@@ -292,23 +231,16 @@ export function Step3SummaryMode({
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新生成
</button>
<button
type="button"
onClick={onViewStatic}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
最终方案展示
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
</button>
</div>
</div>
{/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3">
<aside
data-step3-source-panel
className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[52px]" : "w-[280px]",
"eightbf-roundtable__step3-source-panel shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[80px]" : "w-[280px]",
)}
>
{leftCollapsed ? (
@@ -318,7 +250,7 @@ export function Step3SummaryMode({
onClick={() => setLeftCollapsed(false)}
title="展开研讨原料"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
@@ -351,7 +283,7 @@ export function Step3SummaryMode({
onClick={() => setLeftCollapsed(true)}
title="收起研讨原料"
aria-expanded
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
@@ -379,39 +311,23 @@ export function Step3SummaryMode({
onClick={() => setModal("files")}
/>
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
<div className="mt-1 rounded-[4px] border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。
</div>
</>
)}
</aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位
(sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */}
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
{/* 右栏:智能体进度 + Markdown 沙箱(复用第 2 步)。 */}
<section
data-step3-report-panel
className="eightbf-roundtable__step3-report-panel relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden"
>
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
sidePanel={
flowGraph && flowView ? (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
onSwitchToReport={
mdArtifact
? () => {
setFlowView(false);
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
}
: undefined
}
onChange={summary.setFlowGraph}
/>
) : undefined
}
>
<AgentProgressPanel
phase={phase}
@@ -419,18 +335,6 @@ export function Step3SummaryMode({
error={error}
busy={busy}
mdAvailable={!!mdArtifact}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
pendingClarification={summary.pendingFlowClarification}
onResolveClarification={summary.resolveFlowClarification}
ingestAvailable={summary.ingestAvailable}
ingestStatus={summary.ingestStatus}
pendingIngestClarification={summary.pendingIngestClarification}
onResolveIngestChoice={summary.resolveIngestChoice}
onResolveIngestClarification={summary.resolveIngestClarification}
pendingFlowForIngest={summary.pendingFlowForIngest}
onResolveFlowForIngest={summary.resolveFlowForIngest}
onManualIngest={summary.runIngest}
onRetry={redraw}
onAsk={handleAsk}
onPause={pause}
@@ -438,32 +342,11 @@ export function Step3SummaryMode({
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission}
/>
</Step2SandboxLayout>
{/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */}
{flowGraph && !flowView && (
<button
type="button"
onClick={() => setFlowView(true)}
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
>
{flowGraph.streaming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Workflow className="h-3.5 w-3.5" />
)}
流程图
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
{flowGraph.graph.nodes.length}
</span>
</button>
)}
</section>
</div>
@@ -527,7 +410,7 @@ function SourceCard({
type="button"
onClick={onClick}
disabled={disabled}
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
className="text-left bg-white border border-slate-200/60 rounded-[4px] p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
@@ -560,7 +443,7 @@ function CollapsedSourceCard({
title={title}
disabled={disabled}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
className="w-full shrink-0 flex items-center justify-center rounded-[4px] border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
{icon}
</button>
@@ -575,18 +458,6 @@ function AgentProgressPanel({
error,
busy,
mdAvailable,
flowPhase,
onRegenerateFlow,
pendingClarification,
onResolveClarification,
ingestAvailable,
ingestStatus,
pendingIngestClarification,
onResolveIngestChoice,
onResolveIngestClarification,
pendingFlowForIngest,
onResolveFlowForIngest,
onManualIngest,
onRetry,
onAsk,
onPause,
@@ -594,7 +465,6 @@ function AgentProgressPanel({
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
taskArtifactSubmission,
}: {
@@ -602,25 +472,8 @@ function AgentProgressPanel({
dialogues: UseStep3SummaryReturn["dialogues"];
error: string | null;
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
/** 沙箱里是否已有 Markdown 总结报告。 */
mdAvailable: boolean;
/** 后端流程图抽取阶段(Phase 1)。 */
flowPhase: UseStep3SummaryReturn["flowPhase"];
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
onRegenerateFlow: () => void;
/** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
pendingClarification: string | null;
onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
/** 入库技能是否可用(决定入库询问卡 + 手动入库按钮是否出现)。 */
ingestAvailable: boolean;
ingestStatus: UseStep3SummaryReturn["ingestStatus"];
pendingIngestClarification: string | null;
onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"];
onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"];
/** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */
pendingFlowForIngest: boolean;
onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"];
onManualIngest: UseStep3SummaryReturn["runIngest"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
@@ -628,14 +481,13 @@ function AgentProgressPanel({
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底,
// 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发,
// 新一轮气泡出现(报告生成或用户续问)→ 强制滚到底,
// 让用户看到新阶段。仅在气泡**条数**增加时触发,
// 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。
const prevDialogueCountRef = useRef(dialogues.length);
useEffect(() => {
@@ -651,22 +503,18 @@ function AgentProgressPanel({
const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
const addAskFiles = (picked: File[]) => {
if (picked.length > 0) {
setAskFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
};
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const submitAsk = () => {
const text = askText.trim();
const hasFiles = askFiles.length > 0;
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
@@ -677,7 +525,7 @@ function AgentProgressPanel({
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="shrink-0 flex items-center gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
报
@@ -687,38 +535,11 @@ function AgentProgressPanel({
<div className="text-[10px] text-slate-400 truncate">据各席位成果撰写 Markdown 总结报告</div>
</div>
</div>
<select
id="step3-summary-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "撰写中不可切换;完成后或重新生成时生效"
: "切换撰写使用的推理模型(下次生成生效)"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
<div className="relative flex-1 min-h-0">
<div ref={scrollRef} className="size-full overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -742,9 +563,7 @@ function AgentProgressPanel({
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
{mdAvailable
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
{mdAvailable ? "报告正文实时输出,右侧沙箱同步预览 Markdown" : "正在撰写 Markdown 总结报告"}
</div>
)}
@@ -763,243 +582,54 @@ function AgentProgressPanel({
</div>
)}
{/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
{(flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
</div>
)}
{flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">模型可能不稳定。可在上方切换模型后重试。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
{mdAvailable
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告或提问。"
: "已生成方案总结,可在下方继续修改报告或提问。"}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 意图模糊 → 让用户自己定(不自作主张) */}
{pendingClarification && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
没太理解你的意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
{!atBottom && (
<ScrollToBottomButton
onClick={scrollToBottom}
className="!bottom-1/2 translate-y-1/2"
/>
)}
</div>
{/* 流程图已生成 → 询问是否入库(入库技能可用时)。 */}
{ingestAvailable && ingestStatus === "asking" && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
流程图已生成,是否进行数据入库?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "now", t: "现在入库" },
{ k: "later", t: "稍后入库" },
{ k: "never", t: "不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestChoice(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */}
{ingestAvailable && pendingFlowForIngest && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/70 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
当前还没有生成流程图,数据入库需要流程图。是否先生成流程图?
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "generate", t: "生成流程图" },
{ k: "cancel", t: "暂不入库" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveFlowForIngest(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
{/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */}
{ingestAvailable && pendingIngestClarification && (
<div className="shrink-0 border-t border-emerald-100 bg-emerald-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-emerald-800">
没太理解你的入库意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "start_ingest", t: "开始入库" },
{ k: "add_data", t: "补充内容后再入库" },
{ k: "other", t: "只是提问 / 改报告" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveIngestClarification(o.k)}
className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-emerald-800 transition hover:bg-emerald-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{askFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
<button
type="button"
onClick={() => removeFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={askText}
onChange={(e) => setAskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
rows={1}
placeholder={
busy
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
/>
{/* 手动入库(入库技能可用、且已过流程图询问阶段时显示):直接在总结线程跑入库技能。 */}
{ingestAvailable &&
ingestStatus !== "asking" &&
ingestStatus !== "running" &&
ingestStatus !== "idle" && (
<button
type="button"
onClick={() => onManualIngest()}
title="进行数据入库(由结构化输出智能体调用入库技能,过程见消息列表步骤条)"
className="mb-0.5 flex h-8 shrink-0 items-center gap-1 rounded-xl border border-emerald-200 bg-emerald-50 px-2.5 text-[11px] font-bold text-emerald-700 transition hover:bg-emerald-100 active:scale-95"
>
<Database className="h-3.5 w-3.5" />
数据入库
</button>
)}
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
>
<Paperclip className="h-4 w-4" />
</button>
{/* 暂停(仅生成中显示) */}
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前生成"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
{/* 发送(随时可发:生成中发送=打断并续问) */}
<button
type="submit"
disabled={askText.trim().length === 0 && askFiles.length === 0}
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
<SendHorizontal className="h-4 w-4" />
</button>
</div>
</form>
<div className="position-roundtable-intent-page eightbf-roundtable-step3-input shrink-0">
<PositionIntentInput
value={askText}
onValueChange={setAskText}
attachedFiles={askFiles}
onAddFiles={addAskFiles}
onRemoveFile={removeFile}
charactersLeft={Math.max(0, 500 - askText.length)}
isSending={busy}
canSend={askText.trim().length > 0 || askFiles.length > 0}
pendingClarification={false}
onSend={submitAsk}
onStop={onPause}
onFollowUp={() => undefined}
onSkip={() => undefined}
hideUtilityActions
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={availableModels.map((model) => ({
label: model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name,
value: model.name,
}))}
selectedModel={selectedModel}
onSelectedModelChange={setSelectedModel}
/>
</div>
</div>
);
@@ -39,7 +39,8 @@ export function TaskArtifactSubmitButton({
: error
? "border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300"
: "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15",
compact && "h-7 px-2",
// 沙箱头部的其他分段切换器外框为 40px 高,提交入口与之对齐。
compact && "h-10 px-3 text-xs",
className,
)}
>
@@ -507,6 +507,14 @@ export function useStep2Orchestration(
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
// write_file 的 tool args 会在一个 SSE response 里高频增长。ref 始终保留最新全文,
// 供自动打开 / 最终收口等同步逻辑立即读取;但不要逐帧把整份 stub 写进 React state。
// 后者会重建沙箱 ThreadContext 并让 ArtifactFileDetail 的预览 effect 连锁执行,
// 在「继续会商 + 正在写文件」的高密度帧下曾触发 Maximum update depth exceeded。
const pendingSeatStubMessagesRef = useRef<
Record<string, StubAiMessage[]> | null
>(null);
const seatStubFlushTimerRef = useRef<number | null>(null);
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
@@ -552,6 +560,11 @@ export function useStep2Orchestration(
new Map(),
);
const parallelFlushTimerRef = useRef<number | null>(null);
// 非并行的 leader / 单席位流同样不能逐 token 直接 setState。SSE reader 可能在一次
// 浏览器任务里吐出大量已缓冲帧;逐帧 setState 会与消息区的 effect 连锁,最终触发
// React 的 Maximum update depth 保护。先累积到 ref,再以固定节流频率提交到界面。
const directStreamDeltaRef = useRef<Map<number, string>>(new Map());
const directStreamFlushTimerRef = useRef<number | null>(null);
// flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免
// 把 flush 函数绑进会频繁变化的 state 依赖。
const selectedParallelSeatsRef = useRef<string[]>([]);
@@ -684,6 +697,57 @@ export function useStep2Orchestration(
return ids[agentName] ?? ids[agentName.toLowerCase()];
}, []);
/**
* 把写文件预览的 stub 以约 12fps 合并交给 React。
*
* `seatStubMessagesRef` 已在调用处同步更新,故这里的延迟只影响右侧 Markdown
* 重绘频率,不影响随后读取最新 path/content、也不影响自动打开沙箱。
*/
const flushSeatStubMessages = useCallback(() => {
seatStubFlushTimerRef.current = null;
const pending = pendingSeatStubMessagesRef.current;
if (!pending) return;
pendingSeatStubMessagesRef.current = null;
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
}, []);
const scheduleSeatStubMessagesFlush = useCallback(() => {
if (seatStubFlushTimerRef.current !== null) return;
seatStubFlushTimerRef.current = window.setTimeout(
flushSeatStubMessages,
80,
);
}, [flushSeatStubMessages]);
/** 清掉未提交的流式预览,并立即发布一个确定的快照(草稿恢复 / 重置时使用)。 */
const replaceSeatStubMessages = useCallback(
(next: Record<string, StubAiMessage[]>) => {
pendingSeatStubMessagesRef.current = null;
if (seatStubFlushTimerRef.current !== null) {
window.clearTimeout(seatStubFlushTimerRef.current);
seatStubFlushTimerRef.current = null;
}
seatStubMessagesRef.current = next;
setSeatStubMessages((previous) => (previous === next ? previous : next));
},
[],
);
const resetSeatStubMessages = useCallback(() => {
replaceSeatStubMessages({});
}, [replaceSeatStubMessages]);
// 卸载时清理挂起的预览 flush,避免已离开的页面仍收到一次 setState。
useEffect(
() => () => {
if (seatStubFlushTimerRef.current !== null) {
window.clearTimeout(seatStubFlushTimerRef.current);
seatStubFlushTimerRef.current = null;
}
},
[],
);
/**
* 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次
* 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。
@@ -734,10 +798,11 @@ export function useStep2Orchestration(
[seatThreadId]: [nextAiMsg],
};
seatStubMessagesRef.current = nextMap;
setSeatStubMessages(nextMap);
pendingSeatStubMessagesRef.current = nextMap;
scheduleSeatStubMessagesFlush();
return { messageId: nextAiMsg.id, toolCallId };
},
[],
[scheduleSeatStubMessagesFlush],
);
/**
@@ -1026,12 +1091,6 @@ export function useStep2Orchestration(
args,
);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] auto-open from phase",
{ toolName, path, seatThreadId, virtualUrl },
);
}
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
},
[
@@ -1272,9 +1331,15 @@ export function useStep2Orchestration(
const setDialogueConfidence = useCallback(
(id: number, confidence: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
setStep2RoundtableDialogues((prev) => {
const index = prev.findIndex((dialogue) => dialogue.id === id);
// onPhaseChange 可在一个 SSE read 内收到多帧相同的 phase。状态文字没有变化
// 时必须返回原数组;否则 React 会把无意义的新数组当成一次嵌套更新。
if (index < 0 || prev[index].confidence === confidence) return prev;
const next = [...prev];
next[index] = { ...prev[index], confidence };
return next;
});
},
[],
);
@@ -1286,30 +1351,39 @@ export function useStep2Orchestration(
key: string,
args?: Record<string, unknown>,
) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
// 同 phase:tool 重复回调 —— 流式累积期 args 会持续长大(present_files
// 多文件分批、write_file 内容增长),拿最新 args 覆盖,不要早早定死。
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
setStep2RoundtableDialogues((prev) => {
const dialogueIndex = prev.findIndex((dialogue) => dialogue.id === bubbleId);
if (dialogueIndex < 0) return prev;
const dialogue = prev[dialogueIndex];
const steps: Step2Dialogue["steps"] = dialogue.steps ?? [];
const last = steps[steps.length - 1];
let nextDialogue: Step2Dialogue;
if (last && last.key === key) {
// 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到
// 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个
// content chunk 更新对话 state。
const hasVisibleArgs = (value?: Record<string, unknown>) =>
!!value && Object.keys(value).some((argKey) => argKey !== "content");
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev;
const nextSteps = [...steps];
nextSteps[nextSteps.length - 1] = { ...last, args };
nextDialogue = { ...dialogue, steps: nextSteps };
} else {
nextDialogue = {
...dialogue,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
}
const next = [...prev];
next[dialogueIndex] = nextDialogue;
return next;
});
},
[],
);
@@ -1342,20 +1416,45 @@ export function useStep2Orchestration(
[],
);
/** 将非并行流的已积累增量一次性写入 React state(约 20fps)。 */
const flushDirectStreamDeltas = useCallback(() => {
directStreamFlushTimerRef.current = null;
const pending = directStreamDeltaRef.current;
if (pending.size === 0) return;
directStreamDeltaRef.current = new Map();
setStep2RoundtableDialogues((prev) => {
let changed = false;
const next = prev.map((dialogue) => {
const delta = pending.get(dialogue.id);
if (!delta) return dialogue;
changed = true;
return { ...dialogue, content: (dialogue.content ?? "") + delta };
});
return changed ? next : prev;
});
}, []);
const scheduleDirectStreamFlush = useCallback(() => {
if (directStreamFlushTimerRef.current !== null) return;
directStreamFlushTimerRef.current = window.setTimeout(flushDirectStreamDeltas, 50);
}, [flushDirectStreamDeltas]);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setStep2RoundtableDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
const pending = directStreamDeltaRef.current;
pending.set(id, (pending.get(id) ?? "") + deltaText);
scheduleDirectStreamFlush();
}, [scheduleDirectStreamFlush]);
/** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */
const setDialogueContent = useCallback((id: number, fullText: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)),
);
setStep2RoundtableDialogues((prev) => {
const index = prev.findIndex((dialogue) => dialogue.id === id);
if (index < 0 || prev[index].content === fullText) return prev;
const next = [...prev];
next[index] = { ...prev[index], content: fullText };
return next;
});
}, []);
/**
@@ -1372,6 +1471,7 @@ export function useStep2Orchestration(
const who = info.agentName || "智能体";
triggerToast(`「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
if (isParallel) parallelStreamRef.current.delete(bubbleId);
directStreamDeltaRef.current.delete(bubbleId);
const systemBubble: Step2Dialogue = {
id: Date.now() + Math.random(),
sender: "系统提示",
@@ -1440,8 +1540,17 @@ export function useStep2Orchestration(
}
}, []);
const resetDirectStreamDeltas = useCallback(() => {
directStreamDeltaRef.current = new Map();
if (directStreamFlushTimerRef.current !== null) {
window.clearTimeout(directStreamFlushTimerRef.current);
directStreamFlushTimerRef.current = null;
}
}, []);
// 卸载兜底:清掉可能挂起的 flush 定时器。
useEffect(() => resetParallelStreams, [resetParallelStreams]);
useEffect(() => resetDirectStreamDeltas, [resetDirectStreamDeltas]);
// 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出,
// 证明流没丢、只是之前没渲染,§6.5)。
@@ -1508,21 +1617,40 @@ export function useStep2Orchestration(
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => {
// 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。
directStreamDeltaRef.current.delete(id);
if (
directStreamDeltaRef.current.size === 0 &&
directStreamFlushTimerRef.current !== null
) {
window.clearTimeout(directStreamFlushTimerRef.current);
directStreamFlushTimerRef.current = null;
}
setStep2RoundtableDialogues((prev) => {
let changed = false;
const next = prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(无文本输出)";
if (
d.content === next &&
d.confidence === (confidence ?? d.confidence) &&
d.streaming === false
) {
return d;
}
changed = true;
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
});
return changed ? next : prev;
});
},
[],
);
@@ -2552,7 +2680,7 @@ export function useStep2Orchestration(
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
// stage 也经 appendToDialogue 的节流缓冲,避免高频 SSE 帧嵌套更新。
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
@@ -3182,6 +3310,7 @@ export function useStep2Orchestration(
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
threadIdsRef.current = snapshot.threadIds;
coordinatorNameRef.current = snapshot.coordinatorName;
resetDirectStreamDeltas();
// 历史快照是**静止记录**:任何残留「流式/加载中」的气泡(用户中途点了「停止」,
// 或草稿在流式期间被自动保存)都强制收尾为静止态。否则用户点聊天记录回看时,这些
// 气泡仍显示「调度中 / 等待响应 / 正在输出…」+ 加载光标,让人误以为还在进行(其实早已结束)。
@@ -3203,15 +3332,14 @@ export function useStep2Orchestration(
setConsensusPercentage(snapshot.consensusPercentage);
setBudgetLimit(snapshot.budgetLimit);
const restoredStubs = snapshot.seatStubMessages ?? {};
seatStubMessagesRef.current = restoredStubs;
setSeatStubMessages(restoredStubs);
replaceSeatStubMessages(restoredStubs);
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
hasAutoOpenedRef.current = new Set();
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
// 调用方需要的话再 resetInitGate()。
step2InitRef.current = true;
},
[],
[replaceSeatStubMessages, resetDirectStreamDeltas],
);
// 「查看=恢复流程」:后台作业 SSE 帧 → 第二步实时画面(只更新展示数据,不动
@@ -3225,13 +3353,12 @@ export function useStep2Orchestration(
lastLeaderContent: string;
}) => {
setStep2RoundtableDialogues(frame.dialogues);
seatStubMessagesRef.current = frame.seatStubMessages;
setSeatStubMessages(frame.seatStubMessages);
replaceSeatStubMessages(frame.seatStubMessages);
setConsensusPercentage(frame.consensusPercentage);
setHasConsensus(frame.hasConsensus);
if (frame.lastLeaderContent) setLastLeaderContent(frame.lastLeaderContent);
},
[],
[replaceSeatStubMessages],
);
// 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后,
@@ -3279,11 +3406,11 @@ export function useStep2Orchestration(
setSelectedParallelSeats([]);
setDagNodeStates({});
resetParallelStreams();
resetDirectStreamDeltas();
setActiveArtifactThreadId(null);
artifactPathsRef.current = [];
setArtifacts([]);
seatStubMessagesRef.current = {};
setSeatStubMessages({});
resetSeatStubMessages();
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false;
@@ -3292,7 +3419,14 @@ export function useStep2Orchestration(
gatheredDeliveriesRef.current = [];
deselectArtifact();
step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
}, [
abortAllInFlight,
deselectArtifact,
setArtifacts,
resetParallelStreams,
resetDirectStreamDeltas,
resetSeatStubMessages,
]);
return {
selectedAgents,
@@ -16,16 +16,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { apiFetch } from "@/core/api";
import { uploadFiles } from "@/core/uploads/api";
import type {
DraftStep3SummarySnapshot,
} from "@/roundtable-planning/api/drafts";
import {
buildArtifactDownloadUrl,
initStructureThread,
initSummaryThread,
listSessionArtifacts,
streamMultiAgent,
type StreamMultiAgentRequest,
type StreamPhase,
@@ -78,6 +75,8 @@ interface UseStep3SummaryOptions {
* 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。
*/
autoFlow?: boolean;
/** 第三步是否允许流程图分支。当前报告工作台只交付 Markdown,默认关闭。 */
flowEnabled?: boolean;
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
savedSummary?: DraftStep3SummarySnapshot | null;
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
@@ -485,6 +484,7 @@ export function useStep3Summary(
coordinatorPrompt,
chainLevels,
autoFlow,
flowEnabled = false,
savedSummary,
onSummarySaved,
ingestAvailable,
@@ -573,6 +573,10 @@ export function useStep3Summary(
>(null);
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
// write_file 会在流式过程中多次带回完整 tool_calls。stub 保持同步更新供预览读取,
// React 状态则节流发布,避免每一个工具帧都触发沙箱和消息列表的同步重渲染。
const pendingStubMessagesRef = useRef<Record<string, StubAiMessage[]> | null>(null);
const stubFlushTimerRef = useRef<number | null>(null);
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
@@ -589,6 +593,47 @@ export function useStep3Summary(
// run() 的「自动出图」与用户「跳过报告直接生成流程图」的手动出图竞态,各自建卡片 → 双「调度中」。
const flowAborterRef = useRef<AbortController | null>(null);
const activeFlowBubbleRef = useRef<number | null>(null);
const autoArtifactTimerRef = useRef<number | null>(null);
/** 将写文件预览以约 12fps 合并发布给 React,读取 stub 的流逻辑始终使用最新 ref。 */
const flushStubMessages = useCallback(() => {
stubFlushTimerRef.current = null;
const pending = pendingStubMessagesRef.current;
if (!pending) return;
pendingStubMessagesRef.current = null;
setSeatStubMessages((previous) => (previous === pending ? previous : pending));
}, []);
const scheduleStubMessagesFlush = useCallback(() => {
if (stubFlushTimerRef.current !== null) return;
stubFlushTimerRef.current = window.setTimeout(flushStubMessages, 80);
}, [flushStubMessages]);
/** 重置/恢复草稿时立即发布确定快照,同时取消未提交的流式预览。 */
const replaceStubMessages = useCallback(
(next: Record<string, StubAiMessage[]>) => {
pendingStubMessagesRef.current = null;
if (stubFlushTimerRef.current !== null) {
window.clearTimeout(stubFlushTimerRef.current);
stubFlushTimerRef.current = null;
}
stubRef.current = next;
setSeatStubMessages((previous) => (previous === next ? previous : next));
},
[],
);
useEffect(
() => () => {
if (stubFlushTimerRef.current !== null) {
window.clearTimeout(stubFlushTimerRef.current);
}
if (autoArtifactTimerRef.current !== null) {
window.clearTimeout(autoArtifactTimerRef.current);
}
},
[],
);
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
const appendStreamingDialogue = useCallback(
@@ -683,12 +728,15 @@ export function useStep3Summary(
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
// write_file 的流式工具帧会反复带着新的 content 对象。步骤卡只需要
// 首次拿到可见参数(如 path)时补充一次,正文已经由 stub 节流同步给沙箱。
// 不能为每个 content chunk 更新对话 state,否则会造成嵌套更新风暴。
const hasVisibleArgs = (value?: Record<string, unknown>) =>
!!value && Object.keys(value).some((argKey) => argKey !== "content");
if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return d;
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return {
...d,
@@ -762,14 +810,15 @@ export function useStep3Summary(
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
stubRef.current = nextMap;
setSeatStubMessages(nextMap);
pendingStubMessagesRef.current = nextMap;
scheduleStubMessagesFlush();
// 记住当前报告全文(用于续问写底 / 存草稿)。
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
currentMdRef.current = storeArgs.content;
}
return { messageId: nextMsg.id, toolCallId };
},
[],
[scheduleStubMessagesFlush],
);
const buildWriteFileVirtualUrl = useCallback(
@@ -816,14 +865,36 @@ export function useStep3Summary(
const autoOpenArtifact = useCallback(
(threadId: string, virtualUrl: string) => {
lastAutoThreadRef.current = threadId;
if (!previewEnabledRef.current) {
// 视图不在前台:只记录,不抢沙箱(回到视图时 setPreviewEnabled 再接管)。
lastAutoOpenedUrlRef.current = virtualUrl;
lastAutoThreadRef.current = threadId;
return;
}
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
openArtifact(threadId, virtualUrl);
if (
lastAutoOpenedUrlRef.current === virtualUrl &&
lastAutoThreadRef.current === threadId
) {
return;
}
// onPhaseChange 在 SSE 回调栈中触发。延后一帧再打开沙箱,避免工具帧与
// ArtifactProvider/Markdown 预览的状态更新嵌套在同一次 React 更新里。
lastAutoOpenedUrlRef.current = virtualUrl;
lastAutoThreadRef.current = threadId;
if (autoArtifactTimerRef.current !== null) {
window.clearTimeout(autoArtifactTimerRef.current);
}
autoArtifactTimerRef.current = window.setTimeout(() => {
autoArtifactTimerRef.current = null;
if (
!previewEnabledRef.current ||
lastAutoOpenedUrlRef.current !== virtualUrl ||
lastAutoThreadRef.current !== threadId
) {
return;
}
openArtifact(threadId, virtualUrl);
}, 80);
},
[openArtifact],
);
@@ -953,61 +1024,24 @@ export function useStep3Summary(
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
);
const openRealArtifactFallback = useCallback(
async (threadId: string) => {
if (!previewEnabledRef.current) return; // 不在前台不抢沙箱
try {
const res = await listSessionArtifacts(threadId);
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
if (!file) return;
setActiveArtifactThreadId(threadId);
setArtifacts([file.path]);
selectArtifact(file.path, false);
setArtifactsOpen(true);
} catch {
/* 列文件失败就保持当前状态 */
}
},
[setArtifacts, selectArtifact, setArtifactsOpen],
);
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
// 收尾时直接使用流式 write_file 捕获的 Markdown。当前任务沙箱的文件列表与下载
// 端点不是同一套存储:列表返回的 /mnt/... 路径会被预览器拿去请求 /artifacts/mnt/...
// 并稳定返回 404。此处若再尝试“真实文件对账”,既拿不到更完整的内容,还会触发
// 预览器的失败重试和状态更新循环。write_file 的 stub 在每个上游帧同步更新,因此它
// 是本步骤报告的唯一预览/保存来源。
const reconcileStubFromRealFile = useCallback(
async (
threadId: string,
opts?: { signal?: AbortSignal; force?: boolean },
_opts?: { signal?: AbortSignal; force?: boolean },
): Promise<string> => {
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
(tc) => tc.name === "write_file",
);
const stubPath =
typeof (writeTc?.args as { path?: unknown })?.path === "string"
? ((writeTc!.args as { path?: string }).path as string)
: SUMMARY_OUTPUT_PATH;
const stubContent =
typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
try {
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
if (!mdFile) return stubContent;
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
signal: opts?.signal,
});
if (!res.ok) return stubContent;
const real = await res.text();
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
upsertStub(threadId, "write_file", { path: stubPath, content: real });
return real;
}
} catch {
/* 拉取失败退回 stub 内容 */
}
return stubContent;
return typeof (writeTc?.args as { content?: unknown })?.content === "string"
? ((writeTc!.args as { content?: string }).content as string)
: "";
},
[upsertStub],
[],
);
const abort = useCallback(() => {
@@ -1112,11 +1146,8 @@ export function useStep3Summary(
});
if (aborter.signal.aborted) return;
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
// 流程图轮本就不产文件,不要去沙箱兜底翻文件(也避免把别的文件弹出来)。
if (!hasStubFile && !seedThreadId && !flowRound) await openRealArtifactFallback(threadId);
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
// 以流式 write_file stub 收尾。不要把沙箱文件列表返回的真实路径塞给全局预览:
// 该路径在 artifacts 下载端点不存在,会导致 404 与预览循环。
const md = await reconcileStubFromRealFile(threadId, {
signal: aborter.signal,
force: forceReconcile,
@@ -1155,7 +1186,6 @@ export function useStep3Summary(
appendStepIfNew,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
finalizeDialogue,
seedStubWithCurrentMd,
@@ -1301,6 +1331,9 @@ export function useStep3Summary(
/** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */
const regenerateFlow = useCallback(() => {
// 第三步报告工作台已关闭流程图交付。历史草稿/旧按钮即使仍持有这个回调,
// 也不能在总结收尾后意外启动结构化抽取。
if (!flowEnabled) return;
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
aborterRef.current?.abort();
// 重画流程图 = 重新分析 → 先复位入库状态(隐藏旧图的入库询问/按钮),新图生成完会重新询问。
@@ -1308,7 +1341,7 @@ export function useStep3Summary(
const aborter = new AbortController();
aborterRef.current = aborter;
void runFlowExtract(aborter);
}, [runFlowExtract, seatDeliveries, resetIngestState]);
}, [flowEnabled, runFlowExtract, seatDeliveries, resetIngestState]);
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
const updateFlowGraph = useCallback(
@@ -1335,10 +1368,10 @@ export function useStep3Summary(
setPhase("creating");
setDialogues([]);
lastAutoOpenedUrlRef.current = null;
stubRef.current = {};
lastAutoThreadRef.current = null;
replaceStubMessages({});
currentMdRef.current = "";
liveThreadRef.current = null;
setSeatStubMessages({});
setFlowGraphState(null);
setFlowPhase("idle");
// 清空对话同时复位流程图串行化句柄(旧气泡已随 setDialogues([]) 移除,不能再被复用)。
@@ -1359,7 +1392,7 @@ export function useStep3Summary(
try {
// 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。
// 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。
const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt);
const flowMode = flowEnabled && !autoFlow && detectFlowIntent(coordinatorPrompt);
flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正
const prompt = buildSummaryPrompt({
intentReady,
@@ -1381,6 +1414,7 @@ export function useStep3Summary(
// ask() 会先 abort() 本轮(aborterRef 被置空/换成新 aborter)→ 这里不再自动出图,
// 由 ask 的手动出图独占,避免 auto + manual 双卡片。
if (
flowEnabled &&
autoFlow &&
!aborter.signal.aborted &&
aborterRef.current === aborter &&
@@ -1404,11 +1438,13 @@ export function useStep3Summary(
consensusText,
coordinatorPrompt,
autoFlow,
flowEnabled,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
runFlowExtract,
resetIngestState,
replaceStubMessages,
]);
/**
@@ -1566,6 +1602,12 @@ export function useStep3Summary(
(choice: "generate" | "cancel") => {
setPendingFlowForIngest(false);
if (choice === "generate") {
if (!flowEnabled) {
// 老草稿可能遗留「先生成流程图再入库」的协助卡;报告模式不再提供这条链路。
setIngestStatus("later");
persistSummary();
return;
}
// 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。
setIngestStatus("idle");
regenerateFlow();
@@ -1660,6 +1702,11 @@ export function useStep3Summary(
await runIngest();
return;
}
// 当前第三步只维护 Markdown 报告。即使意图分类命中旧的流程图关键词,
// 也按“重写报告”处理,绝不启动流程图抽取请求。
if (!flowEnabled && (intent === "regenerate_flow" || intent === "modify_flow")) {
intent = "regenerate_report";
}
if (intent === "regenerate_flow" || intent === "modify_flow") {
// 重新/修改流程图 = 重新分析 → 先复位入库状态(隐藏旧图入库询问/按钮),
// runFlowExtract 出新图后会在 idle 时重新询问是否入库。
@@ -1809,6 +1856,7 @@ export function useStep3Summary(
runFlowExtract,
runIngest,
resetIngestState,
flowEnabled,
],
);
@@ -1902,7 +1950,7 @@ export function useStep3Summary(
// 合并:合成 stub 保住「恢复后预览」锚点,历史 stub 保住各气泡步骤卡的真线程预览。
const effectiveStub =
savedStubs && Object.keys(savedStubs).length ? { ...stub, ...savedStubs } : stub;
stubRef.current = effectiveStub;
replaceStubMessages(effectiveStub);
currentMdRef.current = snap.md;
liveThreadRef.current = realThreadId;
// 快照收尾语带 flow-json → 上次主产物是流程图,恢复后续问沿用流程图语境。
@@ -1919,7 +1967,6 @@ export function useStep3Summary(
lastAutoOpenedUrlRef.current = null;
lastAutoThreadRef.current = null;
}
setSeatStubMessages(effectiveStub);
setSummaryThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
@@ -1962,7 +2009,7 @@ export function useStep3Summary(
// 写文件(live write_file 流式)时才自动打开;恢复历史时用户点气泡里的
// 「点击预览」步骤卡随时可开(stub 已种好)。
},
[abort, buildWriteFileVirtualUrl],
[abort, buildWriteFileVirtualUrl, replaceStubMessages],
);
const busy = phase === "creating" || phase === "streaming";
@@ -1534,9 +1534,9 @@ export default function RoundtablePlanningPage({
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
const autoSummaryFlow = true;
// 第三步的交付统一为 Markdown 总结报告。流程图不再作为本页的后续自动产物,
// 避免报告写完后额外触发一次结构化抽取请求。
const autoSummaryFlow = false;
const summaryFlow = useStep3Summary({
intentReady: step1.intentReady,
@@ -1546,6 +1546,7 @@ export default function RoundtablePlanningPage({
coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null,
chainLevels,
autoFlow: autoSummaryFlow,
flowEnabled: false,
savedSummary: step3Report?.summaryReport ?? null,
onSummarySaved: handleSummarySaved,
ingestAvailable,
@@ -2422,7 +2423,6 @@ export default function RoundtablePlanningPage({
useStep1InputAppearance={rootClassName?.includes("eightbf-roundtable") ?? false}
step2RoundtableDialogues={step2.step2RoundtableDialogues}
isInitializing={step2.isInitializing}
isRoundTableRunning={step2.isRoundTableRunning}
isOrchestrating={step2.isOrchestrating}
pendingClarification={step2.pendingClarification}
clarificationDraft={step2.clarificationDraft}
@@ -2450,7 +2450,6 @@ export default function RoundtablePlanningPage({
openArtifactFromStep={step2.openArtifactFromStep}
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
@@ -2995,7 +2994,7 @@ function LeftSidebar({
className={cn(
"min-h-0 shrink-0 flex flex-col gap-3 pr-1 transition-[width] duration-200 ease-out",
isStep2 ? "overflow-y-auto" : "overflow-hidden",
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
isStep2 && !sidebarExpanded ? "w-[80px]" : "w-[280px]",
)}
>
{isStep2 && !sidebarExpanded && (
@@ -3004,7 +3003,7 @@ function LeftSidebar({
onClick={() => setSidebarExpanded(true)}
title="展开侧栏"
aria-expanded={false}
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
className="w-full shrink-0 flex h-10 items-center justify-center rounded-[4px] border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
@@ -3731,7 +3730,7 @@ function Step2RunSwitcher({
type="button"
onClick={() => onSelect(run.id)}
className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors",
"roundtable-step2-run-card w-full rounded-xl border p-2.5 text-left transition-colors",
active
? "border-primary/40 bg-primary/5 text-slate-900"
: "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white",
@@ -3842,7 +3841,7 @@ function HumanParticipationCard({
: 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",
"roundtable-step2-human-row 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",
)}
@@ -4229,7 +4228,7 @@ function CollapsedSidebarSection({
<div
title={title}
className={cn(
"w-full shrink-0 bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
"w-full shrink-0 bg-white border border-slate-200/50 rounded-[4px] p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
className,
)}
>
@@ -4366,7 +4365,7 @@ function RoleRow({
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
"roundtable-step2-role-row flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp