fix: older defects (batch C) and the second batch's review

Chats:
- New Chat right after an answer saves that chat once. Saves run one at
  a time and read the chat on screen when their turn comes; a save
  scheduled for a chat that is no longer on screen is dropped. A chat
  whose id was still on its way to the URL no longer comes back after
  New Chat (the next answer went into it).
- Crossing the 768 px breakpoint keeps the chat panel: a streaming answer,
  unsaved messages and attachments stay. The panel gets the sizes of each
  side, and a panel collapsed on desktop opens on mobile.
- The chat's export waits for its own reply: an edit's history export
  still on its way no longer answers it with the older diagram, and two
  file saves at once no longer swap results.
- A second edit in one answer is previewed on the first edit's result.
- Stop also ends a running screenshot check; a chat that cannot be saved
  (storage full) can be left with "Continue without saving".
- Small diagrams with shapes count as diagrams; the tool card no longer
  crashes on malformed operations.

Quota and providers:
- Requests that reach the server's own endpoints count toward the quota:
  EdgeOne (always its own endpoint now), a private base URL whatever key
  header is sent, keyless Ollama without a URL. With the quota on, a
  redirect is followed only to a public address. The output cap applies
  to these requests too.
- Stop records the tokens of the steps that finished; the screenshot check
  counts its tokens without counting a request.
- EdgeOne configured only by AI_PROVIDER works, also in the admin Test,
  which forwards the access code. Azure set up only in the admin panel
  works in chat. The Test sends a Bedrock session token.
- The admin panel's Test of an entry without a URL uses the server's URL
  as the server does (no private address check for it); the admin panel
  no longer writes an Ollama URL.

MCP server:
- Write tools and start_session run one at a time, so two at once never
  drop each other's change; a cancelled call waiting its turn is skipped.
  get_diagram and export_diagram keep the session they started with.
- Export to .drawio first gets the user's latest edits from the browser.
- History thumbnails: one that arrives after the next AI write is
  dropped; a sync reply keeps the image; a version that changed only page
  settings is its own entry.
- A diagram over the 10 MB limit is saved without its image, or the user
  is told to download it (the server now answers 413 instead of cutting
  the connection).
- Labels holding text like id='1' or parent='1' are no longer read as
  attributes (a layer or a parent was deleted). A broken bare
  <mxGraphModel> file is refused.
- After a sync reply the tab no longer sends its autosave copy again.

Desktop and files:
- A newer switch of the same preset is not rolled back by an older one
  that failed. .env values with escaped quotes are read whole.
- MCP saved files: a file that could not be read stays protected while a
  folder without permission hides it, and is saved again once deleted.
- The desktop app reports "no chats" only when the count was read and no
  model settings are stored.
This commit is contained in:
dayuan.jiang
2026-10-05 18:57:02 +09:00
parent c75f74a5a0
commit c0fa997186
67 changed files with 3152 additions and 531 deletions
+8 -6
View File
@@ -211,7 +211,7 @@ export function ChatMessageDisplay({
</p>
)
}
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const { chartXML, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
@@ -422,10 +422,12 @@ export function ChatMessageDisplay({
// Previous messages are already processed and won't change
const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : []
// The diagram without streamed previews. Undoing a failed edit's
// preview below changes it before chartXML catches up, and an edit
// streaming right after must start from the undone diagram.
let baseXml = chartXML
// The diagram without streamed previews, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
messagesToProcess.forEach((message) => {
// Messages restored from a saved session were applied before it was
@@ -587,7 +589,7 @@ export function ChatMessageDisplay({
})
}
})
}, [messages, handleDisplayChart, chartXML])
}, [messages, handleDisplayChart, chartXMLRef])
return (
<ScrollArea className="h-full w-full scrollbar-thin">
+89 -47
View File
@@ -110,7 +110,7 @@ export default function ChatPanel({
loadDiagram: onDisplayChart,
handleExport: onExport,
handleExportWithoutHistory,
resolverRef,
exportResolversRef,
chartXML,
chartXMLRef: liveChartXMLRef,
latestSvg,
@@ -128,21 +128,15 @@ export default function ChatPanel({
const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => {
// Waits for the reply to its own export, by its tag
const tag = saveToHistory ? onExport() : handleExportWithoutHistory()
return Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
}
if (tag) exportResolversRef.current[tag] = resolve
}),
new Promise<string>((_, reject) => {
const currentResolver = resolverRef.current
setTimeout(() => {
if (resolverRef.current === currentResolver) {
resolverRef.current = null
}
delete exportResolversRef.current[tag]
reject(new Error("Chart export timed out after 10 seconds"))
}, 10000)
}),
@@ -335,7 +329,8 @@ export default function ChatPanel({
const validationRetryCountRef = useRef(0)
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
const { validateWithFallback, cancel: cancelValidation } =
useValidateDiagram()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({
@@ -352,6 +347,7 @@ export default function ChatPanel({
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
sessionId,
isStopped: () => stoppedRef.current,
onValidationStateChange: handleValidationStateChange,
})
@@ -675,6 +671,7 @@ export default function ChatPanel({
isAvailable: sessionIsAvailable,
currentSessionId,
saveCurrentSession,
getChatGeneration,
} = sessionManager
// Use ref for saveCurrentSession to avoid infinite loop
@@ -699,13 +696,14 @@ export default function ChatPanel({
clearTimeout(localStorageDebounceRef.current)
}
// Capture current session ID at schedule time to verify at save time
const scheduledForSessionId = currentSessionId
// Capture the chat on screen at schedule time; the save is dropped
// if another chat is on screen by the time it runs
const scheduledForChat = getChatGeneration()
// Capture whether there's a REAL diagram NOW (not just empty template)
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
// Check if this session was just loaded without a diagram
const isNodiagramSession =
justLoadedSessionIdRef.current === scheduledForSessionId
justLoadedSessionIdRef.current === currentSessionId
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => {
@@ -717,7 +715,7 @@ export default function ChatPanel({
})
await saveCurrentSessionRef.current(
sessionData,
scheduledForSessionId,
scheduledForChat,
)
}
} catch (error) {
@@ -737,6 +735,7 @@ export default function ChatPanel({
status,
sessionIsAvailable,
currentSessionId,
getChatGeneration,
buildSessionData,
])
@@ -921,25 +920,33 @@ export default function ChatPanel({
}
}
// The current chat could not be saved (storage full). The list where
// old chats can be deleted shows only in an empty chat, so let the user
// go on without saving (same toast id: it replaces the plain message)
const offerToContinueUnsaved = useCallback(
(proceed: () => void) => {
toast.error(dict.errors.sessionSaveFailedLeave, {
id: "session-save-failed",
duration: 15000,
action: {
label: dict.errors.continueWithoutSaving,
onClick: proceed,
},
})
},
[dict],
)
// Handle session switching from history dropdown
const handleSelectSession = useCallback(
async (sessionId: string) => {
if (!sessionManager.isAvailable) return
// 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,
})
if (!(await sessionManager.saveCurrentSession(sessionData))) {
return
}
}
// Switch to selected session
const sessionData = await sessionManager.switchSession(sessionId)
if (sessionData) {
const open = async () => {
const sessionData =
await sessionManager.switchSession(sessionId)
if (!sessionData) return
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
justLoadedSessionRef.current = true
@@ -957,8 +964,29 @@ export default function ChatPanel({
syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false })
}
// Save current session before switching (also a diagram drawn
// without messages); if that failed (storage full), stay on it
// unless the user goes on without saving it
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
const sessionData = await buildSessionData({
withThumbnail: true,
})
if (!(await sessionManager.saveCurrentSession(sessionData))) {
offerToContinueUnsaved(open)
return
}
}
await open()
},
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
[
sessionManager,
messages,
buildSessionData,
syncUIWithSession,
router,
offerToContinueUnsaved,
],
)
// Handle session deletion from history dropdown
@@ -976,20 +1004,7 @@ export default function ChatPanel({
[sessionManager, syncUIWithSession, router, pathname],
)
const handleNewChat = useCallback(async () => {
// 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
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
const startNewChat = useCallback(() => {
// Clear session manager state BEFORE clearing URL to prevent race condition
// (otherwise the URL update effect would restore the old session URL)
sessionManager.clearCurrentSession()
@@ -1021,14 +1036,38 @@ export default function ChatPanel({
setMessages,
setSessionId,
sessionManager,
messages,
router,
dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory,
pathname,
])
const handleNewChat = useCallback(async () => {
// 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, unless the
// user goes on without saving it
if (!(await sessionManager.saveCurrentSession(sessionData))) {
offerToContinueUnsaved(startNewChat)
return
}
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
startNewChat()
}, [
sessionManager,
messages,
buildSessionData,
offerToContinueUnsaved,
startNewChat,
])
// Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback(
async (template: { prompt: string }) => {
@@ -1089,6 +1128,9 @@ export default function ChatPanel({
// Handle stop button click
const handleStop = useCallback(() => {
stoppedRef.current = true
// A running screenshot check holds up the chat (the SDK waits for
// the tool handler): end it, so the call gets its result now
cancelValidation()
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)
@@ -1111,7 +1153,7 @@ export default function ChatPanel({
})
stop()
}, [messages, addToolOutput, stop])
}, [messages, addToolOutput, stop, cancelValidation])
// Send chat message with headers
const sendChatMessage = (
+10 -4
View File
@@ -20,9 +20,15 @@ interface ToolCallCardProps {
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
// Streamed or invalid input can hold anything: show only what React can
// render (an object in place of a string would crash the whole chat)
const shown = operations.filter(
(op) => typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) => (typeof value === "string" ? value : "")
return (
<div className="space-y-3">
{operations.map((op, index) => (
{shown.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
@@ -40,13 +46,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
cell_id: {text(op.cell_id)}
</span>
</div>
{op.new_xml && (
{text(op.new_xml) && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
{text(op.new_xml)}
</pre>
</div>
)}
+17 -17
View File
@@ -203,6 +203,7 @@ export function ModelConfigDialog({
p?.awsAccessKeyId,
p?.awsSecretAccessKey,
p?.awsRegion,
p?.awsSessionToken,
p?.vertexApiKey,
])
}
@@ -451,6 +452,9 @@ export function ModelConfigDialog({
awsSecretAccessKey:
selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Temporary AWS credentials, as the chat sends
awsSessionToken:
selectedProvider.awsSessionToken,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
@@ -492,19 +496,18 @@ export function ModelConfigDialog({
validationWarning: undefined,
}
}
// A newer test started: its own results and spinners count,
// whatever the credentials are now (they may have come back)
if (run !== validationRunRef.current) return
// Credentials changed during the test: drop the result. A
// change made in this tab already reset the spinners (and a
// newer test may show its own); one from another tab did
// not, so the latest test clears its own (model ids are
// unique, whatever provider is shown).
// change in another tab left the spinner on, so clear it
// (model ids are unique, whatever provider is shown).
if (credentialsOf(selectedProviderId) !== askedWith) {
if (run === validationRunRef.current) {
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
}
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
return
}
// So did this model's id: the result is for the old one
@@ -532,13 +535,10 @@ export function ModelConfigDialog({
})
}),
)
if (run !== validationRunRef.current) return
if (credentialsOf(selectedProviderId) !== askedWith) {
// The status line belongs to the latest test, and to the
// provider shown now
if (
run === validationRunRef.current &&
selectedProviderIdRef.current === selectedProviderId
) {
// The status line is about the provider shown now
if (selectedProviderIdRef.current === selectedProviderId) {
setValidationStatus("idle")
}
return