Skip to content

Commit 3d1bd87

Browse files
yyq1025claude
andcommitted
app: stream-aware chunked markdown + launch-time shiki init + markdown grammar
- thread per-run `streaming` (last assistant block while the turn runs) so enriched's streamingAnimation is true ONLY for the live tail; settled messages hit the measurement cache (session-enter ~15→35 JS fps) - init the highlighter at app launch (splash-covered) instead of lazily on first session enter; useCodeTokens hook replaces the per-component memo (a cached/time-sliced variant was A/B'd and dropped — the drawer-settle gate covers it) - add the markdown fence grammar (65KB standalone, embedded langs are lazy) + md alias Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 43eb4cf commit 3d1bd87

6 files changed

Lines changed: 128 additions & 35 deletions

File tree

packages/app/src/app/_layout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
DaemonClientProvider,
2626
useDaemonClient,
2727
} from "@/lib/daemon-client-context";
28+
import { initHighlighter } from "@/lib/markdown/code-highlighter";
2829
import { queryClient } from "@/lib/query-client";
2930
import { SafeAreaView } from "@/lib/styled";
3031
import { getThemePreference } from "@/lib/theme-preference";
@@ -63,6 +64,15 @@ export default function RootLayout() {
6364
void getThemePreference().then((pref) => Uniwind.setTheme(pref));
6465
}, []);
6566

67+
// Shiki engine + grammar load (~10-50ms JSON.parse, once per run) at app
68+
// start — the engine README's recommendation, and the upstream example's
69+
// exact idiom (initialize() from the root component's effect). Launch is
70+
// splash-covered, so the parse is invisible here; paying it lazily on the
71+
// first session-enter navigation was visible jank.
72+
useEffect(() => {
73+
initHighlighter();
74+
}, []);
75+
6676
return (
6777
<GestureHandlerRootView style={{ flex: 1 }}>
6878
<SafeAreaProvider>

packages/app/src/components/transcript/chat-panel.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,25 @@ export function ChatPanel({
302302
// triggering its recompute, which jumped the anchored message. Also
303303
// doubles as the gap below the last message.
304304
ListFooterComponent={<ThinkingIndicator active={isThinking} />}
305-
renderItem={({ item }) => {
306-
if (item.kind === "text") return <TextBlock block={item} />;
305+
renderItem={({ item, index }) => {
306+
if (item.kind === "text")
307+
return (
308+
<TextBlock
309+
block={item}
310+
// The streaming message = the LAST block, assistant role,
311+
// while the daemon-pushed activity says the turn is
312+
// running. No protocol settle signal exists; this
313+
// derivation is correct at every boundary (tool_call
314+
// append → no longer last; interrupt/idle → not running;
315+
// cold resume → not running). Worst case of a wrong beat:
316+
// one extra remend pass + a deferred exact re-measure.
317+
streaming={
318+
isRunning &&
319+
item.role === "assistant" &&
320+
index === blocks.length - 1
321+
}
322+
/>
323+
);
307324
if (item.kind === "tool") return <ToolBlock block={item} />;
308325
// `compact_divider` — placeholder renders nothing for this
309326
// commit. Actual divider component (horizontal line + caption)

packages/app/src/components/transcript/text-block.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import type { TextRenderBlock } from "@/lib/transcript-blocks";
1212
* `ChunkedMarkdown` — text/table runs still go through enriched (Fabric,
1313
* Yoga-self-sizing, so rows hit LegendList with real height on first
1414
* layout), while code blocks break out into shiki-highlighted native
15-
* Text. See `ChunkedMarkdown.tsx` header for the design.
15+
* Text. See `chunked-markdown.tsx` header for the design.
1616
*
17-
* `streamDone` is hardcoded `true` for now: there is no per-message
18-
* settle signal yet (daemon ignores content_block_stop), and `true` is
19-
* exact parity with the previous whole-message ChatMarkdown (no remend).
20-
* When a streaming derivation lands (isLast && activity running), wire
21-
* it here to enable tail-run repair.
17+
* `streaming` comes from ChatPanel's derivation (last block && assistant
18+
* && turn running — there is no protocol-level settle signal; daemon
19+
* ignores content_block_stop). It gates three things downstream: tail-run
20+
* remend repair, EOF fence-close marking, and — performance-critical —
21+
* enriched's streamingAnimation, which must be FALSE for settled messages
22+
* (measurement cache) and TRUE only for actively-changing content (see
23+
* ChatMarkdownProps.streaming).
2224
*
2325
* Bubble shape mirrors Claude Desktop's user-message look — pale blue
2426
* background, dark navy text. Role labels (YOU / CLAUDE) are
@@ -31,7 +33,13 @@ import type { TextRenderBlock } from "@/lib/transcript-blocks";
3133
* the blue bubble entirely; text-only messages skip the stack. Both
3234
* paths share the outer `items-end` so alignment stays consistent.
3335
*/
34-
export function TextBlock({ block }: { block: TextRenderBlock }) {
36+
export function TextBlock({
37+
block,
38+
streaming = false,
39+
}: {
40+
block: TextRenderBlock;
41+
streaming?: boolean;
42+
}) {
3543
if (block.role === "user") {
3644
// Materialize base64 → file:// URIs inline. Sync (new
3745
// expo-file-system API), cache-hit fast path on re-render so this
@@ -61,7 +69,7 @@ export function TextBlock({ block }: { block: TextRenderBlock }) {
6169
}
6270
return (
6371
<View className="px-4 py-1.5">
64-
<ChunkedMarkdown markdown={block.text} streamDone />
72+
<ChunkedMarkdown markdown={block.text} streamDone={!streaming} />
6573
</View>
6674
);
6775
}

packages/app/src/lib/markdown/chat-markdown.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,31 @@ const DARK_STYLE = buildStyle(DARK_PALETTE);
226226
export interface ChatMarkdownProps {
227227
/** Assistant message content. May be partial during streaming. */
228228
markdown: string;
229+
/** True ONLY for content actively receiving deltas (in practice: the
230+
* tail run of the streaming message). Drives enriched's
231+
* `streamingAnimation`, which is a PERFORMANCE switch, not just a fade:
232+
*
233+
* - `false` (settled) → enriched's measurement CACHE is active —
234+
* re-mounting a session is cache hits instead of mock-rendering every
235+
* message synchronously just to measure it (the dominant cost of the
236+
* session-enter jank, ~15→35+ JS fps measured 2026-06-12) — and the
237+
* view.bounds measure fast path never engages (the sub-pixel height
238+
* creep loop; see ShadowMeasurementUtils.h).
239+
* - `true` (streaming) → bounds fast path gives cheap re-measures
240+
* between deltas. NEVER use false here: every delta is a new string,
241+
* so the cache misses every tick and enriched would mock-render the
242+
* whole message per delta.
243+
*
244+
* The true→false settle flip forces one exact re-measure upstream
245+
* (ENRMPropsNeedExactStreamingMeasurement), snapping away any rounding
246+
* drift accumulated while streaming. */
247+
streaming?: boolean;
229248
}
230249

231-
export function ChatMarkdown({ markdown }: ChatMarkdownProps) {
250+
export function ChatMarkdown({
251+
markdown,
252+
streaming = false,
253+
}: ChatMarkdownProps) {
232254
const colorScheme = useColorScheme() ?? "light";
233255
const markdownStyle = colorScheme === "dark" ? DARK_STYLE : LIGHT_STYLE;
234256
return (
@@ -238,7 +260,7 @@ export function ChatMarkdown({ markdown }: ChatMarkdownProps) {
238260
underline: true,
239261
}}
240262
flavor="github"
241-
streamingAnimation
263+
streamingAnimation={streaming}
242264
streamingConfig={{ tableMode: "progressive" }}
243265
markdownStyle={markdownStyle}
244266
/>

packages/app/src/lib/markdown/chunked-markdown.tsx

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, useMemo } from "react";
1+
import { memo } from "react";
22
import {
33
ScrollView,
44
Text,
@@ -14,12 +14,7 @@ import {
1414
DARK_PALETTE,
1515
LIGHT_PALETTE,
1616
} from "./chat-markdown";
17-
import {
18-
resolveLang,
19-
type TokenLines,
20-
tokenizeCode,
21-
useHighlighter,
22-
} from "./code-highlighter";
17+
import { useCodeTokens } from "./code-highlighter";
2318
import type { ChunkStats } from "./markdown-chunking";
2419
import { type MarkdownSegment, useMarkdownBlocks } from "./markdown-chunking";
2520
import { useRemend } from "./remend";
@@ -66,13 +61,16 @@ const RunSegment = memo(function RunSegment({ raw }: { raw: string }) {
6661
);
6762
});
6863

69-
/** Tail run while streaming: unterminated inline syntax gets repaired.
64+
/** Tail run while streaming: unterminated inline syntax gets repaired,
65+
* and this is the ONLY place `streaming` is true — enriched's bounds
66+
* fast path for cheap per-delta re-measures (see ChatMarkdownProps).
67+
* Completed runs render settled (measurement cache).
7068
* Same load-bearing View wrapper as RunSegment (see comment there). */
7169
function TailRunSegment({ raw }: { raw: string }) {
7270
const processed = useRemend(raw);
7371
return (
7472
<View>
75-
<ChatMarkdown markdown={processed} />
73+
<ChatMarkdown markdown={processed} streaming />
7674
</View>
7775
);
7876
}
@@ -122,18 +120,11 @@ const CodeBlockSegment = memo(function CodeBlockSegment({
122120
code: string;
123121
onTokenizeMs?: (ms: number) => void;
124122
}) {
125-
const hl = useHighlighter();
126123
const scheme = useColorScheme() === "dark" ? "dark" : "light";
127124
const palette = scheme === "dark" ? DARK_PALETTE : LIGHT_PALETTE;
128-
const langId = resolveLang(lang);
129-
130-
const lines = useMemo<TokenLines | null>(() => {
131-
if (!hl || !langId) return null;
132-
const t0 = performance.now();
133-
const result = tokenizeCode(hl, code, langId, scheme);
134-
onTokenizeMs?.(performance.now() - t0);
135-
return result;
136-
}, [hl, langId, code, scheme, onTokenizeMs]);
125+
// Sync tokenize — see useCodeTokens for why (drawer-settle gate keeps
126+
// mount bursts out of animation windows). null → plain, same metrics.
127+
const lines = useCodeTokens(code, lang, scheme, onTokenizeMs);
137128

138129
return (
139130
<View

packages/app/src/lib/markdown/code-highlighter.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import langDiff from "@shikijs/langs/diff";
44
import langJson from "@shikijs/langs/json";
55
import langJsonc from "@shikijs/langs/jsonc";
66
import langKotlin from "@shikijs/langs/kotlin";
7+
import langMarkdown from "@shikijs/langs/markdown";
78
import langPython from "@shikijs/langs/python";
89
import langRust from "@shikijs/langs/rust";
910
import langShellscript from "@shikijs/langs/shellscript";
@@ -14,7 +15,7 @@ import langTsx from "@shikijs/langs/tsx";
1415
import langYaml from "@shikijs/langs/yaml";
1516
import themeGithubDark from "@shikijs/themes/github-dark";
1617
import themeGithubLight from "@shikijs/themes/github-light";
17-
import { useSyncExternalStore } from "react";
18+
import { useMemo, useSyncExternalStore } from "react";
1819
import {
1920
createNativeEngine,
2021
isNativeEngineAvailable,
@@ -27,7 +28,7 @@ import {
2728
*
2829
* Grammars are imported per-language — NEVER `@shikijs/langs` wholesale
2930
* (7.7MB) or `html` (silently chains javascript+css, ~291KB). Core set
30-
* ~470KB source, chosen by (a) sidecode's domain (TS/Swift/Kotlin) and
31+
* ~535KB source, chosen by (a) sidecode's domain (TS/Swift/Kotlin) and
3132
* (b) what LLMs actually write in fence info-strings. One tsx grammar
3233
* covers the whole JS family via aliases (superset syntax; saves the
3334
* 180KB-each typescript/javascript grammars). Unknown languages degrade
@@ -42,7 +43,12 @@ let highlighter: HighlighterCore | null = null;
4243
let initStarted = false;
4344
const listeners = new Set<() => void>();
4445

45-
function ensureHighlighter() {
46+
/** Kick off engine + grammar loading. Idempotent. Called from the root
47+
* layout once the launch settles (the engine README recommends app-start
48+
* init; the grammar JSON.parse burst (~535KB source) belongs in the
49+
* launch quiet window, not the first session-enter navigation). The
50+
* useHighlighter subscribe path keeps a deferred call as fallback. */
51+
export function initHighlighter() {
4652
if (initStarted) return;
4753
initStarted = true;
4854
if (!isNativeEngineAvailable()) {
@@ -66,6 +72,11 @@ function ensureHighlighter() {
6672
langJson,
6773
langJsonc,
6874
langDiff,
75+
// 65KB standalone — its embedded-language list is LAZY (no chained
76+
// grammar imports, unlike html). Nested fences inside a ```markdown
77+
// block highlight for whatever grammars are loaded above; the rest
78+
// render plain.
79+
langMarkdown,
6980
],
7081
engine: createNativeEngine(),
7182
})
@@ -82,7 +93,9 @@ export function useHighlighter(): HighlighterCore | null {
8293
return useSyncExternalStore(
8394
(cb) => {
8495
listeners.add(cb);
85-
ensureHighlighter();
96+
// Fallback only — the root layout already ran this at app launch in
97+
// any real flow; idempotent no-op here.
98+
initHighlighter();
8699
return () => listeners.delete(cb);
87100
},
88101
() => highlighter,
@@ -118,6 +131,8 @@ const LANG_ALIASES: Record<string, string> = {
118131
jsonc: "jsonc",
119132
diff: "diff",
120133
patch: "diff",
134+
markdown: "markdown",
135+
md: "markdown",
121136
};
122137

123138
/** Info-strings that MEAN plain text — skip highlight without the
@@ -161,3 +176,33 @@ export function tokenizeCode(
161176
theme: scheme === "dark" ? "github-dark" : "github-light",
162177
});
163178
}
179+
180+
// ─── Tokenization hook ──────────────────────────────────────────────────────
181+
182+
/** Tokenized lines for a code block, or null while the engine loads /
183+
* for unsupported languages (null → caller renders plain text with
184+
* identical metrics, so the colored flip is layout-neutral).
185+
*
186+
* Deliberately SYNC on the render frame. A cached + time-sliced variant
187+
* was built and A/B-tested (2026-06-12) and removed as unneeded: the
188+
* drawer-settle gate in the session screen keeps mount bursts out of
189+
* animation windows, and per-block tokenize (~1-3.5ms measured) is within
190+
* budget at our session scale. Revisit (see
191+
* memory/project_transcript_chunked_markdown_plan) if profiling ever
192+
* shows tokenize back on a hot path. */
193+
export function useCodeTokens(
194+
code: string,
195+
infoString: string,
196+
scheme: "light" | "dark",
197+
onTokenizeMs?: (ms: number) => void,
198+
): TokenLines | null {
199+
const hl = useHighlighter();
200+
const langId = resolveLang(infoString);
201+
return useMemo(() => {
202+
if (hl === null || langId === null) return null;
203+
const t0 = performance.now();
204+
const lines = tokenizeCode(hl, code, langId, scheme);
205+
onTokenizeMs?.(performance.now() - t0);
206+
return lines;
207+
}, [hl, langId, code, scheme, onTokenizeMs]);
208+
}

0 commit comments

Comments
 (0)