Skip to content

fix(webchat): clear chatStream before updating chatMessages to prevent duplicate rendering#34164

Closed
qiyuey wants to merge 1 commit into
openclaw:mainfrom
qiyuey:fix/webchat-duplicate-message-rendering
Closed

fix(webchat): clear chatStream before updating chatMessages to prevent duplicate rendering#34164
qiyuey wants to merge 1 commit into
openclaw:mainfrom
qiyuey:fix/webchat-duplicate-message-rendering

Conversation

@qiyuey

@qiyuey qiyuey commented Mar 4, 2026

Copy link
Copy Markdown

Problem

In the Control UI webchat, assistant replies appear duplicated on every message (issue #5964).

Root Cause

In handleChatEvent(), the final and aborted event handlers updated chatMessages before clearing chatStream. Because Lit batches reactive property updates asynchronously, a render cycle could be triggered after chatMessages was updated but before chatStream was cleared. This caused buildChatItems() to include the same assistant message in both the history list (from chatMessages) and the live stream bubble (from chatStream), visually appearing as a duplicate.

Fix

Capture the stream text in a local snapshot, clear chatStream/chatRunId/chatStreamStartedAt first, then append to chatMessages. This guarantees that any render triggered by the chatMessages write sees chatStream as null.

The same fix is applied to both final and aborted event paths.

Changes

  • ui/src/ui/controllers/chat.ts: swap update order in final and aborted branches
  • ui/src/ui/controllers/chat.test.ts: add 2 regression tests that verify chatStream is null at the exact moment chatMessages is written

Testing

npx vitest run ui/src/ui/controllers/chat.test.ts
# 27 tests passed

Fixes #5964

* feat(ios): add live activity connection status and cleanup

Add lock-screen/Dynamic Island connection health states and prune duplicate/stale activities before reuse. This intentionally excludes AI/title generation and heavier UX rewrites from #27488.

Co-authored-by: leepokai <1663017+leepokai@users.noreply.github.com>

* fix(ios): treat ended live activities as inactive

* chore(changelog): add PR reference and author thanks

---------

Co-authored-by: leepokai <1663017+leepokai@users.noreply.github.com>
@greptile-apps

greptile-apps Bot commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a duplicate-rendering bug in the webchat controller where Lit's async batching could trigger a render cycle after chatMessages was updated but before chatStream was cleared, causing the same assistant message to appear in both the history list and the live stream bubble. The fix captures chatStream as a local snapshot, clears all stream state first, then writes chatMessages — guaranteeing no render sees both properties populated with the same content simultaneously. Two regression tests encode this invariant directly by intercepting the chatMessages setter.

However, the PR also includes 12 unrelated iOS files implementing a new Live Activity feature (Lock Screen / Dynamic Island connection status). The changelog fragment for these iOS changes explicitly references PR #33591, not this one, strongly suggesting they were accidentally included here. Key observations:

  • Webchat fix (chat.ts, chat.test.ts): Correct and well-tested. The operation reorder in both final and aborted branches is minimal and safe.
  • iOS Live Activity (all apps/ios/ files): Functionally sound but the activity is never explicitly end()-ed — only updated to a "Disconnected" state — meaning stale banners can linger on the Lock Screen for hours. The three-boolean state model (isIdle/isDisconnected/isConnecting) in OpenClawActivityAttributes.ContentState permits logically invalid combinations; an enum would be safer.
  • SwiftSources.input.xcfilelist: Adds the widget extension entry-point (@main) to a file that may be consumed by build tooling; if used for compilation this would cause a duplicate-@main error.
  • Scope: The inclusion of a substantial, unrelated iOS feature under a webchat-scoped commit message and PR description makes targeted rollback and bisection difficult.

Confidence Score: 3/5

  • The webchat fix is safe to merge; the unverified iOS scope inclusion warrants confirmation before merging.
  • The actual webchat bug fix is minimal, correct, and backed by targeted regression tests — it would be a 5 in isolation. The score is lowered because the PR bundles an entirely separate iOS Live Activity feature (12 files) that the description does not account for. The changelog fragment for those iOS changes references a different PR (feat(ios): add Live Activity connection status + stale cleanup #33591), raising a real possibility these were accidentally included. Until the iOS scope is confirmed intentional, the PR should not be merged as-is.
  • All apps/ios/ files — particularly changelog/fragments/ios-live-activity-status-cleanup.md (references wrong PR), apps/ios/SwiftSources.input.xcfilelist (includes widget @main entry-point), and apps/ios/Sources/LiveActivity/LiveActivityManager.swift (no explicit end() path).

Last reviewed commit: 0e97e38

@@ -0,0 +1 @@
- iOS: add Live Activity connection status (connecting/idle/disconnected) on Lock Screen and Dynamic Island, and clean up duplicate/stale activities before starting a new one (#33591) (thanks @mbelinky, @leepokai)

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.

iOS changes appear unrelated to this PR's stated scope

This changelog entry references #33591 and describes an entirely new iOS Live Activity feature (Lock Screen + Dynamic Island connection status). The PR title and description focus exclusively on a webchat duplicate-rendering fix in ui/src/ui/controllers/chat.ts.

The 12 iOS files added/modified here (LiveActivityManager.swift, OpenClawActivityAttributes.swift, OpenClawLiveActivity.swift, NodeAppModel.swift, project.yml, Signing.xcconfig, Info.plist, etc.) appear to belong to PR #33591, not this fix. Bundling two unrelated features makes the diff harder to review, complicates bisecting if a regression is introduced, and makes targeted rollback impossible.

Please confirm whether these iOS changes were intentionally included or if they were accidentally carried over from another branch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: changelog/fragments/ios-live-activity-status-cleanup.md
Line: 1

Comment:
**iOS changes appear unrelated to this PR's stated scope**

This changelog entry references `#33591` and describes an entirely new iOS Live Activity feature (Lock Screen + Dynamic Island connection status). The PR title and description focus exclusively on a webchat duplicate-rendering fix in `ui/src/ui/controllers/chat.ts`.

The 12 iOS files added/modified here (`LiveActivityManager.swift`, `OpenClawActivityAttributes.swift`, `OpenClawLiveActivity.swift`, `NodeAppModel.swift`, `project.yml`, `Signing.xcconfig`, `Info.plist`, etc.) appear to belong to PR #33591, not this fix. Bundling two unrelated features makes the diff harder to review, complicates bisecting if a regression is introduced, and makes targeted rollback impossible.

Please confirm whether these iOS changes were intentionally included or if they were accidentally carried over from another branch.

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

Comment on lines +64 to +66
func handleDisconnect() {
self.updateCurrent(state: self.disconnectedState())
}

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.

Live Activity is never explicitly ended

handleDisconnect() updates the activity state to "Disconnected" but never calls activity.end(...). This means the Live Activity banner will persist on the Lock Screen and Dynamic Island indefinitely — iOS will eventually clean it up (after its staleness timeout, typically up to 4–8 hours), but users will continue to see "Disconnected" long after the session is gone.

Consider adding an explicit endActivity() method that calls activity.end(dismissalPolicy: .immediate) and hooking it into the user-initiated disconnect or app termination flow if a clean dismissal is desired.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/Sources/LiveActivity/LiveActivityManager.swift
Line: 64-66

Comment:
**Live Activity is never explicitly ended**

`handleDisconnect()` updates the activity state to "Disconnected" but never calls `activity.end(...)`. This means the Live Activity banner will persist on the Lock Screen and Dynamic Island indefinitely — iOS will eventually clean it up (after its staleness timeout, typically up to 4–8 hours), but users will continue to see "Disconnected" long after the session is gone.

Consider adding an explicit `endActivity()` method that calls `activity.end(dismissalPolicy: .immediate)` and hooking it into the user-initiated disconnect or app termination flow if a clean dismissal is desired.

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

Comment on lines +67 to +68
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift

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.

Widget extension @main entry-point included in app source list

ActivityWidget/OpenClawActivityWidgetBundle.swift is decorated with @main and is the entry point for the OpenClawActivityWidget extension target. If SwiftSources.input.xcfilelist is consumed by a build phase that compiles sources into the main app module, this will produce a compile error because Swift only allows one @main per module.

If this file list is exclusively used by analysis tools (e.g., SwiftLint, SwiftFormat), this is harmless. Please add a comment clarifying the intended use, or remove the extension entry-point files if compilation is involved.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/ios/SwiftSources.input.xcfilelist
Line: 67-68

Comment:
**Widget extension `@main` entry-point included in app source list**

`ActivityWidget/OpenClawActivityWidgetBundle.swift` is decorated with `@main` and is the entry point for the `OpenClawActivityWidget` extension target. If `SwiftSources.input.xcfilelist` is consumed by a build phase that compiles sources into the main app module, this will produce a compile error because Swift only allows one `@main` per module.

If this file list is exclusively used by analysis tools (e.g., SwiftLint, SwiftFormat), this is harmless. Please add a comment clarifying the intended use, or remove the extension entry-point files if compilation is involved.

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

@qiyuey

qiyuey commented Mar 4, 2026

Copy link
Copy Markdown
Author

Closing: branch accidentally included an upstream commit due to a bad git amend. Will reopen with a clean branch.

@qiyuey qiyuey closed this Mar 4, 2026
@qiyuey qiyuey deleted the fix/webchat-duplicate-message-rendering branch March 4, 2026 08:29

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e97e38d43

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

await self.refreshAgentsFromGateway()
await self.refreshShareRouteFromGateway()
await self.startVoiceWakeSync()
await MainActor.run { LiveActivityManager.shared.handleReconnect() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Create Live Activity before applying reconnect state

The reconnect callback can run before any activity exists, because handleReconnect() is called from the operator session while activity creation only happens in the node loop. In that ordering, handleReconnect() becomes a no-op (updateCurrent returns when currentActivity is nil), then the node loop later sets the activity to connecting, and no subsequent transition moves it to idle, so users can be left with a stuck “Connecting...” Live Activity despite a healthy connection.

Useful? React with 👍 / 👎.

Comment on lines +94 to +95
Task {
await activity.update(ActivityContent(state: state, staleDate: nil))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Serialize Live Activity state updates

Each status transition is dispatched via an unstructured fire-and-forget Task, so rapid transitions (for example disconnect followed immediately by reconnect) can complete out of order and leave the Live Activity showing stale state. Because updates are not awaited or sequenced, whichever async activity.update(...) finishes last wins, even if it was triggered earlier.

Useful? React with 👍 / 👎.

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.

Control UI webchat: duplicate assistant messages rendered on every reply

2 participants