Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/browser/components/PinnedTodoList/PinnedTodoList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ function buildWorkspaceState(workspaceId: string, state: MockWorkspaceState): Wo
pendingStreamModel: null,
runtimeStatus: null,
autoRetryStatus: null,
streamingTokenCount: undefined,
streamingTPS: undefined,
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/browser/features/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,11 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
// renders parseIncompleteMarkdown=false, matching the prior static render exactly.
const contentElement = (
<TypewriterMarkdown
deltas={[content]}
content={content}
isComplete={!isStreaming}
streamKey={message.historyId}
streamSource={message.streamPresentation?.source}
workspaceId={workspaceId}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ interface MockWorkspaceState {
pendingStreamStartTime: number | null;
pendingStreamModel: string | null;
runtimeStatus: { phase: string; detail?: string } | null;
streamingTokenCount: number | undefined;
streamingTPS: number | undefined;
}

function createWorkspaceState(overrides: Partial<MockWorkspaceState> = {}): MockWorkspaceState {
Expand All @@ -27,8 +25,6 @@ function createWorkspaceState(overrides: Partial<MockWorkspaceState> = {}): Mock
pendingStreamStartTime: null,
pendingStreamModel: null,
runtimeStatus: null,
streamingTokenCount: undefined,
streamingTPS: undefined,
...overrides,
};

Expand All @@ -39,10 +35,17 @@ function createWorkspaceState(overrides: Partial<MockWorkspaceState> = {}): Mock
return state;
}

interface MockStreamingStats {
tokenCount: number;
tps: number;
charsPerSec: number;
}

const STATUS_DISPLAY_DELAY_MS = 1000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

let currentWorkspaceState = createWorkspaceState();
let currentStreamingStats: MockStreamingStats | null = null;
let hasInterruptingStream = false;
const setInterrupting = mock((_workspaceId: string) => undefined);
const interruptStream = mock((_input: unknown) =>
Expand Down Expand Up @@ -70,6 +73,7 @@ void mock.module("@/browser/stores/WorkspaceStore", () => ({
useWorkspaceStoreRaw: () => ({
setInterrupting,
}),
useWorkspaceStreamingStats: () => currentStreamingStats,
}));

void mock.module("@/browser/contexts/API", () => ({
Expand Down Expand Up @@ -119,6 +123,7 @@ describe("StreamingBarrier", () => {
globalThis.document = globalThis.window.document;

currentWorkspaceState = createWorkspaceState();
currentStreamingStats = null;
hasInterruptingStream = false;
setInterrupting.mockClear();
interruptStream.mockClear();
Expand Down Expand Up @@ -289,9 +294,8 @@ describe("StreamingBarrier", () => {
currentWorkspaceState = createWorkspaceState({
canInterrupt: true,
currentModel: "anthropic:claude-opus-4-6",
streamingTokenCount: 42,
streamingTPS: 18,
});
currentStreamingStats = { tokenCount: 42, tps: 18, charsPerSec: 72 };
view.rerender(<StreamingBarrier workspaceId="ws-1" />);

expect(view.getByText("claude-opus-4-6 streaming...")).toBeTruthy();
Expand Down
11 changes: 8 additions & 3 deletions src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useWorkspaceState,
useWorkspaceAggregator,
useWorkspaceStoreRaw,
useWorkspaceStreamingStats,
} from "@/browser/stores/WorkspaceStore";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { useSettings } from "@/browser/contexts/SettingsContext";
Expand Down Expand Up @@ -146,6 +147,9 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
const workspaceState = useWorkspaceState(workspaceId);
const aggregator = useWorkspaceAggregator(workspaceId);
const storeRaw = useWorkspaceStoreRaw();
// Subscribe directly to live streaming stats (token count + TPS) so per-delta
// updates re-render this leaf only, not the entire ChatPane subtree.
const streamingStats = useWorkspaceStreamingStats(workspaceId);
const { api } = useAPI();
const { open: openSettings } = useSettings();

Expand All @@ -172,9 +176,10 @@ export const StreamingBarrier: React.FC<StreamingBarrierProps> = ({
// Only show token count during active streaming/compacting
const showTokenCount = phase === "streaming" || phase === "compacting";

// Get live streaming stats from workspace state (updated on each stream-delta)
const tokenCount = showTokenCount ? workspaceState.streamingTokenCount : undefined;
const tps = showTokenCount ? workspaceState.streamingTPS : undefined;
// Live streaming stats come from a leaf subscription so per-delta updates
// don't cascade through the full chat subtree.
const tokenCount = showTokenCount ? streamingStats?.tokenCount : undefined;
const tps = showTokenCount ? streamingStats?.tps : undefined;

// Model to display:
// - "starting" phase: prefer pendingStreamModel (from muxMetadata), then localStorage
Expand Down
4 changes: 3 additions & 1 deletion src/browser/features/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
);
break;
case "reasoning":
renderedMessage = <ReasoningMessage message={message} className={className} />;
renderedMessage = (
<ReasoningMessage message={message} className={className} workspaceId={workspaceId} />
);
break;
case "stream-error":
renderedMessage = <StreamErrorMessage message={message} className={className} />;
Expand Down
19 changes: 17 additions & 2 deletions src/browser/features/Messages/ReasoningMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { Lightbulb } from "lucide-react";
interface ReasoningMessageProps {
message: DisplayedMessage & { type: "reasoning" };
className?: string;
/**
* Workspace this reasoning belongs to. Forwarded to TypewriterMarkdown so the
* smoothing engine can target the model's live emission rate. Optional β€”
* tests render this component without a workspace context.
*/
workspaceId?: string;
}

const REASONING_FONT_CLASSES = "font-primary text-[12px] leading-[18px]";
Expand Down Expand Up @@ -40,7 +46,11 @@ function parseLeadingBoldSummary(
};
}

export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, className }) => {
export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({
message,
className,
workspaceId,
}) => {
const [isExpanded, setIsExpanded] = useState(message.isStreaming);
// Track the height when expanded to reserve space during collapse transitions
const [expandedHeight, setExpandedHeight] = useState<number | null>(null);
Expand Down Expand Up @@ -119,13 +129,18 @@ export const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message, cla
// stream end would unmount/remount the markdown subtree and visibly flash the
// content. isComplete={!isStreaming} cleanly bypasses the smoothing engine once
// the stream ends, matching the prior static-render behavior.
// React Compiler auto-memoizes this normalize call between renders that
// share the same `content` value; no manual useMemo needed.
const normalizedContent = normalizeReasoningMarkdown(content);

return (
<TypewriterMarkdown
deltas={[normalizeReasoningMarkdown(content)]}
content={normalizedContent}
isComplete={!isStreaming}
preserveLineBreaks
streamKey={message.historyId}
streamSource={message.streamPresentation?.source}
workspaceId={workspaceId}
/>
);
};
Expand Down
50 changes: 48 additions & 2 deletions src/browser/features/Messages/TypewriterMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { UseSmoothStreamingTextOptions } from "@/browser/hooks/useSmoothStreamingText";
import { useSmoothStreamingText as importedUseSmoothStreamingText } from "@/browser/hooks/useSmoothStreamingText";
import { useWorkspaceStreamingStats as importedUseWorkspaceStreamingStats } from "@/browser/stores/WorkspaceStore";
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, render } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";
Expand All @@ -8,6 +9,7 @@ import { TypewriterMarkdown } from "./TypewriterMarkdown";

const actualMarkdownCore = ImportedMarkdownCore;
const actualUseSmoothStreamingText = importedUseSmoothStreamingText;
const actualUseWorkspaceStreamingStats = importedUseWorkspaceStreamingStats;

const mockUseSmoothStreamingText = mock(
(options: UseSmoothStreamingTextOptions): { visibleText: string; isCaughtUp: boolean } => ({
Expand All @@ -16,6 +18,8 @@ const mockUseSmoothStreamingText = mock(
})
);

const mockUseWorkspaceStreamingStats = mock((_workspaceId: string) => null);

function MarkdownCoreStub(props: { content: string }) {
return <div data-testid="markdown-core">{props.content}</div>;
}
Expand All @@ -29,6 +33,9 @@ async function installTypewriterMarkdownModuleMocks() {
await mock.module("@/browser/hooks/useSmoothStreamingText", () => ({
useSmoothStreamingText: mockUseSmoothStreamingText,
}));
await mock.module("@/browser/stores/WorkspaceStore", () => ({
useWorkspaceStreamingStats: mockUseWorkspaceStreamingStats,
}));
}

async function restoreTypewriterMarkdownModuleMocks() {
Expand All @@ -40,6 +47,9 @@ async function restoreTypewriterMarkdownModuleMocks() {
await mock.module("@/browser/hooks/useSmoothStreamingText", () => ({
useSmoothStreamingText: actualUseSmoothStreamingText,
}));
await mock.module("@/browser/stores/WorkspaceStore", () => ({
useWorkspaceStreamingStats: actualUseWorkspaceStreamingStats,
}));
}

describe("TypewriterMarkdown", () => {
Expand All @@ -59,6 +69,7 @@ describe("TypewriterMarkdown", () => {
globalThis.document = globalThis.window.document;
await installTypewriterMarkdownModuleMocks();
mockUseSmoothStreamingText.mockClear();
mockUseWorkspaceStreamingStats.mockClear();
});

afterEach(async () => {
Expand All @@ -77,7 +88,7 @@ describe("TypewriterMarkdown", () => {

const view = render(
<TypewriterMarkdown
deltas={["Hello world"]}
content="Hello world"
isComplete={false}
streamKey="msg-1"
streamSource="live"
Expand All @@ -90,13 +101,14 @@ describe("TypewriterMarkdown", () => {
isStreaming: true,
bypassSmoothing: false,
streamKey: "msg-1",
liveCharsPerSec: 0,
});
});

test("bypasses smoothing for replay streams", () => {
render(
<TypewriterMarkdown
deltas={["Replayed content"]}
content="Replayed content"
isComplete={false}
streamKey="msg-2"
streamSource="replay"
Expand All @@ -107,4 +119,38 @@ describe("TypewriterMarkdown", () => {
expect.objectContaining({ bypassSmoothing: true })
);
});

// Regression: completed historical messages must not subscribe to live
// streaming stats for their workspace, otherwise every assistant message in a
// long transcript re-renders on every stream-delta of an active stream and
// re-introduces the cascade jitter this PR is supposed to eliminate.
test("completed messages subscribe with empty key (no live-stats updates)", () => {
render(
<TypewriterMarkdown
content="Historical reply"
isComplete={true}
streamKey="msg-old"
streamSource="live"
workspaceId="ws-active"
/>
);

// Hook still runs (rules of hooks), but the key must be the no-op sentinel.
expect(mockUseWorkspaceStreamingStats).toHaveBeenCalledWith("");
expect(mockUseWorkspaceStreamingStats).not.toHaveBeenCalledWith("ws-active");
});

test("streaming messages subscribe with the real workspace key", () => {
render(
<TypewriterMarkdown
content="Streaming reply"
isComplete={false}
streamKey="msg-live"
streamSource="live"
workspaceId="ws-active"
/>
);

expect(mockUseWorkspaceStreamingStats).toHaveBeenCalledWith("ws-active");
});
});
51 changes: 39 additions & 12 deletions src/browser/features/Messages/TypewriterMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useMemo } from "react";
import React from "react";
import { useSmoothStreamingText } from "@/browser/hooks/useSmoothStreamingText";
import { useWorkspaceStreamingStats } from "@/browser/stores/WorkspaceStore";
import { cn } from "@/common/lib/utils";
import { MarkdownCore } from "./MarkdownCore";
import { StreamingContext } from "./StreamingContext";

interface TypewriterMarkdownProps {
deltas: string[];
/** Full text to render. During streaming this grows monotonically. */
content: string;
isComplete: boolean;
className?: string;
/**
Expand All @@ -18,31 +20,56 @@ interface TypewriterMarkdownProps {
streamKey?: string;
/** Whether this stream originated from live tokens or replay. Defaults to "live". */
streamSource?: "live" | "replay";
/**
* Workspace this content belongs to. When provided, the smoothing engine is
* fed the live model emission rate so the visible cursor tracks the model's
* actual output rather than the constant BASE rate. Optional because some
* surfaces (storybook, preview popovers) render markdown without a workspace.
*/
workspaceId?: string;
}

// Use React.memo to prevent unnecessary re-renders from parent
export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function TypewriterMarkdown({
deltas,
// React Compiler memoizes this component automatically based on prop changes;
// no manual React.memo wrapper. The previous deltas: string[] shape forced a new
// array literal on every parent render and defeated the memo anyway.
export const TypewriterMarkdown: React.FC<TypewriterMarkdownProps> = ({
content,
isComplete,
className,
preserveLineBreaks,
streamKey,
streamSource = "live",
}) {
const fullContent = deltas.join("");
const isStreaming = !isComplete && fullContent.length > 0;
workspaceId,
}) => {
const isStreaming = !isComplete && content.length > 0;

// Read the live model emission rate (chars/sec) for the active stream of this
// workspace. The hook subscribes to its own MapStore so per-delta updates
// re-render this component WITHOUT cascading through the parent β€” see
// useWorkspaceStreamingStats.
//
// Subscribe to the real workspace key ONLY while this message is actively
// streaming. Completed historical messages subscribe to the stable empty-key
// sentinel, which is never bumped β€” so a long transcript of finished
// assistant messages does not re-render on every delta of a new stream.
// (Hooks must run unconditionally; we toggle the key, not the call site.)
const subscriptionKey = isStreaming && workspaceId ? workspaceId : "";
const streamingStats = useWorkspaceStreamingStats(subscriptionKey);
const liveCharsPerSec = isStreaming && workspaceId ? (streamingStats?.charsPerSec ?? 0) : 0;

// Two-clock streaming: ingestion (fullContent) vs presentation (visibleText).
// Two-clock streaming: ingestion (content) vs presentation (visibleText).
// The jitter buffer reveals text at a steady cadence instead of bursty token clumps.
// Replay and completed streams bypass smoothing entirely.
const { visibleText } = useSmoothStreamingText({
fullText: fullContent,
fullText: content,
isStreaming,
bypassSmoothing: streamSource === "replay",
streamKey: streamKey ?? "",
liveCharsPerSec,
});

const streamingContextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
// React Compiler memoizes this object; no manual useMemo needed.
const streamingContextValue = { isStreaming };

return (
<StreamingContext.Provider value={streamingContextValue}>
Expand All @@ -55,4 +82,4 @@ export const TypewriterMarkdown = React.memo<TypewriterMarkdownProps>(function T
</div>
</StreamingContext.Provider>
);
});
};
Loading
Loading