fix(chat): snappier streaming + no bare assistant header

- yield via microtask budget, not setTimeout(0) — no event-loop starvation
- hoist + gate Hearth bridge streaming behind active binding
- show "Thinking…" placeholder when reasoning hidden and no visible body

Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
PouiiroandSoulForge committed 2026-06-04 22:09:37 +01:00
1 parent 9ccc9b4b1a
commit 8633b15f7c
3 files changed
+54 -9

No files matched your search

+15
View File
@@ -1214,6 +1214,17 @@ const AssistantMessage = memo(function AssistantMessage({
const hasTools = msg.toolCalls && msg.toolCalls.length > 0;
const isEmpty = !hasSegments && !hasContent && !hasTools;
// A message whose only content is reasoning renders an empty body when
// reasoning is hidden — leaving a bare header. Detect that case so we can
// show a "Thinking…" placeholder instead of nothing.
const onlyHiddenReasoning =
!isEmpty &&
!hasContent &&
!hasTools &&
!showReasoning &&
!!msg.segments?.length &&
msg.segments.every((s) => s.type === "reasoning");
// Stable seed from message id for deterministic silly-message selection
const finalResponseSeed = useMemo(() => {
let h = 0;
@@ -1345,6 +1356,10 @@ const AssistantMessage = memo(function AssistantMessage({
<text fg={t.textMuted} attributes={TextAttributes.ITALIC}>
Empty response — model returned no content.
</text>
) : onlyHiddenReasoning ? (
<text fg={t.textMuted} attributes={TextAttributes.ITALIC}>
Thinking… (reasoning hidden)
</text>
) : autoLayout ? (
<>
{autoLayout.opening ? (
+18
View File
@@ -1,4 +1,6 @@
import { TextAttributes } from "@opentui/core";
import { memo, useMemo, useRef } from "react";
import { useTheme } from "../../core/theme/store.js";
import { Markdown } from "./Markdown.js";
import { ReasoningBlock } from "./ReasoningBlock.js";
import { type LiveToolCall, ToolCallDisplay } from "./ToolCallDisplay.js";
@@ -49,6 +51,7 @@ export const StreamSegmentList = memo(function StreamSegmentList({
}) {
// Build a stable id->call lookup. Allocating a new Map each render costs O(N)
// but avoids the O(N²) of repeated linear lookups inside the render loop.
const t = useTheme();
const toolCallMap = useMemo(() => {
const m = new Map<string, LiveToolCall>();
for (const tc of toolCalls) m.set(tc.id, tc);
@@ -120,9 +123,24 @@ export const StreamSegmentList = memo(function StreamSegmentList({
return -1;
}, [merged, streaming]);
// When reasoning is hidden, a turn that has only emitted reasoning so far has
// no visible body — leaving a bare header while the model thinks. Show a
// placeholder until the first visible (text/tools) content streams in.
const hasVisibleContent = merged.some(
(seg) =>
(seg.type === "reasoning" && showReasoning) ||
(seg.type === "text" && seg.content.trim().length > 0) ||
seg.type === "tools",
);
let lastVisibleType: string | null = null;
return (
<>
{streaming && !hasVisibleContent ? (
<text fg={t.textMuted} attributes={TextAttributes.ITALIC}>
Thinking…
</text>
) : null}
{merged.map((seg, i) => {
if (seg.type === "reasoning" && !showReasoning) return null;
+21 -9
View File
@@ -2708,8 +2708,19 @@ export function useChat({
abortController.abort();
}, 10_000);
let streamEventCount = 0;
let yieldBeforeNext = false;
let lastYieldTs = Date.now();
// Yield budget: hand the event loop a turn at most every ~8ms so the
// renderer can paint streamed text. A microtask yield (Promise.resolve)
// can't be starved by a busy event loop the way setTimeout(0) can.
const YIELD_BUDGET_MS = 8;
// Resolve Hearth bridge once. When no surface is paired we skip the
// per-delta bridge streaming entirely, avoiding a microtask per delta
// on the streaming hot path for the common local-TUI case.
const hearthBridgeMod = await import("../hearth/bridge.js");
const hearthLive = hearthBridgeMod.hearthBridge
.listBindings()
.some((b) => b.tabId === tabId);
// Capture the iterator so the watchdog can call .return() on the SAME
// instance the loop holds. Calling [Symbol.asyncIterator]() again would
// try getReader() on an already-locked ReadableStream and throw.
@@ -2720,9 +2731,10 @@ export function useChat({
)[Symbol.asyncIterator]();
for await (const part of { [Symbol.asyncIterator]: () => streamIterator }) {
markActivity();
if (yieldBeforeNext || ++streamEventCount % 5 === 0) {
if (yieldBeforeNext || Date.now() - lastYieldTs >= YIELD_BUDGET_MS) {
yieldBeforeNext = false;
await new Promise<void>((r) => setTimeout(r, 0));
lastYieldTs = Date.now();
await Promise.resolve();
}
switch (part.type) {
case "start-step": {
@@ -2749,9 +2761,7 @@ export function useChat({
case "reasoning-delta": {
gotFirstContent = true;
appendReasoningContent(part.text);
void import("../hearth/bridge.js").then(({ reasoningStreamEmitter }) => {
reasoningStreamEmitter.append(tabId, part.text);
});
if (hearthLive) hearthBridgeMod.reasoningStreamEmitter.append(tabId, part.text);
break;
}
case "reasoning-end":
@@ -2761,9 +2771,11 @@ export function useChat({
gotFirstContent = true;
appendText(part.text);
// Stream visible text to Hearth surfaces in real time (coalesced).
void import("../hearth/bridge.js").then(({ bridgeStreamEmitter }) => {
bridgeStreamEmitter.stream(tabId, { type: "text", content: part.text });
});
if (hearthLive)
hearthBridgeMod.bridgeStreamEmitter.stream(tabId, {
type: "text",
content: part.text,
});
queueMicrotaskFlush();
break;
}