From 62dd6c43953192f47eca2e3c8f26387f5dd67a9d Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:50:18 +0200 Subject: [PATCH 01/10] feat(chat): allow cancelling the response generation Fixes #678 Assisted-by: opencode:glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- .../ChattyLLM/ChattyLLMInputForm.vue | 118 ++++++++++++++++-- src/components/ChattyLLM/InputArea.vue | 16 ++- 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 401345e99..69e456c49 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -230,7 +230,8 @@ class="session-area__input-area" :loading="loading" @submit="handleSubmit" - @submit-audio="handleSubmitAudio" /> + @submit-audio="handleSubmitAudio" + @cancel-generation="cancelGeneration" /> { this.checkSession(sessionId, isAssignment) }, 5000) @@ -875,6 +902,59 @@ export default { this.exitDeletionMode() }, + onKeydownCancelGeneration(event) { + if (event.key !== 'Escape' || this.deletionMode || this.sessionIdsToDelete !== null) { + return + } + // consumed here: swallow the key so it does not leak into the host + // page's own Escape handlers (e.g. Talk deselecting the conversation) + event.stopPropagation() + this.cancelGeneration() + }, + + /** + * Track the running generation task of a session. A stop request made + * before the task id was known is executed once the id is available. + * + * @param {number} taskId Id of the scheduled generation task + * @param {number} sessionId Id of the session the task belongs to + */ + trackGenerationTask(taskId, sessionId) { + // the request may resolve after a session switch: never track a task + // of a session that is not the active one, the stop control would + // cancel the wrong task + if (this.active?.id !== sessionId) { + return + } + this.generationTaskId = taskId + if (this.pendingCancelGeneration) { + this.pendingCancelGeneration = false + cancelTask(taskId).catch(error => { + console.error('cancelGeneration error:', error) + }) + } + }, + + async cancelGeneration() { + if (!this.loading.llmGeneration) { + return + } + const taskId = this.generationTaskId + if (taskId === null) { + // stop requested before the task id is known: run it once known + this.pendingCancelGeneration = true + return + } + try { + await cancelTask(taskId) + } catch (error) { + console.error('cancelGeneration error:', error) + if (this.loading.llmGeneration) { + showError(t('assistant', 'Failed to cancel the generation. It might have already finished.')) + } + } + }, + toggleSessionSelection(sessionId) { if (this.selectedSessionIds.includes(sessionId)) { this.selectedSessionIds = this.selectedSessionIds.filter(id => id !== sessionId) @@ -1070,6 +1150,7 @@ export default { }, async runGenerationTask(sessionId, agencyConfirm = null) { + let taskId = null try { this.scrollToLastMessage() this.slowPickup = false @@ -1087,8 +1168,10 @@ export default { const generationResponse = await axios.get(getChatURL('/generate'), { params }) const generationResponseData = generationResponse.data console.debug('scheduleGenerationTask response:', generationResponseData) - this.startTaskPositionPolling(generationResponseData.taskId, sessionId) - const message = await this.pollGenerationTask(generationResponseData.taskId, sessionId) + taskId = generationResponseData.taskId + this.trackGenerationTask(taskId, sessionId) + this.startTaskPositionPolling(taskId, sessionId) + const message = await this.pollGenerationTask(taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages.push(message) if (this.streamingMessage === null) { @@ -1108,6 +1191,10 @@ export default { this.pollPositionTaskId = null this.pollPositionSessionId = null } + if (taskId !== null && this.generationTaskId === taskId) { + this.generationTaskId = null + this.pendingCancelGeneration = false + } this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1118,6 +1205,7 @@ export default { async runRegenerationTask(messageId) { const sessionId = this.active.id + let taskId = null try { this.loading.llmGeneration = true this.loading.llmRunning = false @@ -1126,8 +1214,10 @@ export default { const regenerationResponse = await axios.get(getChatURL('/regenerate'), { params: { messageId, sessionId } }) const regenerationResponseData = regenerationResponse.data console.debug('scheduleRegenerationTask response:', regenerationResponse) - this.startTaskPositionPolling(regenerationResponseData.taskId, sessionId) - const message = await this.pollGenerationTask(regenerationResponseData.taskId, sessionId) + taskId = regenerationResponseData.taskId + this.trackGenerationTask(taskId, sessionId) + this.startTaskPositionPolling(taskId, sessionId) + const message = await this.pollGenerationTask(taskId, sessionId) console.debug('checkTaskPolling result:', message) this.messages[this.messages.length - 1] = message if (this.streamingMessage === null) { @@ -1147,6 +1237,10 @@ export default { this.pollPositionTaskId = null this.pollPositionSessionId = null } + if (taskId !== null && this.generationTaskId === taskId) { + this.generationTaskId = null + this.pendingCancelGeneration = false + } this.loading.llmGeneration = false this.loading.llmRunning = false this.loading.taskPosition = null @@ -1255,6 +1349,14 @@ export default { if (this.pollMessageGenerationTimerId !== timerId) { return } + // a cancelled task is expected after cancelling the generation, stop polling without an error + if (error.response?.status === 400 && error.response?.data?.task_status === TASK_STATUS_INT.cancelled) { + clearInterval(timerId) + this.pollMessageGenerationTimerId = null + this.pollMessageGenerationCancel = null + reject(new TaskPollCancelledError('generation task was cancelled')) + return + } // do not reject if response code is Http::STATUS_EXPECTATION_FAILED (417) if (error.response?.status !== 417) { console.error('checkTaskPolling error', error) diff --git a/src/components/ChattyLLM/InputArea.vue b/src/components/ChattyLLM/InputArea.vue index 53e976335..2e1f3d555 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -54,7 +54,17 @@ @update:model-value="$emit('update:chatContent', $event)" @submit="onSubmitText" />
- + + + Date: Tue, 13 Oct 2026 16:07:47 +0200 Subject: [PATCH 02/10] fix(chat): finalize the cancel immediately after the request succeeds Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 69e456c49..bdc19924b 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -947,6 +947,9 @@ export default { } try { await cancelTask(taskId) + // the task is cancelled server side: finalize now instead of + // waiting for the next check_generation poll tick + this.pollMessageGenerationCancel?.() } catch (error) { console.error('cancelGeneration error:', error) if (this.loading.llmGeneration) { From b6eba7f709e9ee4f318281d8c9cc7d8604cb6bbc Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:25:52 +0200 Subject: [PATCH 03/10] fix(chat): keep the send action visible while a message is submitted Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- src/components/ChattyLLM/InputArea.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ChattyLLM/InputArea.vue b/src/components/ChattyLLM/InputArea.vue index 2e1f3d555..dae5bfe4a 100644 --- a/src/components/ChattyLLM/InputArea.vue +++ b/src/components/ChattyLLM/InputArea.vue @@ -64,7 +64,7 @@ - Date: Wed, 14 Oct 2026 10:26:12 +0200 Subject: [PATCH 04/10] fix(chat): keep stop requests tied to their own generation Assisted-by: opencode:glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- .../ChattyLLM/ChattyLLMInputForm.vue | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index bdc19924b..a263e2bf7 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -373,6 +373,8 @@ export default { pollPositionSessionId: null, generationTaskId: null, pendingCancelGeneration: false, + pendingCancelSeq: null, + generationRequestSeq: 0, // [{ id: number, session_id: number, role: string, content: string, timestamp: number, sources:string }] messages: [], // null when failed to fetch streamingMessage: null, @@ -523,6 +525,7 @@ export default { this.pollPositionSessionId = null this.generationTaskId = null this.pendingCancelGeneration = false + this.pendingCancelSeq = null this.allMessagesLoaded = false this.loading.llmGeneration = false this.loading.llmRunning = false @@ -626,7 +629,7 @@ export default { this.loading.llmGeneration = true this.loading.llmRunning = false checkTaskId = checkSessionResponseData.messageTaskId - this.trackGenerationTask(checkTaskId, sessionId) + this.trackGenerationTask(checkTaskId, sessionId, this.generationRequestSeq) this.startTaskPositionPolling(checkSessionResponseData.messageTaskId, sessionId) this.userScrolled = false const message = await this.pollGenerationTask(checkSessionResponseData.messageTaskId, sessionId) @@ -918,8 +921,9 @@ export default { * * @param {number} taskId Id of the scheduled generation task * @param {number} sessionId Id of the session the task belongs to + * @param {number} requestSeq Sequence of the scheduling request */ - trackGenerationTask(taskId, sessionId) { + trackGenerationTask(taskId, sessionId, requestSeq) { // the request may resolve after a session switch: never track a task // of a session that is not the active one, the stop control would // cancel the wrong task @@ -927,8 +931,9 @@ export default { return } this.generationTaskId = taskId - if (this.pendingCancelGeneration) { + if (this.pendingCancelGeneration && this.pendingCancelSeq === requestSeq) { this.pendingCancelGeneration = false + this.pendingCancelSeq = null cancelTask(taskId).catch(error => { console.error('cancelGeneration error:', error) }) @@ -941,15 +946,20 @@ export default { } const taskId = this.generationTaskId if (taskId === null) { - // stop requested before the task id is known: run it once known + // stop requested before the task id is known: run it once known, + // owned by the scheduling request currently in flight this.pendingCancelGeneration = true + this.pendingCancelSeq = this.generationRequestSeq return } + // capture the handle now: a session switch during the cancel request + // replaces it with the next generation's poll + const pollCancel = this.pollMessageGenerationCancel try { await cancelTask(taskId) // the task is cancelled server side: finalize now instead of // waiting for the next check_generation poll tick - this.pollMessageGenerationCancel?.() + pollCancel?.() } catch (error) { console.error('cancelGeneration error:', error) if (this.loading.llmGeneration) { @@ -1153,6 +1163,7 @@ export default { }, async runGenerationTask(sessionId, agencyConfirm = null) { + const requestSeq = ++this.generationRequestSeq let taskId = null try { this.scrollToLastMessage() @@ -1172,7 +1183,7 @@ export default { const generationResponseData = generationResponse.data console.debug('scheduleGenerationTask response:', generationResponseData) taskId = generationResponseData.taskId - this.trackGenerationTask(taskId, sessionId) + this.trackGenerationTask(taskId, sessionId, requestSeq) this.startTaskPositionPolling(taskId, sessionId) const message = await this.pollGenerationTask(taskId, sessionId) console.debug('checkTaskPolling result:', message) @@ -1196,7 +1207,10 @@ export default { } if (taskId !== null && this.generationTaskId === taskId) { this.generationTaskId = null + } + if (this.pendingCancelSeq === requestSeq) { this.pendingCancelGeneration = false + this.pendingCancelSeq = null } this.loading.llmGeneration = false this.loading.llmRunning = false @@ -1208,6 +1222,7 @@ export default { async runRegenerationTask(messageId) { const sessionId = this.active.id + const requestSeq = ++this.generationRequestSeq let taskId = null try { this.loading.llmGeneration = true @@ -1218,7 +1233,7 @@ export default { const regenerationResponseData = regenerationResponse.data console.debug('scheduleRegenerationTask response:', regenerationResponse) taskId = regenerationResponseData.taskId - this.trackGenerationTask(taskId, sessionId) + this.trackGenerationTask(taskId, sessionId, requestSeq) this.startTaskPositionPolling(taskId, sessionId) const message = await this.pollGenerationTask(taskId, sessionId) console.debug('checkTaskPolling result:', message) @@ -1242,7 +1257,10 @@ export default { } if (taskId !== null && this.generationTaskId === taskId) { this.generationTaskId = null + } + if (this.pendingCancelSeq === requestSeq) { this.pendingCancelGeneration = false + this.pendingCancelSeq = null } this.loading.llmGeneration = false this.loading.llmRunning = false From 4fb6589cce2334d0e39a125c53863c91d4255e7d Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:45:41 +0200 Subject: [PATCH 05/10] fix(chat): guard the Escape stop and finalize deferred stops Assisted-by: opencode:glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- .../ChattyLLM/ChattyLLMInputForm.vue | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index a263e2bf7..bcbe108f5 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -906,7 +906,9 @@ export default { }, onKeydownCancelGeneration(event) { - if (event.key !== 'Escape' || this.deletionMode || this.sessionIdsToDelete !== null) { + // assignment view has no stop control: keep its scheduled tasks away + // from the chat Escape cancel and let the key reach the host page + if (event.key !== 'Escape' || this.isAssignment || this.deletionMode || this.sessionIdsToDelete !== null) { return } // consumed here: swallow the key so it does not leak into the host @@ -934,8 +936,20 @@ export default { if (this.pendingCancelGeneration && this.pendingCancelSeq === requestSeq) { this.pendingCancelGeneration = false this.pendingCancelSeq = null - cancelTask(taskId).catch(error => { + // stop requested while this task was being scheduled: cancel it + // now and finalize as soon as the request succeeds, the poll + // handle is installed right after this by the scheduling request + cancelTask(taskId).then(() => { + // the task is cancelled server side: finalize now instead of + // waiting for the next check_generation poll tick + if (this.generationTaskId === taskId) { + this.pollMessageGenerationCancel?.() + } + }).catch(error => { console.error('cancelGeneration error:', error) + if (this.loading.llmGeneration && this.generationTaskId === taskId) { + showError(t('assistant', 'Failed to cancel the generation. It might have already finished.')) + } }) } }, From f65eceba8b8b058f44954d9b130511835e8a6e03 Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:26:15 +0200 Subject: [PATCH 06/10] fix(chat): harden the cancel path for regenerations - only finalize the cancelling task's own poll, a session switch during the cancel request must not clobber the next generation's poll handle - drop the regenerated message from the UI when a regeneration is cancelled or fails, the backend already deleted it at scheduling time Assisted-by: opencode:glm-5.3 Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index bcbe108f5..8791654fc 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -972,8 +972,12 @@ export default { try { await cancelTask(taskId) // the task is cancelled server side: finalize now instead of - // waiting for the next check_generation poll tick - pollCancel?.() + // waiting for the next check_generation poll tick, but only + // this task's own poll: a session switch during the request + // may have installed another generation's + if (this.generationTaskId === taskId) { + pollCancel?.() + } } catch (error) { console.error('cancelGeneration error:', error) if (this.loading.llmGeneration) { @@ -1259,10 +1263,18 @@ export default { } } catch (error) { if (error instanceof TaskPollCancelledError) { + // cancelled: the scheduling already deleted the old message, + // drop it instead of showing a ghost + this.messages = this.messages.filter((message) => message.id !== messageId) return } console.error('scheduleRegenerationTask error:', error) showError(error?.response?.data?.userFacingErrorMessage ?? t('assistant', 'Error regenerating a response')) + if (taskId !== null) { + // failed after scheduling: the old message is already + // deleted server side + this.messages = this.messages.filter((message) => message.id !== messageId) + } } finally { if (this.pollPositionSessionId === sessionId) { cancelTaskPositionPolling() From f5713b89d1d93829c7ba24dfce7077e4024675e3 Mon Sep 17 00:00:00 2001 From: WSHAPER <42714629+WSHAPER@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:31:31 +0200 Subject: [PATCH 07/10] fix(chat): scope the Escape stop to the form and anchor focus on the stop control Signed-off-by: WSHAPER <42714629+WSHAPER@users.noreply.github.com> --- src/components/ChattyLLM/ChattyLLMInputForm.vue | 12 +++--------- src/components/ChattyLLM/InputArea.vue | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/components/ChattyLLM/ChattyLLMInputForm.vue b/src/components/ChattyLLM/ChattyLLMInputForm.vue index 8791654fc..40341f25b 100644 --- a/src/components/ChattyLLM/ChattyLLMInputForm.vue +++ b/src/components/ChattyLLM/ChattyLLMInputForm.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->