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) <noreply@anthropic.com>

* fix(ai-chat): don't re-queue a built-in command flushed from the queue

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-24 19:27:46 +02:00
committed by GitHub
parent 89c46c803e
commit 3fafac275d
2 changed files with 105 additions and 25 deletions
@@ -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
@@ -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'
)