From 016e7ebc011d0cc27e712f315b448e6e20a69e79 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 13 Mar 2026 19:36:00 +0530 Subject: [PATCH 1/6] refactor fileEdit diff ui --- src/package.json | 2 +- webview-ui/src/components/chat/ChatRow.tsx | 66 +++- .../src/components/chat/GitHubDiffView.tsx | 309 ++++++++++++++++-- 3 files changed, 331 insertions(+), 46 deletions(-) diff --git a/src/package.json b/src/package.json index 7f1de5d7a..54251d8f7 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "matterai", - "version": "5.6.4", + "version": "5.6.5", "icon": "assets/icons/matterai-ic.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index b4f49b095..e75d5bd18 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -97,40 +97,82 @@ const headerStyle: React.CSSProperties = { } // Build a GitHub-style unified diff for fileEdit when backend doesn't supply one +const stripTruncationMarker = (value: string) => + value + .replace(/\n?\.\.\.\(truncated\)\s*$/g, "") + .replace(/\n?\.\.\. \(truncated\)\s*$/g, "") + .trimEnd() + const buildFileEditDiff = (tool: ClineSayTool): string | undefined => { const path = tool.path || "file" - const oldText = (tool.search ?? "").trimEnd() - const newText = (tool.replace ?? tool.content ?? "").trimEnd() + const oldText = stripTruncationMarker((tool.search ?? "").trimEnd()) + const newText = stripTruncationMarker((tool.replace ?? tool.content ?? "").trimEnd()) if (!oldText && !newText) return undefined const oldLines = oldText.split(/\r?\n/) const newLines = newText.split(/\r?\n/) + let leadingContext = 0 + + while ( + leadingContext < oldLines.length && + leadingContext < newLines.length && + oldLines[leadingContext] === newLines[leadingContext] + ) { + leadingContext += 1 + } + + let trailingContext = 0 + while ( + trailingContext < oldLines.length - leadingContext && + trailingContext < newLines.length - leadingContext && + oldLines[oldLines.length - 1 - trailingContext] === newLines[newLines.length - 1 - trailingContext] + ) { + trailingContext += 1 + } + + const contextBefore = leadingContext > 0 ? [oldLines[leadingContext - 1]] : [] + const contextAfter = trailingContext > 0 ? [oldLines[oldLines.length - trailingContext]] : [] + const changedOldLines = oldLines.slice(leadingContext, Math.max(leadingContext, oldLines.length - trailingContext)) + const changedNewLines = newLines.slice(leadingContext, Math.max(leadingContext, newLines.length - trailingContext)) + const visibleOldLines = [...contextBefore, ...changedOldLines, ...contextAfter] + const visibleNewLines = [...contextBefore, ...changedNewLines, ...contextAfter] const lines: string[] = [] lines.push(`--- a/${path}`) lines.push(`+++ b/${path}`) // Calculate hunk header with line numbers - const oldStart = 1 - const oldCount = oldLines.length - const newStart = 1 - const newCount = newLines.length + const contextOffset = contextBefore.length + const hunkStart = Math.max(1, (tool.startLine ?? 1) + leadingContext - contextOffset) + const oldStart = hunkStart + const oldCount = visibleOldLines.length + const newStart = hunkStart + const newCount = visibleNewLines.length lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`) - // Add old lines with - prefix - for (const line of oldLines) { + for (const line of contextBefore) { + lines.push(` ${line}`) + } + + for (const line of changedOldLines) { lines.push(`-${line}`) } - // Add new lines with + prefix - for (const line of newLines) { + for (const line of changedNewLines) { lines.push(`+${line}`) } + for (const line of contextAfter) { + lines.push(` ${line}`) + } + return lines.join("\n") } +const hasTruncatedDiffContent = (diff?: string | null) => + Boolean(diff && (diff.includes("...(truncated)") || diff.includes("... (truncated)"))) + const computeDiffStats = (diff?: string | null) => { if (!diff) return null let added = 0 @@ -551,7 +593,9 @@ export const ChatRowContent = ({ ) case "fileEdit": { - const fileEditDiff = tool.diff ?? buildFileEditDiff(tool) + const fallbackFileEditDiff = buildFileEditDiff(tool) + const fileEditDiff = + tool.diff && !hasTruncatedDiffContent(tool.diff) ? tool.diff : (fallbackFileEditDiff ?? tool.diff) const diffStats = computeDiffStats(fileEditDiff) // Use startLine from tool if available, otherwise extract from diff const editLineNumber = tool.startLine ?? extractFirstLineNumberFromDiff(fileEditDiff) diff --git a/webview-ui/src/components/chat/GitHubDiffView.tsx b/webview-ui/src/components/chat/GitHubDiffView.tsx index 4efa1203e..e34acb9dd 100644 --- a/webview-ui/src/components/chat/GitHubDiffView.tsx +++ b/webview-ui/src/components/chat/GitHubDiffView.tsx @@ -1,5 +1,7 @@ import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" -import { memo } from "react" +import { getLanguageFromPath } from "@src/utils/getLanguageFromPath" +import { getHighlighter, isLanguageLoaded, normalizeLanguage } from "@src/utils/highlighter" +import { memo, type CSSProperties, useEffect, useMemo, useState } from "react" import { extractFirstLineNumberFromDiff } from "../common/CodeAccordian" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" @@ -20,6 +22,100 @@ interface GitHubDiffViewProps { onOpenFile?: () => void } +type ParsedDiffRow = + | { kind: "spacer"; key: string } + | { kind: "hunk"; key: string; oldStart: number; newStart: number } + | { + kind: "line" + key: string + type: "context" | "addition" | "deletion" + content: string + oldLine?: number + newLine?: number + } + +const HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/ + +const parseUnifiedDiff = (diff: string): ParsedDiffRow[] => { + if (!diff) return [] + + const rows: ParsedDiffRow[] = [] + const lines = diff.split(/\r?\n/) + let oldLine = 0 + let newLine = 0 + let previousVisibleNewLine: number | undefined + let hunkIndex = 0 + + for (const rawLine of lines) { + if (rawLine.startsWith("diff --git") || rawLine.startsWith("--- ") || rawLine.startsWith("+++ ")) { + continue + } + + const hunkMatch = rawLine.match(HUNK_HEADER_REGEX) + if (hunkMatch) { + const nextOldLine = parseInt(hunkMatch[1], 10) + const nextNewLine = parseInt(hunkMatch[2], 10) + + if ( + previousVisibleNewLine !== undefined && + nextNewLine > previousVisibleNewLine + 1 && + rows[rows.length - 1]?.kind !== "spacer" + ) { + rows.push({ kind: "spacer", key: `spacer-${hunkIndex}` }) + } + + oldLine = nextOldLine + newLine = nextNewLine + rows.push({ kind: "hunk", key: `hunk-${hunkIndex}`, oldStart: nextOldLine, newStart: nextNewLine }) + hunkIndex += 1 + continue + } + + if (rawLine.startsWith("\\")) { + continue + } + + if (rawLine.startsWith("+")) { + rows.push({ + kind: "line", + key: `add-${newLine}-${rows.length}`, + type: "addition", + content: rawLine.slice(1), + newLine, + }) + previousVisibleNewLine = newLine + newLine += 1 + continue + } + + if (rawLine.startsWith("-")) { + rows.push({ + kind: "line", + key: `del-${oldLine}-${rows.length}`, + type: "deletion", + content: rawLine.slice(1), + oldLine, + }) + oldLine += 1 + continue + } + + rows.push({ + kind: "line", + key: `ctx-${newLine}-${rows.length}`, + type: "context", + content: rawLine.startsWith(" ") ? rawLine.slice(1) : rawLine, + oldLine, + newLine, + }) + previousVisibleNewLine = newLine + oldLine += 1 + newLine += 1 + } + + return rows +} + const GitHubDiffView = memo( ({ diff, @@ -32,7 +128,7 @@ const GitHubDiffView = memo( onOpenFile, }: GitHubDiffViewProps) => { const firstLineNumber = extractFirstLineNumberFromDiff(diff) - + const language = useMemo(() => getLanguageFromPath(filePath || "") || "text", [filePath]) const fileName = filePath?.split("/").pop() || filePath || "file" return ( @@ -72,8 +168,12 @@ const GitHubDiffView = memo( ) : null} {diffStats ? ( - +{diffStats.added} - -{diffStats.removed} + + +{diffStats.added} + + + -{diffStats.removed} + ) : null} @@ -84,11 +184,23 @@ const GitHubDiffView = memo( {isExpanded && ( -
-
+
+
{/* Diff content */} -
- +
+
@@ -101,40 +213,169 @@ const GitHubDiffView = memo( GitHubDiffView.displayName = "GitHubDiffView" // Unified diff view component -const UnifiedDiffView = memo(({ diff }: { diff: string }) => { - const lines = diff ? diff.split("\n") : [] +const DiffSyntaxLine = memo(({ content, language }: { content: string; language: string }) => { + const normalizedLanguage = useMemo(() => normalizeLanguage(language), [language]) + const normalizedContent = useMemo(() => (content || " ").replace(/\t/g, " "), [content]) + const [tokens, setTokens] = useState>([{ content: normalizedContent }]) + + useEffect(() => { + let isMounted = true + const fallback = [{ content: normalizedContent }] + + const highlight = async () => { + if (!normalizedContent.trim()) { + if (isMounted) setTokens([{ content: " " }]) + return + } + + if (!isLanguageLoaded(normalizedLanguage) && isMounted) { + setTokens(fallback) + } + + const highlighter = await getHighlighter(normalizedLanguage) + if (!isMounted) return + + const tokenResult = (await highlighter.codeToTokens(normalizedContent, { + lang: normalizedLanguage, + theme: document.body.className.toLowerCase().includes("light") ? "github-light" : "github-dark", + })) as { tokens?: Array> } + if (!isMounted) return + + const firstLine = tokenResult.tokens?.[0] + if (!firstLine?.length) { + if (isMounted) setTokens([{ content: normalizedContent }]) + return + } + + if (isMounted) { + setTokens( + firstLine.map((token: { content: string; color?: string }) => ({ + content: token.content, + color: token.color, + })), + ) + } + } + + highlight().catch(() => { + if (isMounted) setTokens(fallback) + }) + + return () => { + isMounted = false + } + }, [normalizedContent, normalizedLanguage]) return ( -
- {lines.map((line, index) => { - const isHeader = line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git") - const isHunk = line.startsWith("@@") - const isAddition = line.startsWith("+") - const isDeletion = line.startsWith("-") - - let lineClass = "" - let bgClass = "" - - if (isHeader) { - lineClass = "text-vscode-descriptionForeground" - } else if (isHunk) { - lineClass = "text-vscode-editorInfo-foreground bg-vscode-editorInfo-background" - } else if (isAddition) { - bgClass = "bg-[var(--vscode-diffEditor-insertedTextBackground)]" + + {tokens.map((token, index) => ( + + {token.content} + + ))} + + ) +}) + +DiffSyntaxLine.displayName = "DiffSyntaxLine" + +const UnifiedDiffView = memo(({ diff, language }: { diff: string; language: string }) => { + const rows = parseUnifiedDiff(diff) + const lineNumberStyle: CSSProperties = { + width: "3.75rem", + flexShrink: 0, + paddingRight: "0.625rem", + textAlign: "right", + userSelect: "none", + fontVariantNumeric: "tabular-nums", + color: "var(--vscode-editorLineNumber-foreground)", + } + + return ( +
+ {rows.map((row) => { + if (row.kind === "spacer") { + return ( +
+ + + ... + +
+ ) + } + + if (row.kind === "hunk") { + return null + } + + const isAddition = row.type === "addition" + const isDeletion = row.type === "deletion" + const displayLineNumber = row.newLine ?? row.oldLine + + let background = "transparent" + const textColor = "var(--vscode-editor-foreground)" + let accent = "transparent" + let lineNumberBackground: string | undefined + + if (isAddition) { + background = + "color-mix(in srgb, var(--vscode-diffEditor-insertedLineBackground) 22%, var(--vscode-editor-background))" + accent = "var(--vscode-gitDecoration-addedResourceForeground)" + lineNumberBackground = + "color-mix(in srgb, var(--vscode-diffEditor-insertedLineBackground) 36%, var(--vscode-editor-background))" } else if (isDeletion) { - bgClass = "bg-[var(--vscode-diffEditor-removedTextBackground)]" + background = + "color-mix(in srgb, var(--vscode-diffEditor-removedLineBackground) 22%, var(--vscode-editor-background))" + accent = "var(--vscode-gitDecoration-deletedResourceForeground)" + lineNumberBackground = + "color-mix(in srgb, var(--vscode-diffEditor-removedLineBackground) 36%, var(--vscode-editor-background))" } return (
- {/* Line number column */} - - {isHeader || isHunk ? "" : index + 1} + key={row.key} + className="flex w-full" + style={{ + minHeight: "1.65rem", + background, + color: textColor, + boxShadow: accent === "transparent" ? undefined : `inset 1px 0 0 ${accent}`, + }}> + + {displayLineNumber ?? ""} + + + - {/* Content */} - {line}
) })} From 5d82a156566c15c34851db496033bf5254de6daf Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 13 Mar 2026 19:38:52 +0530 Subject: [PATCH 2/6] refactor fileEdit diff ui + --- webview-ui/src/components/chat/GitHubDiffView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/GitHubDiffView.tsx b/webview-ui/src/components/chat/GitHubDiffView.tsx index e34acb9dd..156f2d30e 100644 --- a/webview-ui/src/components/chat/GitHubDiffView.tsx +++ b/webview-ui/src/components/chat/GitHubDiffView.tsx @@ -188,7 +188,7 @@ const GitHubDiffView = memo(
Date: Fri, 13 Mar 2026 19:56:37 +0530 Subject: [PATCH 3/6] fix think rendering for new models --- .../providers/__tests__/openrouter.spec.ts | 32 ++++++++++++++ src/api/providers/openrouter.ts | 3 -- src/core/task/Task.ts | 5 +-- src/core/task/__tests__/Task.spec.ts | 42 +++++++++++++++++++ .../src/components/settings/SettingsView.tsx | 33 ++++++++------- 5 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index ebc860614..28f90c3df 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -187,6 +187,38 @@ describe("OpenRouterHandler", () => { ) }) + it("yields reasoning chunks from reasoning_content deltas", async () => { + const handler = new OpenRouterHandler(mockOptions) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { reasoning_content: "Working through the answer." } }], + } + yield { + id: "test-id", + choices: [{ delta: { content: "Final answer" } }], + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const chunks = [] + for await (const chunk of handler.createMessage("test system prompt", [])) { + chunks.push(chunk) + } + + expect(chunks).toEqual([ + { type: "reasoning", text: "Working through the answer." }, + { type: "text", text: "Final answer" }, + ]) + }) + it("supports the middle-out transform", async () => { const handler = new OpenRouterHandler({ ...mockOptions, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index c9b4cf080..9970a660b 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -297,9 +297,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH isThinking = false } - // newText = newText.replace(/<\/?think>/g, "") - // newText = newText.replace(//g, "") - yield { type: "reasoning", text: newText, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 83c95d1b3..ac689c569 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2289,9 +2289,8 @@ export class Task extends EventEmitter implements TaskLike { "$1\n\n**$2**", ) } - if (formattedReasoning.includes("")) { - formattedReasoning = formattedReasoning.replace(/<\/?think>/g, "") - formattedReasoning = formattedReasoning.replace(//g, "") + formattedReasoning = formattedReasoning.replace(/<\/?think>/g, "") + if (formattedReasoning.trim()) { await this.say("reasoning", formattedReasoning, undefined, true) } break diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 2544a9ce5..5bf311c2d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -82,6 +82,7 @@ vi.mock("vscode", () => { return { TabInputTextDiff: vi.fn(), + ThemeColor: vi.fn((id: string) => ({ id })), CodeActionKind: { QuickFix: { value: "quickfix" }, RefactorRewrite: { value: "refactor.rewrite" }, @@ -95,6 +96,7 @@ vi.mock("vscode", () => { dispose: vi.fn(), }), visibleTextEditors: [mockTextEditor], + onDidChangeWindowState: vi.fn(() => mockDisposable), tabGroups: { all: [mockTabGroup], close: vi.fn(), @@ -1722,6 +1724,46 @@ describe("Cline", () => { // The skip flag should be reset after the call expect((task as any).skipPrevResponseIdOnce).toBe(false) }) + + it("should render plain reasoning chunks without think tags", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + context: mockExtensionContext, + }) + + mockProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: mockApiConfig, + }) + + const saySpy = vi.spyOn(task, "say") + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { type: "reasoning", text: "Working through the answer." } + yield { type: "text", text: "Done." } + }, + async next() { + return { done: true, value: undefined } + }, + async return() { + return { done: true, value: undefined } + }, + async throw(e: any) { + throw e + }, + [Symbol.asyncDispose]: async () => {}, + } as AsyncGenerator + + vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream) + + const iterator = task.attemptApiRequest(0) + await iterator.next() + + expect(saySpy).toHaveBeenCalledWith("reasoning", "Working through the answer.", undefined, true) + }) }) describe("abortTask", () => { it("should set abort flag and emit TaskAborted event", async () => { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5c575b707..46d1bb6a7 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -4,6 +4,7 @@ import { CheckCheck, CircleUserRound, GitPullRequest, + Info, // Info, // kilocode_change: hidden for now Languages, LucideIcon, @@ -27,7 +28,7 @@ import React, { // kilocode_change import { ensureBodyPointerEventsRestored } from "@/utils/fixPointerEvents" -import type { ProviderSettings } from "@roo-code/types" +import type { ProviderSettings, TelemetrySetting } from "@roo-code/types" import { AlertDialog, @@ -71,6 +72,7 @@ import { Section } from "./Section" import { SlashCommandsSettings } from "./SlashCommandsSettings" import { TerminalSettings } from "./TerminalSettings" import { UISettings } from "./UISettings" +import { About } from "./About" export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden bg-vscode-editor-background" @@ -99,7 +101,7 @@ const sectionNames = [ "language", // "mcp", // kilocode_change: hidden for now "codeReview", // kilocode_change - // "about", // kilocode_change: hidden for now + "about", // kilocode_change: hidden for now ] as const type SectionName = (typeof sectionNames)[number] // kilocode_change @@ -354,17 +356,16 @@ const SettingsView = forwardRef(({ onDone, t // }) // }, []) - // kilocode_change: hidden for now - About section removed - // const setTelemetrySetting = useCallback((setting: TelemetrySetting) => { - // setCachedState((prevState) => { - // if (prevState.telemetrySetting === setting) { - // return prevState - // } + const setTelemetrySetting = useCallback((setting: TelemetrySetting) => { + setCachedState((prevState) => { + if (prevState.telemetrySetting === setting) { + return prevState + } - // setChangeDetected(true) - // return { ...prevState, telemetrySetting: setting } - // }) - // }, []) + setChangeDetected(true) + return { ...prevState, telemetrySetting: setting } + }) + }, []) // const setOpenRouterImageApiKey = useCallback((apiKey: string) => { // setCachedState((prevState) => { @@ -612,7 +613,7 @@ const SettingsView = forwardRef(({ onDone, t // { id: "experimental", icon: FlaskConical }, { id: "language", icon: Languages }, // { id: "mcp", icon: Server }, // kilocode_change: hidden for now - // { id: "about", icon: Info }, // kilocode_change: hidden for now + { id: "about", icon: Info }, // kilocode_change: hidden for now ], [], // kilocode_change ) @@ -1042,9 +1043,9 @@ const SettingsView = forwardRef(({ onDone, t )} {/* About Section - hidden for now */} - {/* {activeTab === "about" && ( - - )} */} + {activeTab === "about" && ( + + )}
From 43f829b1a98459da1b0fc81d5d0c1a1437b6dbe5 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 13 Mar 2026 20:46:59 +0530 Subject: [PATCH 4/6] fix bg agents state + running shimmer --- src/core/webview/ClineProvider.ts | 77 +++++++++- .../webview/__tests__/ClineProvider.spec.ts | 51 +++++++ src/core/webview/webviewMessageHandler.ts | 5 + src/shared/ExtensionMessage.ts | 2 +- src/shared/WebviewMessage.ts | 4 +- webview-ui/src/components/chat/ChatView.tsx | 139 +++++++++++------- .../src/context/ExtensionStateContext.tsx | 2 +- webview-ui/src/index.css | 24 ++- webview-ui/src/utils/customIcons.tsx | 50 +++++++ 9 files changed, 291 insertions(+), 63 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5bdc7c18b..c4ea8b828 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -561,6 +561,33 @@ export class ClineProvider await this.postStateToWebview() } + + public async dismissBackgroundTask(taskId: string) { + const bgStack = this.backgroundTasks.get(taskId) + if (!bgStack || bgStack.length === 0) { + this.log(`[dismissBackgroundTask] Task ${taskId} not found in background map`) + return + } + + for (const task of [...bgStack].reverse()) { + try { + await task.abortTask(true) + } catch (e) { + this.log( + `[dismissBackgroundTask] background task abort failed: ${e instanceof Error ? e.message : String(e)}`, + ) + } + + const cleanupFunctions = this.taskEventListeners.get(task) + if (cleanupFunctions) { + cleanupFunctions.forEach((cleanup) => cleanup()) + } + this.taskEventListeners.delete(task) + } + + this.backgroundTasks.delete(taskId) + await this.postStateToWebview() + } // kilocode_change: multi-chat support end getTaskStackSize(): number { @@ -1995,6 +2022,45 @@ ${prompt} return this.mergeCommandLists("deniedCommands", "denied", globalStateCommands) } + private getBackgroundTaskStatus(task: Task): "running" | "completed" | "waiting_approval" | "waiting_input" { + const askType = task.taskAsk?.ask + + if (task.abort || task.abandoned) { + return "completed" + } + + if ( + askType === "tool" || + askType === "command" || + askType === "browser_action_launch" || + askType === "use_mcp_server" || + askType === "auto_approval_max_req_reached" + ) { + return "waiting_approval" + } + + if (askType === "completion_result" || askType === "resume_completed_task") { + return "completed" + } + + if (askType) { + return "waiting_input" + } + + const hasPendingAssistantWork = + task.isStreaming || + task.isWaitingForAskResponse || + task.presentAssistantMessageLocked || + task.currentStreamingContentIndex < task.assistantMessageContent.length || + !task.didCompleteReadingStream + + if (hasPendingAssistantWork) { + return "running" + } + + return "completed" + } + /** * Common utility for merging command lists from global state and workspace configuration. * Implements the Command Denylist feature's merging strategy with proper validation. @@ -2201,10 +2267,7 @@ ${prompt} : "Image Task" : "New Task") - const isCompleted = - rootTask.abandoned || - rootTask.abort || - rootTask.clineMessages.some((msg) => msg.type === "say" && msg.say === "completion_result") + const status = this.getBackgroundTaskStatus(rootTask) // Get model information from task's API configuration const apiProvider = rootTask.apiConfiguration?.apiProvider @@ -2256,13 +2319,13 @@ ${prompt} } } - return { taskId, taskLabel, isCompleted, apiProvider, apiModelId, ts: historyItem?.ts || 0 } + return { taskId, taskLabel, status, apiProvider, apiModelId, ts: historyItem?.ts || 0 } }) .sort((a, b) => (b.ts as number) - (a.ts as number)) - .map(({ taskId, taskLabel, isCompleted, apiProvider, apiModelId }) => ({ + .map(({ taskId, taskLabel, status, apiProvider, apiModelId }) => ({ taskId, taskLabel, - isCompleted, + status, apiProvider, apiModelId, })) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 770b4a109..656f0b534 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -156,6 +156,7 @@ vi.mock("vscode", () => ({ showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), + onDidChangeWindowState: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), // kilocode_change }, @@ -744,6 +745,56 @@ describe("ClineProvider", () => { }) }) + describe("background task status", () => { + test("marks approval asks as waiting on approval", () => { + const status = (provider as any).getBackgroundTaskStatus({ + abort: false, + abandoned: false, + taskAsk: { ask: "tool" }, + isStreaming: false, + isWaitingForAskResponse: true, + presentAssistantMessageLocked: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + didCompleteReadingStream: true, + }) + + expect(status).toBe("waiting_approval") + }) + + test("marks quiescent tasks as completed", () => { + const status = (provider as any).getBackgroundTaskStatus({ + abort: false, + abandoned: false, + taskAsk: undefined, + isStreaming: false, + isWaitingForAskResponse: false, + presentAssistantMessageLocked: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + didCompleteReadingStream: true, + }) + + expect(status).toBe("completed") + }) + + test("marks active background work as running", () => { + const status = (provider as any).getBackgroundTaskStatus({ + abort: false, + abandoned: false, + taskAsk: undefined, + isStreaming: true, + isWaitingForAskResponse: false, + presentAssistantMessageLocked: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + didCompleteReadingStream: false, + }) + + expect(status).toBe("running") + }) + }) + test("addClineToStack adds multiple Cline instances to the stack", async () => { // Setup Cline instance with auto-mock from the top of the file const mockCline1 = new Task(defaultTaskOptions) // Create a new mocked instance diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 20887bf19..34037c570 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1041,6 +1041,11 @@ export const webviewMessageHandler = async ( await provider.bringTaskToForeground(message.taskId) } break + case "dismissBackgroundTask": + if (message.taskId) { + await provider.dismissBackgroundTask(message.taskId) + } + break case "webviewDidLaunch": // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index cb8ecddc5..3f60126b3 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -466,7 +466,7 @@ export type ExtensionState = Pick< backgroundRunningTasks?: Array<{ taskId: string taskLabel: string - isCompleted: boolean + status: "running" | "completed" | "waiting_approval" | "waiting_input" apiProvider?: string apiModelId?: string }> diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d3d6d6953..e68fe7d89 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -80,6 +80,7 @@ export interface WebviewMessage { | "shareCurrentTask" | "showTaskWithId" | "deleteTaskWithId" + | "dismissBackgroundTask" | "exportTaskWithId" | "importSettings" | "toggleToolAutoApprove" @@ -193,6 +194,7 @@ export interface WebviewMessage { | "maxOpenTabsContext" | "maxWorkspaceFiles" | "switchToBackgroundTask" // multi-chat + | "dismissBackgroundTask" // multi-chat | "humanRelayResponse" | "updateTaskModel" // Task-local model update for isolation | "humanRelayCancel" @@ -388,7 +390,7 @@ export interface WebviewMessage { hasSystemPromptOverride?: boolean terminalOperation?: "continue" | "abort" messageTs?: number - taskId?: string // For switchToBackgroundTask + taskId?: string // For switchToBackgroundTask and dismissBackgroundTask restoreCheckpoint?: boolean historyPreviewCollapsed?: boolean filters?: { type?: string; search?: string; tags?: string[] } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 4ba97fb19..61ba30460 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -68,8 +68,9 @@ import StickyUserMessage from "../kilocode/StickyUserMessage" // kilocode_change import AutoApproveMenu from "./AutoApproveMenu" import SystemPromptWarning from "./SystemPromptWarning" // import ProfileViolationWarning from "./ProfileViolationWarning" kilocode_change: unused -import { LinkSquare01Icon, PlayCircleIcon } from "@/utils/customIcons" +import { ListVideoIcon } from "@/utils/customIcons" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { X } from "lucide-react" import { KilocodeNotifications } from "../kilocode/KilocodeNotifications" // kilocode_change import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" @@ -657,6 +658,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ + running: { + dotClassName: "bg-[var(--vscode-charts-blue)] animate-pulse", + label: "Running in background", + }, + completed: { + dotClassName: "bg-[var(--vscode-testing-iconPassed)]", + label: "Completed", + }, + waiting_approval: { + dotClassName: "bg-[var(--vscode-charts-yellow)]", + label: "Waiting on approval", + }, + waiting_input: { + dotClassName: "bg-[var(--vscode-charts-yellow)]", + label: "Waiting for input", + }, + }), + [], + ) + const markFollowUpAsAnswered = useCallback(() => { const lastFollowUpMessage = messagesRef.current.findLast((msg: ClineMessage) => msg.ask === "followup") if (lastFollowUpMessage) { @@ -2449,66 +2472,78 @@ const ChatViewComponent: React.ForwardRefRenderFunction