While a coding agent is mid-turn, it isn't producing one clean stream of text — it's interleaving reasoning, tool calls, web lookups, and assistant prose, often out of the order a human would narrate them in, and frequently with results that arrive late or never arrive at all. A chat UI that paints whatever it receives, whenever it receives it, ends up with Thought bubbles that jump above the message they belong to, or a "thinking" spinner that never turns off because one tool call never got its result. Influxx Mobile 1.2 includes a small set of pure functions whose entire job is to keep that stream honest: deciding when the agent is actually still working, and pinning each piece of reasoning to the turn it belongs to instead of wherever it happened to arrive.
Too Many Things Streaming at Once
A single agent turn can emit, in whatever order the provider decides to send them: reasoning frames, tool-call and tool-result pairs, research activity like web searches and page fetches, and the assistant's actual prose reply. Providers don't guarantee narrative order — reasoning can arrive before the user's message is even echoed back, or wedge itself between a finished reply and the next thing the user sends. Render messages in raw arrival order and "Thought" can end up above the user bubble it was reasoning about, reading as the model thinking before the person spoke — a small detail that breaks the illusion of a coherent conversation.
The Gate That Decides "Still Working"
The single most visible symptom of a jittery agent UI is the working indicator flickering or sticking. Influxx Mobile isolates that decision into one pure function, resolveMobileNativeChatAgentWorking, in mobile-native-chat-working-gate.ts. It takes a handful of signals — whether the user hit Stop, whether a permission prompt is blocking, pending-send count, whether a turn is in flight, the provider's transcript lifecycle state, and how many user messages exist in the visible thread — and returns a single boolean.
The checks run in a strict priority order. A Stop press or an open permission prompt always wins and hides the indicator immediately. Next, if the provider's transcript explicitly marks the turn interrupted and nothing is pending, that settles the UI too — but only interrupted does this; a completed lifecycle alone deliberately isn't enough to force-hide the indicator, since the prior boundary might still be in flight while a new turn starts. After that, a pending send or an in-flight turn both show "Stop," since the user genuinely did something. Only then does the function fall through to a guard against empty new chats: no user messages yet means the indicator stays hidden no matter what.
Why the Raw "Working" Flag Isn't Trusted
The function's argument list includes statusWorking — the provider's own raw hook-reported working flag — but the decision logic never actually branches on it. That's not an oversight; it's the point of the module. A comment in the source explains why: provider hooks "often stick on working after a settled reply," and a relaunch can leave hooks reporting working on a brand-new, empty chat before the user sends anything. Trusting that flag directly would mean a spinner that never turns off, or one that shows before a conversation even starts. Instead, the gate treats turnInFlight, pendingCount, and the transcript's lifecycle state as the sources of truth — the raw hook status just comes along for the ride.
"The naive version of this function is `return statusWorking`. It took us an embarrassingly long debugging session to realize the hook status from some providers just doesn't reset reliably after a reply settles. Once we stopped trusting it and started deriving 'working' from things we could actually verify — is a send pending, is a turn in flight — the spinner stopped lying to people."
— Grace Milton, Mobile Platform Engineer, Influxx
Keeping Thought Pinned to the Turn It Belongs To
Deciding whether to show a working indicator solves only half the jitter problem. The other half is ordering: reasoning ("Thought") has to render directly under the user message it's responding to, not wherever it streamed in. That's handled by a separate, shared function — placeReasoningAfterAssistant, in native-chat-reasoning-order.ts, used by both desktop and mobile so the platforms can't drift into different orderings of the same transcript. Its rule is fixed: user → Thought/Thinking → assistant prose → tools.
For every reasoning row, the function first looks ahead: if every message between that row and the next user message is "skippable" — more reasoning, tool-only assistant output with no prose, raw tool rows, system rows, or the synthetic streaming placeholder — it attaches the reasoning under that next user message. If the path isn't clean, because real assistant prose sits in the way, it attaches instead to the nearest preceding user message, since that's the turn the Thought actually belongs to. Reasoning with no user message anywhere in the transcript is pushed to the end rather than left floating at the top. Real assistant prose is deliberately excluded from the skippable set, so Thought stays with the prior turn instead of getting dragged forward past an actual reply.
Reordering Twice — Once Before the Fold, Once After
mobile-native-chat-render-data.ts runs this reordering at two points in the same pipeline. The first pass happens before tool messages fold into their parent assistant turn — reasoning has to be repositioned first, because once tool-only rows merge, the "skippable" gaps the placement logic looks for no longer exist in the same shape. The second pass happens after the transient render data is assembled: once optimistic pending user messages and a live streaming bubble are appended to the tail, the whole sequence runs through placeReasoningAfterAssistant again, so a still-live piece of Thought lands under the pending user message instead of stranded above it.
That second pass exists because of a real race the test suite documents directly: a CLI can leave a live reasoning row (r_live) at the end of the folded transcript, arriving after the assistant's prior reply already finished. If a new message is queued and the model starts streaming its reply, naive ordering would show that stale-looking Thought above the message the user just sent — again reading as thinking that happened before they spoke. Re-running the placement logic after appending the pending message and streaming bubble fixes this: the final order becomes prior user, prior assistant reply, the new pending user message, the live Thought, then the streaming reply.
"I send a follow-up while the agent's still thinking about something from two messages ago, and it used to show that leftover Thought bubble floating above what I'd just typed, like it was reading my mind before I finished. Now it just... doesn't do that. Small thing, but it's the kind of small thing that made me trust the app was actually tracking the conversation and not just splatting events on screen."
— Owen Castellano, indie developer, Influxx user
Collapsing a Research Run Into One Line
Web searches and page fetches get the same ordering discipline, plus a display trick to keep them from dominating the transcript. MobileNativeChatResearchGroup.tsx collapses a run of research rows into a single line — an icon, a headline like "Searched the web," and a count — with a disclosure chevron that expands into individual rows via MobileNativeChatResearchRow.tsx. The component is deliberately chrome-less: no card, no fill, no border, so it sits inside the assistant's own text flow next to Thought rows instead of reading as a separate boxed artifact. Below a threshold of two rows (RESEARCH_GROUP_MIN_ROWS), a single lookup renders as its own row instead of a pointless one-item group. Collapsed, the group still surfaces favicon source chips for what it read, so the summary line isn't a dead end.
A Turn Boundary Is the Source of Truth, Not the Spinner
The same distrust of raw streaming state that shapes the working gate reappears in how research rows decide when to stop showing "Searching…" or "Reading…". A row is only "live" — eligible for that in-flight text — when it has no result yet, hasn't failed, and the surrounding turn is still active. The moment the turn ends, displaySettled and the group-level summarizeResearchRows both force every unpaired row into its settled state, regardless of whether that row's tool call ever got a matching result. It's the same idea as the working gate's refusal to trust a sticky status flag, applied to a different symptom: without it, a tool call that races past its result — common enough in CLI behavior — would leave "Searching…" pinned on screen forever, with no event left to clear it. Tying the settle condition to the turn boundary instead of a specific event's arrival means the UI always has a way out of a stuck-live state.
"There's no glamorous feature here — nobody opens the app because the Thought row is correctly ordered. But every one of these small consistency guarantees is what separates a chat UI that feels like a real conversation partner from one that feels like you're watching raw debug output. We spend a disproportionate amount of engineering time on exactly this kind of plumbing because it's what people actually notice when it's wrong, even if they can't name what's wrong."
— Simone Kessler, VP of Engineering, Influxx
Small Functions, Not a Bigger State Machine
None of this is implemented as one large stateful controller. The working gate, the reasoning-placement function, and the research-row settle logic are each small, pure functions with their own test files, composed by the render-data pipeline rather than tangled into it. That separation is what makes a race like the pending-message-versus-live-Thought case reproducible and fixable in isolation — the test for it constructs exactly that transcript shape and asserts the final message order, nothing else running. A live agent stream will always be messy at the source; the discipline here is refusing to let that messiness leak into what the reader actually sees.
Frequently Asked Questions
Does the "Thinking" indicator in Influxx Mobile ever get stuck on after a reply finishes?
It shouldn't. The gate deliberately ignores the raw provider "working" hook status once a turn has settled and nothing is pending, specifically because that flag is known to stick on working after a reply completes.
Can a reasoning ("Thought") bubble appear above the user message it's responding to?
The ordering logic is built specifically to prevent that. Reasoning is pinned either to the next user message (if only skippable content sits between them) or to the nearest preceding user message, and is re-run again after pending messages and live streaming text are appended, to catch races where reasoning arrives late.
What happens if a web search or page fetch never returns a result?
Once the surrounding agent turn ends, that row is forced into its settled display state regardless of whether its tool call ever got a matching result, so it can't show "Searching…" indefinitely.
Does opening a permission prompt or hitting Stop affect the working indicator?
Yes, and both take priority over everything else in the gate — a blocking permission prompt or a user-initiated interrupt hides the working indicator immediately, ahead of any other signal.
Why would a brand-new, empty chat ever show a "working" state at all?
It can, in a specific edge case: some provider hooks report "working" immediately on a relaunched or freshly opened chat before any message has been sent. The gate explicitly checks for zero user messages and suppresses the indicator in that case.
Are single web searches shown differently from a run of several?
Yes. A single lookup renders as its own row. Two or more collapse into a summarized group with a headline and count, expandable via a disclosure chevron, so a turn that hits the web several times doesn't dominate the transcript.
Is this ordering logic shared between the desktop and mobile apps?
Yes — the reasoning-placement function lives in the shared codebase and is used by both platforms specifically so they can't drift into showing the same transcript in a different order.
None of these functions do anything a user would describe as a feature — no button, no setting, nothing to discover. What they buy is a chat that never contradicts its own timeline, which is most of what makes a live AI stream feel trustworthy rather than merely watched.

