fix(chat): stable code-block geometry, hydration reveal, deduped file stats

Three first-render polish issues:

Code blocks jumped at end-of-stream: streaming defers the per-line
line-number markup, so the finished decorate pass inserted the gutter
column and shifted every code line right. The gutter's horizontal
footprint is now reserved with CSS while the markup is deferred, so the
final pass only fills in numbers and colors.

File references were verified twice per render and re-verified with the
wrong directory: the annotation pass's own DOM writes re-triggered its
MutationObserver, and the pass also ran before the effective directory
resolved — issuing stat probes under an empty directory and a second
time under the real one. The observer now ignores the pass's own
mutations and annotation waits for a resolved directory.

Content replacing the hydration skeleton popped in: it now plays a
one-shot 180ms fade (reduced-motion aware); cached session switches
never carry the class and stay instant. Also removed the dead
disableStaging prop and its unused pendingRevealWork threading.

Verified on a production build over CDP: a streamed code block's text
keeps its exact x-position across end-of-stream, and stat probes for a
message with file mentions are unique per path with the directory header
always present. The hydration fade path could not be exercised in the
harness (the live event stream pre-populates parts in a single-project
environment) and needs an eyeball check on a cold multi-project open.
This commit is contained in:
Bohdan Triapitsyn
2026-08-25 17:51:43 +03:00
parent 8c102cddd0
commit b946fc7083
5 changed files with 94 additions and 9 deletions
@@ -160,7 +160,6 @@ type ChatViewportProps = {
onAnchorSizeChanged: (messageId: string) => void; onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void; onIsAtEndChange: (isAtEnd: boolean) => void;
onTimelineDataChange: () => void; onTimelineDataChange: () => void;
pendingRevealWork: boolean;
renderedMessages: SessionMessageRecord[]; renderedMessages: SessionMessageRecord[];
isLoadingOlder: boolean; isLoadingOlder: boolean;
sessionIsWorking: boolean; sessionIsWorking: boolean;
@@ -174,6 +173,9 @@ type ChatViewportProps = {
} | null; } | null;
scrollToBottom: () => void; scrollToBottom: () => void;
endPinningReleased: boolean; endPinningReleased: boolean;
// One-shot fade for content that replaced the hydration skeleton;
// cached sessions render instantly without it.
revealContent: boolean;
sessionQuestions: QuestionRequest[]; sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[]; sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean; isProgrammaticFollowActive: boolean;
@@ -202,7 +204,6 @@ const ChatViewport = React.memo(({
onAnchorSizeChanged, onAnchorSizeChanged,
onIsAtEndChange, onIsAtEndChange,
onTimelineDataChange, onTimelineDataChange,
pendingRevealWork,
renderedMessages, renderedMessages,
isLoadingOlder, isLoadingOlder,
sessionIsWorking, sessionIsWorking,
@@ -211,6 +212,7 @@ const ChatViewport = React.memo(({
retryOverlay, retryOverlay,
scrollToBottom, scrollToBottom,
endPinningReleased, endPinningReleased,
revealContent,
sessionQuestions, sessionQuestions,
sessionPermissions, sessionPermissions,
isProgrammaticFollowActive, isProgrammaticFollowActive,
@@ -377,7 +379,8 @@ const ChatViewport = React.memo(({
'relative min-h-0', 'relative min-h-0',
isDesktopExpandedInput isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none' ? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1' : 'flex-1',
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
)} )}
aria-hidden={isDesktopExpandedInput} aria-hidden={isDesktopExpandedInput}
> >
@@ -386,7 +389,6 @@ const ChatViewport = React.memo(({
key={currentSessionKey} key={currentSessionKey}
ref={messageListRef} ref={messageListRef}
sessionKey={currentSessionId} sessionKey={currentSessionId}
disableStaging={pendingRevealWork}
messages={renderedMessages} messages={renderedMessages}
sessionIsWorking={sessionIsWorking} sessionIsWorking={sessionIsWorking}
activeStreamingMessageId={streamingMessageId} activeStreamingMessageId={streamingMessageId}
@@ -433,7 +435,6 @@ const ChatViewport = React.memo(({
&& prev.directory === next.directory && prev.directory === next.directory
&& prev.scrollRef === next.scrollRef && prev.scrollRef === next.scrollRef
&& prev.messageListRef === next.messageListRef && prev.messageListRef === next.messageListRef
&& prev.pendingRevealWork === next.pendingRevealWork
&& prev.renderedMessages === next.renderedMessages && prev.renderedMessages === next.renderedMessages
&& prev.isLoadingOlder === next.isLoadingOlder && prev.isLoadingOlder === next.isLoadingOlder
&& prev.sessionIsWorking === next.sessionIsWorking && prev.sessionIsWorking === next.sessionIsWorking
@@ -442,6 +443,7 @@ const ChatViewport = React.memo(({
&& prev.retryOverlay === next.retryOverlay && prev.retryOverlay === next.retryOverlay
&& prev.scrollToBottom === next.scrollToBottom && prev.scrollToBottom === next.scrollToBottom
&& prev.endPinningReleased === next.endPinningReleased && prev.endPinningReleased === next.endPinningReleased
&& prev.revealContent === next.revealContent
&& prev.sessionQuestions === next.sessionQuestions && prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions && prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
@@ -815,6 +817,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return () => setWorkStatusPanelVisible(false); return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]); }, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef<MessageListHandle | null>(null); const messageListRef = React.useRef<MessageListHandle | null>(null);
// Session keys that showed the hydration skeleton this app run; their
// content gets a one-shot reveal fade once it replaces the skeleton.
const hydrationRevealKeyRef = React.useRef<string | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -1150,6 +1156,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isSessionHydrating = const isSessionHydrating =
Boolean(currentSessionId) Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot; && !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
// One-shot: forget the key after the reveal animation has played so a
// later (now cached) visit to the same session opens instantly.
const timer = setTimeout(() => {
hydrationRevealKeyRef.current = null;
}, 400);
return () => clearTimeout(timer);
}, [isSessionHydrating, currentSessionKey]);
const retrySessionLoad = React.useCallback(() => { const retrySessionLoad = React.useCallback(() => {
if (!messagesEnabled || !currentSessionId) return; if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
@@ -1270,7 +1285,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return <DraftWelcome exiting={draftPresentationExiting} />; return <DraftWelcome exiting={draftPresentationExiting} />;
} }
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
if (showHydrationSkeleton) {
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
}
if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') { if (sessionMessageLoadState.status === 'error') {
return ( return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6"> <div className="flex min-h-0 flex-1 items-center justify-center px-6">
@@ -1290,6 +1309,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return ( return (
<div <div
data-chat-hydration-skeleton=""
className={cn( className={cn(
'relative min-h-0', 'relative min-h-0',
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1', isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
@@ -1353,7 +1373,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
onIsAtEndChange={onIsAtEndChange} onIsAtEndChange={onIsAtEndChange}
onTimelineDataChange={onTimelineDataChange} onTimelineDataChange={onTimelineDataChange}
messageListRef={messageListRef} messageListRef={messageListRef}
pendingRevealWork={timelineController.pendingRevealWork}
renderedMessages={timelineController.renderedMessages} renderedMessages={timelineController.renderedMessages}
isLoadingOlder={timelineController.isLoadingOlder} isLoadingOlder={timelineController.isLoadingOlder}
sessionIsWorking={sessionIsWorking} sessionIsWorking={sessionIsWorking}
@@ -1362,6 +1381,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
retryOverlay={retryOverlay} retryOverlay={retryOverlay}
scrollToBottom={resumeToLatestInstant} scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll} endPinningReleased={userOwnsScroll}
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
sessionQuestions={sessionQuestions} sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions} sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically} isProgrammaticFollowActive={isFollowingProgrammatically}
@@ -345,6 +345,13 @@ const useFileReferenceInteractions = ({
if (!container) { if (!container) {
return; return;
} }
// Wait for the real directory: annotating against an empty/fallback
// directory issues stat probes under the wrong cache key (and the wrong
// server directory), and the pass reruns anyway once the directory
// resolves — every link ended up verified twice.
if (enabled && !effectiveDirectory) {
return;
}
let cancelled = false; let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit(); const fileReferenceLinkLimit = getFileReferenceLinkLimit();
// On mobile surfaces, file-reference highlighting is disabled entirely — not // On mobile surfaces, file-reference highlighting is disabled entirely — not
@@ -398,6 +405,19 @@ const useFileReferenceInteractions = ({
}; };
const annotateFileLinks = () => { const annotateFileLinks = () => {
annotationWriteDepth += 1;
try {
annotateFileLinksInner();
} finally {
// Let the mutation events from our own writes flush before the
// observer starts listening for real content changes again.
queueMicrotask(() => {
annotationWriteDepth -= 1;
});
}
};
const annotateFileLinksInner = () => {
if (fileReferencesEnabled) { if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container); wrapBlockCodePathTokens(container);
} }
@@ -526,7 +546,12 @@ const useFileReferenceInteractions = ({
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS); scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
// Our own annotation writes (path-token wrapping, attribute updates) fire
// childList mutations too; observing them re-ran the whole pass — every
// link was scanned and verified twice per render.
let annotationWriteDepth = 0;
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
if (annotationWriteDepth > 0) return;
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS); scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
}); });
observer.observe(container, { observer.observe(container, {
@@ -305,7 +305,6 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
interface MessageListProps { interface MessageListProps {
sessionKey: string; sessionKey: string;
disableStaging?: boolean;
messages: ChatMessageEntry[]; messages: ChatMessageEntry[];
sessionIsWorking?: boolean; sessionIsWorking?: boolean;
activeStreamingMessageId?: string | null; activeStreamingMessageId?: string | null;
@@ -131,6 +131,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
const code = pre.querySelector<HTMLElement>(':scope > code'); const code = pre.querySelector<HTMLElement>(':scope > code');
if (!code || code.hasAttribute('data-md-code-lines')) return; if (!code || code.hasAttribute('data-md-code-lines')) return;
// The real gutter takes over the reserved footprint.
pre.removeAttribute('data-md-gutter-reserved');
const text = code.textContent ?? ''; const text = code.textContent ?? '';
const hasTrailingNewline = text.endsWith('\n'); const hasTrailingNewline = text.endsWith('\n');
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n'); const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
@@ -263,7 +266,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
pre.style.margin = '0'; pre.style.margin = '0';
pre.style.background = 'transparent'; pre.style.background = 'transparent';
pre.classList.add('min-w-0', 'w-full', 'flex-1'); pre.classList.add('min-w-0', 'w-full', 'flex-1');
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre); if (!ctx.deferCodeLineNumberSync) {
layoutCodeLines(pre);
} else {
// Streaming defers the per-line gutter markup, but the gutter's
// horizontal footprint is reserved immediately — otherwise the
// end-of-stream decorate pass shifts every code line right by the
// gutter column and the finished message visibly jumps.
pre.setAttribute('data-md-gutter-reserved', '');
}
body.appendChild(pre); body.appendChild(pre);
wrapper.appendChild(header); wrapper.appendChild(header);
wrapper.appendChild(body); wrapper.appendChild(body);
+30
View File
@@ -1356,6 +1356,36 @@ html:not(.dark) .chat-scroll {
min-width: 100%; min-width: 100%;
} }
/* First uncached open of a session shows a hydration skeleton; the real
timeline replacing it fades in once instead of popping. Cached session
switches never carry this class and stay instant. */
@keyframes oc-chat-hydration-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.oc-chat-hydration-reveal {
animation: oc-chat-hydration-reveal 180ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.oc-chat-hydration-reveal {
animation: none;
}
}
/* While streaming defers the per-line gutter markup, hold its horizontal
footprint (2rem column + 0.75rem gap) so the finished pass only fills in
the numbers instead of shifting every code line. */
.markdown-content pre[data-md-gutter-reserved] > code {
display: block;
padding-left: 2.75rem;
}
.markdown-content [data-md-code-line-number] { .markdown-content [data-md-code-line-number] {
align-self: stretch; align-self: stretch;
padding-right: 0.75rem; padding-right: 0.75rem;