Browse Source

[Condense] Add a button to condense the task context (#3623)

* [Condense] Add a button to condense the task context

* wip

* wip

* wip

* bring back delete size

* account for the system prompt in the context

* update tests to use systemPrompt

* add type

* translations

* nit

* update tests

* filter to the current task

* nit

* refactor

* nit

* non interactive option

* simplify chat summary UI

* changeset

* nit

* fix check-types

* throw
Canyon Robins 9 months ago
parent
commit
c3716f1107
33 changed files with 242 additions and 105 deletions
  1. 5 0
      .changeset/tired-dogs-worry.md
  2. 8 5
      src/core/condense/__tests__/index.test.ts
  3. 6 5
      src/core/condense/index.ts
  4. 20 0
      src/core/sliding-window/__tests__/sliding-window.test.ts
  5. 1 1
      src/core/sliding-window/index.ts
  6. 68 31
      src/core/task/Task.ts
  7. 16 0
      src/core/webview/ClineProvider.ts
  8. 3 0
      src/core/webview/webviewMessageHandler.ts
  9. 1 0
      src/shared/ExtensionMessage.ts
  10. 1 0
      src/shared/WebviewMessage.ts
  11. 2 0
      webview-ui/src/__tests__/ContextWindowProgress.test.tsx
  12. 24 0
      webview-ui/src/components/chat/ChatView.tsx
  13. 1 4
      webview-ui/src/components/chat/ContextCondenseRow.tsx
  14. 35 24
      webview-ui/src/components/chat/TaskActions.tsx
  15. 16 2
      webview-ui/src/components/chat/TaskHeader.tsx
  16. 2 0
      webview-ui/src/components/chat/__tests__/TaskHeader.test.tsx
  17. 2 2
      webview-ui/src/i18n/locales/ca/chat.json
  18. 2 2
      webview-ui/src/i18n/locales/de/chat.json
  19. 1 1
      webview-ui/src/i18n/locales/en/chat.json
  20. 2 2
      webview-ui/src/i18n/locales/es/chat.json
  21. 2 2
      webview-ui/src/i18n/locales/fr/chat.json
  22. 2 2
      webview-ui/src/i18n/locales/hi/chat.json
  23. 2 2
      webview-ui/src/i18n/locales/it/chat.json
  24. 2 2
      webview-ui/src/i18n/locales/ja/chat.json
  25. 2 2
      webview-ui/src/i18n/locales/ko/chat.json
  26. 2 2
      webview-ui/src/i18n/locales/nl/chat.json
  27. 2 2
      webview-ui/src/i18n/locales/pl/chat.json
  28. 2 2
      webview-ui/src/i18n/locales/pt-BR/chat.json
  29. 2 2
      webview-ui/src/i18n/locales/ru/chat.json
  30. 2 2
      webview-ui/src/i18n/locales/tr/chat.json
  31. 2 2
      webview-ui/src/i18n/locales/vi/chat.json
  32. 2 2
      webview-ui/src/i18n/locales/zh-CN/chat.json
  33. 2 2
      webview-ui/src/i18n/locales/zh-TW/chat.json

+ 5 - 0
.changeset/tired-dogs-worry.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Adds a button to intelligently condense the context window

+ 8 - 5
src/core/condense/__tests__/index.test.ts

@@ -97,13 +97,16 @@ describe("summarizeConversation", () => {
 		} as unknown as ApiHandler
 	})
 
+	// Default system prompt for tests
+	const defaultSystemPrompt = "You are a helpful assistant."
+
 	it("should not summarize when there are not enough messages", async () => {
 		const messages: ApiMessage[] = [
 			{ role: "user", content: "Hello", ts: 1 },
 			{ role: "assistant", content: "Hi there", ts: 2 },
 		]
 
-		const result = await summarizeConversation(messages, mockApiHandler)
+		const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt)
 		expect(result.messages).toEqual(messages)
 		expect(result.cost).toBe(0)
 		expect(result.summary).toBe("")
@@ -122,7 +125,7 @@ describe("summarizeConversation", () => {
 			{ role: "user", content: "Tell me more", ts: 7 },
 		]
 
-		const result = await summarizeConversation(messages, mockApiHandler)
+		const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt)
 		expect(result.messages).toEqual(messages)
 		expect(result.cost).toBe(0)
 		expect(result.summary).toBe("")
@@ -141,7 +144,7 @@ describe("summarizeConversation", () => {
 			{ role: "user", content: "Tell me more", ts: 7 },
 		]
 
-		const result = await summarizeConversation(messages, mockApiHandler)
+		const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt)
 
 		// Check that the API was called correctly
 		expect(mockApiHandler.createMessage).toHaveBeenCalled()
@@ -199,7 +202,7 @@ describe("summarizeConversation", () => {
 			return messages.map(({ role, content }: { role: string; content: any }) => ({ role, content }))
 		})
 
-		const result = await summarizeConversation(messages, mockApiHandler)
+		const result = await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt)
 
 		// Should return original messages when summary is empty
 		expect(result.messages).toEqual(messages)
@@ -222,7 +225,7 @@ describe("summarizeConversation", () => {
 			{ role: "user", content: "Tell me more", ts: 7 },
 		]
 
-		await summarizeConversation(messages, mockApiHandler)
+		await summarizeConversation(messages, mockApiHandler, defaultSystemPrompt)
 
 		// Verify the final request message
 		const expectedFinalMessage = {

+ 6 - 5
src/core/condense/index.ts

@@ -57,12 +57,13 @@ export type SummarizeResponse = {
  *
  * @param {ApiMessage[]} messages - The conversation messages
  * @param {ApiHandler} apiHandler - The API handler to use for token counting.
+ * @param {string} systemPrompt - The system prompt for API requests, which should be considered in the context token count
  * @returns {SummarizeResponse} - The result of the summarization operation (see above)
  */
 export async function summarizeConversation(
 	messages: ApiMessage[],
 	apiHandler: ApiHandler,
-	systemPrompt?: string,
+	systemPrompt: string,
 ): Promise<SummarizeResponse> {
 	const response: SummarizeResponse = { messages, cost: 0, summary: "" }
 	const messagesToSummarize = getMessagesSinceLastSummary(messages.slice(0, -N_MESSAGES_TO_KEEP))
@@ -111,10 +112,10 @@ export async function summarizeConversation(
 
 	// Count the tokens in the context for the next API request
 	// We only estimate the tokens in summaryMesage if outputTokens is 0, otherwise we use outputTokens
-	const contextMessages = outputTokens ? [...keepMessages] : [summaryMessage, ...keepMessages]
-	if (systemPrompt) {
-		contextMessages.unshift({ role: "user", content: systemPrompt })
-	}
+	const systemPromptMessage: ApiMessage = { role: "user", content: systemPrompt }
+	const contextMessages = outputTokens
+		? [systemPromptMessage, ...keepMessages]
+		: [systemPromptMessage, summaryMessage, ...keepMessages]
 	const contextBlocks = contextMessages.flatMap((message) =>
 		typeof message.content === "string" ? [{ text: message.content, type: "text" as const }] : message.content,
 	)

+ 20 - 0
src/core/sliding-window/__tests__/sliding-window.test.ts

@@ -248,6 +248,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 
 		// Check the new return type
@@ -276,6 +277,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 
 		expect(result).toEqual({
@@ -302,6 +304,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo1.contextWindow,
 			maxTokens: modelInfo1.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 
 		const result2 = await truncateConversationIfNeeded({
@@ -310,6 +313,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo2.contextWindow,
 			maxTokens: modelInfo2.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 
 		expect(result1.messages).toEqual(result2.messages)
@@ -325,6 +329,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo1.contextWindow,
 			maxTokens: modelInfo1.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 
 		const result4 = await truncateConversationIfNeeded({
@@ -333,6 +338,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo2.contextWindow,
 			maxTokens: modelInfo2.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 
 		expect(result3.messages).toEqual(result4.messages)
@@ -363,6 +369,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(resultWithSmall).toEqual({
 			messages: messagesWithSmallContent,
@@ -392,6 +399,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(resultWithLarge.messages).not.toEqual(messagesWithLargeContent) // Should truncate
 		expect(resultWithLarge.summary).toBe("")
@@ -414,6 +422,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(resultWithVeryLarge.messages).not.toEqual(messagesWithVeryLargeContent) // Should truncate
 		expect(resultWithVeryLarge.summary).toBe("")
@@ -439,6 +448,7 @@ describe("truncateConversationIfNeeded", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result).toEqual({
 			messages: expectedResult,
@@ -524,6 +534,7 @@ describe("truncateConversationIfNeeded", () => {
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
 			autoCondenseContext: true,
+			systemPrompt: "System prompt",
 		})
 
 		// Verify summarizeConversation was called
@@ -559,6 +570,7 @@ describe("truncateConversationIfNeeded", () => {
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
 			autoCondenseContext: false,
+			systemPrompt: "System prompt",
 		})
 
 		// Verify summarizeConversation was not called
@@ -612,6 +624,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result1).toEqual({
 			messages: messagesWithSmallContent,
@@ -627,6 +640,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result2.messages).not.toEqual(messagesWithSmallContent)
 		expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
@@ -650,6 +664,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result1).toEqual({
 			messages: messagesWithSmallContent,
@@ -665,6 +680,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result2.messages).not.toEqual(messagesWithSmallContent)
 		expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
@@ -687,6 +703,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result1.messages).toEqual(messagesWithSmallContent)
 
@@ -697,6 +714,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result2).not.toEqual(messagesWithSmallContent)
 		expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction
@@ -717,6 +735,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result1.messages).toEqual(messagesWithSmallContent)
 
@@ -727,6 +746,7 @@ describe("getMaxTokens", () => {
 			contextWindow: modelInfo.contextWindow,
 			maxTokens: modelInfo.maxTokens,
 			apiHandler: mockApiHandler,
+			systemPrompt: "System prompt",
 		})
 		expect(result2).not.toEqual(messagesWithSmallContent)
 		expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction

+ 1 - 1
src/core/sliding-window/index.ts

@@ -64,7 +64,7 @@ type TruncateOptions = {
 	maxTokens?: number | null
 	apiHandler: ApiHandler
 	autoCondenseContext?: boolean
-	systemPrompt?: string
+	systemPrompt: string
 }
 
 type TruncateResponse = SummarizeResponse & { prevContextTokens: number }

+ 68 - 31
src/core/task/Task.ts

@@ -76,7 +76,7 @@ import {
 } from "../checkpoints"
 import { processUserContentMentions } from "../mentions/processUserContentMentions"
 import { ApiMessage } from "../task-persistence/apiMessages"
-import { getMessagesSinceLastSummary } from "../condense"
+import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
 import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
 
 export type ClineEvents = {
@@ -481,6 +481,39 @@ export class Task extends EventEmitter<ClineEvents> {
 		}
 	}
 
+	public async condenseContext(): Promise<void> {
+		const systemPrompt = await this.getSystemPrompt()
+		const {
+			messages,
+			summary,
+			cost,
+			newContextTokens = 0,
+		} = await summarizeConversation(this.apiConversationHistory, this.api, systemPrompt)
+		if (!summary) {
+			return
+		}
+		const lastMessageContent = this.apiConversationHistory.at(-1)?.content
+		await this.overwriteApiConversationHistory(messages)
+		const { contextTokens } = this.getTokenUsage()
+		const lastContent =
+			typeof lastMessageContent === "string"
+				? [{ type: "text" as const, text: lastMessageContent }]
+				: lastMessageContent
+		const lastMessageTokens = lastContent ? await this.api.countTokens(lastContent) : 0
+		const prevContextTokens = contextTokens + lastMessageTokens
+		const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
+		await this.say(
+			"condense_context",
+			undefined /* text */,
+			undefined /* images */,
+			false /* partial */,
+			undefined /* checkpoint */,
+			undefined /* progressStatus */,
+			{ isNonInteractive: true } /* options */,
+			contextCondense,
+		)
+	}
+
 	async say(
 		type: ClineSay,
 		text?: string,
@@ -1368,35 +1401,9 @@ export class Task extends EventEmitter<ClineEvents> {
 		}
 	}
 
-	public async *attemptApiRequest(retryAttempt: number = 0): ApiStream {
+	private async getSystemPrompt(): Promise<string> {
+		const { mcpEnabled } = (await this.providerRef.deref()?.getState()) ?? {}
 		let mcpHub: McpHub | undefined
-
-		const { apiConfiguration, mcpEnabled, autoApprovalEnabled, alwaysApproveResubmit, requestDelaySeconds } =
-			(await this.providerRef.deref()?.getState()) ?? {}
-
-		let rateLimitDelay = 0
-
-		// Only apply rate limiting if this isn't the first request
-		if (this.lastApiRequestTime) {
-			const now = Date.now()
-			const timeSinceLastRequest = now - this.lastApiRequestTime
-			const rateLimit = apiConfiguration?.rateLimitSeconds || 0
-			rateLimitDelay = Math.ceil(Math.max(0, rateLimit * 1000 - timeSinceLastRequest) / 1000)
-		}
-
-		// Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there.
-		if (rateLimitDelay > 0 && retryAttempt === 0) {
-			// Show countdown timer
-			for (let i = rateLimitDelay; i > 0; i--) {
-				const delayMessage = `Rate limiting for ${i} seconds...`
-				await this.say("api_req_retry_delayed", delayMessage, undefined, true)
-				await delay(1000)
-			}
-		}
-
-		// Update last request time before making the request
-		this.lastApiRequestTime = Date.now()
-
 		if (mcpEnabled ?? true) {
 			const provider = this.providerRef.deref()
 
@@ -1432,7 +1439,7 @@ export class Task extends EventEmitter<ClineEvents> {
 
 		const { customModes } = (await this.providerRef.deref()?.getState()) ?? {}
 
-		const systemPrompt = await (async () => {
+		return await (async () => {
 			const provider = this.providerRef.deref()
 
 			if (!provider) {
@@ -1457,6 +1464,36 @@ export class Task extends EventEmitter<ClineEvents> {
 				rooIgnoreInstructions,
 			)
 		})()
+	}
+
+	public async *attemptApiRequest(retryAttempt: number = 0): ApiStream {
+		const { apiConfiguration, autoApprovalEnabled, alwaysApproveResubmit, requestDelaySeconds, experiments } =
+			(await this.providerRef.deref()?.getState()) ?? {}
+
+		let rateLimitDelay = 0
+
+		// Only apply rate limiting if this isn't the first request
+		if (this.lastApiRequestTime) {
+			const now = Date.now()
+			const timeSinceLastRequest = now - this.lastApiRequestTime
+			const rateLimit = apiConfiguration?.rateLimitSeconds || 0
+			rateLimitDelay = Math.ceil(Math.max(0, rateLimit * 1000 - timeSinceLastRequest) / 1000)
+		}
+
+		// Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there.
+		if (rateLimitDelay > 0 && retryAttempt === 0) {
+			// Show countdown timer
+			for (let i = rateLimitDelay; i > 0; i--) {
+				const delayMessage = `Rate limiting for ${i} seconds...`
+				await this.say("api_req_retry_delayed", delayMessage, undefined, true)
+				await delay(1000)
+			}
+		}
+
+		// Update last request time before making the request
+		this.lastApiRequestTime = Date.now()
+
+		const systemPrompt = await this.getSystemPrompt()
 
 		const { contextTokens } = this.getTokenUsage()
 		if (contextTokens) {
@@ -1495,7 +1532,7 @@ export class Task extends EventEmitter<ClineEvents> {
 					false /* partial */,
 					undefined /* checkpoint */,
 					undefined /* progressStatus */,
-					undefined /* options */,
+					{ isNonInteractive: true } /* options */,
 					contextCondense,
 				)
 			}

+ 16 - 0
src/core/webview/ClineProvider.ts

@@ -1111,6 +1111,22 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
 		await downloadTask(historyItem.ts, apiConversationHistory)
 	}
 
+	/* Condenses a task's message history to use fewer tokens. */
+	async condenseTaskContext(taskId: string) {
+		let task: Task | undefined
+		for (let i = this.clineStack.length - 1; i >= 0; i--) {
+			if (this.clineStack[i].taskId === taskId) {
+				task = this.clineStack[i]
+				break
+			}
+		}
+		if (!task) {
+			throw new Error(`Task with id ${taskId} not found in stack`)
+		}
+		await task.condenseContext()
+		await this.postMessageToWebview({ type: "condenseTaskContextResponse", text: taskId })
+	}
+
 	// this function deletes a task from task hidtory, and deletes it's checkpoints and delete the task folder
 	async deleteTaskWithId(id: string) {
 		try {

+ 3 - 0
src/core/webview/webviewMessageHandler.ts

@@ -197,6 +197,9 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
 		case "showTaskWithId":
 			provider.showTaskWithId(message.text!)
 			break
+		case "condenseTaskContextRequest":
+			provider.condenseTaskContext(message.text!)
+			break
 		case "deleteTaskWithId":
 			provider.deleteTaskWithId(message.text!)
 			break

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -69,6 +69,7 @@ export interface ExtensionMessage {
 		| "setHistoryPreviewCollapsed"
 		| "commandExecutionStatus"
 		| "vsCodeSetting"
+		| "condenseTaskContextResponse"
 	text?: string
 	action?:
 		| "chatButtonClicked"

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -131,6 +131,7 @@ export interface WebviewMessage {
 		| "searchFiles"
 		| "toggleApiConfigPin"
 		| "setHistoryPreviewCollapsed"
+		| "condenseTaskContextRequest"
 	text?: string
 	disabled?: boolean
 	askResponse?: ClineAskResponse

+ 2 - 0
webview-ui/src/__tests__/ContextWindowProgress.test.tsx

@@ -56,6 +56,8 @@ describe("ContextWindowProgress", () => {
 			totalCost: 0.001,
 			contextTokens: 1000,
 			onClose: jest.fn(),
+			buttonsDisabled: false,
+			handleCondenseContext: jest.fn((_taskId: string) => {}),
 		}
 
 		return render(

+ 24 - 0
webview-ui/src/components/chat/ChatView.tsx

@@ -69,6 +69,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	const modeShortcutText = `${isMac ? "⌘" : "Ctrl"} + . ${t("chat:forNextMode")}`
 	const {
 		clineMessages: messages,
+		currentTaskItem,
 		taskHistory,
 		apiConfiguration,
 		mcpServers,
@@ -138,6 +139,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 	const lastTtsRef = useRef<string>("")
 	const [wasStreaming, setWasStreaming] = useState<boolean>(false)
 	const [showCheckpointWarning, setShowCheckpointWarning] = useState<boolean>(false)
+	const [isCondensing, setIsCondensing] = useState<boolean>(false)
 
 	// UI layout depends on the last 2 messages
 	// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@@ -619,15 +621,26 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 							handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
 							break
 					}
+					break
+				case "condenseTaskContextResponse":
+					if (message.text && message.text === currentTaskItem?.id) {
+						if (isCondensing && sendingDisabled) {
+							setSendingDisabled(false)
+						}
+						setIsCondensing(false)
+					}
+					break
 			}
 			// textAreaRef.current is not explicitly required here since React
 			// guarantees that ref will be stable across re-renders, and we're
 			// not using its value but its reference.
 		},
 		[
+			isCondensing,
 			isHidden,
 			sendingDisabled,
 			enableButtons,
+			currentTaskItem,
 			handleChatReset,
 			handleSendMessage,
 			handleSetChatBoxMessage,
@@ -1242,6 +1255,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 		},
 	}))
 
+	const handleCondenseContext = (taskId: string) => {
+		if (isCondensing || sendingDisabled) {
+			return
+		}
+		setIsCondensing(true)
+		setSendingDisabled(true)
+		vscode.postMessage({ type: "condenseTaskContextRequest", text: taskId })
+	}
+
 	return (
 		<div className={isHidden ? "hidden" : "fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden"}>
 			{showAnnouncement && <Announcement hideAnnouncement={hideAnnouncement} />}
@@ -1256,6 +1278,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 						cacheReads={apiMetrics.totalCacheReads}
 						totalCost={apiMetrics.totalCost}
 						contextTokens={apiMetrics.contextTokens}
+						buttonsDisabled={sendingDisabled}
+						handleCondenseContext={handleCondenseContext}
 						onClose={handleTaskCloseButtonClick}
 					/>
 

+ 1 - 4
webview-ui/src/components/chat/ContextCondenseRow.tsx

@@ -27,10 +27,7 @@ export const ContextCondenseRow = ({ cost, prevContextTokens, newContextTokens,
 
 			{isExpanded && (
 				<div className="mt-2 ml-0 p-4 bg-vscode-editor-background rounded text-vscode-foreground text-sm">
-					<h3 className="font-bold mb-5 -mt-1 ml-2">{t("chat:contextCondense.conversationSummary")}</h3>
-					<div className="-ml-6">
-						<Markdown markdown={summary} />
-					</div>
+					<Markdown markdown={summary} />
 				</div>
 			)}
 		</div>

+ 35 - 24
webview-ui/src/components/chat/TaskActions.tsx

@@ -3,43 +3,54 @@ import prettyBytes from "pretty-bytes"
 import { useTranslation } from "react-i18next"
 
 import { vscode } from "@/utils/vscode"
-import { Button } from "@/components/ui"
-
 import { HistoryItem } from "@roo/shared/HistoryItem"
 
 import { DeleteTaskDialog } from "../history/DeleteTaskDialog"
+import { IconButton } from "./IconButton"
+
+interface TaskActionsProps {
+	item?: HistoryItem
+	buttonsDisabled: boolean
+	handleCondenseContext: (taskId: string) => void
+}
 
-export const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {
+export const TaskActions = ({ item, buttonsDisabled, handleCondenseContext }: TaskActionsProps) => {
 	const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
 	const { t } = useTranslation()
 
 	return (
 		<div className="flex flex-row gap-1">
-			<Button
-				variant="ghost"
-				size="sm"
+			<IconButton
+				iconClass="codicon-desktop-download"
 				title={t("chat:task.export")}
-				onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
-				<span className="codicon codicon-desktop-download" />
-			</Button>
+				disabled={buttonsDisabled}
+				onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
+			/>
 			{!!item?.size && item.size > 0 && (
 				<>
-					<Button
-						variant="ghost"
-						size="sm"
-						title={t("chat:task.delete")}
-						onClick={(e) => {
-							e.stopPropagation()
+					<IconButton
+						iconClass="codicon-file-zip"
+						title={t("chat:task.condenseContext")}
+						disabled={buttonsDisabled}
+						onClick={() => handleCondenseContext(item.id)}
+					/>
+					<div className="flex items-center">
+						<IconButton
+							iconClass="codicon-trash"
+							title={t("chat:task.delete")}
+							disabled={buttonsDisabled}
+							onClick={(e) => {
+								e.stopPropagation()
 
-							if (e.shiftKey) {
-								vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
-							} else {
-								setDeleteTaskId(item.id)
-							}
-						}}>
-						<span className="codicon codicon-trash" />
-						{prettyBytes(item.size)}
-					</Button>
+								if (e.shiftKey) {
+									vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
+								} else {
+									setDeleteTaskId(item.id)
+								}
+							}}
+						/>
+						<span className="ml-1 text-xs text-vscode-foreground opacity-85">{prettyBytes(item.size)}</span>
+					</div>
 					{deleteTaskId && (
 						<DeleteTaskDialog
 							taskId={deleteTaskId}

+ 16 - 2
webview-ui/src/components/chat/TaskHeader.tsx

@@ -28,6 +28,8 @@ export interface TaskHeaderProps {
 	cacheReads?: number
 	totalCost: number
 	contextTokens: number
+	buttonsDisabled: boolean
+	handleCondenseContext: (taskId: string) => void
 	onClose: () => void
 }
 
@@ -40,6 +42,8 @@ const TaskHeader = ({
 	cacheReads,
 	totalCost,
 	contextTokens,
+	buttonsDisabled,
+	handleCondenseContext,
 	onClose,
 }: TaskHeaderProps) => {
 	const { t } = useTranslation()
@@ -152,7 +156,13 @@ const TaskHeader = ({
 										</span>
 									)}
 								</div>
-								{!totalCost && <TaskActions item={currentTaskItem} />}
+								{!totalCost && (
+									<TaskActions
+										item={currentTaskItem}
+										buttonsDisabled={buttonsDisabled}
+										handleCondenseContext={handleCondenseContext}
+									/>
+								)}
 							</div>
 
 							{doesModelSupportPromptCache &&
@@ -181,7 +191,11 @@ const TaskHeader = ({
 										<span className="font-bold">{t("chat:task.apiCost")}</span>
 										<span>${totalCost?.toFixed(2)}</span>
 									</div>
-									<TaskActions item={currentTaskItem} />
+									<TaskActions
+										item={currentTaskItem}
+										buttonsDisabled={buttonsDisabled}
+										handleCondenseContext={handleCondenseContext}
+									/>
 								</div>
 							)}
 						</div>

+ 2 - 0
webview-ui/src/components/chat/__tests__/TaskHeader.test.tsx

@@ -40,6 +40,8 @@ describe("TaskHeader", () => {
 		doesModelSupportPromptCache: true,
 		totalCost: 0.05,
 		contextTokens: 200,
+		buttonsDisabled: false,
+		handleCondenseContext: jest.fn(),
 		onClose: jest.fn(),
 	}
 

+ 2 - 2
webview-ui/src/i18n/locales/ca/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Finestra de context:",
 		"closeAndStart": "Tancar tasca i iniciar-ne una de nova",
 		"export": "Exportar historial de tasques",
-		"delete": "Eliminar tasca (Shift + Clic per ometre confirmació)"
+		"delete": "Eliminar tasca (Shift + Clic per ometre confirmació)",
+		"condenseContext": "Condensar context de la tasca"
 	},
 	"unpin": "Desfixar",
 	"pin": "Fixar",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Context condensat",
-		"conversationSummary": "Resum de la conversa",
 		"tokens": "tokens"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/de/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Kontextfenster:",
 		"closeAndStart": "Aufgabe schließen und neue starten",
 		"export": "Aufgabenverlauf exportieren",
-		"delete": "Aufgabe löschen (Shift + Klick zum Überspringen der Bestätigung)"
+		"delete": "Aufgabe löschen (Shift + Klick zum Überspringen der Bestätigung)",
+		"condenseContext": "Task-Kontext komprimieren"
 	},
 	"unpin": "Lösen von oben",
 	"pin": "Anheften",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Kontext komprimiert",
-		"conversationSummary": "Gesprächszusammenfassung",
 		"tokens": "Tokens"
 	},
 	"followUpSuggest": {

+ 1 - 1
webview-ui/src/i18n/locales/en/chat.json

@@ -7,6 +7,7 @@
 		"tokens": "Tokens:",
 		"cache": "Cache:",
 		"apiCost": "API Cost:",
+		"condenseContext": "Condense task context",
 		"contextWindow": "Context Length:",
 		"closeAndStart": "Close task and start a new one",
 		"export": "Export task history",
@@ -131,7 +132,6 @@
 	},
 	"contextCondense": {
 		"title": "Context Condensed",
-		"conversationSummary": "Conversation Summary",
 		"tokens": "tokens"
 	},
 	"instructions": {

+ 2 - 2
webview-ui/src/i18n/locales/es/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Longitud del contexto:",
 		"closeAndStart": "Cerrar tarea e iniciar una nueva",
 		"export": "Exportar historial de tareas",
-		"delete": "Eliminar tarea (Shift + Clic para omitir confirmación)"
+		"delete": "Eliminar tarea (Shift + Clic para omitir confirmación)",
+		"condenseContext": "Condensar contexto de la tarea"
 	},
 	"unpin": "Desfijar",
 	"pin": "Fijar",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Contexto condensado",
-		"conversationSummary": "Resumen de la conversación",
 		"tokens": "tokens"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/fr/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Durée du contexte :",
 		"closeAndStart": "Fermer la tâche et en commencer une nouvelle",
 		"export": "Exporter l'historique des tâches",
-		"delete": "Supprimer la tâche (Shift + Clic pour ignorer la confirmation)"
+		"delete": "Supprimer la tâche (Shift + Clic pour ignorer la confirmation)",
+		"condenseContext": "Condenser le contexte de la tâche"
 	},
 	"unpin": "Désépingler",
 	"pin": "Épingler",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Contexte condensé",
-		"conversationSummary": "Résumé de la conversation",
 		"tokens": "tokens"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/hi/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "संदर्भ लंबाई:",
 		"closeAndStart": "कार्य बंद करें और नया शुरू करें",
 		"export": "कार्य इतिहास निर्यात करें",
-		"delete": "कार्य हटाएं (पुष्टि को छोड़ने के लिए Shift + क्लिक)"
+		"delete": "कार्य हटाएं (पुष्टि को छोड़ने के लिए Shift + क्लिक)",
+		"condenseContext": "कार्य संदर्भ संघनित करें"
 	},
 	"unpin": "पिन करें",
 	"pin": "अवपिन करें",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "संदर्भ संक्षिप्त किया गया",
-		"conversationSummary": "वार्तालाप का सारांश",
 		"tokens": "टोकन"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/it/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Lunghezza del contesto:",
 		"closeAndStart": "Chiudi attività e iniziane una nuova",
 		"export": "Esporta cronologia attività",
-		"delete": "Elimina attività (Shift + Clic per saltare la conferma)"
+		"delete": "Elimina attività (Shift + Clic per saltare la conferma)",
+		"condenseContext": "Condensa contesto attività"
 	},
 	"unpin": "Rilascia",
 	"pin": "Fissa",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Contesto condensato",
-		"conversationSummary": "Riepilogo della conversazione",
 		"tokens": "token"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/ja/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "コンテキストウィンドウ:",
 		"closeAndStart": "タスクを閉じて新しいタスクを開始",
 		"export": "タスク履歴をエクスポート",
-		"delete": "タスクを削除(Shift + クリックで確認をスキップ)"
+		"delete": "タスクを削除(Shift + クリックで確認をスキップ)",
+		"condenseContext": "タスクコンテキストを圧縮"
 	},
 	"unpin": "ピン留めを解除",
 	"pin": "ピン留め",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "コンテキスト要約",
-		"conversationSummary": "会話の要約",
 		"tokens": "トークン"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/ko/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "컨텍스트 창:",
 		"closeAndStart": "작업 닫고 새 작업 시작",
 		"export": "작업 기록 내보내기",
-		"delete": "작업 삭제 (Shift + 클릭으로 확인 생략)"
+		"delete": "작업 삭제 (Shift + 클릭으로 확인 생략)",
+		"condenseContext": "작업 컨텍스트 압축"
 	},
 	"unpin": "고정 해제하기",
 	"pin": "고정하기",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "컨텍스트 요약됨",
-		"conversationSummary": "대화 요약",
 		"tokens": "토큰"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/nl/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Contextlengte:",
 		"closeAndStart": "Taak sluiten en een nieuwe starten",
 		"export": "Taakgeschiedenis exporteren",
-		"delete": "Taak verwijderen (Shift + Klik om bevestiging over te slaan)"
+		"delete": "Taak verwijderen (Shift + Klik om bevestiging over te slaan)",
+		"condenseContext": "Taakcontext samenvatten"
 	},
 	"unpin": "Losmaken",
 	"pin": "Vastmaken",
@@ -210,7 +211,6 @@
 	},
 	"contextCondense": {
 		"title": "Context samengevat",
-		"conversationSummary": "Gespreksoverzicht",
 		"tokens": "tokens"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/pl/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Okno kontekstu:",
 		"closeAndStart": "Zamknij zadanie i rozpocznij nowe",
 		"export": "Eksportuj historię zadań",
-		"delete": "Usuń zadanie (Shift + Kliknięcie, aby pominąć potwierdzenie)"
+		"delete": "Usuń zadanie (Shift + Kliknięcie, aby pominąć potwierdzenie)",
+		"condenseContext": "Skondensuj kontekst zadania"
 	},
 	"unpin": "Odepnij",
 	"pin": "Przypnij",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Kontekst skondensowany",
-		"conversationSummary": "Podsumowanie rozmowy",
 		"tokens": "tokeny"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/pt-BR/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Janela de contexto:",
 		"closeAndStart": "Fechar tarefa e iniciar nova",
 		"export": "Exportar histórico de tarefas",
-		"delete": "Excluir tarefa (Shift + Clique para pular confirmação)"
+		"delete": "Excluir tarefa (Shift + Clique para pular confirmação)",
+		"condenseContext": "Condensar contexto da tarefa"
 	},
 	"unpin": "Desfixar",
 	"pin": "Fixar",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Contexto condensado",
-		"conversationSummary": "Resumo da conversa",
 		"tokens": "tokens"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/ru/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Длина контекста:",
 		"closeAndStart": "Закрыть задачу и начать новую",
 		"export": "Экспортировать историю задач",
-		"delete": "Удалить задачу (Shift + клик для пропуска подтверждения)"
+		"delete": "Удалить задачу (Shift + клик для пропуска подтверждения)",
+		"condenseContext": "Сжать контекст задачи"
 	},
 	"unpin": "Открепить",
 	"pin": "Закрепить",
@@ -210,7 +211,6 @@
 	},
 	"contextCondense": {
 		"title": "Контекст сжат",
-		"conversationSummary": "Сводка разговора",
 		"tokens": "токены"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/tr/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Bağlam Uzunluğu:",
 		"closeAndStart": "Görevi kapat ve yeni bir görev başlat",
 		"export": "Görev geçmişini dışa aktar",
-		"delete": "Görevi sil (Onayı atlamak için Shift + Tıkla)"
+		"delete": "Görevi sil (Onayı atlamak için Shift + Tıkla)",
+		"condenseContext": "Görev bağlamını yoğunlaştır"
 	},
 	"unpin": "Sabitlemeyi iptal et",
 	"pin": "Sabitle",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Bağlam Özetlendi",
-		"conversationSummary": "Konuşma Özeti",
 		"tokens": "token"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/vi/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "Chiều dài bối cảnh:",
 		"closeAndStart": "Đóng nhiệm vụ và bắt đầu nhiệm vụ mới",
 		"export": "Xuất lịch sử nhiệm vụ",
-		"delete": "Xóa nhiệm vụ (Shift + Click để bỏ qua xác nhận)"
+		"delete": "Xóa nhiệm vụ (Shift + Click để bỏ qua xác nhận)",
+		"condenseContext": "Cô đọng ngữ cảnh tác vụ"
 	},
 	"unpin": "Bỏ ghim khỏi đầu",
 	"pin": "Ghim lên đầu",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "Ngữ cảnh đã tóm tắt",
-		"conversationSummary": "Tóm tắt cuộc hội thoại",
 		"tokens": "token"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/zh-CN/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "上下文长度:",
 		"closeAndStart": "关闭任务并开始新任务",
 		"export": "导出任务历史",
-		"delete": "删除任务(Shift + 点击跳过确认)"
+		"delete": "删除任务(Shift + 点击跳过确认)",
+		"condenseContext": "压缩任务上下文"
 	},
 	"unpin": "取消置顶",
 	"pin": "置顶",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "上下文已压缩",
-		"conversationSummary": "对话摘要",
 		"tokens": "tokens"
 	},
 	"followUpSuggest": {

+ 2 - 2
webview-ui/src/i18n/locales/zh-TW/chat.json

@@ -10,7 +10,8 @@
 		"contextWindow": "上下文長度:",
 		"closeAndStart": "關閉現有工作並開始一項新的工作",
 		"export": "匯出工作紀錄",
-		"delete": "刪除工作(按住 Shift 並點選可跳過確認)"
+		"delete": "刪除工作(按住 Shift 並點選可跳過確認)",
+		"condenseContext": "壓縮工作上下文"
 	},
 	"unpin": "取消置頂",
 	"pin": "置頂",
@@ -200,7 +201,6 @@
 	},
 	"contextCondense": {
 		"title": "上下文已壓縮",
-		"conversationSummary": "對話摘要",
 		"tokens": "tokens"
 	},
 	"followUpSuggest": {