From 3fafac275d2100a6f89924040650cf959945d209 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:27:46 +0200 Subject: [PATCH] feat(ai-chat): add /clear session command to start a fresh conversation (#9769) * feat(ai-chat): add /clear session command to start a fresh conversation Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ai-chat): don't re-queue a built-in command flushed from the queue Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 54 ++++++++----- .../copilot/chat/AIChatManager.test.ts | 76 +++++++++++++++++-- 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 4d3528ac66..50effabb47 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -118,6 +118,10 @@ const USER_CANCEL_REASON = 'user_cancelled' // regular message that merely mentions "/compact" mid-sentence is unaffected. const COMPACT_COMMAND_NAME = 'compact' const COMPACT_COMMAND_RE = /^\/compact\s*$/ +// Built-in `/clear` session command — saves the conversation to history and +// resets to a fresh chat (the "New chat" action), instead of sending a turn. +const CLEAR_COMMAND_NAME = 'clear' +const CLEAR_COMMAND_RE = /^\/clear\s*$/ const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' const WEB_SEARCH_ERROR_HINT = @@ -337,11 +341,12 @@ export class AIChatManager { private globalSkillsRefreshId = 0 // Built-in session-chat slash commands, listed in the command picker - // alongside workspace skills. Unlike a skill, `/compact` runs locally - // (compactManually) and never reaches the model; the submit path intercepts - // it first, so it shadows any workspace skill of the same name. + // alongside workspace skills. Unlike a skill, these run locally and never + // reach the model; the submit path intercepts them first, so they shadow any + // workspace skill of the same name. readonly sessionBuiltinCommands: AiSkillListItem[] = [ - { name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' } + { name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' }, + { name: CLEAR_COMMAND_NAME, description: 'Clear the conversation and start a new chat' } ] // Built-ins followed by workspace skills, with any skill whose name collides @@ -1375,9 +1380,11 @@ export class AIChatManager { isPreprocessor?: boolean } = {} ) => { - // Returns whether the message was actually turned into a chat turn — - // the queue flush uses this to restore messages dropped by an early - // return instead of silently losing them. + // Returns whether the input was consumed: true when it was sent as a chat + // turn OR handled as a local built-in command, false when it was dropped + // without being acted on (mode hidden, empty, beforeSend failed). The + // queue flush restores the queued message only on false, so a consumed + // command isn't re-queued and re-fired into the next conversation. const requestedMode = options.mode ?? this.mode if (!isAIModeVisible(requestedMode)) { return false @@ -1392,19 +1399,26 @@ export class AIChatManager { if (!this.instructions.trim()) { return false } - // Built-in `/compact` session command: summarize the conversation locally - // instead of sending a turn to the model. Intercepted here — before the - // beforeSend workspace commit, file regrants, and skill expansion — and not - // turned into a chat turn. Scoped to session chat GLOBAL mode, where the - // slash-command UI lives. - if ( - this.isSessionChat && - this.mode === AIMode.GLOBAL && - COMPACT_COMMAND_RE.test(this.instructions.trim()) - ) { - this.instructions = '' - await this.compactManually() - return false + // Built-in session commands run locally instead of becoming a chat turn. + // Intercepted here — before the beforeSend workspace commit, file regrants, + // and skill expansion. Scoped to session chat GLOBAL mode, where the + // slash-command UI lives. Return true (consumed, not dropped) so that a + // command flushed from the queue isn't restored and re-fired into the next + // conversation. + if (this.isSessionChat && this.mode === AIMode.GLOBAL) { + const trimmed = this.instructions.trim() + // `/compact`: summarize the conversation locally to free up context. + if (COMPACT_COMMAND_RE.test(trimmed)) { + this.instructions = '' + await this.compactManually() + return true + } + // `/clear`: save the conversation to history and start a fresh chat. + if (CLEAR_COMMAND_RE.test(trimmed)) { + this.instructions = '' + await this.saveAndClear() + return true + } } // Re-grant any locked File System Access handles within this send gesture, so the // file tools can read the live files. requestPermission() needs a user gesture, and diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 2815e451eb..4957fefa03 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -1127,8 +1127,9 @@ describe('AIChatManager manual compaction', () => { const sent = await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL }) - // The command never became a chat turn... - expect(sent).toBe(false) + // Consumed as a local command (true so the queue flush won't re-fire it), + // without ever reaching the model loop... + expect(sent).toBe(true) expect(mocks.runChatLoop).not.toHaveBeenCalled() // ...it ran the summarizer and compacted in place, clearing the composer. expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1) @@ -1164,6 +1165,71 @@ describe('AIChatManager manual compaction', () => { expect(manager.queuedMessage).toBe('') }) + it('routes the /clear session command to a fresh chat instead of the model', async () => { + const manager = new AIChatManager() + manager.isSessionChat = true + seedExchange(manager) + + const sent = await manager.sendRequest({ instructions: '/clear', mode: AIMode.GLOBAL }) + + // Consumed as a local command (true so the queue flush won't re-fire it), + // without ever reaching the model... + expect(sent).toBe(true) + expect(mocks.runChatLoop).not.toHaveBeenCalled() + expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled() + // ...it reset the conversation and cleared the composer. + expect(manager.displayMessages).toEqual([]) + expect(manager.messages).toEqual([]) + expect(manager.instructions).toBe('') + }) + + it('consumes a /clear flushed from the queue without re-queuing it', async () => { + // A normal turn that commits cleanly, so its epilogue flushes the queue. + mocks.runChatLoop.mockImplementation(async (config: any) => { + const message = { role: 'assistant' as const, content: 'done' } + config.addedMessages?.push(message) + return { + addedMessages: [message], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + hitMaxIterations: false + } + }) + const manager = new AIChatManager() + manager.isSessionChat = true + manager.changeMode(AIMode.GLOBAL) + seedExchange(manager) + // `/clear` typed while the turn was streaming gets queued, not sent. + manager.queuedMessage = '/clear' + + await manager.sendRequest({ instructions: 'a normal message', mode: AIMode.GLOBAL }) + + // The committed turn's flush ran `/clear` (resetting the conversation) and, + // because the command reports itself as consumed, did NOT restore it — so a + // stale `/clear` can't re-fire and wipe the next conversation. + expect(manager.queuedMessage).toBe('') + expect(manager.displayMessages).toEqual([]) + expect(manager.messages).toEqual([]) + }) + + it('does not intercept /clear outside session chat', async () => { + mocks.runChatLoop.mockImplementation(async (config: any) => { + const message = { role: 'assistant' as const, content: 'done' } + config.addedMessages?.push(message) + return { + addedMessages: [message], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + hitMaxIterations: false + } + }) + const manager = new AIChatManager() + manager.isSessionChat = false + + await manager.sendRequest({ instructions: '/clear', mode: AIMode.GLOBAL }) + + // Without the session-chat command surface, /clear is a normal message. + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + }) + it('does not intercept /compact outside session chat', async () => { mocks.runChatLoop.mockImplementation(async (config: any) => { const message = { role: 'assistant' as const, content: 'done' } @@ -1191,10 +1257,10 @@ describe('AIChatManager manual compaction', () => { { name: 'review-code', description: 'review code for bugs' } ] - // Built-in `compact` comes first and the colliding skill is dropped, so the - // picker never renders two leaves with the same `skill:compact` key. + // Built-ins come first and the colliding skill is dropped, so the picker + // never renders two leaves with the same `skill:compact` key. const names = manager.sessionCommands.map((c) => c.name) - expect(names).toEqual(['compact', 'review-code']) + expect(names).toEqual(['compact', 'clear', 'review-code']) expect(manager.sessionCommands[0].description).toBe( 'Summarize the conversation to free up context' )