fix(chat): keep the canvas after an unrelated error, and more review fixes

Found by the second PR review:
- After a streamed edit, an older render of the stream stored the edit's
  original diagram again, and the next failed request (no quota, a lost
  connection) put that old diagram back on the canvas. The tool handler
  now marks its call as handled, so the preview code leaves it alone.
- An edit applied before the UI showed an earlier broken edit's error was
  erased when that error undid its preview, or was built on that preview.
  The handler now starts from the diagram before all unhandled previews,
  and reads the diagram state that updates at once.
- A failed or stopped display_diagram left its half drawn diagram on the
  canvas. Its preview is undone now, like an edit's.
- "New chat" cleared a chat that could not be saved (storage full).
- The settings dialog showed a model list, a fetch error or a test result
  on the provider that was opened after the request started, and marked a
  model id changed during the test as tested.
- A tool call with broken JSON was shown as cut off by the output limit.
- History entries and session thumbnails could pair with a later diagram
  when draw.io answered an export late.
- A server model saved before non-ASCII provider names got into the id
  was reset to the default model.
This commit is contained in:
dayuan.jiang
2026-10-05 10:52:03 +09:00
parent 963839c4a9
commit 2e84e02666
13 changed files with 562 additions and 115 deletions
+64 -54
View File
@@ -453,71 +453,81 @@ export function ChatMessageDisplay({
if (isRestoredMessage) return
if (
part.type === "tool-display_diagram" &&
input?.xml
part.type !== "tool-display_diagram" &&
part.type !== "tool-edit_diagram"
) {
const xml = input.xml as string
return
}
// Failed or stopped: if the original XML is still
// stored, the tool handler never ran (invalid JSON,
// or the user pressed stop), so undo the streamed
// preview here. Invalid JSON leaves no input, so
// check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(
toolCallId,
)
onDisplayChart(originalXml, true)
baseXml = originalXml
}
return
}
// Input complete, or the tool handler, a stop or an
// error took the call already: the tool handler loads
// the checked diagram (with the original XML). The
// messages update at most every 150 ms (useChat
// throttle in chat-panel), so they can still show the
// call streaming after that.
if (
state !== "input-streaming" ||
processedToolCalls.current.has(toolCallId)
) {
processedToolCalls.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
lastProcessedXmlRef.current.delete(
`${toolCallId}-opCount`,
)
return
}
if (part.type === "tool-display_diagram") {
const xml = input?.xml as string | undefined
// Skip if XML hasn't changed since last processing
const lastXml =
lastProcessedXmlRef.current.get(toolCallId)
if (lastXml === xml) {
return // Skip redundant processing
}
// Messages update at most every 150 ms while
// streaming (useChat throttle in chat-panel)
if (state === "input-streaming") {
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
} else if (
!processedToolCalls.current.has(toolCallId)
if (
!xml ||
lastProcessedXmlRef.current.get(toolCallId) ===
xml
) {
// Input complete: the tool handler loads the
// validated diagram
processedToolCalls.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
return
}
// Keep the diagram from before the preview, to
// undo it on a stop or an error
if (
!editDiagramOriginalXmlRef.current.has(
toolCallId,
)
) {
editDiagramOriginalXmlRef.current.set(
toolCallId,
baseXml || BLANK_MXFILE,
)
}
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
return
}
// Handle edit_diagram streaming - apply operations incrementally for preview
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
if (part.type === "tool-edit_diagram") {
// Failed or stopped: if the original XML is still
// stored, the tool handler never ran (invalid
// JSON, or the user pressed stop), so undo the
// streamed preview here. Invalid JSON leaves no
// operations in the input, so check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(
toolCallId,
)
onDisplayChart(originalXml, true)
baseXml = originalXml
}
return
}
if (!input?.operations) return
if (state !== "input-streaming") {
// Input complete: the tool handler applies the
// checked edit (it reads the original XML too)
if (
!processedToolCalls.current.has(toolCallId)
) {
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
)
processedToolCalls.current.add(toolCallId)
}
return
}
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
+24 -7
View File
@@ -112,6 +112,7 @@ export default function ChatPanel({
handleExportWithoutHistory,
resolverRef,
chartXML,
chartXMLRef: liveChartXMLRef,
latestSvg,
clearDiagram,
getThumbnailSvg,
@@ -280,8 +281,9 @@ export default function ChatPanel({
// Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs
const processedToolCallsRef = useRef<Set<string>>(new Set())
// Store original XML for edit_diagram streaming - shared between streaming preview and tool handler
// Key: toolCallId, Value: original XML before any operations applied
// Store original XML for display_diagram and edit_diagram streaming -
// shared between streaming preview and tool handler
// Key: toolCallId, Value: XML before the call's preview was drawn
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
// Debounce timeout for localStorage writes (prevents blocking during streaming)
@@ -336,8 +338,10 @@ export default function ChatPanel({
const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
processedToolCallsRef,
validationRetryCountRef,
chartXMLRef,
// A preview undone just before the tool call is in this one already
chartXMLRef: liveChartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
@@ -363,10 +367,22 @@ export default function ChatPanel({
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onFinish: ({ message, isAbort, isError }) => {
// Stopped or failed: tool calls still streaming never reach the
// tool handler. Mark them handled so a later render of the
// stream does not draw their preview again.
if (!isAbort && !isError) return
for (const part of message.parts as any[]) {
if (part.state === "input-streaming" && part.toolCallId) {
processedToolCallsRef.current.add(part.toolCallId)
}
}
},
onError: (error) => {
// An edit still streaming when the request failed never reaches
// the tool handler: undo its preview. The first stored original
// is the diagram before any of them.
// A diagram still streaming when the request failed never
// reaches the tool handler: undo its preview. Only previews not
// handled yet are stored, and the first one holds the diagram
// before any of them.
const [originalXml] = editDiagramOriginalXmlRef.current.values()
if (originalXml) onDisplayChart(originalXml, true)
editDiagramOriginalXmlRef.current.clear()
@@ -948,7 +964,8 @@ export default function ChatPanel({
// Save current session before creating new one
if (sessionManager.isAvailable && messages.length > 0) {
const sessionData = await buildSessionData({ withThumbnail: true })
await sessionManager.saveCurrentSession(sessionData)
// Not saved (storage full): keep the chat on screen
if (!(await sessionManager.saveCurrentSession(sessionData))) return
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
+4 -2
View File
@@ -93,12 +93,14 @@ export function ToolCallCard({
}
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves
// stopped the generation themselves. Without an input the JSON was
// broken (the server repairs JSON cut short by the limit).
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
typeof input?.xml === "string" &&
!isMxCellXmlComplete(input.xml)
const handleCopy = () => {
let textToCopy = ""
+36 -5
View File
@@ -186,6 +186,12 @@ export function ModelConfigDialog({
const selectedProvider = config.providers.find(
(p) => p.id === selectedProviderId,
)
// For requests that finish after the user switched provider or edited
// a model id
const selectedProviderIdRef = useRef(selectedProviderId)
selectedProviderIdRef.current = selectedProviderId
const configRef = useRef(config)
configRef.current = config
// Discard an unfinished model ID edit when the dialog closes
useEffect(() => {
@@ -231,13 +237,15 @@ export function ModelConfigDialog({
},
)
const data = await response.json().catch(() => ({}))
// The picker and the error belong to the provider shown
const stillShown = selectedProviderIdRef.current === providerId
if (Array.isArray(data.models)) {
setFetchedModels((current) => ({
...current,
[providerId]: data.models,
}))
setModelPickerOpen(true)
} else {
if (stillShown) setModelPickerOpen(true)
} else if (stillShown) {
const hints = dict.errors.llm as Record<string, string>
setFetchModelsError(
[hints[data.code], data.error].filter(Boolean).join(" ") ||
@@ -245,7 +253,9 @@ export function ModelConfigDialog({
)
}
} catch {
setFetchModelsError(dict.errors.networkError)
if (selectedProviderIdRef.current === providerId) {
setFetchModelsError(dict.errors.networkError)
}
} finally {
setFetchingModels(false)
}
@@ -387,6 +397,7 @@ export function ModelConfigDialog({
let allValid = true
let errorCount = 0
let idChanged = false
const credentialsVersion = credentialsVersionRef.current
// For EdgeOne, construct baseUrl from current origin
@@ -464,6 +475,19 @@ export function ModelConfigDialog({
}
// Credentials changed during the test: drop the result
if (credentialsVersionRef.current !== credentialsVersion) return
// So did this model's id: the result is for the old one
const current = configRef.current.providers
.find((p) => p.id === selectedProviderId)
?.models.find((m) => m.id === model.id)
if (current?.modelId !== model.modelId) {
idChanged = true
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
return
}
if (update.validated === false) {
allValid = false
errorCount++
@@ -478,9 +502,16 @@ export function ModelConfigDialog({
)
if (credentialsVersionRef.current !== credentialsVersion) return
if (allValid) {
setValidationStatus("success")
// A model whose id changed was not tested
if (allValid && !idChanged) {
updateProvider(selectedProviderId, { validated: true })
}
// The status line is about the provider shown now
if (selectedProviderIdRef.current !== selectedProviderId) return
if (idChanged) {
setValidationStatus("idle")
} else if (allValid) {
setValidationStatus("success")
// Reset to idle after showing success briefly (with cleanup)
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)