Skip to content

fix(ui): avoid rerendering chat thread on draft changes#54905

Closed
a77ming wants to merge 1 commit into
openclaw:mainfrom
a77ming:fix/webchat-thread-rerender-54874
Closed

fix(ui): avoid rerendering chat thread on draft changes#54905
a77ming wants to merge 1 commit into
openclaw:mainfrom
a77ming:fix/webchat-thread-rerender-54874

Conversation

@a77ming
Copy link
Copy Markdown

@a77ming a77ming commented Mar 26, 2026

Closes #54874

Summary

  • keep the chat thread behind a Lit guard() so draft-only updates do not rebuild the full message tree
  • track deleted-message mutations with a lightweight version counter so thread cache invalidates when messages are hidden
  • add a regression test that verifies changing the draft does not re-render message groups

Testing

  • pnpm --dir ui exec vitest run src/ui/views/chat.thread-cache.test.ts src/ui/views/chat.test.ts
  • pnpm --dir ui exec vitest run src/ui/views/chat.browser.test.ts
  • pnpm exec oxlint ui/src/ui/views/chat.ts ui/src/ui/views/chat.thread-cache.test.ts ui/src/ui/chat/deleted-messages.ts

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 26, 2026

Greptile Summary

This PR introduces a Lit guard() directive around the chat thread template to skip expensive message-tree rebuilds when only props.draft changes, along with a lightweight version counter on DeletedMessages so message-deletion mutations still correctly invalidate the cached thread. A regression test verifies both the skip-on-draft-change path and the rebuild-on-message-change path.\n\n- deleted-messages.ts: _version counter + version getter added; bumpVersion() is called after every load() and save(), giving the guard a reliable change signal for deletion/restore/clear mutations.\n- chat.ts: Thread template wrapped in guard([…]). Key array covers all data-bearing props (messages, stream, streamSegments, search state, deleted.version, etc.) but omits callback props onChatScroll, onOpenSidebar, and onRequestUpdate. These are captured by the factory closure and could become stale if the parent passes new function references while other guard keys remain stable (see inline comment).\n- chat.thread-cache.test.ts: Two well-structured unit tests using vi.hoisted / vi.mock and cleanupChatModuleState in afterEach; they accurately exercise the guard's skip and rebuild behaviours.

Confidence Score: 5/5

Safe to merge; the optimisation is well-targeted and the regression test covers the primary concern.

The change is narrowly scoped, correctly designed, and backed by new tests. The only finding (stale callback props in guard keys) is a low-probability edge case that won't manifest unless callers recreate those specific function references independently of any message/session state change — and even then it degrades gracefully rather than crashing. No data-loss or security risk is introduced.

ui/src/ui/views/chat.ts — verify the guard key array is kept in sync if new callback props are added to ChatProps in future.

Important Files Changed

Filename Overview
ui/src/ui/chat/deleted-messages.ts Adds a _version counter and version getter to DeletedMessages. bumpVersion() is called unconditionally at the end of both load() and save(), meaning the initial constructor load sets version to 1 and every subsequent mutation increments it — correct behaviour for use as a guard dependency key.
ui/src/ui/views/chat.ts Wraps the entire chat thread template in a Lit guard() directive keyed on all meaningful state except some callback props (onChatScroll, onOpenSidebar, onRequestUpdate), which are captured by the factory closure but absent from the key array — a minor stale-handler risk. All other guard key choices look correct.
ui/src/ui/views/chat.thread-cache.test.ts New test file with two focused cases: draft-only change does not rebuild the message thread, and a message-list change does. Uses vi.hoisted + vi.mock correctly and cleans up module state via cleanupChatModuleState() in afterEach.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/views/chat.ts
Line: 921-1019

Comment:
**Callback props not included in guard keys**

`props.onChatScroll` (line 925), `props.onOpenSidebar` (lines 989, 999), and the `requestUpdate` closure derived from `props.onRequestUpdate` (line 1009) are captured inside the guard factory but are **not** listed in the guard's dependency keys. Lit's `guard()` returns `noChange` — skipping all DOM diffing — when the keys are identical, which means updated handler references will be silently ignored until some other key changes.

In practice this is low-risk if the calling component passes stable method references, but if `renderChat` is ever called with freshly-created arrow functions for these props (common in hook-based patterns), the bound handlers will become stale. Adding them to the guard key array keeps the optimisation intact while removing the latent footgun:

```suggestion
  const thread = guard(
    [
      props.loading,
      props.messages,
      props.toolMessages,
      props.streamSegments,
      props.stream,
      props.streamStartedAt,
      props.showToolCalls,
      showReasoning,
      props.sessionKey,
      props.assistantName,
      assistantIdentity.avatar,
      props.basePath,
      activeSession?.contextTokens ?? null,
      props.sessions?.defaults?.contextTokens ?? null,
      vs.searchOpen,
      vs.searchQuery,
      deleted.version,
      props.onChatScroll,
      props.onOpenSidebar,
      props.onRequestUpdate,
    ],
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(ui): avoid rerendering chat thread o..." | Re-trigger Greptile

Comment thread ui/src/ui/views/chat.ts
Comment on lines +921 to +1019
<div
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
@click=${handleCodeBlockCopy}
>
<div class="chat-thread-inner">
${
props.loading
? html`
<div class="chat-loading-skeleton" aria-label="Loading chat">
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--medium" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
<div class="chat-line user" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
</div>
<div class="chat-line assistant" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`
: nothing
}
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${
isEmpty && vs.searchOpen
? html`
<div class="agent-chat__empty">No matching messages</div>
`
: nothing
}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
`
: nothing
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${
isEmpty && vs.searchOpen
? html`
<div class="agent-chat__empty">No matching messages</div>
`
: nothing
}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
}
return nothing;
},
});
}
return nothing;
},
)}
</div>
</div>
`;
)}
</div>
</div>
`;
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Callback props not included in guard keys

props.onChatScroll (line 925), props.onOpenSidebar (lines 989, 999), and the requestUpdate closure derived from props.onRequestUpdate (line 1009) are captured inside the guard factory but are not listed in the guard's dependency keys. Lit's guard() returns noChange — skipping all DOM diffing — when the keys are identical, which means updated handler references will be silently ignored until some other key changes.

In practice this is low-risk if the calling component passes stable method references, but if renderChat is ever called with freshly-created arrow functions for these props (common in hook-based patterns), the bound handlers will become stale. Adding them to the guard key array keeps the optimisation intact while removing the latent footgun:

Suggested change
<div
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
@click=${handleCodeBlockCopy}
>
<div class="chat-thread-inner">
${
props.loading
? html`
<div class="chat-loading-skeleton" aria-label="Loading chat">
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--medium" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
<div class="chat-line user" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
</div>
<div class="chat-line assistant" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`
: nothing
}
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${
isEmpty && vs.searchOpen
? html`
<div class="agent-chat__empty">No matching messages</div>
`
: nothing
}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
`
: nothing
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${
isEmpty && vs.searchOpen
? html`
<div class="agent-chat__empty">No matching messages</div>
`
: nothing
}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
}
return nothing;
},
});
}
return nothing;
},
)}
</div>
</div>
`;
)}
</div>
</div>
`;
},
const thread = guard(
[
props.loading,
props.messages,
props.toolMessages,
props.streamSegments,
props.stream,
props.streamStartedAt,
props.showToolCalls,
showReasoning,
props.sessionKey,
props.assistantName,
assistantIdentity.avatar,
props.basePath,
activeSession?.contextTokens ?? null,
props.sessions?.defaults?.contextTokens ?? null,
vs.searchOpen,
vs.searchQuery,
deleted.version,
props.onChatScroll,
props.onOpenSidebar,
props.onRequestUpdate,
],
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/views/chat.ts
Line: 921-1019

Comment:
**Callback props not included in guard keys**

`props.onChatScroll` (line 925), `props.onOpenSidebar` (lines 989, 999), and the `requestUpdate` closure derived from `props.onRequestUpdate` (line 1009) are captured inside the guard factory but are **not** listed in the guard's dependency keys. Lit's `guard()` returns `noChange` — skipping all DOM diffing — when the keys are identical, which means updated handler references will be silently ignored until some other key changes.

In practice this is low-risk if the calling component passes stable method references, but if `renderChat` is ever called with freshly-created arrow functions for these props (common in hook-based patterns), the bound handlers will become stale. Adding them to the guard key array keeps the optimisation intact while removing the latent footgun:

```suggestion
  const thread = guard(
    [
      props.loading,
      props.messages,
      props.toolMessages,
      props.streamSegments,
      props.stream,
      props.streamStartedAt,
      props.showToolCalls,
      showReasoning,
      props.sessionKey,
      props.assistantName,
      assistantIdentity.avatar,
      props.basePath,
      activeSession?.contextTokens ?? null,
      props.sessions?.defaults?.contextTokens ?? null,
      vs.searchOpen,
      vs.searchQuery,
      deleted.version,
      props.onChatScroll,
      props.onOpenSidebar,
      props.onRequestUpdate,
    ],
```

How can I resolve this? If you propose a fix, please make it concise.

@a77ming
Copy link
Copy Markdown
Author

a77ming commented Mar 26, 2026

Closing per author request.

@a77ming a77ming closed this Mar 26, 2026
@a77ming a77ming deleted the fix/webchat-thread-rerender-54874 branch March 26, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Slow typing in webchat input with keystroke delay

1 participant