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
7 changes: 6 additions & 1 deletion frontend/src/components/cortex/cortex-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ import {
import { Onboarding } from "./onboarding";
import { CaptureModal } from "./capture";
import { Markdown } from "./markdown";
import { StreamingAnswer } from "./streaming-answer";
import { MediaBlock } from "./media-block";
import { SourceChips, type SourceItem } from "./sources";

Expand Down Expand Up @@ -3885,7 +3886,11 @@ export function CortexApp({
<MediaBlock media={m.media} />
) : (
<div className="atext">
{m.streaming ? m.a : <Markdown text={m.a} />}
{m.streaming ? (
<StreamingAnswer />
) : (
<Markdown text={m.a} />
)}
</div>
)}
{!m.streaming && !m.media && (
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/cortex/streaming-answer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client";

import { useStreamStore } from "@/lib/cortex/stream-store";

// Renders the in-flight answer text while it streams. It subscribes ONLY to the
// stream store, so each typewriter tick re-renders this node alone rather than the
// whole app. Once streaming finishes the chat message holds the final text and is
// rendered as Markdown instead.
export function StreamingAnswer() {
const text = useStreamStore((s) => s.text);
return <>{text}</>;
}
21 changes: 9 additions & 12 deletions frontend/src/lib/cortex/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";
import { create } from "zustand";
import { useStreamStore } from "./stream-store";
import {
type Memory,
type CortexEvent,
Expand Down Expand Up @@ -999,16 +1000,17 @@ export const useCortex = create<State>((set, get) => ({
activeId: get().activeId,
});

// Reveal the answer progressively, but cap the work: each store write
// re-renders the whole app tree (the top-level view subscribes to the store),
// so a per-word/30ms tick meant ~33 full re-renders per second. Reveal several
// words per tick and bound the run to STREAM_MAX_TICKS updates regardless of
// length, keeping the typewriter feel at a fraction of the render cost.
// Reveal the answer progressively. Each per-tick partial goes into the dedicated
// stream store (see stream-store.ts), NOT the main store, so only the small
// <StreamingAnswer /> node re-renders per tick instead of the whole app tree
// (the top-level view subscribes to the entire main store). The final text is
// committed back to the chat ONCE, which is the only main-store write of the run.
const STREAM_MAX_TICKS = 24;
const STREAM_INTERVAL_MS = 55;
const stream = (text: string) => {
const words = text.split(" ");
const perTick = Math.max(1, Math.ceil(words.length / STREAM_MAX_TICKS));
useStreamStore.getState().setText("");
let i = 0;
const tick = setInterval(() => {
if (i >= words.length) {
Expand All @@ -1022,6 +1024,7 @@ export const useCortex = create<State>((set, get) => ({
}
return { chat };
});
useStreamStore.getState().setText("");
persist({
memories: get().memories,
events: get().events,
Expand All @@ -1033,13 +1036,7 @@ export const useCortex = create<State>((set, get) => ({
return;
}
i = Math.min(words.length, i + perTick);
const partial = words.slice(0, i).join(" ");
set((s) => {
const chat = [...s.chat];
const last = chat[chat.length - 1];
if (last) last.a = partial;
return { chat };
});
useStreamStore.getState().setText(words.slice(0, i).join(" "));
}, STREAM_INTERVAL_MS);
};

Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/cortex/stream-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { create } from "zustand";

// The answer typewriter reveals text in ~24 ticks. Writing each tick into the main
// store re-rendered the whole app tree (the top-level view subscribes to the entire
// store), which is the biggest source of jank during a reply. Park the in-flight
// partial text here instead: only the small <StreamingAnswer /> reads it, so a tick
// re-renders that node alone. The final text is committed back to the chat once,
// streaming is done.
interface StreamState {
text: string;
setText: (text: string) => void;
}

export const useStreamStore = create<StreamState>((set) => ({
text: "",
setText: (text) => set({ text }),
}));
Loading