Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";

Expand All @@ -21,6 +21,11 @@ type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>;
const highlightedCodeCache = new Map<string, HighlightedCode>();
const highlightedCodePromiseCache = new Map<string, Promise<HighlightedCode>>();
const HIGHLIGHTED_CODE_CACHE_LIMIT = 64;
// A code change on a mounted block means the text is still streaming (or a
// backlog replay is appending to it). Highlighting every intermediate state
// runs Shiki once per delta and evicts settled entries from the cache, so wait
// for the text to hold still before spending a highlight pass.
const HIGHLIGHT_STREAMING_DEBOUNCE_MS = 200;

function nodeKey(node: MarkdownNode, index: number): string {
return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
Expand Down Expand Up @@ -127,8 +132,11 @@ function useHighlightedCode(
tokens: highlightedCodeCache.get(key) ?? null,
}));

const hasRunRef = useRef(false);
Comment thread
colonelpanic8 marked this conversation as resolved.
useEffect(() => {
let active = true;
const isFirstRun = !hasRunRef.current;
hasRunRef.current = true;
const cached = highlightedCodeCache.get(key);
if (cached) {
cacheHighlightedCode(key, cached);
Expand All @@ -138,19 +146,31 @@ function useHighlightedCode(
};
}

void loadHighlightedCode(code, language, theme, highlightCode)
.then((tokens) => {
if (active) {
setHighlighted({ key, tokens });
}
})
.catch(() => {
if (active) {
setHighlighted({ key, tokens: null });
}
});
const load = () => {
void loadHighlightedCode(code, language, theme, highlightCode)
.then((tokens) => {
if (active) {
setHighlighted({ key, tokens });
}
})
.catch(() => {
if (active) {
setHighlighted({ key, tokens: null });
}
});
};

if (isFirstRun) {
load();
return () => {
active = false;
};
}

const timer = setTimeout(load, HIGHLIGHT_STREAMING_DEBOUNCE_MS);
return () => {
active = false;
clearTimeout(timer);
};
}, [code, highlightCode, key, language, theme]);

Expand Down
21 changes: 17 additions & 4 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
COMPOSER_EXPANDED_CHROME,
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed } from "./ThreadFeed";
import { isLiveThreadEventTimestamp, ThreadFeed } from "./ThreadFeed";
import type { ThreadContentPresentation } from "./threadContentPresentation";

export interface ThreadDetailScreenProps {
Expand All @@ -49,6 +49,8 @@ export interface ThreadDetailScreenProps {
readonly connectionError: string | null;
readonly environmentLabel: string | null;
readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
/** occurredAt of the latest applied detail event; stale ⇒ backlog replay. */
readonly lastDetailEventAt?: string | null;
readonly activeWorkStartedAt: string | null;
readonly activePendingApproval: PendingApproval | null;
readonly respondingApprovalId: ApprovalRequestId | null;
Expand Down Expand Up @@ -119,7 +121,11 @@ function latestStreamingAssistantMessage(
return null;
}

function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedEntry>) {
function useStreamingHaptics(
threadId: ThreadId,
feed: ReadonlyArray<ThreadFeedEntry>,
lastEventAt: string | null | undefined,
) {
const lastStreamingAssistantRef = useRef<{
readonly id: string;
readonly textLength: number;
Expand Down Expand Up @@ -159,14 +165,20 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
return;
}

// Replayed backlog growth is not live typing — buzzing through a
// catch-up burst would fire haptics for output the user already missed.
if (!isLiveThreadEventTimestamp(lastEventAt)) {
return;
}

const now = Date.now();
if (!isNewStream && now - lastStreamHapticAtRef.current < 320) {
return;
}

lastStreamHapticAtRef.current = now;
void Haptics.selectionAsync();
}, [threadId, feed]);
}, [threadId, feed, lastEventAt]);
}

export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
Expand Down Expand Up @@ -224,7 +236,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const isSplitLayout = layoutVariant === "split";
const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined;
const selectedInstanceId = props.selectedThread.modelSelection.instanceId;
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed);
useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed, props.lastDetailEventAt);
const selectedProviderSkills = useMemo(
() =>
props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId)
Expand Down Expand Up @@ -360,6 +372,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
agentLabel={agentLabel}
latestTurn={props.selectedThread.latestTurn}
activeWorkStartedAt={props.activeWorkStartedAt}
lastEventAt={props.lastDetailEventAt}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ function isFreshTimestamp(input: string): boolean {
return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
}

// The reducer stamps the detail thread's updatedAt with each event's original
// occurredAt, so a stale value while the feed is changing means the client is
// replaying a backlog (thread reopen, resume after backgrounding) rather than
// receiving live output. Replay bursts should not pay for per-commit layout
// animations, animated end scrolls, or haptics.
export function isLiveThreadEventTimestamp(input: string | null | undefined): boolean {
return input == null || isFreshTimestamp(input);
}

export interface ThreadFeedProps {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
Expand All @@ -130,6 +139,8 @@ export interface ThreadFeedProps {
readonly agentLabel: string;
readonly latestTurn: ThreadFeedLatestTurn | null;
readonly activeWorkStartedAt: string | null;
/** occurredAt of the latest applied detail event; stale ⇒ backlog replay. */
readonly lastEventAt?: string | null;
readonly listRef: RefObject<LegendListRef | null>;
readonly freeze: SharedValue<boolean>;
readonly anchorMessageId: MessageId | null;
Expand Down Expand Up @@ -1440,6 +1451,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
return ids;
}, [expandedWorkGroups]);
const replayCatchUp = !isLiveThreadEventTimestamp(props.lastEventAt);
const presentedFeed = useMemo(
() =>
deriveThreadFeedPresentation(
Expand Down Expand Up @@ -1725,7 +1737,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
: { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION}
itemLayoutAnimation={replayCatchUp ? undefined : FEED_ITEM_LAYOUT_TRANSITION}
// Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
// lets its scroll math clamp programmatic scrolls to -headerInset
// instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
Expand Down Expand Up @@ -1753,7 +1765,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
disclosureToggleSettling
? false
: {
animated: true,
// Instant (not animated) during backlog replay — an
// animated scrollToEnd per catch-up publication churns the
// scroll view for content the user never watched stream.
animated: !replayCatchUp,
Comment thread
colonelpanic8 marked this conversation as resolved.
on: {
dataChange: true,
itemLayout: true,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ function ThreadRouteContent(
connectionError={routeConnectionError}
environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
selectedThreadFeed={composer.selectedThreadFeed}
lastDetailEventAt={selectedThreadDetail?.updatedAt ?? null}
activeWorkStartedAt={composer.activeWorkStartedAt}
activePendingApproval={requests.activePendingApproval}
respondingApprovalId={requests.respondingApprovalId}
Expand Down
31 changes: 27 additions & 4 deletions apps/mobile/src/features/threads/markdownCodeHighlightState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";

import {
highlightCodeSnippet,
Expand All @@ -11,6 +11,23 @@ import {
} from "../review/shikiReviewHighlighter";

const MARKDOWN_CODE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000;
// A code change on a mounted block means the text is still streaming (or a
// backlog replay is appending to it). Feeding every intermediate state into
// the atom family would run Shiki per delta and retain one atom per delta for
// the idle TTL, so wait for the text to hold still before highlighting.
const MARKDOWN_CODE_HIGHLIGHT_STREAMING_DEBOUNCE_MS = 200;

function useSettledValue<T>(value: T, delayMs: number): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
if (settled === value) {
return;
}
const timer = setTimeout(() => setSettled(value), delayMs);
return () => clearTimeout(timer);
}, [delayMs, settled, value]);
return settled;
}

export type MarkdownHighlightedCode = ReadonlyArray<ReadonlyArray<ReviewHighlightedToken>>;

Expand Down Expand Up @@ -72,16 +89,22 @@ export function useMarkdownCodeHighlight(input: {
const normalizedLanguage = input.language?.trim() || "text";
const enabled = input.enabled && Boolean(input.language?.trim());
const atomLanguage = enabled ? normalizedLanguage : "text";
const settledCode = useSettledValue(input.code, MARKDOWN_CODE_HIGHLIGHT_STREAMING_DEBOUNCE_MS);
const highlightAtom = useMemo(
() =>
markdownCodeHighlightAtom({
code: enabled ? input.code : "",
code: enabled ? settledCode : "",
enabled,
language: atomLanguage,
theme: input.theme,
}),
[atomLanguage, enabled, input.code, input.theme],
[atomLanguage, enabled, settledCode, input.theme],
);
const result = useAtomValue(highlightAtom);
return AsyncResult.isSuccess(result) ? result.value : null;
if (!AsyncResult.isSuccess(result)) {
return null;
}
// Highlighted tokens carry their own text; never render tokens for code
// that no longer matches what is on screen.
return settledCode === input.code ? result.value : null;
}
Loading
Loading