mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
perf(webview): avoid resending taskHistory in state updates (#10842)
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
This commit is contained in:
parent
1a1827d806
commit
06039400cd
14 changed files with 792 additions and 68 deletions
|
|
@ -29,6 +29,8 @@ export interface ExtensionMessage {
|
|||
type:
|
||||
| "action"
|
||||
| "state"
|
||||
| "taskHistoryUpdated"
|
||||
| "taskHistoryItemUpdated"
|
||||
| "selectedImages"
|
||||
| "theme"
|
||||
| "workspaceUpdated"
|
||||
|
|
@ -114,7 +116,11 @@ export interface ExtensionMessage {
|
|||
| "switchTab"
|
||||
| "toggleAutoApprove"
|
||||
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
|
||||
state?: ExtensionState
|
||||
/**
|
||||
* Partial state updates are allowed to reduce message size (e.g. omit large fields like taskHistory).
|
||||
* The webview is responsible for merging.
|
||||
*/
|
||||
state?: Partial<ExtensionState>
|
||||
images?: string[]
|
||||
filePaths?: string[]
|
||||
openedTabs?: Array<{
|
||||
|
|
@ -194,6 +200,9 @@ export interface ExtensionMessage {
|
|||
childrenCost: number
|
||||
}
|
||||
historyItem?: HistoryItem
|
||||
taskHistory?: HistoryItem[] // For taskHistoryUpdated: full sorted task history
|
||||
/** For taskHistoryItemUpdated: single updated/added history item */
|
||||
taskHistoryItem?: HistoryItem
|
||||
}
|
||||
|
||||
export interface OpenAiCodexRateLimitsMessage {
|
||||
|
|
|
|||
|
|
@ -458,6 +458,7 @@ describe("importExport", () => {
|
|||
const mockProvider = {
|
||||
settingsImportedAt: 0,
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
// Mock the showErrorMessage to capture the error
|
||||
|
|
|
|||
|
|
@ -590,7 +590,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
this.messageQueueStateChangedHandler = () => {
|
||||
this.emit(RooCodeEventName.TaskUserMessage, this.taskId)
|
||||
this.providerRef.deref()?.postStateToWebview()
|
||||
this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
|
||||
}
|
||||
|
||||
this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler)
|
||||
|
|
@ -1137,7 +1137,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
private async addToClineMessages(message: ClineMessage) {
|
||||
this.clineMessages.push(message)
|
||||
const provider = this.providerRef.deref()
|
||||
await provider?.postStateToWebview()
|
||||
// Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update.
|
||||
// taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated.
|
||||
await provider?.postStateToWebviewWithoutTaskHistory()
|
||||
this.emit(RooCodeEventName.Message, { action: "created", message })
|
||||
await this.saveClineMessages()
|
||||
|
||||
|
|
@ -1866,69 +1868,77 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
private async startTask(task?: string, images?: string[]): Promise<void> {
|
||||
if (this.enableBridge) {
|
||||
try {
|
||||
await BridgeOrchestrator.subscribeToTask(this)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
try {
|
||||
if (this.enableBridge) {
|
||||
try {
|
||||
await BridgeOrchestrator.subscribeToTask(this)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// `conversationHistory` (for API) and `clineMessages` (for webview)
|
||||
// need to be in sync.
|
||||
// If the extension process were killed, then on restart the
|
||||
// `clineMessages` might not be empty, so we need to set it to [] when
|
||||
// we create a new Cline client (otherwise webview would show stale
|
||||
// messages from previous session).
|
||||
this.clineMessages = []
|
||||
this.apiConversationHistory = []
|
||||
|
||||
// The todo list is already set in the constructor if initialTodos were provided
|
||||
// No need to add any messages - the todoList property is already set
|
||||
|
||||
await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
|
||||
|
||||
await this.say("text", task, images)
|
||||
|
||||
// Check for too many MCP tools and warn the user
|
||||
const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount()
|
||||
if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) {
|
||||
await this.say(
|
||||
"too_many_tools_warning",
|
||||
JSON.stringify({
|
||||
toolCount: enabledToolCount,
|
||||
serverCount: enabledServerCount,
|
||||
threshold: MAX_MCP_TOOLS_THRESHOLD,
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ isNonInteractive: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
this.isInitialized = true
|
||||
|
||||
// `conversationHistory` (for API) and `clineMessages` (for webview)
|
||||
// need to be in sync.
|
||||
// If the extension process were killed, then on restart the
|
||||
// `clineMessages` might not be empty, so we need to set it to [] when
|
||||
// we create a new Cline client (otherwise webview would show stale
|
||||
// messages from previous session).
|
||||
this.clineMessages = []
|
||||
this.apiConversationHistory = []
|
||||
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
|
||||
|
||||
// The todo list is already set in the constructor if initialTodos were provided
|
||||
// No need to add any messages - the todoList property is already set
|
||||
|
||||
await this.providerRef.deref()?.postStateToWebview()
|
||||
|
||||
await this.say("text", task, images)
|
||||
|
||||
// Check for too many MCP tools and warn the user
|
||||
const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount()
|
||||
if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) {
|
||||
await this.say(
|
||||
"too_many_tools_warning",
|
||||
JSON.stringify({
|
||||
toolCount: enabledToolCount,
|
||||
serverCount: enabledServerCount,
|
||||
threshold: MAX_MCP_TOOLS_THRESHOLD,
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ isNonInteractive: true },
|
||||
)
|
||||
}
|
||||
this.isInitialized = true
|
||||
|
||||
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
|
||||
|
||||
// Task starting
|
||||
|
||||
await this.initiateTaskLoop([
|
||||
{
|
||||
type: "text",
|
||||
text: `<user_message>\n${task}\n</user_message>`,
|
||||
},
|
||||
...imageBlocks,
|
||||
]).catch((error) => {
|
||||
// Swallow loop rejection when the task was intentionally abandoned/aborted
|
||||
// during delegation or user cancellation to prevent unhandled rejections.
|
||||
if (this.abandoned === true || this.abortReason === "user_cancelled") {
|
||||
// Task starting
|
||||
await this.initiateTaskLoop([
|
||||
{
|
||||
type: "text",
|
||||
text: `<user_message>\n${task}\n</user_message>`,
|
||||
},
|
||||
...imageBlocks,
|
||||
]).catch((error) => {
|
||||
// Swallow loop rejection when the task was intentionally abandoned/aborted
|
||||
// during delegation or user cancellation to prevent unhandled rejections.
|
||||
if (this.abandoned === true || this.abortReason === "user_cancelled") {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
})
|
||||
} catch (error) {
|
||||
// In tests and some UX flows, tasks can be aborted while `startTask` is still
|
||||
// initializing. Treat abort/abandon as expected and avoid unhandled rejections.
|
||||
if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async resumeTaskFromHistory() {
|
||||
|
|
@ -2678,7 +2688,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
} satisfies ClineApiReqInfo)
|
||||
|
||||
await this.saveClineMessages()
|
||||
await this.providerRef.deref()?.postStateToWebview()
|
||||
await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
|
||||
|
||||
try {
|
||||
let cacheWriteTokens = 0
|
||||
|
|
@ -3446,7 +3456,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
await this.saveClineMessages()
|
||||
await this.providerRef.deref()?.postStateToWebview()
|
||||
await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()
|
||||
|
||||
// Reset parser after each complete conversation round (XML protocol only)
|
||||
this.assistantMessageParser?.reset()
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ describe("Cline", () => {
|
|||
// Mock provider methods
|
||||
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({
|
||||
historyItem: {
|
||||
id,
|
||||
|
|
@ -987,6 +988,7 @@ describe("Cline", () => {
|
|||
getSkillsManager: vi.fn().mockReturnValue(undefined),
|
||||
say: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
|
@ -1901,6 +1903,7 @@ describe("Queued message processing after condense", () => {
|
|||
const provider = new ClineProvider(ctx, output as any, "sidebar", new ContextProxy(ctx)) as any
|
||||
provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
provider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
|
||||
provider.getState = vi.fn().mockResolvedValue({})
|
||||
return provider
|
||||
}
|
||||
|
|
@ -2039,6 +2042,7 @@ describe("pushToolResultToUserContent", () => {
|
|||
|
||||
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it("should add tool_result when not a duplicate", () => {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ describe("Task - sticky provider profile init race", () => {
|
|||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ describe("Task token usage throttling", () => {
|
|||
getState: vi.fn().mockResolvedValue({ mode: "code" }),
|
||||
log: vi.fn(),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ describe("flushPendingToolResultsToHistory", () => {
|
|||
|
||||
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ describe("Grace Retry Error Handling", () => {
|
|||
|
||||
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
|
||||
mockProvider.getState = vi.fn().mockResolvedValue({})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ describe("Task grounding sources handling", () => {
|
|||
// Mock provider with necessary methods
|
||||
mockProvider = {
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
mode: "code",
|
||||
experiments: {},
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ describe("Task reasoning preservation", () => {
|
|||
// Mock provider with necessary methods
|
||||
mockProvider = {
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
mode: "code",
|
||||
experiments: {},
|
||||
|
|
|
|||
|
|
@ -1822,6 +1822,25 @@ export class ClineProvider
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like postStateToWebview but intentionally omits taskHistory.
|
||||
*
|
||||
* Rationale:
|
||||
* - taskHistory can be large and was being resent on every chat message update.
|
||||
* - The webview maintains taskHistory in-memory and receives updates via
|
||||
* `taskHistoryUpdated` / `taskHistoryItemUpdated`.
|
||||
*/
|
||||
async postStateToWebviewWithoutTaskHistory(): Promise<void> {
|
||||
const state = await this.getStateToPostToWebview()
|
||||
const { taskHistory: _omit, ...rest } = state
|
||||
this.postMessageToWebview({ type: "state", state: rest })
|
||||
|
||||
// Preserve existing MDM redirect behavior
|
||||
if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) {
|
||||
await this.postMessageToWebview({ type: "action", action: "cloudButtonClicked" })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches marketplace data on demand to avoid blocking main state updates
|
||||
*/
|
||||
|
|
@ -2474,11 +2493,19 @@ export class ClineProvider
|
|||
}
|
||||
}
|
||||
|
||||
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
|
||||
/**
|
||||
* Updates a task in the task history and optionally broadcasts the updated history to the webview.
|
||||
* @param item The history item to update or add
|
||||
* @param options.broadcast Whether to broadcast the updated history to the webview (default: true)
|
||||
* @returns The updated task history array
|
||||
*/
|
||||
async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise<HistoryItem[]> {
|
||||
const { broadcast = true } = options
|
||||
const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || []
|
||||
const existingItemIndex = history.findIndex((h) => h.id === item.id)
|
||||
const wasExisting = existingItemIndex !== -1
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
if (wasExisting) {
|
||||
// Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten.
|
||||
// This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened,
|
||||
// terminated, or when routine message persistence occurs.
|
||||
|
|
@ -2493,9 +2520,39 @@ export class ClineProvider
|
|||
await this.updateGlobalState("taskHistory", history)
|
||||
this.recentTasksCache = undefined
|
||||
|
||||
// Broadcast the updated history to the webview if requested.
|
||||
// Prefer per-item updates to avoid repeatedly cloning/sending the full history.
|
||||
if (broadcast && this.isViewLaunched) {
|
||||
const updatedItem = wasExisting ? history[existingItemIndex] : item
|
||||
await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem })
|
||||
}
|
||||
|
||||
return history
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a task history update to the webview.
|
||||
* This sends a lightweight message with just the task history, rather than the full state.
|
||||
* @param history The task history to broadcast (if not provided, reads from global state)
|
||||
*/
|
||||
public async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise<void> {
|
||||
if (!this.isViewLaunched) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskHistory = history ?? (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) ?? []
|
||||
|
||||
// Sort and filter the history the same way as getStateToPostToWebview
|
||||
const sortedHistory = taskHistory
|
||||
.filter((item: HistoryItem) => item.ts && item.task)
|
||||
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts)
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "taskHistoryUpdated",
|
||||
taskHistory: sortedHistory,
|
||||
})
|
||||
}
|
||||
|
||||
// ContextProxy
|
||||
|
||||
// @deprecated - Use `ContextProxy#setValue` instead.
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ describe("ClineProvider flicker-free cancel", () => {
|
|||
})
|
||||
|
||||
provider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
|
||||
provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined)
|
||||
// Mock private method using any cast
|
||||
;(provider as any).updateGlobalState = vi.fn().mockResolvedValue(undefined)
|
||||
provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined)
|
||||
|
|
|
|||
596
src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts
Normal file
596
src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.taskHistory.spec.ts
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import type { HistoryItem, ExtensionMessage } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { ContextProxy } from "../../config/ContextProxy"
|
||||
import { ClineProvider } from "../ClineProvider"
|
||||
|
||||
// Mock setup
|
||||
vi.mock("p-wait-for", () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(""),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
rmdir: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({ data: { data: [] } }),
|
||||
post: vi.fn(),
|
||||
},
|
||||
get: vi.fn().mockResolvedValue({ data: { data: [] } }),
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("delay", () => {
|
||||
const delayFn = (_ms: number) => Promise.resolve()
|
||||
delayFn.createDelay = () => delayFn
|
||||
delayFn.reject = () => Promise.reject(new Error("Delay rejected"))
|
||||
delayFn.range = () => Promise.resolve()
|
||||
return { default: delayFn }
|
||||
})
|
||||
|
||||
vi.mock("../../prompts/sections/custom-instructions")
|
||||
|
||||
vi.mock("../../../utils/storage", () => ({
|
||||
getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"),
|
||||
getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"),
|
||||
getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"),
|
||||
}))
|
||||
|
||||
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
|
||||
CallToolResultSchema: {},
|
||||
ListResourcesResultSchema: {},
|
||||
ListResourceTemplatesResultSchema: {},
|
||||
ListToolsResultSchema: {},
|
||||
ReadResourceResultSchema: {},
|
||||
ErrorCode: {
|
||||
InvalidRequest: "InvalidRequest",
|
||||
MethodNotFound: "MethodNotFound",
|
||||
InternalError: "InternalError",
|
||||
},
|
||||
McpError: class McpError extends Error {
|
||||
code: string
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.name = "McpError"
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../../services/browser/BrowserSession", () => ({
|
||||
BrowserSession: vi.fn().mockImplementation(() => ({
|
||||
testConnection: vi.fn().mockResolvedValue({ success: false }),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("../../../services/browser/browserDiscovery", () => ({
|
||||
discoverChromeHostUrl: vi.fn().mockResolvedValue("http://localhost:9222"),
|
||||
tryChromeHostUrl: vi.fn().mockResolvedValue(false),
|
||||
testBrowserConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
Client: vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
listTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({
|
||||
StdioClientTransport: vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
ExtensionContext: vi.fn(),
|
||||
OutputChannel: vi.fn(),
|
||||
WebviewView: vi.fn(),
|
||||
Uri: {
|
||||
joinPath: vi.fn(),
|
||||
file: vi.fn(),
|
||||
},
|
||||
CodeActionKind: {
|
||||
QuickFix: { value: "quickfix" },
|
||||
RefactorRewrite: { value: "refactor.rewrite" },
|
||||
},
|
||||
commands: {
|
||||
executeCommand: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showWarningMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
},
|
||||
workspace: {
|
||||
getConfiguration: vi.fn().mockReturnValue({
|
||||
get: vi.fn().mockReturnValue([]),
|
||||
update: vi.fn(),
|
||||
}),
|
||||
onDidChangeConfiguration: vi.fn().mockImplementation(() => ({
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
},
|
||||
env: {
|
||||
uriScheme: "vscode",
|
||||
language: "en",
|
||||
appName: "Visual Studio Code",
|
||||
},
|
||||
ExtensionMode: {
|
||||
Production: 1,
|
||||
Development: 2,
|
||||
Test: 3,
|
||||
},
|
||||
version: "1.85.0",
|
||||
}))
|
||||
|
||||
vi.mock("../../../utils/tts", () => ({
|
||||
setTtsEnabled: vi.fn(),
|
||||
setTtsSpeed: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../../../api", () => ({
|
||||
buildApiHandler: vi.fn().mockReturnValue({
|
||||
getModel: vi.fn().mockReturnValue({
|
||||
id: "claude-3-sonnet",
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../../prompts/system", () => ({
|
||||
SYSTEM_PROMPT: vi.fn().mockImplementation(async () => "mocked system prompt"),
|
||||
codeMode: "code",
|
||||
}))
|
||||
|
||||
vi.mock("../../../integrations/workspace/WorkspaceTracker", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
initializeFilePaths: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../../task/Task", () => ({
|
||||
Task: vi.fn().mockImplementation((options: any) => ({
|
||||
api: undefined,
|
||||
abortTask: vi.fn(),
|
||||
handleWebviewAskResponse: vi.fn(),
|
||||
clineMessages: [],
|
||||
apiConversationHistory: [],
|
||||
overwriteClineMessages: vi.fn(),
|
||||
overwriteApiConversationHistory: vi.fn(),
|
||||
getTaskNumber: vi.fn().mockReturnValue(0),
|
||||
setTaskNumber: vi.fn(),
|
||||
setParentTask: vi.fn(),
|
||||
setRootTask: vi.fn(),
|
||||
taskId: options?.historyItem?.id || "test-task-id",
|
||||
emit: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("../../../integrations/misc/extract-text", () => ({
|
||||
extractTextFromFile: vi.fn().mockResolvedValue("file content"),
|
||||
}))
|
||||
|
||||
vi.mock("../../../api/providers/fetchers/modelCache", () => ({
|
||||
getModels: vi.fn().mockResolvedValue({}),
|
||||
flushModels: vi.fn(),
|
||||
getModelsFromCache: vi.fn().mockReturnValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("../../../shared/modes", () => ({
|
||||
modes: [{ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", groups: ["read", "edit"] }],
|
||||
getModeBySlug: vi.fn().mockReturnValue({
|
||||
slug: "code",
|
||||
name: "Code Mode",
|
||||
roleDefinition: "You are a code assistant",
|
||||
groups: ["read", "edit"],
|
||||
}),
|
||||
getGroupName: vi.fn().mockReturnValue("General Tools"),
|
||||
defaultModeSlug: "code",
|
||||
}))
|
||||
|
||||
vi.mock("../diff/strategies/multi-search-replace", () => ({
|
||||
MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({
|
||||
getToolDescription: () => "test",
|
||||
getName: () => "test-strategy",
|
||||
applyDiff: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@roo-code/cloud", () => ({
|
||||
CloudService: {
|
||||
hasInstance: vi.fn().mockReturnValue(true),
|
||||
get instance() {
|
||||
return {
|
||||
isAuthenticated: vi.fn().mockReturnValue(false),
|
||||
getAllowList: vi.fn().mockResolvedValue("*"),
|
||||
getUserInfo: vi.fn().mockReturnValue(null),
|
||||
canShareTask: vi.fn().mockResolvedValue(false),
|
||||
canSharePublicly: vi.fn().mockResolvedValue(false),
|
||||
getOrganizationSettings: vi.fn().mockReturnValue(null),
|
||||
getOrganizationMemberships: vi.fn().mockResolvedValue([]),
|
||||
getUserSettings: vi.fn().mockReturnValue(null),
|
||||
isTaskSyncEnabled: vi.fn().mockReturnValue(false),
|
||||
}
|
||||
},
|
||||
},
|
||||
BridgeOrchestrator: {
|
||||
isEnabled: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("ClineProvider Task History Synchronization", () => {
|
||||
let provider: ClineProvider
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockOutputChannel: vscode.OutputChannel
|
||||
let mockWebviewView: vscode.WebviewView
|
||||
let mockPostMessage: ReturnType<typeof vi.fn>
|
||||
let taskHistoryState: HistoryItem[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
if (!TelemetryService.hasInstance()) {
|
||||
TelemetryService.createInstance([])
|
||||
}
|
||||
|
||||
// Initialize task history state
|
||||
taskHistoryState = []
|
||||
|
||||
const globalState: Record<string, any> = {
|
||||
mode: "code",
|
||||
currentApiConfigName: "current-config",
|
||||
taskHistory: taskHistoryState,
|
||||
}
|
||||
|
||||
const secrets: Record<string, string | undefined> = {}
|
||||
|
||||
mockContext = {
|
||||
extensionPath: "/test/path",
|
||||
extensionUri: {} as vscode.Uri,
|
||||
globalState: {
|
||||
get: vi.fn().mockImplementation((key: string) => globalState[key]),
|
||||
update: vi.fn().mockImplementation((key: string, value: any) => {
|
||||
globalState[key] = value
|
||||
if (key === "taskHistory") {
|
||||
taskHistoryState = value
|
||||
}
|
||||
}),
|
||||
keys: vi.fn().mockImplementation(() => Object.keys(globalState)),
|
||||
},
|
||||
secrets: {
|
||||
get: vi.fn().mockImplementation((key: string) => secrets[key]),
|
||||
store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)),
|
||||
delete: vi.fn().mockImplementation((key: string) => delete secrets[key]),
|
||||
},
|
||||
subscriptions: [],
|
||||
extension: {
|
||||
packageJSON: { version: "1.0.0" },
|
||||
},
|
||||
globalStorageUri: {
|
||||
fsPath: "/test/storage/path",
|
||||
},
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
mockOutputChannel = {
|
||||
appendLine: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
} as unknown as vscode.OutputChannel
|
||||
|
||||
mockPostMessage = vi.fn()
|
||||
|
||||
mockWebviewView = {
|
||||
webview: {
|
||||
postMessage: mockPostMessage,
|
||||
html: "",
|
||||
options: {},
|
||||
onDidReceiveMessage: vi.fn(),
|
||||
asWebviewUri: vi.fn(),
|
||||
cspSource: "vscode-webview://test-csp-source",
|
||||
},
|
||||
visible: true,
|
||||
onDidDispose: vi.fn().mockImplementation((callback) => {
|
||||
callback()
|
||||
return { dispose: vi.fn() }
|
||||
}),
|
||||
onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })),
|
||||
} as unknown as vscode.WebviewView
|
||||
|
||||
provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
|
||||
|
||||
// Mock the custom modes manager
|
||||
;(provider as any).customModesManager = {
|
||||
updateCustomMode: vi.fn().mockResolvedValue(undefined),
|
||||
getCustomModes: vi.fn().mockResolvedValue([]),
|
||||
dispose: vi.fn(),
|
||||
}
|
||||
|
||||
// Mock getMcpHub
|
||||
provider.getMcpHub = vi.fn().mockReturnValue({
|
||||
listTools: vi.fn().mockResolvedValue([]),
|
||||
callTool: vi.fn().mockResolvedValue({ content: [] }),
|
||||
listResources: vi.fn().mockResolvedValue([]),
|
||||
readResource: vi.fn().mockResolvedValue({ contents: [] }),
|
||||
getAllServers: vi.fn().mockReturnValue([]),
|
||||
})
|
||||
})
|
||||
|
||||
// Helper to create valid HistoryItem with required fields
|
||||
const createHistoryItem = (overrides: Partial<HistoryItem> & { id: string; task: string }): HistoryItem => ({
|
||||
number: 1,
|
||||
ts: Date.now(),
|
||||
tokensIn: 100,
|
||||
tokensOut: 50,
|
||||
totalCost: 0.01,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to find calls by message type
|
||||
const findCallsByType = (calls: any[][], type: string) => {
|
||||
return calls.filter((call) => call[0]?.type === type)
|
||||
}
|
||||
|
||||
describe("updateTaskHistory", () => {
|
||||
it("broadcasts task history update by default", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
provider.isViewLaunched = true
|
||||
|
||||
const historyItem = createHistoryItem({
|
||||
id: "task-1",
|
||||
task: "Test task",
|
||||
})
|
||||
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
|
||||
// Should have called postMessage with taskHistoryItemUpdated
|
||||
const taskHistoryItemUpdatedCalls = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated")
|
||||
|
||||
expect(taskHistoryItemUpdatedCalls.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const lastCall = taskHistoryItemUpdatedCalls[taskHistoryItemUpdatedCalls.length - 1]
|
||||
expect(lastCall[0].type).toBe("taskHistoryItemUpdated")
|
||||
expect(lastCall[0].taskHistoryItem).toBeDefined()
|
||||
expect(lastCall[0].taskHistoryItem.id).toBe("task-1")
|
||||
})
|
||||
|
||||
it("does not broadcast when broadcast option is false", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
provider.isViewLaunched = true
|
||||
|
||||
// Clear previous calls
|
||||
mockPostMessage.mockClear()
|
||||
|
||||
const historyItem = createHistoryItem({
|
||||
id: "task-2",
|
||||
task: "Test task 2",
|
||||
})
|
||||
|
||||
await provider.updateTaskHistory(historyItem, { broadcast: false })
|
||||
|
||||
// Should NOT have called postMessage with taskHistoryItemUpdated
|
||||
const taskHistoryItemUpdatedCalls = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated")
|
||||
|
||||
expect(taskHistoryItemUpdatedCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
it("does not broadcast when view is not launched", async () => {
|
||||
// Do not resolve webview and keep isViewLaunched false
|
||||
provider.isViewLaunched = false
|
||||
|
||||
const historyItem = createHistoryItem({
|
||||
id: "task-3",
|
||||
task: "Test task 3",
|
||||
})
|
||||
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
|
||||
// Should NOT have called postMessage with taskHistoryItemUpdated
|
||||
const taskHistoryItemUpdatedCalls = findCallsByType(mockPostMessage.mock.calls, "taskHistoryItemUpdated")
|
||||
|
||||
expect(taskHistoryItemUpdatedCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
it("updates existing task in history", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
provider.isViewLaunched = true
|
||||
|
||||
const historyItem = createHistoryItem({
|
||||
id: "task-update",
|
||||
task: "Original task",
|
||||
})
|
||||
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
|
||||
// Update the same task
|
||||
const updatedItem: HistoryItem = {
|
||||
...historyItem,
|
||||
task: "Updated task",
|
||||
tokensIn: 200,
|
||||
}
|
||||
|
||||
await provider.updateTaskHistory(updatedItem)
|
||||
|
||||
// Verify the update was persisted
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith(
|
||||
"taskHistory",
|
||||
expect.arrayContaining([expect.objectContaining({ id: "task-update", task: "Updated task" })]),
|
||||
)
|
||||
|
||||
// Should not have duplicates
|
||||
const allCalls = (mockContext.globalState.update as ReturnType<typeof vi.fn>).mock.calls
|
||||
const lastUpdateCall = allCalls.find((call: any[]) => call[0] === "taskHistory")
|
||||
const historyArray = lastUpdateCall?.[1] as HistoryItem[]
|
||||
const matchingItems = historyArray?.filter((item: HistoryItem) => item.id === "task-update")
|
||||
expect(matchingItems?.length).toBe(1)
|
||||
})
|
||||
|
||||
it("returns the updated task history array", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
provider.isViewLaunched = true
|
||||
|
||||
const historyItem = createHistoryItem({
|
||||
id: "task-return",
|
||||
task: "Return test task",
|
||||
})
|
||||
|
||||
const result = await provider.updateTaskHistory(historyItem)
|
||||
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.some((item) => item.id === "task-return")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("broadcastTaskHistoryUpdate", () => {
|
||||
it("sends taskHistoryUpdated message with sorted history", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
provider.isViewLaunched = true
|
||||
|
||||
const now = Date.now()
|
||||
const items: HistoryItem[] = [
|
||||
createHistoryItem({ id: "old", ts: now - 10000, task: "Old task" }),
|
||||
createHistoryItem({ id: "new", ts: now, task: "New task", number: 2 }),
|
||||
]
|
||||
|
||||
// Clear previous calls
|
||||
mockPostMessage.mockClear()
|
||||
|
||||
await provider.broadcastTaskHistoryUpdate(items)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "taskHistoryUpdated",
|
||||
taskHistory: expect.any(Array),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify the history is sorted (newest first)
|
||||
const calls = mockPostMessage.mock.calls as any[][]
|
||||
const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated")
|
||||
const sentHistory = call?.[0]?.taskHistory as HistoryItem[]
|
||||
expect(sentHistory[0].id).toBe("new") // Newest should be first
|
||||
expect(sentHistory[1].id).toBe("old") // Oldest should be second
|
||||
})
|
||||
|
||||
it("filters out invalid history items", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
provider.isViewLaunched = true
|
||||
|
||||
const now = Date.now()
|
||||
const items: HistoryItem[] = [
|
||||
createHistoryItem({ id: "valid", ts: now, task: "Valid task" }),
|
||||
createHistoryItem({ id: "no-ts", ts: 0, task: "No timestamp", number: 2 }), // Invalid: ts is 0/falsy
|
||||
createHistoryItem({ id: "no-task", ts: now, task: "", number: 3 }), // Invalid: empty task
|
||||
]
|
||||
|
||||
// Clear previous calls
|
||||
mockPostMessage.mockClear()
|
||||
|
||||
await provider.broadcastTaskHistoryUpdate(items)
|
||||
|
||||
const calls = mockPostMessage.mock.calls as any[][]
|
||||
const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated")
|
||||
const sentHistory = call?.[0]?.taskHistory as HistoryItem[]
|
||||
|
||||
// Only valid item should be included
|
||||
expect(sentHistory.length).toBe(1)
|
||||
expect(sentHistory[0].id).toBe("valid")
|
||||
})
|
||||
|
||||
it("reads from global state when no history is provided", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
provider.isViewLaunched = true
|
||||
|
||||
// Set up task history in global state
|
||||
const now = Date.now()
|
||||
const stateHistory: HistoryItem[] = [createHistoryItem({ id: "from-state", ts: now, task: "State task" })]
|
||||
|
||||
// Update the mock to return our history
|
||||
;(mockContext.globalState.get as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
|
||||
if (key === "taskHistory") return stateHistory
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Clear previous calls
|
||||
mockPostMessage.mockClear()
|
||||
|
||||
await provider.broadcastTaskHistoryUpdate()
|
||||
|
||||
const calls = mockPostMessage.mock.calls as any[][]
|
||||
const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated")
|
||||
const sentHistory = call?.[0]?.taskHistory as HistoryItem[]
|
||||
|
||||
expect(sentHistory.length).toBe(1)
|
||||
expect(sentHistory[0].id).toBe("from-state")
|
||||
})
|
||||
})
|
||||
|
||||
describe("task history includes all workspaces", () => {
|
||||
it("getStateToPostToWebview returns tasks from all workspaces", async () => {
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
|
||||
const now = Date.now()
|
||||
const multiWorkspaceHistory: HistoryItem[] = [
|
||||
createHistoryItem({
|
||||
id: "ws1-task",
|
||||
ts: now,
|
||||
task: "Workspace 1 task",
|
||||
workspace: "/path/to/workspace1",
|
||||
}),
|
||||
createHistoryItem({
|
||||
id: "ws2-task",
|
||||
ts: now - 1000,
|
||||
task: "Workspace 2 task",
|
||||
workspace: "/path/to/workspace2",
|
||||
number: 2,
|
||||
}),
|
||||
createHistoryItem({
|
||||
id: "ws3-task",
|
||||
ts: now - 2000,
|
||||
task: "Workspace 3 task",
|
||||
workspace: "/different/workspace",
|
||||
number: 3,
|
||||
}),
|
||||
]
|
||||
|
||||
// Update the mock to return multi-workspace history
|
||||
;(mockContext.globalState.get as ReturnType<typeof vi.fn>).mockImplementation((key: string) => {
|
||||
if (key === "taskHistory") return multiWorkspaceHistory
|
||||
return undefined
|
||||
})
|
||||
|
||||
const state = await provider.getStateToPostToWebview()
|
||||
|
||||
// All tasks from all workspaces should be included
|
||||
expect(state.taskHistory.length).toBe(3)
|
||||
expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/path/to/workspace1")).toBe(true)
|
||||
expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/path/to/workspace2")).toBe(true)
|
||||
expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/different/workspace")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -171,7 +171,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
|
||||
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||
|
||||
export const mergeExtensionState = (prevState: ExtensionState, newState: ExtensionState) => {
|
||||
export const mergeExtensionState = (prevState: ExtensionState, newState: Partial<ExtensionState>) => {
|
||||
const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState
|
||||
|
||||
const {
|
||||
|
|
@ -182,13 +182,19 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Extensi
|
|||
...newRest
|
||||
} = newState
|
||||
|
||||
const customModePrompts = { ...prevCustomModePrompts, ...newCustomModePrompts }
|
||||
const experiments = { ...prevExperiments, ...newExperiments }
|
||||
const customModePrompts = { ...prevCustomModePrompts, ...(newCustomModePrompts ?? {}) }
|
||||
const experiments = { ...prevExperiments, ...(newExperiments ?? {}) }
|
||||
const rest = { ...prevRest, ...newRest }
|
||||
|
||||
// Note that we completely replace the previous apiConfiguration and customSupportPrompts objects
|
||||
// with new ones since the state that is broadcast is the entire objects so merging is not necessary.
|
||||
return { ...rest, apiConfiguration, customModePrompts, customSupportPrompts, experiments }
|
||||
return {
|
||||
...rest,
|
||||
apiConfiguration: apiConfiguration ?? prevState.apiConfiguration,
|
||||
customModePrompts,
|
||||
customSupportPrompts: customSupportPrompts ?? prevState.customSupportPrompts,
|
||||
experiments,
|
||||
}
|
||||
}
|
||||
|
||||
export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
|
|
@ -322,7 +328,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
const message: ExtensionMessage = event.data
|
||||
switch (message.type) {
|
||||
case "state": {
|
||||
const newState = message.state!
|
||||
const newState = message.state ?? {}
|
||||
setState((prevState) => mergeExtensionState(prevState, newState))
|
||||
setShowWelcome(!checkExistKey(newState.apiConfiguration))
|
||||
setDidHydrateState(true)
|
||||
|
|
@ -424,6 +430,40 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
}
|
||||
break
|
||||
}
|
||||
case "taskHistoryUpdated": {
|
||||
// Efficiently update just the task history without replacing entire state
|
||||
if (message.taskHistory !== undefined) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
taskHistory: message.taskHistory!,
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
case "taskHistoryItemUpdated": {
|
||||
const item = message.taskHistoryItem
|
||||
if (!item) {
|
||||
break
|
||||
}
|
||||
setState((prevState) => {
|
||||
const existingIndex = prevState.taskHistory.findIndex((h) => h.id === item.id)
|
||||
let nextHistory: typeof prevState.taskHistory
|
||||
if (existingIndex === -1) {
|
||||
nextHistory = [item, ...prevState.taskHistory]
|
||||
} else {
|
||||
nextHistory = [...prevState.taskHistory]
|
||||
nextHistory[existingIndex] = item
|
||||
}
|
||||
// Keep UI semantics consistent with extension: newest-first ordering.
|
||||
nextHistory.sort((a, b) => b.ts - a.ts)
|
||||
return {
|
||||
...prevState,
|
||||
taskHistory: nextHistory,
|
||||
currentTaskItem: prevState.currentTaskItem?.id === item.id ? item : prevState.currentTaskItem,
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[setListApiConfigMeta],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue