fix(chat): undo rejected or stopped previews, and more fixes from the third review

- A call the server runs (get_shape_library) still reaches the browser's
  tool handler, and it dropped the stored diagram of an earlier broken
  edit, whose preview then stayed. Only the tools that draw take it now.
- A display_diagram whose final XML fails the checks loads the diagram
  from before its preview again, as a failed edit does.
- After Stop, a tool result that arrives later (a screenshot check still
  running) no longer sends a new request; Stop also skips calls the tool
  handler already took.
- New chat and opening another chat kept nothing of a diagram drawn
  without messages when it could not be saved; now they stay on it.
- The settings dialog drops a model list or test result whose provider
  credentials changed meanwhile, also in another tab.
- A saved provider this version does not know crashed the whole page on
  load; it is skipped.
- The input emptied a moment after the message showed in the chat, so it
  briefly appeared twice (seen as a flaky e2e test).
- The desktop app's preset switch on the same port refetches the server
  models instead of reloading the page, which lost unsent attachments.
This commit is contained in:
dayuan.jiang
2026-10-05 13:04:12 +09:00
parent d5f31cb253
commit 49468f9478
8 changed files with 425 additions and 30 deletions
+39 -14
View File
@@ -281,6 +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())
// Set by Stop until the user sends the next message
const stoppedRef = useRef(false)
// 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
@@ -458,6 +461,10 @@ export default function ChatPanel({
// diagram preview draws on each update, so this also limits redraws
experimental_throttle: 150,
sendAutomaticallyWhen: ({ messages }) => {
// The user stopped: a tool result that arrives later (a VLM
// check still running) must not start a new request
if (stoppedRef.current) return false
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(messages)
@@ -869,12 +876,12 @@ export default function ChatPanel({
// Add the combined text as the first part
parts.unshift({ type: "text", text: userText })
await sendWithCurrentDiagram(parts)
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
await sendWithCurrentDiagram(parts, () => {
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
})
} catch (error) {
console.error("Error fetching chart data:", error)
toast.error(dict.errors.failedToExport)
@@ -882,8 +889,13 @@ export default function ChatPanel({
}
}
// Export the current diagram, snapshot it for this message, and send
const sendWithCurrentDiagram = async (parts: any[]) => {
// Export the current diagram, snapshot it for this message, and send.
// onSent runs right after sending, so the input empties as the message
// shows in the chat
const sendWithCurrentDiagram = async (
parts: any[],
onSent?: () => void,
) => {
const chartXml = formatXML(await onFetchChart())
const previousXml = getPreviousXml(messages.length)
@@ -895,6 +907,7 @@ export default function ChatPanel({
)
sendChatMessage(parts, chartXml, previousXml, sessionId)
onSent?.()
}
// Send VLM validation feedback as a new user message through the normal send path
@@ -913,12 +926,15 @@ export default function ChatPanel({
async (sessionId: string) => {
if (!sessionManager.isAvailable) return
// Save current session before switching
if (messages.length > 0) {
// Save current session before switching (also a diagram drawn
// without messages); if that failed (storage full), stay on it
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
const sessionData = await buildSessionData({
withThumbnail: true,
})
await sessionManager.saveCurrentSession(sessionData)
if (!(await sessionManager.saveCurrentSession(sessionData))) {
return
}
}
// Switch to selected session
@@ -961,8 +977,12 @@ export default function ChatPanel({
)
const handleNewChat = useCallback(async () => {
// Save current session before creating new one
if (sessionManager.isAvailable && messages.length > 0) {
// Save current session before creating new one (also a diagram
// drawn without messages)
if (
sessionManager.isAvailable &&
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
) {
const sessionData = await buildSessionData({ withThumbnail: true })
// Not saved (storage full): keep the chat on screen
if (!(await sessionManager.saveCurrentSession(sessionData))) return
@@ -1068,11 +1088,15 @@ export default function ChatPanel({
// Handle stop button click
const handleStop = useCallback(() => {
stoppedRef.current = true
const lastMessage = messages[messages.length - 1]
// Calls the tool handler already took can still show as streaming:
// the messages update at most every 150 ms (useChat throttle)
const toolParts = lastMessage?.parts?.filter(
(part: any) =>
part.type?.startsWith("tool-") &&
part.state === "input-streaming",
part.state === "input-streaming" &&
!processedToolCallsRef.current.has(part.toolCallId),
)
toolParts?.forEach((part: any) => {
@@ -1101,6 +1125,7 @@ export default function ChatPanel({
continuationRetryCountRef.current = 0
validationRetryCountRef.current = 0
partialXmlRef.current = ""
stoppedRef.current = false
const config = getSelectedAIConfig()
+23 -8
View File
@@ -157,9 +157,6 @@ export function ModelConfigDialog({
id: string
value: string
} | null>(null)
// Bumped on every credential edit so a running test can tell that its
// results belong to the old credentials
const credentialsVersionRef = useRef(0)
// Models fetched from the provider, per provider config
const [fetchedModels, setFetchedModels] = useState<
Record<string, ListedModel[]>
@@ -192,6 +189,20 @@ export function ModelConfigDialog({
selectedProviderIdRef.current = selectedProviderId
const configRef = useRef(config)
configRef.current = config
// A model list or test result belongs to the credentials it was asked
// with; they can change meanwhile, here or in another tab
const credentialsOf = (providerId: string) => {
const p = configRef.current.providers.find((x) => x.id === providerId)
return JSON.stringify([
p?.provider,
p?.apiKey,
p?.baseUrl,
p?.awsAccessKeyId,
p?.awsSecretAccessKey,
p?.awsRegion,
p?.vertexApiKey,
])
}
// Discard an unfinished model ID edit when the dialog closes
useEffect(() => {
@@ -217,6 +228,7 @@ export function ModelConfigDialog({
const handleFetchModels = async () => {
if (!selectedProvider) return
const providerId = selectedProvider.id
const askedWith = credentialsOf(providerId)
setFetchingModels(true)
setFetchModelsError("")
try {
@@ -237,6 +249,7 @@ export function ModelConfigDialog({
},
)
const data = await response.json().catch(() => ({}))
if (credentialsOf(providerId) !== askedWith) return
// The picker and the error belong to the provider shown
const stillShown = selectedProviderIdRef.current === providerId
if (Array.isArray(data.models)) {
@@ -253,7 +266,10 @@ export function ModelConfigDialog({
)
}
} catch {
if (selectedProviderIdRef.current === providerId) {
if (
selectedProviderIdRef.current === providerId &&
credentialsOf(providerId) === askedWith
) {
setFetchModelsError(dict.errors.networkError)
}
} finally {
@@ -313,7 +329,6 @@ export function ModelConfigDialog({
"vertexApiKey",
]
if (credentialFields.includes(field)) {
credentialsVersionRef.current++
setValidationStatus("idle")
setValidatingModelIds(new Set())
setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest)
@@ -398,7 +413,7 @@ export function ModelConfigDialog({
let allValid = true
let errorCount = 0
let idChanged = false
const credentialsVersion = credentialsVersionRef.current
const askedWith = credentialsOf(selectedProviderId)
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
@@ -474,7 +489,7 @@ export function ModelConfigDialog({
}
}
// Credentials changed during the test: drop the result
if (credentialsVersionRef.current !== credentialsVersion) return
if (credentialsOf(selectedProviderId) !== askedWith) return
// So did this model's id: the result is for the old one
const current = configRef.current.providers
.find((p) => p.id === selectedProviderId)
@@ -500,7 +515,7 @@ export function ModelConfigDialog({
})
}),
)
if (credentialsVersionRef.current !== credentialsVersion) return
if (credentialsOf(selectedProviderId) !== askedWith) return
// A model whose id changed was not tested
if (allValid && !idChanged) {