diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0703876..049d577 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1933,7 +1933,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry", ] [[package]] @@ -5040,7 +5040,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "url", - "windows-registry 0.5.3", + "windows-registry", "windows-result 0.3.4", ] @@ -5064,13 +5064,15 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.4.5" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" dependencies = [ "anyhow", "dunce", "glob", + "log", + "objc2-foundation", "percent-encoding", "schemars 0.8.22", "serde", @@ -6555,17 +6557,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" diff --git a/src/openacp/api/client.ts b/src/openacp/api/client.ts index fb4689e..8715ea6 100644 --- a/src/openacp/api/client.ts +++ b/src/openacp/api/client.ts @@ -178,7 +178,7 @@ export function createApiClient(server: ServerInfo, workspaceId?: string) { /** Cancel/abort the current prompt in a session */ async cancelPrompt(sessionID: string): Promise { - await api(`/sessions/${encodeURIComponent(sessionID)}/cancel`, { + await api(`/sse/sessions/${encodeURIComponent(sessionID)}/cancel`, { method: "POST", }) }, diff --git a/src/openacp/context/chat.tsx b/src/openacp/context/chat.tsx index 55e9865..d5f7aaa 100644 --- a/src/openacp/context/chat.tsx +++ b/src/openacp/context/chat.tsx @@ -195,8 +195,8 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv const messagesRef = useRef>({}) const abortedSessions = useRef(new Set()) - /** turnId of the turn that was aborted — used to allow next queued turn to proceed */ - const abortedTurnId = useRef(undefined) + /** Per-session turnId of the turn that was aborted — used to allow next queued turn to proceed */ + const abortedTurnIds = useRef(new Map()) /** Cached messageMode setting — read on mount + settings change, avoids async in critical path */ const messageModeRef = useRef<"queue" | "instant">("queue") const assistantMsgId = useRef(new Map()) @@ -319,13 +319,36 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv const localAstBlocks = local.filter((m) => m.role === "assistant").reduce((n, m) => n + m.blocks.length, 0) if (serverAstBlocks > 0 && serverAstBlocks >= localAstBlocks) { - // Server is authoritative — interrupted turns already have stopReason: "interrupted" - // which turnToMessage() maps to interrupted: true. No cache merge needed. if (assistantMsgId.current.get(sessionID) === streamingPlaceholderAtStart) { assistantMsgId.current.delete(sessionID) setStore((draft) => { draft.streaming = false; draft.streamingSession = undefined }) } + // Preserve client-side interrupted flags: the client is the source of truth + // for interruption since the server may not have persisted the stopReason yet. + // Local turnIds (random UUID) differ from server turnIds (turn index), so match + // by position: find the Nth interrupted assistant message locally, mark the Nth + // assistant message from the server. + const localInterruptedIndices = new Set() + let localAstIdx = 0 + for (const m of local) { + if (m.role === "assistant") { + if (m.interrupted) localInterruptedIndices.add(localAstIdx) + localAstIdx++ + } + } + if (localInterruptedIndices.size > 0) { + let serverAstIdx = 0 + for (const msg of serverMessages) { + if (msg.role === "assistant") { + if (localInterruptedIndices.has(serverAstIdx) && !msg.interrupted) { + msg.interrupted = true + } + serverAstIdx++ + } + } + } + const lastServerTime = new Date(history.turns[history.turns.length - 1].timestamp).getTime() const serverUserTexts = new Set( serverMessages @@ -570,9 +593,12 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv const sessionID = event.sessionId if (!sessionID) return // Drop events belonging to the aborted turn; allow events from subsequent turns. - // If abortedTurnId is unknown (turn completed before abort), block ALL events for safety. if (abortedSessions.current.has(sessionID)) { - if (!abortedTurnId.current || event.turnId === abortedTurnId.current) return + const blockedTurnId = abortedTurnIds.current.get(sessionID) + // If we know which turnId was aborted, only block that specific turn. + // If turnId is unknown (undefined), block all events for this session + // until handleMessageProcessing clears the guard on the next turn. + if (blockedTurnId === undefined || event.turnId === blockedTurnId) return } // Broadcast for consumers outside chat context (file tree, notifications, etc.) @@ -845,10 +871,11 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv // If this session was aborted, check if this is the aborted turn or a new one if (abortedSessions.current.has(sid)) { - if (ev.turnId === abortedTurnId.current) return // still the aborted turn + const blockedTurnId = abortedTurnIds.current.get(sid) + if (blockedTurnId !== undefined && ev.turnId === blockedTurnId) return // still the aborted turn // New turn from queue — clear abort guard so it can proceed abortedSessions.current.delete(sid) - abortedTurnId.current = undefined + abortedTurnIds.current.delete(sid) } const processingStartedAt = new Date(ev.timestamp).getTime() @@ -1097,9 +1124,10 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv } // Instant mode: interrupt current turn before sending new message. - // Uses cached ref (no async) so abort happens synchronously before any events slip through. + // Await abort so the server has acknowledged cancellation before we send the new prompt, + // preventing the race where cancel arrives after the new prompt starts processing. if (store.streaming && store.activeSession && messageModeRef.current === "instant") { - abort() + await abort() // Do NOT clear the abort guard here — handleMessageProcessing // clears it when the NEW turn's message:processing event arrives. } @@ -1226,10 +1254,13 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv void loadHistory(id) }, [workspace.client]) - const abort = useCallback(() => { + const abort = useCallback(async () => { const sessionID = store.activeSession if (!sessionID) return + // Already aborting this session — avoid double-fire + if (abortedSessions.current.has(sessionID)) return + // Find the turnId of the currently streaming turn so we can block only its events const currentMsgId = assistantMsgId.current.get(sessionID) let currentTurnId: string | undefined @@ -1240,7 +1271,7 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv } abortedSessions.current.add(sessionID) - abortedTurnId.current = currentTurnId + abortedTurnIds.current.set(sessionID, currentTurnId) // Discard unrevealed content — do NOT flush buffers to the UI. // Text already rendered (up to charStream cursor) stays; everything @@ -1266,16 +1297,21 @@ export function ChatProvider({ children, onPermissionRequest, onPermissionResolv assistantMsgId.current.delete(sessionID) setStore((draft) => { draft.streaming = false; draft.streamingSession = undefined }) // Tell server to cancel only the current prompt (queue preserved). + // Await so instant-mode callers know the server has acknowledged before sending a new prompt. // Guard stays active until: // - handleMessageProcessing receives a NEW turn (queue drained) // - user sends a new message (doSendPrompt clears it) - // - 30s fallback timeout - workspace.client.cancelPrompt(sessionID).catch(() => {}) - // Fallback: clear guard after 30s even if server never responds + // - 10s fallback timeout + try { + await workspace.client.cancelPrompt(sessionID) + } catch (e) { + console.warn("[Chat] cancelPrompt failed:", e) + } + // Fallback: clear guard after 10s even if server never responds setTimeout(() => { abortedSessions.current.delete(sessionID) - abortedTurnId.current = undefined - }, 30_000) + abortedTurnIds.current.delete(sessionID) + }, 10_000) }, [store.activeSession, workspace.client]) // Load messageMode setting on mount