fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新
content 先于 path 到达时用临时文件名开沙箱,流式快照改走外部 store 避免整页重渲染卡顿。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
e939dc4548
commit
69e455274a
5 files changed
+480
-117
No files matched your search
@@ -121,8 +121,9 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
|
||||
export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
mimeType = null,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
@@ -131,6 +132,8 @@ export function ArtifactFileDetail({
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
/** Server/tool supplied type wins over path guessing when available. */
|
||||
mimeType?: string | null;
|
||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
@@ -150,16 +153,38 @@ export function ArtifactFileDetail({
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
const filepath = useMemo(() => {
|
||||
if (isWriteFileVirtual) {
|
||||
const url = new URL(filepathFromProps);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
return filepathFromProps;
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
// A streamed tool call can be opened before the path has been normalized by
|
||||
// the artifacts API. Keep the format supplied by the writer so Markdown is
|
||||
// rendered as Markdown instead of falling back to a TEXT source preview.
|
||||
const writeFileLanguageHint = useMemo(() => {
|
||||
if (!isWriteFileVirtual) return null;
|
||||
try {
|
||||
const language = new URL(filepathFromProps).searchParams.get("language");
|
||||
return language === "markdown" ? language : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
const mimeLanguageHint = useMemo(() => {
|
||||
const normalized = mimeType?.toLowerCase().trim() ?? "";
|
||||
if (normalized.includes("markdown")) return "markdown";
|
||||
if (normalized.includes("html")) return "html";
|
||||
if (normalized.includes("mermaid")) return "mermaid";
|
||||
if (normalized.startsWith("text/")) return "text";
|
||||
return null;
|
||||
}, [mimeType]);
|
||||
const filepath = useMemo(() => {
|
||||
if (isWriteFileVirtual) {
|
||||
const url = new URL(filepathFromProps);
|
||||
return decodeURIComponent(url.pathname).trim();
|
||||
}
|
||||
// Tool arguments may contain harmless surrounding whitespace. Passing it
|
||||
// into extension detection turns `report.md ` into a generic TEXT file.
|
||||
return filepathFromProps.trim();
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
const isSkillFile = useMemo(() => {
|
||||
return filepath.endsWith(".skill");
|
||||
}, [filepath]);
|
||||
@@ -186,17 +211,22 @@ export function ArtifactFileDetail({
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
|
||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage = checkCodeFile(filepath).language;
|
||||
nextLanguage ??= "text";
|
||||
return { isCodeFile: true, language: nextLanguage };
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage =
|
||||
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
|
||||
nextLanguage ??= "text";
|
||||
return { isCodeFile: true, language: nextLanguage };
|
||||
}
|
||||
if (isSkillFile) {
|
||||
return { isCodeFile: true, language: "markdown" };
|
||||
}
|
||||
return checkCodeFile(filepath);
|
||||
}, [filepath, useWriteFileStreaming, isSkillFile]);
|
||||
const detected = checkCodeFile(filepath);
|
||||
if (mimeLanguageHint) {
|
||||
return { isCodeFile: true as const, language: mimeLanguageHint };
|
||||
}
|
||||
return detected;
|
||||
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return (
|
||||
language === "html" ||
|
||||
@@ -209,7 +239,8 @@ export function ArtifactFileDetail({
|
||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||
// 不会被旧的内存 stub 值覆盖回退。
|
||||
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
|
||||
filepath:
|
||||
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||||
enabled: isCodeFile && !useWriteFileStreaming,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,10 +25,13 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
};
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
toolCallId?: string;
|
||||
};
|
||||
|
||||
export type ToolStartEvent = ToolEndEvent;
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
@@ -47,10 +50,11 @@ export type ThreadStreamOptions = {
|
||||
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||
threadMetadata?: Record<string, unknown>;
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolStart?: (event: ToolStartEvent) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
@@ -447,10 +451,11 @@ export function useThreadStream({
|
||||
assistantId,
|
||||
runOptions,
|
||||
threadMetadata,
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
@@ -466,16 +471,17 @@ export function useThreadStream({
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
});
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
// Keep listeners ref updated with latest callbacks
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolEnd };
|
||||
}, [onStart, onFinish, onToolEnd]);
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
|
||||
}, [onStart, onFinish, onToolStart, onToolEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedThreadId = threadId ?? null;
|
||||
@@ -545,12 +551,6 @@ export function useThreadStream({
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.data,
|
||||
});
|
||||
}
|
||||
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
||||
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
||||
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
||||
@@ -572,8 +572,32 @@ export function useThreadStream({
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
|
||||
// mode. `events` is a separate LangChain trace channel; using it for UI
|
||||
// reactions can miss a tool that the conversation has already rendered.
|
||||
onToolEvent:
|
||||
onToolStart || onToolEnd
|
||||
? (event) => {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_start") {
|
||||
listeners.current.onToolStart?.({
|
||||
name: event.name,
|
||||
data: event.input,
|
||||
toolCallId: event.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.output,
|
||||
toolCallId: event.toolCallId,
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
onUpdateEvent(data) {
|
||||
markStreamActivity();
|
||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||
|
||||
@@ -135,7 +135,9 @@ PositionRoundtablePage
|
||||
设计要点:
|
||||
|
||||
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
|
||||
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
|
||||
- `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付;
|
||||
- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染;
|
||||
- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容;
|
||||
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
|
||||
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useEditMessage, useThreadStream } from "@/core/threads/hooks";
|
||||
import {
|
||||
useEditMessage,
|
||||
useThreadStream,
|
||||
type ToolStartEvent,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
@@ -40,7 +44,6 @@ import {
|
||||
readConversationDraft,
|
||||
readConversationMessages,
|
||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||
|
||||
interface PositionNodeSwitchOption {
|
||||
node: PositionRoundtableNode;
|
||||
agentName?: string | null;
|
||||
@@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string {
|
||||
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
|
||||
}
|
||||
|
||||
function normalizeWriteFilePath(path: string): string {
|
||||
let normalized = path.trim();
|
||||
const first = normalized[0];
|
||||
const last = normalized.at(-1);
|
||||
if (
|
||||
normalized.length >= 2 &&
|
||||
((first === '"' && last === '"') ||
|
||||
(first === "'" && last === "'") ||
|
||||
(first === "`" && last === "`"))
|
||||
) {
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isMarkdownPath(path: string): boolean {
|
||||
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
|
||||
return pathname.endsWith(".md") || pathname.endsWith(".markdown");
|
||||
}
|
||||
|
||||
function hasCompleteArtifactExtension(path: string): boolean {
|
||||
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0] ?? "";
|
||||
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
// During JSON argument streaming a Markdown path arrives as `.m` and then
|
||||
// `.md`. Opening on the partial suffix permanently labels the preview TEXT.
|
||||
// Extensionless files still open from on_tool_start once the full path is known.
|
||||
return /\.[a-z0-9]{2,12}$/i.test(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* `write_file` arguments stream as one JSON object and the key order is up to
|
||||
* the model. Agents that emit `content` before `path` leave the file nameless
|
||||
* for as long as the body takes to write — a minute for a full report. The
|
||||
* sandbox follows a streaming file by message/tool-call id, not by path, so it
|
||||
* can open under a provisional name and adopt the real one later. Sniff the
|
||||
* body so the provisional name at least carries the right extension.
|
||||
*/
|
||||
function provisionalWriteFilePath(content: string): string {
|
||||
const head = content.slice(0, 256).trimStart().toLowerCase();
|
||||
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
||||
return "未命名文件.html";
|
||||
}
|
||||
return "未命名文件.md";
|
||||
}
|
||||
|
||||
function writeFilePathFromToolEvent(data: unknown, depth = 0): string | null {
|
||||
if (depth > 3 || data == null) return null;
|
||||
if (typeof data === "string") {
|
||||
const value = data.trim();
|
||||
if (!value) return null;
|
||||
if (value.startsWith("{") || value.startsWith("[")) {
|
||||
try {
|
||||
return writeFilePathFromToolEvent(JSON.parse(value), depth + 1);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof data !== "object" || Array.isArray(data)) return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
if (typeof record.path === "string") {
|
||||
const path = normalizeWriteFilePath(record.path);
|
||||
return path || null;
|
||||
}
|
||||
for (const key of ["input", "inputs", "args", "kwargs"]) {
|
||||
const path = writeFilePathFromToolEvent(record[key], depth + 1);
|
||||
if (path) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
|
||||
const language = isMarkdownPath(path) ? "&language=markdown" : "";
|
||||
return new URL(
|
||||
`write-file:${path}?message_id=${encodeURIComponent(
|
||||
messageId,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" {
|
||||
return name === "write_file" || name === "str_replace";
|
||||
}
|
||||
|
||||
function latestStreamingWriteFile(
|
||||
messages: AgentThreadState["messages"],
|
||||
): { key: string; path: string; name: string; mimeType: string | null } | null {
|
||||
): {
|
||||
key: string;
|
||||
path: string;
|
||||
sourcePath: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
} | null {
|
||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const message = messages[messageIndex] as (typeof messages)[number] & {
|
||||
id?: string;
|
||||
@@ -183,17 +269,33 @@ function latestStreamingWriteFile(
|
||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||
const toolCall = message.tool_calls[toolIndex];
|
||||
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
|
||||
const rawPath = toolCall.args?.path;
|
||||
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
|
||||
const content = toolCall.args?.content;
|
||||
if (typeof content !== "string" || content.length === 0) continue;
|
||||
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
|
||||
if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue;
|
||||
const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : "";
|
||||
const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : "";
|
||||
const normalizedPath = normalizeWriteFilePath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
if (!hasUsablePath) {
|
||||
// `str_replace` edits an existing file, so it can only be previewed once
|
||||
// its real path is known. A new file is identified by its tool call, so
|
||||
// its body can be previewed as soon as the first characters arrive.
|
||||
if (toolCall.name !== "write_file" || !rawContent.trim()) continue;
|
||||
}
|
||||
const sourcePath = hasUsablePath ? normalizedPath : provisionalWriteFilePath(rawContent);
|
||||
// Do not wait for the complete `content` argument. LangGraph streams
|
||||
// write_file arguments incrementally, and the virtual artifact loader
|
||||
// will redraw as `content` arrives. Waiting for it turns this into a
|
||||
// post-generation preview instead of a live sandbox.
|
||||
const previewPath =
|
||||
toolCall.name === "write_file"
|
||||
? buildWriteFileVirtualUrl(sourcePath, message.id, toolCall.id)
|
||||
: sourcePath;
|
||||
return {
|
||||
key: `${message.id}:${toolCall.id}`,
|
||||
path: virtualPath,
|
||||
name: artifactDisplayName(rawPath),
|
||||
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
|
||||
path: previewPath,
|
||||
sourcePath,
|
||||
name: artifactDisplayName(sourcePath),
|
||||
mimeType: isMarkdownPath(sourcePath) ? "text/markdown" : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -214,7 +316,7 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
|
||||
};
|
||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||
for (const toolCall of message.tool_calls) {
|
||||
if (toolCall?.name === "write_file" && toolCall.id) {
|
||||
if (isArtifactWriteTool(toolCall?.name) && toolCall.id) {
|
||||
keys.add(`${message.id}:${toolCall.id}`);
|
||||
}
|
||||
}
|
||||
@@ -265,11 +367,21 @@ export function PositionNodeChat({
|
||||
);
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||
const previousNodeBindingRef = useRef({
|
||||
nodeKey: node.nodeKey,
|
||||
threadId: node.threadId,
|
||||
});
|
||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
||||
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
|
||||
const autoPreviewRunArmedRef = useRef(false);
|
||||
const writeFilePreviewSequenceRef = useRef(0);
|
||||
const activeWriteFilePreviewRef = useRef<{
|
||||
path: string;
|
||||
previewKey: string;
|
||||
toolCallId?: string;
|
||||
} | null>(null);
|
||||
const editMessage = useEditMessage();
|
||||
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
||||
const persistedMessages = useMemo(
|
||||
@@ -288,12 +400,28 @@ export function PositionNodeChat({
|
||||
!node.invalidatedBy;
|
||||
|
||||
useEffect(() => {
|
||||
const previousBinding = previousNodeBindingRef.current;
|
||||
previousNodeBindingRef.current = { nodeKey: node.nodeKey, threadId: node.threadId };
|
||||
// `onStart` binds a newly created LangGraph thread before the session API
|
||||
// persists that id back into `node.threadId`. That prop update happens
|
||||
// while the first turn is already streaming; it is not a node switch and
|
||||
// must not clear the preview run that has just been armed.
|
||||
const isOwnThreadBinding =
|
||||
previousBinding.nodeKey === node.nodeKey &&
|
||||
previousBinding.threadId == null &&
|
||||
node.threadId != null &&
|
||||
node.threadId === boundThreadIdRef.current;
|
||||
if (isOwnThreadBinding) return;
|
||||
|
||||
setBoundThreadId(node.threadId);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
||||
setMessageContentOverrides({});
|
||||
autoPreviewedArtifactKeyRef.current = null;
|
||||
autoPreviewBaselineCallKeysRef.current = new Set();
|
||||
autoPreviewRunArmedRef.current = false;
|
||||
writeFilePreviewSequenceRef.current = 0;
|
||||
activeWriteFilePreviewRef.current = null;
|
||||
// The component is keyed by nodeKey in the page. This reset is therefore
|
||||
// only a defensive guard for callers that reuse the component instance.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -314,6 +442,43 @@ export function PositionNodeChat({
|
||||
[node.nodeKey, onNodeUpdate, sessionId],
|
||||
);
|
||||
|
||||
const handleToolStart = useCallback(
|
||||
(event: ToolStartEvent) => {
|
||||
if (!isArtifactWriteTool(event.name) || !autoPreviewRunArmedRef.current) return;
|
||||
const path = writeFilePathFromToolEvent(event.data);
|
||||
const threadId = boundThreadIdRef.current;
|
||||
if (!path || !threadId) return;
|
||||
|
||||
// Message chunks and on_tool_start can describe the same invocation in
|
||||
// either order. Reuse the first preview key so a manual close suppresses
|
||||
// only this write, while a later rewrite receives a fresh key and opens.
|
||||
const activePreview = activeWriteFilePreviewRef.current;
|
||||
if (
|
||||
activePreview?.path === path &&
|
||||
(activePreview.toolCallId === event.toolCallId ||
|
||||
(event.toolCallId != null && activePreview.previewKey.endsWith(`:${event.toolCallId}`)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previewKey = event.toolCallId
|
||||
? `tool-start:${event.toolCallId}`
|
||||
: `tool-start:${node.nodeKey}:${++writeFilePreviewSequenceRef.current}`;
|
||||
activeWriteFilePreviewRef.current = { path, previewKey, toolCallId: event.toolCallId };
|
||||
const artifactKey = `${threadId}:${previewKey}`;
|
||||
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||
onPreviewArtifact?.({
|
||||
threadId,
|
||||
path,
|
||||
name: artifactDisplayName(path),
|
||||
mimeType: isMarkdownPath(path) ? "text/markdown" : null,
|
||||
previewKey,
|
||||
auto: true,
|
||||
});
|
||||
},
|
||||
[node.nodeKey, onPreviewArtifact],
|
||||
);
|
||||
|
||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||
threadId: boundThreadId,
|
||||
assistantId: node.agentId,
|
||||
@@ -334,15 +499,21 @@ export function PositionNodeChat({
|
||||
agent_id: node.agentId,
|
||||
},
|
||||
onStart: bindCreatedThread,
|
||||
onToolStart: handleToolStart,
|
||||
onFinish: (state) => {
|
||||
// A turn is not considered delivered by the UI alone. The persisted
|
||||
// completion call records the answer, file manifest and upstream version
|
||||
// so the service can decide whether this seat unlocks its downstream.
|
||||
const threadId = boundThreadIdRef.current;
|
||||
const latestAi = [...(state.messages ?? [])]
|
||||
// `present_files` is often the last AI tool call and has no prose of
|
||||
// its own. Keep walking backwards to the last actual answer; otherwise
|
||||
// a successful file delivery would return early here and never refresh
|
||||
// the node's artifact cards.
|
||||
const latestAnswer = [...(state.messages ?? [])]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
|
||||
.filter((message) => message.type === "ai")
|
||||
.map((message) => textOfMessage(message) ?? "")
|
||||
.find((content) => content.trim()) ?? "";
|
||||
if (!threadId || !latestAnswer.trim()) return;
|
||||
void (async () => {
|
||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||
@@ -378,6 +549,10 @@ export function PositionNodeChat({
|
||||
|
||||
const activeThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
||||
// A sandbox must always receive the server thread id. In particular, do not
|
||||
// attach a streaming preview to the provisional UUID used for a new node
|
||||
// before `useThreadStream` has created and bound its thread.
|
||||
const previewThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const persistedThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const recoveredMessages = useMemo(
|
||||
@@ -566,13 +741,17 @@ export function PositionNodeChat({
|
||||
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
|
||||
[thread.isLoading, thread.messages],
|
||||
);
|
||||
const newStreamingArtifact =
|
||||
streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||
? streamingArtifact
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!thread.isLoading ||
|
||||
!streamingArtifact ||
|
||||
!autoPreviewRunArmedRef.current ||
|
||||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||
!newStreamingArtifact ||
|
||||
!previewThreadId ||
|
||||
!autoPreviewRunArmedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -580,18 +759,34 @@ export function PositionNodeChat({
|
||||
// guards manual close state, because it owns the page-wide singleton sandbox.
|
||||
// A report is often rewritten to the same output path. The tool-call key
|
||||
// (not just the path) makes that a new previewable delivery.
|
||||
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
|
||||
const startedPreview = activeWriteFilePreviewRef.current;
|
||||
const previewKey =
|
||||
startedPreview?.path === newStreamingArtifact.sourcePath
|
||||
? startedPreview.previewKey
|
||||
: newStreamingArtifact.key;
|
||||
if (!startedPreview || startedPreview.path !== newStreamingArtifact.sourcePath) {
|
||||
activeWriteFilePreviewRef.current = {
|
||||
path: newStreamingArtifact.sourcePath,
|
||||
previewKey,
|
||||
toolCallId: startedPreview?.toolCallId,
|
||||
};
|
||||
}
|
||||
// The path takes part in the identity so that a preview opened under a
|
||||
// provisional name is re-published once the real path streams in. The
|
||||
// preview key stays the same, so the parent treats it as an update of the
|
||||
// open panel rather than a second delivery.
|
||||
const artifactKey = `${previewThreadId}:${previewKey}:${newStreamingArtifact.sourcePath}`;
|
||||
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||
onPreviewArtifact?.({
|
||||
threadId: activeThreadId,
|
||||
path: streamingArtifact.path,
|
||||
name: streamingArtifact.name,
|
||||
mimeType: streamingArtifact.mimeType,
|
||||
previewKey: streamingArtifact.key,
|
||||
threadId: previewThreadId,
|
||||
path: newStreamingArtifact.path,
|
||||
name: newStreamingArtifact.name,
|
||||
mimeType: newStreamingArtifact.mimeType,
|
||||
previewKey,
|
||||
auto: true,
|
||||
});
|
||||
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
|
||||
}, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]);
|
||||
|
||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||
// watching the actual stream id as well closes the last race where a page
|
||||
@@ -628,6 +823,7 @@ export function PositionNodeChat({
|
||||
: null;
|
||||
// A thread retains historical write_file tool calls. Only files whose
|
||||
// call id appears after this exact submission may open the sandbox.
|
||||
activeWriteFilePreviewRef.current = null;
|
||||
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
||||
autoPreviewRunArmedRef.current = true;
|
||||
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
@@ -336,6 +344,72 @@ function sandboxCloseKey(
|
||||
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
|
||||
}
|
||||
|
||||
type SandboxThreadStore = {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => SandboxThreadSnapshot | null;
|
||||
publish: (next: SandboxThreadSnapshot | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The streaming thread reaches the sandbox through a subscription rather than
|
||||
* page state. This page is large — task panel, intent results, flowchart, node
|
||||
* chat — and a streamed file publishes several times per second. Driving that
|
||||
* through `useState` here re-renders the whole page at that rate and starves the
|
||||
* browser so badly that even the message list stops painting until the run ends.
|
||||
* With a store, only `SandboxThreadProvider` and the preview below it re-render.
|
||||
*/
|
||||
function createSandboxThreadStore(): SandboxThreadStore {
|
||||
let snapshot: SandboxThreadSnapshot | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSnapshot: () => snapshot,
|
||||
publish(next) {
|
||||
const isUnchanged =
|
||||
snapshot === next ||
|
||||
(snapshot != null &&
|
||||
next != null &&
|
||||
snapshot.threadId === next.threadId &&
|
||||
snapshot.signature === next.signature);
|
||||
if (isUnchanged) return;
|
||||
snapshot = next;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function SandboxThreadProvider({
|
||||
store,
|
||||
threadId,
|
||||
fallbackThread,
|
||||
children,
|
||||
}: {
|
||||
store: SandboxThreadStore;
|
||||
threadId: string;
|
||||
fallbackThread: BaseStream<AgentThreadState>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||
const thread = snapshot?.threadId === threadId ? snapshot.thread : fallbackThread;
|
||||
const value = useMemo(() => ({ thread, isMock: false }), [thread]);
|
||||
return <ThreadContext.Provider value={value}>{children}</ThreadContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on how often the streaming thread is re-published to the sandbox.
|
||||
* Message chunks arrive roughly every 30ms and re-rendering the artifact panel
|
||||
* is expensive, so updates are coalesced — but never postponed past this gap,
|
||||
* otherwise the panel visibly freezes while the file is being written.
|
||||
*/
|
||||
const SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160;
|
||||
|
||||
function sandboxThreadSignature(thread: BaseStream<AgentThreadState>): string {
|
||||
const messages = thread.messages ?? [];
|
||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
@@ -872,10 +946,11 @@ export default function PositionRoundtablePage({
|
||||
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
||||
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
|
||||
const closedSandboxKeyRef = useRef<string | null>(null);
|
||||
const [sandboxThreadSnapshot, setSandboxThreadSnapshot] = useState<SandboxThreadSnapshot | null>(null);
|
||||
const sandboxThreadStore = useMemo(createSandboxThreadStore, []);
|
||||
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||
const pendingSandboxThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
|
||||
const sandboxSnapshotTimerRef = useRef<number | null>(null);
|
||||
const sandboxSnapshotAppliedAtRef = useRef(0);
|
||||
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
|
||||
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
|
||||
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
|
||||
@@ -908,7 +983,10 @@ export default function PositionRoundtablePage({
|
||||
sandboxArtifactRef.current = sandboxArtifact;
|
||||
}, [sandboxArtifact]);
|
||||
const flushSandboxThreadSnapshot = useCallback(() => {
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
if (sandboxSnapshotTimerRef.current !== null) {
|
||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
}
|
||||
const pending = pendingSandboxThreadSnapshotRef.current;
|
||||
pendingSandboxThreadSnapshotRef.current = null;
|
||||
if (!pending) return;
|
||||
@@ -916,21 +994,28 @@ export default function PositionRoundtablePage({
|
||||
if (!currentArtifact || currentArtifact.threadId !== pending.threadId) {
|
||||
return;
|
||||
}
|
||||
setSandboxThreadSnapshot((current) =>
|
||||
current?.threadId === pending.threadId && current.signature === pending.signature
|
||||
? current
|
||||
: pending,
|
||||
);
|
||||
}, []);
|
||||
sandboxSnapshotAppliedAtRef.current = Date.now();
|
||||
sandboxThreadStore.publish(pending);
|
||||
}, [sandboxThreadStore]);
|
||||
const queueSandboxThreadSnapshot = useCallback(
|
||||
(snapshot: SandboxThreadSnapshot) => {
|
||||
pendingSandboxThreadSnapshotRef.current = snapshot;
|
||||
if (sandboxSnapshotTimerRef.current !== null) return;
|
||||
if (typeof window === "undefined") {
|
||||
// Leading-edge throttle: publish straight away whenever the previous
|
||||
// publish is old enough. Trailing timers alone are not dependable here —
|
||||
// a continuous stream keeps the main thread busy and the panel then stays
|
||||
// frozen on the content it had when it opened.
|
||||
const sinceApplied = Date.now() - sandboxSnapshotAppliedAtRef.current;
|
||||
if (typeof window === "undefined" || sinceApplied >= SANDBOX_SNAPSHOT_MIN_INTERVAL_MS) {
|
||||
flushSandboxThreadSnapshot();
|
||||
return;
|
||||
}
|
||||
sandboxSnapshotTimerRef.current = window.setTimeout(flushSandboxThreadSnapshot, 160);
|
||||
// Still inside the quiet window: make sure the last chunk of a stream that
|
||||
// stops right now is not left unpublished.
|
||||
if (sandboxSnapshotTimerRef.current !== null) return;
|
||||
sandboxSnapshotTimerRef.current = window.setTimeout(
|
||||
flushSandboxThreadSnapshot,
|
||||
SANDBOX_SNAPSHOT_MIN_INTERVAL_MS - sinceApplied,
|
||||
);
|
||||
},
|
||||
[flushSandboxThreadSnapshot],
|
||||
);
|
||||
@@ -938,6 +1023,7 @@ export default function PositionRoundtablePage({
|
||||
() => () => {
|
||||
if (sandboxSnapshotTimerRef.current !== null) {
|
||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -2014,7 +2100,7 @@ export default function PositionRoundtablePage({
|
||||
}
|
||||
closedSandboxKeyRef.current = null;
|
||||
setSandboxArtifact(null);
|
||||
setSandboxThreadSnapshot(null);
|
||||
sandboxThreadStore.publish(null);
|
||||
intent.resetIntent();
|
||||
hydratedIntentSessionRef.current = null;
|
||||
restoringIntentSessionRef.current = null;
|
||||
@@ -2102,7 +2188,7 @@ export default function PositionRoundtablePage({
|
||||
// 会话的窗口期仍接收旧 thread 增量;hydrate 入口还会再做一次兜底清理。
|
||||
intent.stopIntentStream();
|
||||
setSandboxArtifact(null);
|
||||
setSandboxThreadSnapshot(null);
|
||||
sandboxThreadStore.publish(null);
|
||||
setActiveNodeKey(null);
|
||||
setActiveBuiltinKind(null);
|
||||
setSelectedAgentId(null);
|
||||
@@ -2225,7 +2311,7 @@ export default function PositionRoundtablePage({
|
||||
return;
|
||||
}
|
||||
|
||||
const artifactKey = sandboxCloseKey(artifact);
|
||||
const artifactKey = sandboxCloseKey(artifact);
|
||||
if (artifact.auto) {
|
||||
// Closing an old file should only suppress that exact preview. A later
|
||||
// write_file in the same thread is a new deliverable and must still be
|
||||
@@ -2236,22 +2322,47 @@ export default function PositionRoundtablePage({
|
||||
}
|
||||
closedSandboxKeyRef.current = null;
|
||||
|
||||
setSandboxArtifact((current) => {
|
||||
const isSamePreview =
|
||||
current?.threadId === artifact.threadId &&
|
||||
current.path === artifact.path &&
|
||||
current.previewKey === artifact.previewKey;
|
||||
return isSamePreview ? current : artifact;
|
||||
});
|
||||
setSandboxThreadSnapshot((current) =>
|
||||
current?.threadId === artifact.threadId ? current : null,
|
||||
);
|
||||
// Follow the same lifecycle as the regular conversation view. The
|
||||
// position workspace owns a custom visual container, but the artifact
|
||||
// selection/open state must still advance at tool-start so its preview is
|
||||
// not deferred until the final artifact list is written.
|
||||
const ctx = artifactsCtxRef.current;
|
||||
ctx?.select(artifact.path, true);
|
||||
ctx?.setOpen(true);
|
||||
|
||||
const openedArtifact = sandboxArtifactRef.current;
|
||||
const isSamePreview =
|
||||
openedArtifact?.threadId === artifact.threadId &&
|
||||
openedArtifact.path === artifact.path &&
|
||||
openedArtifact.previewKey === artifact.previewKey;
|
||||
// A streaming file is previewed under a provisional name until its real path
|
||||
// arrives. Adopting that path renames the open panel; it does not deliver a
|
||||
// second file, so the surrounding layout must not be reshuffled again.
|
||||
const isPathUpgrade =
|
||||
!isSamePreview &&
|
||||
Boolean(artifact.previewKey) &&
|
||||
openedArtifact?.threadId === artifact.threadId &&
|
||||
openedArtifact.previewKey === artifact.previewKey;
|
||||
if (!isSamePreview) {
|
||||
setSandboxArtifact(artifact);
|
||||
// The snapshot queued below is published synchronously, before the state
|
||||
// update above commits, so the ref that gates it has to advance here.
|
||||
sandboxArtifactRef.current = artifact;
|
||||
// A newly opened panel must show the file's current content immediately
|
||||
// instead of waiting out the throttle window of the previous preview.
|
||||
sandboxSnapshotAppliedAtRef.current = 0;
|
||||
}
|
||||
if (sandboxThreadStore.getSnapshot()?.threadId !== artifact.threadId) {
|
||||
sandboxThreadStore.publish(null);
|
||||
}
|
||||
|
||||
const latestSnapshot = latestNodeThreadSnapshotRef.current;
|
||||
if (latestSnapshot?.threadId === artifact.threadId) {
|
||||
queueSandboxThreadSnapshot(latestSnapshot);
|
||||
}
|
||||
setRightPanelOpen(false);
|
||||
if (!isPathUpgrade) {
|
||||
setRightPanelOpen(false);
|
||||
}
|
||||
}, [queueSandboxThreadSnapshot]);
|
||||
const handlePreviewFlowArtifact = useCallback(
|
||||
(item: ResultItem) => {
|
||||
@@ -2322,18 +2433,6 @@ export default function PositionRoundtablePage({
|
||||
}) as unknown as BaseStream<AgentThreadState>,
|
||||
[artifactsCtx?.artifacts],
|
||||
);
|
||||
const sandboxThread = useMemo(
|
||||
() =>
|
||||
sandboxArtifact &&
|
||||
sandboxThreadSnapshot?.threadId === sandboxArtifact.threadId
|
||||
? sandboxThreadSnapshot.thread
|
||||
: sandboxThreadStub,
|
||||
[sandboxArtifact, sandboxThreadSnapshot, sandboxThreadStub],
|
||||
);
|
||||
const sandboxThreadContextValue = useMemo(
|
||||
() => ({ thread: sandboxThread, isMock: false }),
|
||||
[sandboxThread],
|
||||
);
|
||||
const handleNodeThreadSnapshot = useCallback(
|
||||
(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) => {
|
||||
const signature = sandboxThreadSignature(snapshot.thread);
|
||||
@@ -2356,10 +2455,16 @@ export default function PositionRoundtablePage({
|
||||
if (ctx?.open) {
|
||||
ctx?.setOpen(false);
|
||||
}
|
||||
if (sandboxSnapshotTimerRef.current !== null) {
|
||||
window.clearTimeout(sandboxSnapshotTimerRef.current);
|
||||
sandboxSnapshotTimerRef.current = null;
|
||||
}
|
||||
pendingSandboxThreadSnapshotRef.current = null;
|
||||
sandboxSnapshotAppliedAtRef.current = 0;
|
||||
sandboxArtifactRef.current = null;
|
||||
setSandboxArtifact(null);
|
||||
setSandboxThreadSnapshot(null);
|
||||
}, []);
|
||||
sandboxThreadStore.publish(null);
|
||||
}, [sandboxThreadStore]);
|
||||
const handleSelectBuiltinStage = useCallback(
|
||||
(kind: ClosureStageKind) => {
|
||||
// The closing agents are part of the testing-analysis workspace only;
|
||||
@@ -2993,18 +3098,23 @@ export default function PositionRoundtablePage({
|
||||
)}
|
||||
{sandboxOpen && sandboxArtifact && artifactsCtx && (
|
||||
<aside className="flex w-[min(48vw,720px)] min-w-[440px] shrink-0 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none">
|
||||
<ThreadContext.Provider value={sandboxThreadContextValue}>
|
||||
<SandboxThreadProvider
|
||||
store={sandboxThreadStore}
|
||||
threadId={sandboxArtifact.threadId}
|
||||
fallbackThread={sandboxThreadStub}
|
||||
>
|
||||
<ArtifactFileDetail
|
||||
key={sandboxCloseKey(sandboxArtifact)}
|
||||
className="min-h-0 flex-1"
|
||||
filepath={sandboxArtifact.path}
|
||||
threadId={sandboxArtifact.threadId}
|
||||
mimeType={sandboxArtifact.mimeType}
|
||||
onClose={closeSandbox}
|
||||
allowVirtualMarkdownEdit
|
||||
disableDesensitize
|
||||
hideFileName
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
</SandboxThreadProvider>
|
||||
</aside>
|
||||
)}
|
||||
<DeliverableDetailDialog
|
||||
|
||||
Reference in new issue
Block a user