Skip to content

Cut the desktop app's render load with many hosted agents - #1293

Merged
alexeyzimarev merged 6 commits into
mainfrom
alexeyzimarev/ai-3451-hosted-agent-transcript-delayed-in-the-app
Oct 2, 2026
Merged

alexeyzimarev merged 6 commits into
mainfrom
alexeyzimarev/ai-3451-hosted-agent-transcript-delayed-in-the-app

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

AI-3451 — no GitHub issue: the report was filed in Linear only.

What & why

With nine hosted agents the app burned half a core and typing in the terminal lagged, while the chat looked behind the terminal. The chat was not behind: the terminal prints thinking blocks that the chat dropped. The CPU went on the compositor drawing the whole window at 60 fps, because every in-flight marker ran its own keyframe opacity animation, through OpenGL on Metal, while the hidden terminal re-laid out every row per PTY chunk behind the chat. The app now renders through Metal, fades markers on one shared 10 Hz clock bound to their state, holds a hidden terminal's output for one second at a time, shows thinking as a muted row, and says why a queued message is still queued.

Where to look

PulseClock replaces the .pulsing and Border.toolRunning keyframes; the classes stay as markers for tests. A thinking row closes an open tool group the way prose does, so groups split where the terminal splits them.

Verification

Live app, nine agents, 60 Hz display, dotnet-trace over 45 s and then 6 s:

beta.5 this branch
CPU share 28–53% 3.4%
Composition frames per second 59 8.4

The Metal flicker the removed comment described did not reproduce on macOS 26 in a side-by-side run. Capacitor.App.Tests.Unit: 2843 of 2844 pass; Size_label_and_image_flag fails only under a comma-decimal culture ("2,3 MB") and is untouched here.

🤖 Generated with Claude Code

alexeyzimarev and others added 5 commits October 2, 2026 13:25
OpenGL on macOS is Apple's emulation over Metal and costs CPU in the driver for every
frame; the alternate-frame colour flicker that kept Metal out no longer reproduces.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude Code prints a thinking block as prose in its own terminal, so a chat that dropped
it read as a transcript running behind. Like any prose, a thinking row closes the open
tool group, which is also how the terminal groups the calls around it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A prompt sent mid-turn is echoed to the transcript only when the runtime picks it up at
its next step boundary, so the row can outlive a long tool run by minutes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A keyframe opacity animation in a style runs per marker at the display's refresh rate, so
one working session kept the compositor drawing the whole window 60 times a second; the
shared clock ticks ten times a second and only for markers whose state is in flight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The terminal control stays laid out at opacity 0 behind the chat tab, because the attach
takes its PTY size from the measured surface, and it re-lays out every row it draws on
each feed. Holding chunks while the tab is hidden keeps that cost off the chat the user is
reading; the backlog is fed first the moment the tab is shown.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-02T12:28:23.173988Z 41da81b PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@linear-code

linear-code Bot commented Oct 2, 2026

Copy link
Copy Markdown

AI-3451

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Reduce desktop render load and clarify hosted-agent chat

🐞 Bug fix ✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Reduce compositor work with Metal rendering and one state-driven, 10 Hz pulse clock.
• Batch hidden-terminal output while keeping its backlog ready when the terminal opens.
• Show thinking blocks and explain why accepted prompts remain queued.
Diagram

graph TD
  Workspace["Workspace tabs"] --> TerminalVM["Terminal view model"] --> Surface["Terminal surface"] --> Renderer["Metal renderer"]
  Workspace --> ChatVM["Chat view model"] --> ChatView["Chat view"] --> Renderer
  Clock["Pulse clock"] --> Markers["Active markers"] --> Renderer
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Defer all hidden-terminal feeds until visible
  • ➕ Eliminates terminal layout work while the tab is hidden.
  • ➖ Output can accumulate indefinitely and cause a large feed when the tab opens.

Recommendation: Keep the one-second batching strategy: it substantially reduces hidden layout work without deferring an entire session's output until navigation. The shared clock also addresses the independent marker animations directly rather than throttling unrelated UI.

Files changed (18) +282 / -34

Enhancement (4) +37 / -3
ChatItems.csRepresent assistant thinking as a chat item +5/-0

Represent assistant thinking as a chat item

• Adds a distinct item for reasoning text so it can appear in transcript order.

src/Capacitor.App/ViewModels/ChatItems.cs

ChatTabViewModel.csInsert thinking rows between tool groups +5/-0

Insert thinking rows between tool groups

• Converts nonblank thinking events into chat items and closes open tool and shell groups before inserting them.

src/Capacitor.App/ViewModels/ChatTabViewModel.cs

QueuedChatMessage.csIdentify prompts awaiting runtime pickup +15/-1

Identify prompts awaiting runtime pickup

• Exposes an awaiting-pickup state for accepted, locally queued messages and notifies the view when confirmation changes.

src/Capacitor.App/ViewModels/QueuedChatMessage.cs

ChatTabView.axamlShow thinking, queue status, and clocked tool marks +12/-2

Show thinking, queue status, and clocked tool marks

• Adds a muted thinking row and an awaiting-pickup explanation. Activates the shared clock for running tool and subagent indicators.

src/Capacitor.App/Views/ChatTabView.axaml

Bug fix (7) +67 / -25
App.axamlRemove per-marker keyframe pulses +0/-17

Remove per-marker keyframe pulses

• Removes the shared style that ran an independent, continuous opacity animation for each in-flight marker.

src/Capacitor.App/App.axaml

TerminalTabViewModel.csBatch output for hidden terminal surfaces +55/-2

Batch output for hidden terminal surfaces

• Collects decoded output while hidden and feeds it once per second. Flushes held output when shown and disposes the timer during teardown.

src/Capacitor.App/ViewModels/TerminalTabViewModel.cs

WorkspaceViewModel.csTie terminal visibility to workspace navigation +2/-1

Tie terminal visibility to workspace navigation

• Initializes the terminal as hidden on the default chat tab and updates its visibility when the active tab changes.

src/Capacitor.App/ViewModels/WorkspaceViewModel.cs

AgentStatusMark.axamlClock active agent status marks +1/-1

Clock active agent status marks

• Binds the status glyph to PulseClock only while its state calls for a pulse.

src/Capacitor.App/Views/AgentStatusMark.axaml

PulseClock.csSet shared pulse cadence to 10 Hz +3/-1

Set shared pulse cadence to 10 Hz

• Changes the existing shared timer interval to 100 ms so active markers cause fewer render passes.

src/Capacitor.App/Views/PulseClock.cs

WorkContextView.axamlClock running work-context indicators +4/-2

Clock running work-context indicators

• Activates PulseClock for running subagent and active plan-task marks according to their bound states.

src/Capacitor.App/Views/WorkContextView.axaml

WorkspaceView.axamlClock the starting-agent indicator +2/-1

Clock the starting-agent indicator

• Binds the starting dot's pulse to the workspace's starting state.

src/Capacitor.App/Views/WorkspaceView.axaml

Tests (6) +175 / -3
ChatTabViewModelTests.csVerify thinking splits tool groups +21/-1

Verify thinking splits tool groups

• Checks that thinking appears in file order between separate tool groups. Adjusts an existing grouping test to exclude thinking.

test/Capacitor.App.Tests.Unit/ChatTabViewModelTests.cs

ChatTabViewSmokeTests.csVerify new chat presentation states +25/-0

Verify new chat presentation states

• Checks the thinking chip and muted text, awaiting-pickup explanation, and pulse activation only for running indicators.

test/Capacitor.App.Tests.Unit/ChatTabViewSmokeTests.cs

MainWindowSmokeTests.csVerify live rail indicators pulse +3/-2

Verify live rail indicators pulse

• Extends the running-subagent rail test to assert that a visible marker uses PulseClock.

test/Capacitor.App.Tests.Unit/MainWindowSmokeTests.cs

PulseClockTests.csCover the shared pulse cadence and lifecycle +64/-0

Cover the shared pulse cadence and lifecycle

• Tests the 100 ms interval, absence of marker keyframe styles, and opacity restoration when a marker stops.

test/Capacitor.App.Tests.Unit/PulseClockTests.cs

TerminalTabViewModelTests.csCover hidden-output batching and reveal +43/-0

Cover hidden-output batching and reveal

• Uses a fake clock to verify one-second combined feeds and immediate flushing before newer output when the surface is shown.

test/Capacitor.App.Tests.Unit/TerminalTabViewModelTests.cs

WorkspaceNavigationTests.csCover terminal visibility across tabs +19/-0

Cover terminal visibility across tabs

• Verifies that the terminal starts hidden and is shown only while its tab is active.

test/Capacitor.App.Tests.Unit/WorkspaceNavigationTests.cs

Other (1) +3 / -3
Program.csPrefer Metal on macOS +3/-3

Prefer Metal on macOS

• Places Metal ahead of OpenGL and software in Avalonia's rendering fallback order.

src/Capacitor.App/Program.cs

@qodo-code-review

qodo-code-review Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (1) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Withdrawn prompts still promise delivery ✓ Resolved
Description
IsAwaitingPickup becomes true for every own queue row that is not marked unconfirmed, even if the
server no longer lists its dispatch ID. When an accepted prompt is withdrawn without a transcript
echo, ApplyServerQueue retains the own row, so its new note continues saying the agent will read
it at the next step.
Code

src/Capacitor.App/ViewModels/QueuedChatMessage.cs[34]

+    public bool IsAwaitingPickup => !IsForeign && !IsUnconfirmed;
Relevance

●●● Strong

Recent queue reconciliation precedent accepted fixes for stale or incorrectly retained queued
messages.

PR-#889
PR-#1116

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Server reconciliation expressly allows an absent prompt to have been withdrawn, yet removes only
foreign rows. The new condition still labels any retained, confirmed own row as awaiting pickup.

src/Capacitor.App/ViewModels/ChatTabViewModel.cs[648-668]
src/Capacitor.App/ViewModels/QueuedChatMessage.cs[32-48]
src/Capacitor.App/Views/ChatTabView.axaml[278-279]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An own prompt absent from a later server queue snapshot can retain a pickup promise even when it was withdrawn.

## Fix Focus Areas
- src/Capacitor.App/ViewModels/QueuedChatMessage.cs[32-48]
- src/Capacitor.App/ViewModels/ChatTabViewModel.cs[648-669]
- src/Capacitor.App/Views/ChatTabView.axaml[278-279]

## Recommended Fix
Track whether an own dispatch remains server-queued separately from transport acceptance. Clear or replace the pickup note when its dispatch disappears without a transcript acknowledgement.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Closing a workspace can fault an output feed 🐞 Bug ☼ Reliability
Description
Deliver arms _heldTimer without synchronizing with TeardownAsync, which disposes that timer
before retiring the output generation. If teardown runs concurrently after an output callback passes
its Retired check, the callback can call Change on the disposed timer and throw
ObjectDisposedException.
Code

src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[R317-318]

+        _heldTimer ??= _time.CreateTimer(_ => RxSchedulers.MainThreadScheduler.Schedule(FeedHeld), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+        _heldTimer.Change(HiddenFeedInterval, Timeout.InfiniteTimeSpan);
Relevance

●●● Strong

The team recently accepted analogous TerminalTabViewModel race fixes involving stale generation and
lifecycle invalidation.

PR-#730
PR-#979

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The output callback checks retirement once and then enters the new timer-arming path. Teardown
disposes that timer independently before incrementing the attempt generation, leaving a concurrent
interval in which Change targets a disposed timer.

src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[302-319]
src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[607-612]
src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[683-698]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A hidden terminal feed can arm its timer while concurrent teardown disposes it.

## Fix Focus Areas
- src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[302-328]
- src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[608-612]
- src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[683-698]

## Recommended Fix
Coordinate delivery and timer disposal on the same serialized execution path, and retire the attempt before a queued output callback can arm the timer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗



Informational

3. A new chat row breaks file layout 📘 Rule violation ⚙ Maintainability
Description
AssistantThinkingItem is added as another public top-level type in ChatItems.cs, alongside other
public chat item types, rather than in a file named for it. ChatTabViewModel creates the row and
ChatTabView.axaml binds it, so it is not a private implementation confined to this file's
hierarchy.
Code

src/Capacitor.App/ViewModels/ChatItems.cs[R20-23]

+/// The model's reasoning, shown as plain text in the assistant column.
+public sealed class AssistantThinkingItem(string text) : ChatItemViewModel {
+    public string Text { get; } = text;
+}
Relevance

● Weak

Recent precedent rejected the same one-type-per-file complaint for ChatItems.cs.

PR-#1117

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new public declaration is in ChatItems.cs, which already contains other public top-level
types. Its use by the view model and view rules out the exception for private implementations
confined to a closely related hierarchy.

Rule 3162234: One primary type per file, with only narrow documented exceptions
src/Capacitor.App/ViewModels/ChatItems.cs[15-26]
src/Capacitor.App/ViewModels/ChatTabViewModel.cs[895-899]
src/Capacitor.App/Views/ChatTabView.axaml[113-119]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`AssistantThinkingItem` adds a public top-level type to a file containing other public chat item types, contrary to the type-per-file rule.

## Fix Focus Areas
- src/Capacitor.App/ViewModels/ChatItems.cs[20-23]

## Recommended Fix
Move `AssistantThinkingItem` to `AssistantThinkingItem.cs` in the same namespace, preserving its public API and existing references.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


4. Hidden markers keep the pulse clock running 🐞 Bug ➹ Performance
Description
The new PulseClock.IsActive bindings follow marker state, while PulseClock removes markers only
when that state changes or the visual detaches. Collapsing a subagent or plan section leaves its
controls attached with IsVisible=false, so their hidden markers continue receiving ticks and can
keep the render clock alive.
Code

src/Capacitor.App/Views/WorkContextView.axaml[258]

+                            views:PulseClock.IsActive="{Binding Converter={x:Static ObjectConverters.Equal}, ConverterParameter={x:Static vm:SubagentState.Running}}" />
Relevance

● Weak

Recent exact PulseClock precedent rejected requiring effective visibility tracking for hidden active
markers.

PR-#1158

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new subagent and plan-task bindings activate markers by running state. Their containing sections
can be hidden without removing the controls, but the clock's registration logic does not check
effective visibility.

src/Capacitor.App/Views/WorkContextView.axaml[254-258]
src/Capacitor.App/Views/WorkContextView.axaml[696-696]
src/Capacitor.App/Views/WorkContextView.axaml[772-798]
src/Capacitor.App/Views/PulseClock.cs[25-62]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Collapsed sections retain active pulse markers and keep the shared render clock running.

## Fix Focus Areas
- src/Capacitor.App/Views/PulseClock.cs[25-62]
- src/Capacitor.App/Views/WorkContextView.axaml[256-258]
- src/Capacitor.App/Views/WorkContextView.axaml[299-301]

## Recommended Fix
Make clock membership depend on effective visibility as well as `IsActive`, including ancestor visibility changes, and remove hidden markers until they become visible again.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


Grey Divider

Context sources
✅ Compliance rules (platform): 64 rules
✅ Cross-repo context — repo relationships
Review mode: 🧠 Deep: This is a broad, behavior-changing performance refactor spanning rendering, terminal buffering, event grouping, navigation state, and platform configuration, with many independent logic sites where subtle regressions are easy to miss.

Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Capacitor.App/ViewModels/QueuedChatMessage.cs Outdated
Comment on lines +317 to +318
_heldTimer ??= _time.CreateTimer(_ => RxSchedulers.MainThreadScheduler.Schedule(FeedHeld), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
_heldTimer.Change(HiddenFeedInterval, Timeout.InfiniteTimeSpan);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Closing a workspace can fault an output feed 🐞 Bug ☼ Reliability

Deliver arms _heldTimer without synchronizing with TeardownAsync, which disposes that timer
before retiring the output generation. If teardown runs concurrently after an output callback passes
its Retired check, the callback can call Change on the disposed timer and throw
ObjectDisposedException.
Agent Prompt
## Issue description
A hidden terminal feed can arm its timer while concurrent teardown disposes it.

## Fix Focus Areas
- src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[302-328]
- src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[608-612]
- src/Capacitor.App/ViewModels/TerminalTabViewModel.cs[683-698]

## Recommended Fix
Coordinate delivery and timer disposal on the same serialized execution path, and retire the attempt before a queued output callback can arm the timer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

@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: 41da81b984

ℹ️ 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".

.With(new AvaloniaNativePlatformOptions {
RenderingMode = [AvaloniaNativeRenderingMode.OpenGl, AvaloniaNativeRenderingMode.Software],
RenderingMode = [
AvaloniaNativeRenderingMode.Metal, AvaloniaNativeRenderingMode.OpenGl, AvaloniaNativeRenderingMode.Software,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Metal disabled on affected macOS 26 systems

On macOS 26 configurations affected by the renderer issue documented immediately before this change, putting Metal first makes Avalonia select the backend known to alternate color matching between frames, so focused windows flicker whenever the caret triggers a redraw. A successful run on one machine does not make that backend safe across affected displays; preserve OpenGL-first rendering or gate Metal behind a configuration known not to exhibit the issue.

Useful? React with 👍 / 👎.

…listed

A collapsed section keeps its rows attached, so an active marker there would cost a render
pass per tick for nothing; and a dispatch the server stopped listing was delivered or
withdrawn, which only the transcript echo can tell apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit ffbbecf into main Oct 2, 2026
8 checks passed
@alexeyzimarev
alexeyzimarev deleted the alexeyzimarev/ai-3451-hosted-agent-transcript-delayed-in-the-app branch October 2, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant