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
+19 -1
View File
@@ -64,6 +64,9 @@ interface UseDiagramToolHandlersParams {
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
sessionId?: string
// The user pressed Stop: a screenshot check that has not started is
// skipped (one already running is cancelled by the caller)
isStopped?: () => boolean
onValidationStateChange?: (
toolCallId: string,
state: ValidationState,
@@ -90,6 +93,7 @@ export function useDiagramToolHandlers({
validateDiagram,
enableVlmValidation = true,
sessionId,
isStopped,
onValidationStateChange,
}: UseDiagramToolHandlersParams) {
// Helper to update validation state
@@ -257,7 +261,11 @@ ${finalXml}
await new Promise((resolve) => setTimeout(resolve, 100))
capturedPngData = await captureValidationPng()
if (capturedPngData) {
// Stopped while the screenshot was taken: no check. The
// chat waits for this handler, so it must end now.
if (isStopped?.()) {
updateValidationState(toolCall.toolCallId, "skipped")
} else if (capturedPngData) {
if (DEBUG) {
console.log(
"[display_diagram] Captured PNG for validation",
@@ -363,6 +371,16 @@ ${finalXml}
updateValidationState(toolCall.toolCallId, "skipped")
}
} catch (error) {
// Cancelled by Stop: the diagram stays, unchecked
if ((error as Error)?.name === "AbortError") {
updateValidationState(toolCall.toolCallId, "skipped")
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
return
}
// VLM validation error - log but don't block the user
console.warn(
"[display_diagram] VLM validation error:",
+151 -110
View File
@@ -13,10 +13,12 @@ import {
getSession,
isIndexedDBAvailable,
migrateFromLocalStorage,
readSessionCount,
type SessionMetadata,
type StoredMessage,
saveSession,
} from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
export interface SessionData {
messages: StoredMessage[]
@@ -37,14 +39,17 @@ export interface UseSessionManagerReturn {
// Actions
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)
// chatGeneration: getChatGeneration() when the save was scheduled (by
// default, now); the save is dropped if another chat is on screen when
// its turn comes
// Resolves to false when the save failed (the user was told)
saveCurrentSession: (
data: SessionData,
forSessionId?: string | null,
chatGeneration?: number,
) => Promise<boolean>
refreshSessions: () => Promise<void>
clearCurrentSession: () => void
getChatGeneration: () => number
}
// Reading the session list loads every stored session in full, and window
@@ -79,6 +84,20 @@ export function useSessionManager(
const isInitializedRef = useRef(false)
// Sequence guard for URL changes - prevents out-of-order async resolution
const urlChangeSequenceRef = useRef(0)
// The chat on screen, read by saves that run after a render or a wait
const currentSessionRef = useRef<ChatSession | null>(null)
// Goes up each time another chat is put on screen (creating the
// session of the chat on screen does not count)
const chatGenerationRef = useRef(0)
// Saves run one at a time, so two saves of a new chat create it once
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
const changeChat = useCallback((session: ChatSession | null) => {
chatGenerationRef.current++
currentSessionRef.current = session
setCurrentSession(session)
setCurrentSessionId(session?.id ?? null)
}, [])
// Load sessions list
const refreshSessions = useCallback(async () => {
@@ -115,18 +134,32 @@ export function useSessionManager(
const metadata = await getAllSessionMetadata()
setSessions(metadata)
// The desktop app may try its other port next launch, where
// an older version may have saved the chats
window.electronAPI
?.chatsLoaded?.(metadata.length)
.catch(() => {})
// an older version may have saved the chats: only when this
// origin surely has none (a failed read is not "none") and
// keeps no model settings or keys either
if (window.electronAPI?.chatsLoaded) {
const count = await readSessionCount()
// The app saves an empty config on its first load; the
// providers are what holds the keys
let hasSettings = true
try {
const config = JSON.parse(
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
"{}",
)
hasSettings = (config.providers?.length ?? 0) > 0
} catch {
// Unreadable: treat as settings, and stay
}
if (count !== null && !hasSettings) {
window.electronAPI.chatsLoaded(count).catch(() => {})
}
}
// Only load a session if initialSessionId is provided (from URL param)
if (initialSessionId) {
const session = await getSession(initialSessionId)
if (session) {
setCurrentSession(session)
setCurrentSessionId(session.id)
}
if (session) changeChat(session)
// If session not found, stay in blank state (URL has invalid session ID)
}
// If no initialSessionId, start with blank state (no auto-restore)
@@ -138,7 +171,7 @@ export function useSessionManager(
}
init()
}, [initialSessionId])
}, [initialSessionId, changeChat])
// Handle URL session ID changes after initialization
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions
@@ -153,6 +186,7 @@ export function useSessionManager(
async function handleSessionIdChange() {
if (initialSessionId) {
const generation = chatGenerationRef.current
// URL has session ID - load it
const session = await getSession(initialSessionId)
@@ -161,16 +195,13 @@ export function useSessionManager(
if (currentSequence !== urlChangeSequenceRef.current) {
return
}
// Another chat was put on screen meanwhile (New Chat right
// after this one got its session id in the URL): keep it
if (generation !== chatGenerationRef.current) return
if (session) {
// Only update if the session is different from current
setCurrentSessionId((current) => {
if (current !== session.id) {
setCurrentSession(session)
return session.id
}
return current
})
// Only update if the session is different from current
if (session && currentSessionRef.current?.id !== session.id) {
changeChat(session)
}
}
// Removed: else clause that clears session
@@ -179,7 +210,7 @@ export function useSessionManager(
}
handleSessionIdChange()
}, [initialSessionId, isAvailable])
}, [initialSessionId, isAvailable, changeChat])
// Refresh sessions on window focus (multi-tab sync), at most once per interval
const lastFocusRefreshRef = useRef(0)
@@ -201,9 +232,11 @@ export function useSessionManager(
async (id: string): Promise<SessionData | null> => {
if (id === currentSessionId) return null
// Save current session first if it has messages
if (currentSession && currentSession.messages.length > 0) {
await saveSession(currentSession)
// Save current session first if it has messages (as saved
// last: the caller may have just saved it)
const current = currentSessionRef.current
if (current && current.messages.length > 0) {
await saveSession(current)
}
// Load the target session
@@ -213,9 +246,7 @@ export function useSessionManager(
return null
}
// Update state
setCurrentSession(session)
setCurrentSessionId(session.id)
changeChat(session)
return {
messages: session.messages,
@@ -225,7 +256,7 @@ export function useSessionManager(
diagramHistory: session.diagramHistory,
}
},
[currentSessionId, currentSession],
[currentSessionId, changeChat],
)
// Delete a session
@@ -235,112 +266,121 @@ export function useSessionManager(
await deleteSessionFromDB(id)
// If deleting current session, clear state (caller will show new empty session)
if (wasCurrentSession) {
setCurrentSession(null)
setCurrentSessionId(null)
}
if (wasCurrentSession) changeChat(null)
await refreshSessions()
return { wasCurrentSession }
},
[currentSessionId, refreshSessions],
[currentSessionId, refreshSessions, changeChat],
)
// Save current session data (debounced externally by caller)
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
const saveCurrentSession = useCallback(
async (
data: SessionData,
forSessionId?: string | null,
): 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 true
}
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return true
(data: SessionData, chatGeneration?: number): Promise<boolean> => {
// The data is of the chat on screen when the save was asked for
const generation = chatGeneration ?? chatGenerationRef.current
const run = async (): Promise<boolean> => {
// That chat is no longer on screen (leaving it saved it)
if (generation !== chatGenerationRef.current) return true
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return true
// The user may put another chat on screen while this one is
// written; the stored copy is still right, the state is not
const stillOnScreen = () =>
chatGenerationRef.current === generation
const currentSession = currentSessionRef.current
if (!currentSession) {
// Create a new session if none exists
const newSession: ChatSession = {
...createEmptySession(),
if (!currentSession) {
// Create a new session if none exists
const newSession: ChatSession = {
...createEmptySession(),
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return false
}
await enforceSessionLimit()
if (stillOnScreen()) {
currentSessionRef.current = newSession
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
}
await refreshSessions()
return true
}
// Update existing session
const updatedSession: ChatSession = {
...currentSession,
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
thumbnailDataUrl:
data.thumbnailDataUrl ??
currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return false
}
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
await refreshSessions()
if (stillOnScreen()) {
currentSessionRef.current = updatedSession
setCurrentSession(updatedSession)
}
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length >
0,
thumbnailDataUrl:
updatedSession.thumbnailDataUrl,
}
: s,
),
)
return true
}
// Update existing session
const updatedSession: ChatSession = {
...currentSession,
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl:
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
}
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return false
}
setCurrentSession(updatedSession)
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length > 0,
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
}
: s,
),
)
return true
const result = saveQueueRef.current.then(run)
saveQueueRef.current = result.catch(() => {})
return result
},
[currentSession, currentSessionId, refreshSessions, dict],
[refreshSessions, dict],
)
// Clear current session state (for starting fresh without loading another session)
const clearCurrentSession = useCallback(() => {
setCurrentSession(null)
setCurrentSessionId(null)
}, [])
changeChat(null)
}, [changeChat])
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
return {
sessions,
@@ -353,5 +393,6 @@ export function useSessionManager(
saveCurrentSession,
refreshSessions,
clearCurrentSession,
getChatGeneration,
}
}
+15
View File
@@ -103,9 +103,22 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
[submit],
)
/**
* End a running check (the user pressed Stop): its promise rejects with
* an AbortError, so the tool handler can finish at once.
*/
const cancel = useCallback(() => {
const pending = pendingValidationRef.current
if (!pending) return
pendingValidationRef.current = null
stop()
pending.reject(new DOMException("Validation cancelled", "AbortError"))
}, [stop])
/**
* Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures.
* A cancelled check is passed on as its AbortError.
*/
const validateWithFallback = useCallback(
async (
@@ -115,6 +128,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
try {
return await validate(imageData, sessionId)
} catch (error) {
if ((error as Error)?.name === "AbortError") throw error
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
@@ -130,6 +144,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
validate,
validateWithFallback,
stop,
cancel,
// State
isValidating: isLoading,