Skip to content

fix(native-chat): show Thinking only while a reasoning block is open - #24545

Closed
brennanb2025 wants to merge 21 commits into
brennanb2025/nc-reasoning-rowfrom
brennanb2025/reasoning-lifecycle
Closed

brennanb2025 wants to merge 21 commits into
brennanb2025/nc-reasoning-rowfrom
brennanb2025/reasoning-lifecycle

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 25 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1422 $\color{#cf222e}{\Huge{\mathbf{−}}}$​208 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1214
Prod 33 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​957 $\color{#cf222e}{\Huge{\mathbf{−}}}$​487 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​470

ELI5

The chat's activity line says "Thinking" when the agent is thinking. Until now Orca guessed that from history: if the newest row in the transcript was reasoning, it said "Thinking". That guess was wrong in both directions. "Thinking" showed up late, only once reasoning text had been written. It then stuck around after the thinking ended, for example while Claude wrote out a large file. For Codex, whose reasoning arrives without text on Orca's settings, "Thinking through the request" stayed on screen after the reasoning had finished.

This PR is step 2 of 2, stacked on #19221 (step 1). The host now tells every client, live, whether a reasoning block is open right now. "Thinking" shows exactly while one is open in the running turn, and at no other time.

What Changed

What you see (before = today's release → after both steps):

Before (today's release) After
Claude starts thinking (current CLI, as Orca launches it) No "Thinking" at all: thinking text reaches Orca empty, so no reasoning row exists to guess from "Thinking" from the moment the thinking block starts, even before any summary text arrives
Claude finishes thinking, then writes a large file (older CLIs that send thinking text) "Thinking" appears only after the block ends, then sticks through the file write (up to 30 s in recordings) "Thinking" stops when the block ends; the write reads "Working…"
Codex reasons (no summary text on Orca's settings) "Thinking through the request" lingers after reasoning finishes, until the next item starts (up to 1.4 s in recordings) Cleared the moment the reasoning item completes
A Codex subagent (helper thread) reasons Nothing "Thinking" shows next to the subagent's name while it reasons
Mobile Same history guess Same live signal

How it works:

  • A live signal from the host. The host's live turn activity (the same channel the activity line already reads, see fix(native-chat): the working line shows only what the agent is doing now #24218) gains an optional reasoning field: { session, subagents }. session says whether the session's own agent has a reasoning block open, and subagents lists the subagents that do. It is never written to the journal.
  • One tracker per provider drives both this signal and step 1's row open/closed state, so they cannot drift apart.
    • Claude: a block opens at its start (also when no text ever arrives) and closes at its final frame, its block-stop frame, a new message, or the end of the turn.
    • Codex: a reasoning item opens at item/started and closes at item/completed, or at the end of the turn or session.
  • Clients: desktop and mobile both use one shared check in src/shared/native-chat-reasoning-row.ts (the signal says open, and this is the live turn). It drives the activity line, the subagent section headings, and step 1's rule that hides a reasoning row while it is still open. The old "newest row is reasoning" guess is deleted.
  • Codex: when a reasoning item completes, the activity text it set ("Thinking through the request") is cleared. Only the reasoning item that set the text clears it. Other completions leave the line alone, so a command that took over the line keeps it.

Why

The root cause was that "Thinking" was inferred from history (the newest journal row) instead of read from what the agent is doing now. That is the same bug class #24218 fixed for the activity line's tool text. The common pattern is to show "Thinking" only while a reasoning lifecycle is open in an active turn; this PR follows it, with the host owning the lifecycle. Differences from the common pattern, each labelled:

  • The signal travels on the existing live-activity channel, not with the transcript. Intended: it reuses that channel's replay and catch-up and needs no new wire message.
  • Claude opens at the start of the thinking block, not at its first text. Intended: in real recordings the first summary text arrives 2–4.6 s after the block starts, and with summaries off it never arrives.
  • The signal is per agent (the session's own agent, plus each subagent). Intended: Orca draws subagents as sections inside the parent chat, so a subagent's thinking shows on its own section heading. Visible result: the parent's activity line does not say "Thinking" when only a subagent is reasoning (the common pattern shows one thread-wide indicator).
  • No reasoning text in the signal, and no row while a block is open. Intended: one live indicator. A block that has no text yet must never become a row: apps already in users' hands draw a blank reasoning row as an empty dimmed gap, and every Codex reasoning item is blank on Orca's settings. The text appears in the row once the block closes (step 1).
  • No fallback for a new app connected to an older host. Intended: such a client shows the host's own activity text, or "Working…". It loses the "Thinking" label but never shows a wrong one. Falling back to the old guess would bring back the bug this fixes.
  • Codex clears only reasoning activity on completion. Temporary: "Running a command" can still linger about 4 s after its command finishes. Some Codex items, such as subagent coordination, complete immediately while their work continues, so a general rule needs per-item-type semantics. That is a follow-up (no ticket yet).

Mixed versions:

  • An app from before this change, connected to a new host, ignores the new field and keeps its old "Thinking" rule (see step 1's notes). One improvement does reach it: Codex's "Thinking through the request" now clears when reasoning completes, because the host clears it, so such an app shows "Working…" until the next item starts.
  • A new app connected to a host without step 2 shows no reasoning-based "Thinking": it shows the host's own activity text, or "Working…". If that host has step 1, a reasoning row that is still streaming shows the neutral headline "Reasoning", never "Thought", until it closes.
  • Nothing is stored, so there is nothing to migrate.

Limits: Claude Code (2.1.280) never streams a subagent's thinking (recorded: no subagent stream frames in any run; in Orca's launch setup the subagent's thinking is not sent at all, only its start and finish). So a Claude subagent's section heading does not say "Thinking". A Codex subagent can only show "Thinking" while the parent's turn is running.

Linked Issue

Maintainer change; no issue. Stacked on #19221.

Visual Proof

Real recordings replayed at 1x into hidden test builds of step 1 (#19221) and of this PR on a separate Mac. A stand-in claude / Codex app-server replayed real Claude 2.1.280 and Codex 0.159 sessions; no real CLI or credentials.

Codex, 2 s after a reasoning item completed (a 2 s pause was added after each reasoning item so a lingering label is visible)

Before (step 1) After
Before: Thinking through the request still shown after reasoning ended After: Working… once reasoning ended

Measured over the whole turn (250 ms sampler):

  • Codex: before, "Thinking through the request" stayed through each 2 s pause after reasoning (until the next item). After, it cleared 0.05 s and 0.19 s after each reasoning item completed. On a second recording (with a helper subagent) the parent's reasoning text lingered 3.9 s before and cleared at once after.
  • Claude: "Thinking" appeared 2.27 s and 3.01 s after each thinking block started before (it waited for the first summary text); after, within one sampler tick (0.30 s and 0.20 s). Both show "Working…" while Claude writes files.

Testing

  • Automated tests added/updated. Real Claude 2.1.280 and Codex 0.159 recordings were replayed through Orca's real translators. Claude opens at block start: blocks span 3510→8160 ms while the first text lands at 8085. With summaries off, "Thinking" still shows and no rows are written. Codex is open exactly on its reasoning items' start→completion spans. Every piece has a test that fails when its fix is removed, including a cross-version test that loads the latest release's own client code.
  • Typecheck (node, web, cli) green locally. Mobile is covered by CI.
  • Manual QA on a separate Mac (macOS) as above: Claude and Codex activity line over whole turns, and a real recording of a Codex helper subagent. With its entry list expanded, the helper's entry read "Thinking" from 0.09 s after its reasoning started to 0.16 s after it ended (the collapsed default row shows its usual "working"). Five quit-and-relaunch runs with Claude and Codex chats open on both builds all exited cleanly; one earlier run of a previous build did not exit within 30 s and has not reproduced in 11 tries since.

AI Disclosure

Author: @BrennanKB5

Review

Two rounds of independent review (host tracker and signal, clients and wire, design challenge, data path, UI and outcomes) plus a release-readiness pass (SSH/remote, crash/growth, security, performance, correctness, backward compatibility, cross-platform, organization). Round 1 found that clients dropped updates that changed only the reasoning signal; fixed, with a test through the real host-to-client path. Nothing P0–P2 is open. Deferred (P3): a Claude API retry keeps "Thinking" through the retry wait; a Codex subagent can only show "Thinking" while the parent's turn runs.

Agent skill upstream boundary

  • Not applicable.

Notes

No dependency changes. SSH/remote: the signal is published by the host that runs the agent, and older clients ignore it. Performance: the signal is published only when it changes.

Checklist

  • This PR is small and focused (step 2 of 2)
  • I explained what changed and why
  • Before/after screenshots attached
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • Typecheck and targeted tests pass locally; CI covers the rest

The live turn's activity gains an optional reasoning field: whether the
session's own agent, and which subagents, have a reasoning block or item open
right now. Each provider derives it from the one tracker that also stamps the
reasoning rows' running/completed state, so the signal and the rows cannot
drift.

Claude opens a block at content_block_start, blank blocks included, and closes
it at its final frame, its content_block_stop, a new message in its stream, or
the turn's end. Codex reads its live item set.

A Codex item's activity copy now belongs to the item that set it: that item's
completion clears it, and any other item's completion leaves it. This ends
'Thinking through the request' and 'Running a command' lingering after their
item finished.

Text and reasoning compose in one per-turn channel, so neither writer clears
the other. A new shared gate, nativeChatReasoningOpen, is the one place clients
read the signal.
…ever a row

The live turn line said Thinking whenever the newest root journal row was a
reasoning row. That row is history, not the present: nothing said Thinking
before a summary's first text or when summaries are off, and a finished row
kept saying it until another row landed.

Desktop and mobile now read one gate, nativeChatReasoningOpen, for the turn
line, for hiding a reasoning row while it is still being written, and for a
subagent's section head and roster entry, which show Thinking while that
subagent reasons. The newest-row inference is deleted. A host that sends no
signal shows no reasoning-derived Thinking.
…ponent file

Also assert the Codex signal is open before each close the tests pin.
Clearing the line on whichever item set it was too broad: a subagent spawn's
item completes at once while the subagent keeps working, so 'Coordinating with
another agent' vanished early. A reasoning item ending does mean the reasoning
ended, so only that clears its own words; every other completion leaves the
line as it was.
…en reasoning

A caught-up client gets each activity as an empty batch at its cursor, and the
reducer dropped one whose turn and words matched what it held. A frame that only
opened or closed reasoning was therefore lost on desktop and mobile. The host's
channel and the reducer now share one equality, which compares the reasoning too.

Codex also published a reasoning item's end as two frames, words cleared and
then reasoning closed, so Thinking flashed between them. One Codex event's
activity updates now publish once, as their net result.
…gate

- A running reasoning row that draws (an older host, a dropped frame) reads
  'Reasoning', not past-tense 'Thought'.
- The gate is keyed on what it answers, so activity text changes no longer
  rebuild the transcript.
- A subagent's 'Thinking' uses the type its entry's state text already uses.
- A subagent's unfinished reasoning is hidden only in the live turn, as the
  session's own is.
setActivity can be refused like any ordinary write when the sink's queue is
full and holds no earlier activity to replace. The channel recorded the value
as sent anyway, so a refused open or close was never sent again while it stayed
current. setActivity now returns the queue's admission, and a refused value goes
again on the next derive. The shared equality is renamed
agentSessionTurnActivityEqual.
…s it works

A Codex helper turn that never completes leaves its reasoning open on the host
until the session ends, so the heading would keep saying Thinking across later
turns.
Claude CLI 2.1.280 streams no subagent frames; a subagent's thinking arrives
only finished. The streamed-subagent test is replaced by one built from that
captured frame. A Codex helper thread's reasoning is pinned from a real 0.159
capture: the child's agent over exactly its span, never the parent's.
…SetActivity

Giving setActivity a return type broke every sink mock that returns what it
pushed. The admission now comes from trySetActivity, as tryAppendItem and
tryPublish do beside their void forms, and the channel uses it when the sink
has it.
…en reasoning

Both joined ids with a newline, so ['a\nb'] read the same as ['a', 'b']. The
equality now compares ids one by one, and the gate key is JSON of the session
flag and the sorted ids.
…of latching a re-send

A refused activity was re-sent only on the next provider event, and a refused
turn-end clear never; Codex can go seconds without one. The activity op
coalesces, so at most one small one is ever queued: it is now admitted past the
ordinary watermark. It still counts toward pausing reading, never declares
backpressure itself, and is refused once the queue is closed or failed. The
re-send latch and trySetActivity are gone; setActivity stays void.

Three Codex hard-watermark tests counted the clear as dropped; their counts move
by the one activity op and its publish.
…he gate shut

Real translator, sink queue, journal, subscribers and reducer: a block cut off by
an error_during_execution result closes in the same delivery that clears the
signal, because an activity publish carries every row journaled before it.
A subagent shows Thinking while the host reports its reasoning open and its
roster entry says it works; its unfinished reasoning row hides exactly then,
whichever turn its section sits in. This replaces the row hide's live-turn
guard. On a roster entry, Thinking takes the place of the state word instead of
standing beside it.
…d Gate only

- The gate key tolerates a reasoning field with no subagent list, as the
  activity equality already does.
- One exported NATIVE_CHAT_NOTHING_REASONING_OPEN replaces three local copies
  and the module's private one.
- nativeChatReasoningOpen, used only by tests, is gone; they read the gate the
  way clients do.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Status (step 2 of 2): reviewed and QA'd; stays draft until #19221 merges, then retarget to main and mark ready.
Head 8345641 contains step 1 at 23879b1 with current main; node/web/cli typechecks green locally; lockfiles identical to main.
Review: two independent rounds plus a release-readiness pass; round 1 found that clients dropped updates that changed only the reasoning signal, fixed with a test through the real host-to-client path. Nothing P0–P2 open.
Manual QA on a separate Mac with replays of real sessions: Claude "Thinking" starts within one 250 ms sample of the thinking block (was 2–3 s late); Codex "Thinking through the request" clears 0.05–0.19 s after reasoning completes; a Codex helper's entry reads "Thinking" exactly while it reasons.
Known limits (in the body): Claude does not send a subagent's thinking, so only Codex subagents show "Thinking"; Codex "Running a command" can still linger about 4 s (follow-up).

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this stale second-step implementation after a replacement issue is created. #19221 provides reasoning row lifetimes, but the remaining work is making Thinking follow the live open block.

The remaining problem is tracked in #27656.

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