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
+19 -12
View File
@@ -52,6 +52,8 @@ type ValidateDiagramFn = (
interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
// Tool calls the streaming preview must leave alone (shared with it)
processedToolCallsRef: MutableRefObject<Set<string>>
// Failed VLM validations in the current user turn (reset on each user message)
validationRetryCountRef: MutableRefObject<number>
chartXMLRef: MutableRefObject<string>
@@ -78,6 +80,7 @@ interface UseDiagramToolHandlersParams {
export function useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
processedToolCallsRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
@@ -118,10 +121,22 @@ export function useDiagramToolHandlers({
)
}
// Stored originals belong to previews not handled yet: this call's,
// and those of earlier calls with invalid input, which never get
// here. The first is the diagram before all of them. This call's
// result replaces those previews, so the preview code must neither
// draw them again nor undo them later.
const [originalXml] = editDiagramOriginalXmlRef.current.values()
for (const id of editDiagramOriginalXmlRef.current.keys()) {
processedToolCallsRef.current.add(id)
}
processedToolCallsRef.current.add(toolCall.toolCallId)
editDiagramOriginalXmlRef.current.clear()
if (toolCall.toolName === "display_diagram") {
await handleDisplayDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "edit_diagram") {
await handleEditDiagram(toolCall, addToolOutput)
await handleEditDiagram(toolCall, addToolOutput, originalXml)
} else if (toolCall.toolName === "append_diagram") {
handleAppendDiagram(toolCall, addToolOutput)
}
@@ -369,20 +384,18 @@ ${finalXml}
}
}
// originalXml: the diagram before the streamed previews, if any were drawn.
// Operations apply to it, the same base XML that streaming used.
const handleEditDiagram = async (
toolCall: ToolCall,
addToolOutput: AddToolOutputFn,
originalXml: string | undefined,
) => {
const { operations } = toolCall.input as {
operations: DiagramOperation[]
}
let currentXml = ""
// Use the original XML captured during streaming (shared with chat-message-display)
// This ensures we apply operations to the same base XML that streaming used
const originalXml = editDiagramOriginalXmlRef.current.get(
toolCall.toolCallId,
)
// On failure, undo the streaming preview so the canvas matches the XML
// reported back to the model
const restoreOriginal = () => {
@@ -423,8 +436,6 @@ ${currentXml}
Please check the cell IDs and retry.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
return
}
@@ -435,8 +446,6 @@ Please check the cell IDs and retry.`,
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
} catch (error) {
console.error("[edit_diagram] Failed:", error)
@@ -457,8 +466,6 @@ ${currentXml || "No XML available"}
Please check cell IDs and retry, or use display_diagram to regenerate.`,
})
// Clean up the shared original XML ref even on error
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
}
}
+13 -1
View File
@@ -192,7 +192,19 @@ export function useModelConfig(): UseModelConfigReturn {
id?.startsWith("server:") &&
!raw.some((m) => m.id === id)
if (id && !isStale) return prev
const fallback = defaultServerModelId(raw)
// Saved before non-ASCII characters in provider names
// got into the id: they were dropped from it
const renamed = raw.filter(
(m) =>
`server:${m.providerLabel
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")}:${m.modelId}` === id,
)
const fallback =
renamed.length === 1
? renamed[0].id
: defaultServerModelId(raw)
return fallback === id
? prev
: { ...prev, selectedModelId: fallback }
+9 -7
View File
@@ -38,10 +38,11 @@ export interface UseSessionManagerReturn {
switchSession: (id: string) => Promise<SessionData | null>
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
// forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
// Resolves to false when the save failed (the user was told)
saveCurrentSession: (
data: SessionData,
forSessionId?: string | null,
) => Promise<void>
) => Promise<boolean>
refreshSessions: () => Promise<void>
clearCurrentSession: () => void
}
@@ -247,17 +248,17 @@ export function useSessionManager(
async (
data: SessionData,
forSessionId?: string | null,
): Promise<void> => {
): Promise<boolean> => {
// If forSessionId is provided, verify it matches current session
// This prevents stale debounced saves from overwriting a newly switched session
if (
forSessionId !== undefined &&
forSessionId !== currentSessionId
) {
return
return true
}
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return
if (!isIndexedDBAvailable()) return true
if (!currentSession) {
// Create a new session if none exists
@@ -274,13 +275,13 @@ export function useSessionManager(
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
return false
}
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
await refreshSessions()
return
return true
}
// Update existing session
@@ -304,7 +305,7 @@ export function useSessionManager(
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
return false
}
setCurrentSession(updatedSession)
@@ -325,6 +326,7 @@ export function useSessionManager(
: s,
),
)
return true
},
[currentSession, currentSessionId, refreshSessions, dict],
)