Skip to content

fix(native-chat): keep a turn's bar on the prompt that opened it - #23573

Merged
brennanb2025 merged 2 commits into
mainfrom
fix-turn-bar-owning-message
Sep 29, 2026
Merged

brennanb2025 merged 2 commits into
mainfrom
fix-turn-bar-owning-message

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​560 $\color{#cf222e}{\Huge{\mathbf{−}}}$​6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​554
Prod 15 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​194 $\color{#cf222e}{\Huge{\mathbf{−}}}$​83 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​111

Where this fits: the plan for messages sent while the agent is working

The problem. In a structured (native) chat, a message you sent while the agent was still working went straight to the agent. What happened next depended on the agent:

  • Claude folds it into the work it is already doing, at its next step.
  • Other agents start a new turn or refuse it.

Orca then drew the conversation wrong:

  • The "Working for" bar jumped to the newest message.
  • A single piece of work showed up as two turns (for example "Worked for 17s" and "Worked for 52s"), with the time counted twice.
  • On the phone, the running turn's rows could disappear.

The plan. A message sent mid-turn waits as a card above the message box, with three actions:

  • Steer: send it now, into the running turn.
  • Edit.
  • Delete.

Otherwise it sends by itself when the turn ends. Only Steer puts a message into a running turn. With queueing turned off in Settings, mid-turn messages still go straight in, as they do today.

The work is split into six PRs:

# PR What it does Why the queue needs it
1 #23573 The turn's bar stays on the message that opened the turn. A steered message still lands mid-turn, and so does every mid-turn message with queueing off.
2 #23671 (on #23573) Chat rows are grouped by the turn that produced them, not by the nearest message above them. A steered message sits among its turn's rows. A turn the agent opens by itself gets its own bar.
3 #23621 Claude: a message folded into the running turn is recorded as joining that turn, not as a new turn. Steer on Claude is exactly this fold. Without it, every Steer splits the turn in two; desktop QA of the queue showed that split.
4 #23726 (on #23026) The queue itself, held by the computer running the agent. The core.
5 #23731 (on #23726) Desktop cards. The desktop interface.
6 #23736 (on #23726) Phone cards. The phone interface.

Steer on Codex also needs Codex's turn-steer support from #21062, which is still open.

The queue stays switched off, meaning the host does not advertise it and every client behaves as today, until PRs 1–6 and #21062 have landed. Turning it on is then a one-line change.

This PR is step 1. It keeps the running turn's bar under the message that opened the turn, as the host records it, instead of under whichever message is newest. It is needed with the queue on (a steered message) and with it off (every mid-turn send).

ELI5

If you send a second message while the agent is still working on your first one, the "Working for" timer now stays under the message the agent is actually working on, and each message's "Worked for" time no longer counts the time it spent waiting in line.

What Changed

Follow-up to #23537, found in live QA of that PR on desktop and phone.

Before: you send prompt A, and a few seconds later, while A is still running, you send message B.

  • The live "Working for" bar jumped under B right away, even though the agent was still working on A. It counted from A's start, and A had no bar at all.
  • When the agent moved on to B, a "Worked for 17s" bar popped in under A and pushed the content down.
  • When everything finished, B read "Worked for 1m 5s". That included the ~13 s B spent waiting behind A, so the two bars claimed 82 s for about 70 s of real time.
  • With Codex, which folds a mid-turn message into the running turn, the finished chat showed two "Worked for" bars with the same time.

After:

  • The live bar stays under A until the agent starts on B. B shows nothing while it waits.
  • When B's turn starts, A settles in place to "Worked for 17s" and B's bar starts counting from that moment. B finishes at "Worked for 51s", so the two add up to the real time.
  • A message Codex folds into the running turn never gets its own bar.
  • A single turn with no mid-turn message is unchanged, down to the same numbers.

Mechanism: both desktop and mobile picked "the newest user message" as the owner of the running turn, and dated every turn from its own send. A mid-turn send shows in the transcript immediately, so it became the "newest message" while the earlier turn was still running. The host already records, on each turn, which message opened it. The owner now comes from that record, resolved through the same alias lookup the settled durations already used. A turn's clock now starts at its send, or at the end of the previous turn if that is later. The live counter and the settled duration read that same start, so they still agree. Everything is derived on the client from journal rows that already exist: there is no host, wire, or storage change. Hosts that do not name an opener, and chats that are not structured, keep the old "newest message" rule.

Why

The bug came from deciding ownership by position when the host already knows the answer. Reading the host's turn record removes the cause, and it fixes the Codex double bar with the same change.

Alternative considered: hold a mid-turn message out of the transcript as a "queued" row until its turn starts, so the newest message is always the running turn's opener. That does not fit Orca. The host has already delivered the message to the agent, and a message Codex folds into the running turn never opens a turn of its own, so it would stay hidden forever. Orca keeps showing the message immediately and derives its bar and clock from the host's record instead.

Known limits, not introduced here:

  • A turn the agent opened on its own (for example a background wakeup) has no opener in the host record, so a message sent during it still takes the live bar, as before.
  • If the turn a message queued behind has been paged out of the loaded history, that message counts from its own send until older history loads.

Linked Issue

None; found during QA of #23537.

Visual Proof

Live structured Claude chat. Prompt A runs four sleep 15 tool calls, and message B ("Also say the word banana at the end.") is sent a few seconds later while A is still running. Claude ends A's turn after the first sleep and runs the rest in B's turn.

Desktop (dev build; before = main, after = this PR):

B waiting, A still running Settled
Before before, B waiting before, settled
After after, B waiting after, settled

Before: the live bar sits under B and counts from A's start while A is still running, and A has no bar. B settles at 1m 4s, so the two bars add up to 81 s for about 70 s of real time. After: the bar stays under A ("Working for 10s") while B waits. Sampled every second, A settled in place at 17 s (same position), B's bar started at 0 s, and B settled at 52 s. 17 + 52 matches the time from sending A to the end.

Phone (iOS Simulator, paired to a dev host running this PR, messages sent from the phone):

phone, B waiting phone, B turn opens phone, settled

B waiting ("Working for 12s" under A, nothing under B) → B's turn opens (A "Worked for 17s", B "Working for 0s") → settled (17s and 51s). Each bar expands only its own turn's commands (1 sleep under A, 3 under B), and an ordinary single turn afterward still shows its live bar and settles in place.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

New structured-agent-session-turn-bars.test.ts replays the host journal recorded in the live repro. It checks where the bar sits while B waits, A settling at 17 s, B counting from A's end, the two durations summing to wall time, a Codex fold getting a single bar, and unchanged fallbacks for older hosts, provider-opened turns and unverifiable turns. The desktop turn-indicator and mobile turn-disclosure suites cover the same ownership sequence. Reverting the production change fails all the new ownership and timing tests. Desktop native-chat and shared suites pass (2,025 tests), mobile session suites pass (1,836), and pnpm tc, mobile tsc, oxlint, the changed-code quality gate and React Doctor all pass.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Renderer, mobile and shared client code only. No wire, host, or storage change, so SSH hosts, mixed-version remotes and paired phones need nothing new. Performance: the one pass over the journal replaces the old full pass plus a reverse scan, is memoized on the same inputs, and adds no timers.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

A message sent while a structured turn runs appears in the transcript at
once, so "the newest user message" is not the running turn's owner. The
live "Working for" bar moved to the mid-turn message and counted from the
earlier prompt's start, and a send queued behind the running turn counted
its wait twice: once in the previous turn and again from its own send.

Derive both from the host's turn records in one ordered pass:
- The running turn's bar belongs to the user message its lifecycle row
  names (resolved exactly as settled timing resolves it). A message sent
  mid-turn gets no bar until its own turn opens; a send folded into the
  running turn never gets one. Surfaces fall back to the latest user
  message only when the host names no opener.
- A turn counts from its send, but never before the previous turn in the
  journal ended (its recorded end, else its row's last host revision),
  capped at the turn's own start. The same origin feeds the live counter
  and the settled duration.

Desktop and mobile share the derivation; no wire, host, or storage change.
@brennanb2025
brennanb2025 marked this pull request as ready for review September 28, 2026 08:29
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for review

Head: 268fc6e066d, rebased on current main. CI green so far (24 passed, 13 skipped, none failing). No host, wire or storage change.

Origin: found in live QA of #23537 on desktop and phone. When a message is sent while an earlier turn is still running, the live bar jumped to the new message and counted from the earlier turn's start, and the settled times double-counted the wait. The host journal from that repro is replayed in the new tests.

Review: one full review loop came back clean with no changes. It confirmed:

  • The journal ordering the fix relies on: a turn's row keeps its first position across revisions.
  • The ownership and start rules for the Claude queued case, the Codex folded case, older hosts, provider-opened turns, reloads, a phone attaching mid-turn, and an optimistic phone send before its echo.
  • That the one-pass selector costs no more than before.

Live QA:

  • Desktop (dev build on a separate Mac, before = main, after = this PR, sampled every second): passes. While B waits, the bar stays under A. When B's turn opens, A settles in place at 17 s and B starts at 0 s. B settles at 52 s, and 17 + 52 matches wall time. Main reproduced the bug: the bar sat under B, B started at 12 s, and the total was 81 s for about 70 s of real time.
  • Phone (iOS Simulator, paired dev host on this PR, sent from the phone): passes on the same criteria (17 s + 51 s against 68–70 s of wall time). Each bar expands only its own turn, and a normal single turn is unchanged.

Already on main, not changed here:

  • On the phone, while a newer message is waiting, the earlier running turn's tool rows are hidden. Only its bar and the "Working…" line below the newer message show. Desktop shows the rows.
  • In both builds, when a turn settles its tool rows fold away, so the content below moves up. The bar itself does not move.
  • A turn the agent opens on its own has no opener in the host record, so a message sent during it still takes the live bar.

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 0591b6ae-a297-45c6-81bd-64ed4c87e034

📥 Commits

Reviewing files that changed from the base of the PR and between 268fc6e and 2a047f6.

📒 Files selected for processing (5)
  • mobile/src/session/MobileNativeChatView.tsx
  • src/renderer/src/components/native-chat/NativeChatMessageList.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
  • src/renderer/src/components/native-chat/native-chat-transcript-slots.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Shared turn timing now records queue boundaries and opener identity. Renderer and mobile chat pass the opener through to turn-status logic and use user-message keys to place active and settled bars. Tests cover overlapping prompts, opener changes, fallback attribution, and timing.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 2a047

Queued-turn durations remain anchored to the prompt submission or later, and opener attribution uses transcript message keys. No actionable merge-blocking risk is established.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 2a047

The change appears limited to which chat message displays a turn’s status and duration. No new security capability was identified, but some transcript lifecycle behavior remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The observed effect of the new opener key is confined to turn timing and status presentation in the traced desktop and mobile consumers; those paths show no authority-bearing sink.

Trust Boundaries and Controls

  • observed — The journal reader resolves opener references against journal item IDs or submission aliases before supplying the UI key; the downstream selector accepts a non-null resolved key without checking membership in currently loaded messages.

Resilience and Maintainability Implications

  • observed — Mobile rejects retained timing from a different chat scope, and the timing reducer removes non-active keys no longer present among valid user messages.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 35 functions across 22 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: keeping a native-chat turn bar under the prompt that opened the turn.
Description check ✅ Passed The description is detailed and covers the user impact, implementation, rationale, visual proof, testing, compatibility, and checklist requirements. The Linked Issue section states "None" instead of p…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 47f6b4e8-b84b-46d0-a14b-5ac96cd101ef

📥 Commits

Reviewing files that changed from the base of the PR and between 1bfbc15 and 268fc6e.

📒 Files selected for processing (22)
  • mobile/src/session/MobileNativeChatOverlay.tsx
  • mobile/src/session/MobileNativeChatView.tsx
  • mobile/src/session/mobile-native-chat-controller-contract.ts
  • mobile/src/session/use-mobile-native-chat-controller.ts
  • mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx
  • mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
  • mobile/src/session/use-mobile-native-chat-turn-status.ts
  • mobile/src/session/use-mobile-structured-agent-turn-timing.ts
  • src/renderer/src/components/native-chat/NativeChatMessageList.tsx
  • src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
  • src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts
  • src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts
  • src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts
  • src/renderer/src/components/native-chat/native-chat-transcript-slots.ts
  • src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts
  • src/renderer/src/components/native-chat/use-native-chat-turn-status.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session.ts
  • src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts
  • src/shared/native-chat-turn-status.ts
  • src/shared/structured-agent-session-turn-bars.test.ts
  • src/shared/structured-agent-session-turn-timing.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Comment on lines 125 to 143
// writing a ref during render, which react-freeze can discard.
turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined,
// With no user boundary at all, the session's working state stays authoritative.
activeTurnIsWorking:
enabled &&
isWorking &&
(turnKey === activeTurnKey ||
(turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY))
activeTurnIsWorking: enabled && isWorking && turnKey === latestTurnKey
}
},
[turnKeys, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, isWorking]
[
turnKeys,
enabled,
activeTurnKey,
latestTurnKey,
active,
completedByTurn,
expandedTurnIds,
isWorking
]
)

return {

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '75,155p' mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
rg -n 'activeTurnIsWorking|latestTurnKey|UNANCHORED' mobile/src/session/use-mobile-native-chat-turn-disclosure.ts mobile/src/session/MobileNativeChatView.tsx

Repository: stablyai/orca

Length of output: 3161


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- focused diff ---'
git diff --unified=35 080c56289863d45bd7cf76325e178273fafa2357 268fc6e066d5c4c0f87ee51a61d9201897f4d684 -- mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
printf '%s\n' '--- consumers and related symbols ---'
rg -n -C 5 'activeTurnIsWorking|useMobileNativeChatTurnDisclosure|useMobileNativeChatTurnStatus|MOBILE_UNANCHORED_TURN_KEY' mobile/src
printf '%s\n' '--- hook outline ---'
ast-grep outline mobile/src/session/use-mobile-native-chat-turn-disclosure.ts

Repository: stablyai/orca

Length of output: 38246


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/conventions

Length of output: 25767


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- status hook ---'
cat -n mobile/src/session/use-mobile-native-chat-turn-status.ts | sed -n '1,240p'
printf '%s\n' '--- view call and row render ---'
cat -n mobile/src/session/MobileNativeChatView.tsx | sed -n '245,390p'
cat -n mobile/src/session/MobileNativeChatMessage.tsx | sed -n '95,180p'
printf '%s\n' '--- focused disclosure tests ---'
cat -n mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx | sed -n '130,275p'
printf '%s\n' '--- current view tests around grouping ---'
cat -n mobile/src/session/MobileNativeChatView.test.ts | sed -n '680,920p'
printf '%s\n' '--- base disclosure test diff ---'
git diff --unified=12 080c56289863d45bd7cf76325e178273fafa2357 268fc6e066d5c4c0f87ee51a61d9201897f4d684 -- mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx mobile/src/session/MobileNativeChatView.test.ts

Repository: stablyai/orca

Length of output: 35774


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- shared turn selector and grouping ---'
rg -n -C 12 'selectNativeChatActiveTurnKey|NATIVE_CHAT_UNANCHORED_TURN_KEY|selectNativeChatTurnStatuses' src/shared mobile
printf '%s\n' '--- active tool selector ---'
rg -n -C 12 'function selectActiveToolCall|const selectActiveToolCall|selectActiveToolCall' mobile/src

Repository: stablyai/orca

Length of output: 42003


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- active tool implementation ---'
rg -l 'function selectActiveToolCall|selectActiveToolCall' src/shared mobile | sort
file=$(rg -l 'function selectActiveToolCall|selectActiveToolCall' src/shared mobile | head -n 1)
printf 'selected=%s\n' "$file"
cat -n "$file" | sed -n '1,180p'
printf '%s\n' '--- active tool tests ---'
rg -n -C 12 'selectActiveToolCall|activeTurnIsWorking|running.*tool|live.*tool' src/shared mobile/src --glob '*test*'

Repository: stablyai/orca

Length of output: 41876


🏁 Script executed:

#!/bin/bash
set -e
cat -n src/shared/native-chat-tool-activity.ts | sed -n '1,130p'
printf '%s\n' '--- shared mid-turn contract ---'
cat -n src/shared/structured-agent-session-status-tool-line.test.ts | sed -n '100,125p'

Repository: stablyai/orca

Length of output: 6208


Keep the running tool row live after a mid-turn send.

If turn A owns a running tool call and the user submits turn B, the tool row remains grouped under A. The new latestTurnKey check marks it non-working because B is latest. selectActiveToolCall then returns null, so MobileNativeChatMessage can hide the collapsed running tool row.

Suggested fix
-import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
+import {
+  NATIVE_CHAT_UNANCHORED_TURN_KEY,
+  type NativeChatSettledTurns
+} from '../../../src/shared/native-chat-turn-status'

...

-  const latestTurnKey = turnKeys.at(-1)
   const activeActivityText = enabled && isWorking ? (activityText ?? null) : null

...

-        activeTurnIsWorking: enabled && isWorking && turnKey === latestTurnKey
+        activeTurnIsWorking:
+          enabled &&
+          isWorking &&
+          (turnKey === activeTurnKey ||
+            (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY))
...
-      latestTurnKey,

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No critical issues — one edge case in the new owner-resolution fallback.

Reviewed changes

  • Turn ownership from the host record — desktop and mobile now take the running turn's bar owner from the host's userItemId (resolved through the submission alias), so a message sent mid-turn no longer steals the "Working for" bar from the prompt that opened the turn.
  • Queued-send origin — readTiming records queuedUntil (previous turn's end, clamped to this turn's start), and structuredAgentTurnOrigin / completedStructuredAgentTurnSeconds count from it, so live and settled bars sum to wall time.
  • Single journal pass — readStructuredAgentJournalTurns replaces the separate settled/run scans, and selectStructuredAgentTurnBars becomes the one entry point for settled durations, running timing, and owner.
  • Mobile liveness split — row liveness follows the transcript's latest prompt (latestTurnKey), decoupled from the bar's owner.
  • Consolidated unanchored key + tests — NATIVE_CHAT_UNANCHORED_TURN_KEY replaces the duplicated per-surface literals; new shared, desktop, and mobile tests cover the queued, Codex-fold, older-host, and unverifiable cases.

I verified the new shared suite (structured-agent-session-turn-bars.test.ts), the desktop turn-indicator suite, and the mobile disclosure suite pass, and that the new assertions exercise exact durations and placements rather than loose bounds.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +116 to +120
return (
activeTurnOpenedBy ??
messages.findLast((message) => message.role === 'user')?.id ??
NATIVE_CHAT_UNANCHORED_TURN_KEY
)

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.

activeTurnOpenedBy is returned without checking it is still among messages. Client-side trimming keeps the last 1024 items but the last 256 submissions (structured-agent-session-reducer.ts:53-56,148-169), so a turn's opener item can be trimmed while its turn row and submission survive — then activeTurnOpenedBy resolves through the surviving submission alias to an id no longer in the transcript. No row then satisfies message.id === activeTurnKey, so the live "Working for" bar disappears on both surfaces instead of falling back to the newest prompt (which is what it did before this PR). Reachable in a long session where a mid-turn send's turn only opens >1024 rows after the send.

Technical details
# Fall back when the host-named opener is not in the transcript

## Affected sites
- `src/shared/native-chat-turn-status.ts:116-120` — returns `activeTurnOpenedBy` unconditionally.
- `src/renderer/src/components/native-chat/native-chat-transcript-slots.ts:133-140` — live status only attaches when `message.id === activeTurnKey`, so a non-present key renders no bar.
- `mobile/src/session/use-mobile-native-chat-turn-disclosure.ts:113-118` — same on mobile.

## Required outcome
- When `activeTurnOpenedBy` is non-null but not present among `messages`, fall back to the latest user message (and then to the unanchored key), so a bar is always rendered while the turn works.

## Suggested approach
- Guard the first branch of the `??` chain on membership, e.g. only use `activeTurnOpenedBy` when `messages.some((message) => message.id === activeTurnOpenedBy)`; otherwise let the existing newest-user fallback run. This also absorbs the id-universe divergence where `readStructuredAgentJournalTurns` builds `itemIds` from all items while `projectStructuredAgentSessionMessages` hides rejected ones.
- Consider whether `readStructuredAgentJournalTurns` should validate the alias target against `itemIds` as well, though the client-side membership guard is the behaviourally important one.

## Open questions for the human
- Is a missing opener a case you want to show under the newest loaded prompt, or to leave barless as the PR body's "paged out" note implies? The code currently does the latter, while the note describes the queued message counting from its own send.

…essage

# Conflicts:
#	src/renderer/src/components/native-chat/NativeChatMessageList.tsx
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

[Medium risk] Refactors turn status bar anchoring in native chat.

The PR is not ready to merge because queued-turn timing, retained-history bar placement, and mobile live-tool visibility can be wrong.

Findings

  1. P1 Running tool activity disappears ▶
  2. P1 Recovery wait counts as work ▶
  3. P1 Live bar loses its anchor ▶

Summary

The PR derives desktop and mobile turn-bar ownership from host lifecycle records and excludes queued time from turn durations.

  • The ownership change leaves mobile running tool activity hidden after a mid-turn send.
  • Timing and bar placement still need handling for an unverifiable preceding turn and an opener outside the loaded transcript.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  J[Journal turns and submissions] --> B[Select settled and running turn timing]
  B --> O[Resolve opening message]
  O --> D[Desktop and mobile turn bars]
  B --> C[Live and settled clock origin]
Loading

Reviews (1) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

isWorking &&
(turnKey === activeTurnKey ||
(turnKey === undefined && activeTurnKey === MOBILE_UNANCHORED_TURN_KEY))
activeTurnIsWorking: enabled && isWorking && turnKey === latestTurnKey

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 Running tool activity disappears When B is sent while A is still running a tool, this line marks A’s tool row as nonworking because B is now the latest prompt. The mobile message component then suppresses the active tool call and can hide the tool run, even while A’s bar says “Working for.” Tool-row liveness needs to follow the running turn, not the newest message.

Knowledge Base Used: Mobile sessions and terminal interaction

Comment on lines +121 to +122
const timing = readTiming(item, precedingTurnEndedAt)
precedingTurnEndedAt = timing?.completedAt ?? item.observedAt

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 Recovery wait counts as work If a preceding turn becomes unverifiable without a recorded end, item.observedAt is still the time its row was created, not the time of its last revision. For a message queued during that turn, this timestamp usually predates the send, so queuedUntil is omitted and the next turn’s duration includes time spent waiting for recovery.

Knowledge Base Used: Agent workspaces and sessions

activeTurnOpenedBy?: string | null
): string {
return (
activeTurnOpenedBy ??

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 Live bar loses its anchor A long-running turn can outlast the loaded journal window, leaving its opening message outside the displayed transcript while its submission alias remains available. This returns the absent message’s ID without checking the visible rows. Once a newer message arrives, no visible user row matches that ID, so the running turn has no visible bar.

Knowledge Base Used: Agent workspaces and sessions

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Update (head 2a047f6e616): merged current main to clear the conflict in NativeChatMessageList.tsx (main renamed failedDeliveryMessageIds to deliveryNotices and added subagent labels; both kept). To stay under the file's line limit, the showTurnStatus gate on the host's turn opener was dropped: the only caller that turns status off never passes an opener. Web and mobile typecheck, targeted tests and lint pass. The three red checks (RPC recording pin, localization catalogs, and their verify rollup) fail on current main itself, not on this change.

@brennanb2025
brennanb2025 merged commit 50a8ef1 into main Sep 29, 2026
32 of 35 checks passed
brennanb2025 added a commit that referenced this pull request Sep 29, 2026
…3700, #22943, #23720, #23793, #23776, #23796) into #23059

#23573 (keep a turn's bar on the prompt that opened it) is ported onto the
turn-record membership instead of kept as a second owner lookup:
- The running turn's bar comes from nativeChatTurnMembership, which now
  returns barTurnKey beside liveTurnKey. Where the host states each row's
  turn they are the same key: the running root turn's anchor, and a
  message handed over mid-turn is already scoped to that turn. Where the
  host states no scope, rows still group by position, so barTurnKey is the
  user entry the running record names (through the same anchors) while
  liveTurnKey keeps the positional live group, as #23573 kept row liveness
  on the newest prompt.
- activeTurnOpenedBy, selectStructuredAgentTurnBars' openedBy and
  selectNativeChatActiveTurnKey are removed; the desktop list, the slot
  builder and both turn-status hooks read barTurnKey.
- The queued-send clock (queuedUntil: a turn counts from the previous
  turn's end when its send waited behind it) is kept, read in the same
  one-pass walk over root turn records that keys timing by anchor.
- Its tests are adapted to pass a journal: each runs on a host that
  states no scope (the original expectations), and the desktop and mobile
  bar tests also run on a scoped host.

#23621 (a message Claude folds into its running turn no longer splits the
turn) is host-only and merged as is: the translator contract carries both
openTurnInLiveProviderCycle and #23059's command-turn members.

Both sides grew the Claude translator past its line budget, so its result
branch moves unchanged into claude-result-journaling.ts, beside
claude-message-journaling.ts (the pre-commit lint refuses the merge
without it).

#23801's Codex child path (no error-driven end) and #23059's Codex /compact
guard (a command turn ends only on turn/completed) both stand.
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