Skip to content

Commit 3fa5e86

Browse files
allquixoticclaude
andcommitted
perf(webview): send appended chat messages as deltas, not full history
Appending a new chat message used to call postTaskStateToWebview, which re-serialized and re-sent the entire clineMessages array (and rebuilt the full state object) over the webview postMessage bridge on every message. For long or multiple concurrent conversations that array dominates the per-update payload and hurts responsiveness. Streaming token updates already use the single-message `messageUpdated` delta; this closes the remaining full-payload path — message append. - Add a `messageAdded` extension->webview message type carrying one ClineMessage. - ClineProvider.postTaskMessageAddedToWebview: for the visible task, post the single new message then refresh the remaining task-scoped fields via postStateToWebviewWithoutClineMessages (omits clineMessages + taskHistory); for a background task, just schedule the active-conversations summary update. - Task.addToClineMessages routes appends through this delta path, with a fallback to a full task-state push for provider shapes that lack it. - Webview appends the delta in place (idempotent: replaces by ts if already present, e.g. if a later full-state push raced it). taskHistory was already deltified previously; clineMessages was the remaining large array re-sent on each update. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6120ad9 commit 3fa5e86

4 files changed

Lines changed: 87 additions & 13 deletions

File tree

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface ExtensionMessage {
3333
| "workspaceUpdated"
3434
| "invoke"
3535
| "messageUpdated"
36+
| "messageAdded"
3637
| "mcpServers"
3738
| "enhancedPrompt"
3839
| "commitSearchResults"

src/core/task/Task.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,21 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
719719
}
720720
}
721721

722+
/**
723+
* Push a newly-appended message to the webview as a delta when the provider supports
724+
* it, falling back to a full task-state push for older provider shapes / tests.
725+
*/
726+
private async postTaskMessageAddedToWebview(message: ClineMessage): Promise<void> {
727+
const provider = this.providerRef.deref()
728+
if (typeof provider?.postTaskMessageAddedToWebview === "function") {
729+
await provider.postTaskMessageAddedToWebview(this.taskId, message)
730+
return
731+
}
732+
if (typeof provider?.postTaskStateToWebview === "function") {
733+
await provider.postTaskStateToWebview(this.taskId)
734+
}
735+
}
736+
722737
/**
723738
* Wait for the task mode to be initialized before proceeding.
724739
* This method ensures that any operations depending on the task mode
@@ -1102,9 +1117,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11021117

11031118
private async addToClineMessages(message: ClineMessage) {
11041119
this.clineMessages.push(message)
1105-
// Avoid resending large, mostly-static fields (notably taskHistory) on every chat message update.
1106-
// taskHistory is maintained in-memory in the webview and updated via taskHistoryItemUpdated.
1107-
await this.postTaskStateToWebview()
1120+
// Send only the newly-appended message as a delta instead of re-serializing and
1121+
// re-sending the entire clineMessages array (and taskHistory) on every chat message.
1122+
// The webview appends it in-place; the remaining task-scoped fields are refreshed
1123+
// without the large message/history arrays. Falls back to a full task-state push
1124+
// for provider shapes that don't support the delta channel.
1125+
await this.postTaskMessageAddedToWebview(message)
11081126
this.emit(RooCodeEventName.Message, { action: "created", message })
11091127
await this.saveClineMessages()
11101128

@@ -2912,9 +2930,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
29122930
if (signal.aborted) {
29132931
reject(new Error("Request cancelled by user"))
29142932
} else {
2915-
signal.addEventListener("abort", () => {
2916-
reject(new Error("Request cancelled by user"))
2917-
}, { once: true })
2933+
signal.addEventListener(
2934+
"abort",
2935+
() => {
2936+
reject(new Error("Request cancelled by user"))
2937+
},
2938+
{ once: true },
2939+
)
29182940
}
29192941
})
29202942
return await Promise.race([nextPromise, abortPromise])
@@ -4456,10 +4478,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
44564478
const iterator = stream[Symbol.asyncIterator]()
44574479

44584480
// Set up abort handling - when the signal is aborted, clean up the controller reference
4459-
abortSignal.addEventListener("abort", () => {
4460-
console.log(`[Task#${this.taskId}.${this.instanceId}] AbortSignal triggered for current request`)
4461-
this.currentRequestAbortController = undefined
4462-
}, { once: true })
4481+
abortSignal.addEventListener(
4482+
"abort",
4483+
() => {
4484+
console.log(`[Task#${this.taskId}.${this.instanceId}] AbortSignal triggered for current request`)
4485+
this.currentRequestAbortController = undefined
4486+
},
4487+
{ once: true },
4488+
)
44634489

44644490
try {
44654491
// Awaiting first chunk to see if it will throw an error.
@@ -4471,9 +4497,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
44714497
if (abortSignal.aborted) {
44724498
reject(new Error("Request cancelled by user"))
44734499
} else {
4474-
abortSignal.addEventListener("abort", () => {
4475-
reject(new Error("Request cancelled by user"))
4476-
}, { once: true })
4500+
abortSignal.addEventListener(
4501+
"abort",
4502+
() => {
4503+
reject(new Error("Request cancelled by user"))
4504+
},
4505+
{ once: true },
4506+
)
44774507
}
44784508
})
44794509

src/core/webview/ClineProvider.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,29 @@ export class ClineProvider
582582
this.scheduleActiveConversationsStateToWebview()
583583
}
584584

585+
/**
586+
* Push a newly-appended chat message as a delta instead of re-sending the entire
587+
* clineMessages array.
588+
*
589+
* Appending a single message used to trigger a full task-state push, which
590+
* re-serialized and re-sent every message in the conversation over the webview
591+
* postMessage bridge. For long or multiple concurrent conversations that is the
592+
* dominant responsiveness cost. Here we send only the new message (`messageAdded`)
593+
* and then refresh the remaining task-scoped fields (token totals, todos, queue,
594+
* active conversations) WITHOUT the large clineMessages/taskHistory arrays.
595+
*
596+
* Streaming token updates already use the single-message `messageUpdated` delta;
597+
* this closes the remaining full-payload path (message append).
598+
*/
599+
public async postTaskMessageAddedToWebview(taskId: string, message: ClineMessage): Promise<void> {
600+
if (this.isTaskVisible(taskId)) {
601+
await this.postMessageToWebview({ type: "messageAdded", clineMessage: message })
602+
await this.postStateToWebviewWithoutClineMessages()
603+
return
604+
}
605+
this.scheduleActiveConversationsStateToWebview()
606+
}
607+
585608
/**
586609
* Initialize the TaskHistoryStore and migrate from globalState if needed.
587610
*/

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,26 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
412412
})
413413
break
414414
}
415+
case "messageAdded": {
416+
// Delta append: a single newly-created message instead of a full
417+
// clineMessages re-send. Idempotent — if a message with this ts is
418+
// already present (e.g. a later full-state push raced us), replace it
419+
// in place rather than duplicating.
420+
const clineMessage = message.clineMessage!
421+
setState((prevState) => {
422+
const existingIndex = findLastIndex(
423+
prevState.clineMessages,
424+
(msg) => msg.ts === clineMessage.ts,
425+
)
426+
if (existingIndex !== -1) {
427+
const newClineMessages = [...prevState.clineMessages]
428+
newClineMessages[existingIndex] = clineMessage
429+
return { ...prevState, clineMessages: newClineMessages }
430+
}
431+
return { ...prevState, clineMessages: [...prevState.clineMessages, clineMessage] }
432+
})
433+
break
434+
}
415435
case "skills": {
416436
if (message.skills) {
417437
setSkills(message.skills)

0 commit comments

Comments
 (0)